From ade93d959bdda2f48a095d34af3071040df756ed Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 15:46:21 +0300 Subject: [PATCH] feat(bot): GigaChat function-calling provider for the chat agent; never 500 on provider errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenRouter returns 403 «Access denied by security policy» from the RF server, and parsing that string error crashed the request (500 instead of the fallback answer). Error parsing is robust now and any agent exception degrades to the extractive answer. GigaChat tool provider (GigaChat-2-Pro by default) converts the agent's OpenAI-style history to function_call / role=function; auto mode prefers GigaChat when credentials are set. Co-Authored-By: Claude Opus 5.5 --- app/config.py | 1 + app/llm/agent/__init__.py | 15 ++++--- app/llm/agent/providers.py | 89 ++++++++++++++++++++++++++++++++++++-- tests/test_llm_agent.py | 72 ++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 8 deletions(-) diff --git a/app/config.py b/app/config.py index 0f0069c..35d10e4 100644 --- a/app/config.py +++ b/app/config.py @@ -35,6 +35,7 @@ class Settings(BaseSettings): openrouter_api_key: str = "" openrouter_model: str = "google/gemini-2.5-flash-lite" openrouter_base_url: str = "https://openrouter.ai/api/v1" + gigachat_agent_model: str = "GigaChat-2-Pro" # function calling quality matters more than price here chat_agent_timeout_s: float = 25.0 chat_agent_max_steps: int = 5 diff --git a/app/llm/agent/__init__.py b/app/llm/agent/__init__.py index dc0386d..7641a7f 100644 --- a/app/llm/agent/__init__.py +++ b/app/llm/agent/__init__.py @@ -22,6 +22,7 @@ from app.config import Settings, get_settings from app.core.types import ScrubbedText from app.llm.agent.prompts import SYSTEM_PROMPT from app.llm.agent.providers import ( + GigaChatToolProvider, MockAgentProvider, OpenRouterProvider, ProviderError, @@ -49,7 +50,7 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None: s = settings or get_settings() mode = s.chat_agent if mode == "auto": - mode = "openrouter" if s.openrouter_api_key else "off" + mode = "gigachat" if s.gigachat_credentials else "openrouter" if s.openrouter_api_key else "off" if mode == "openrouter": if not s.openrouter_api_key: log.warning("chat agent: openrouter selected without OPENROUTER_API_KEY — disabled") @@ -58,15 +59,16 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None: if mode == "mock": return MockAgentProvider() if mode == "gigachat": - # TODO(GigaChat): function calling via the `gigachat` package (functions=..., message.function_call). - log.warning("chat agent: gigachat provider not implemented yet — disabled") - return None + if not s.gigachat_credentials: + log.warning("chat agent: gigachat selected without GIGACHAT_CREDENTIALS — disabled") + return None + return GigaChatToolProvider(s) return None async def _chat_with_retry(provider: ToolChatProvider, messages: list[dict[str, Any]]): """One retry: OpenRouter occasionally fails a single call (seen live as intermittent ProviderError).""" - wire = _wire(messages) if provider.name != "mock" else messages + wire = _wire(messages) if provider.name == "openrouter" else messages # gigachat needs the tool `name` try: return await provider.chat(wire, TOOL_SPECS) except ProviderError as exc: @@ -139,6 +141,9 @@ async def answer_chat( except (ProviderError, TimeoutError) as exc: log.error("chat agent provider=%s failed: %s %s", provider.name, type(exc).__name__, exc) return None + except Exception: # noqa: BLE001 — the agent is optional: any bug must degrade to the extractive answer, not a 500 + log.exception("chat agent provider=%s crashed", provider.name) + return None def _finish(text: str, ctx: ToolContext, provider: str) -> AgentAnswer | None: diff --git a/app/llm/agent/providers.py b/app/llm/agent/providers.py index 27524ba..5ef6fb2 100644 --- a/app/llm/agent/providers.py +++ b/app/llm/agent/providers.py @@ -68,9 +68,10 @@ class OpenRouterProvider: if resp.status_code != 200: # Provider error bodies are generic (no patient text) — log the message to make failures diagnosable. try: - detail = str(resp.json().get("error", {}).get("message", ""))[:200] - except ValueError: - detail = "" + err = resp.json().get("error", "") + detail = str(err.get("message", "") if isinstance(err, dict) else err)[:200] + except (ValueError, AttributeError): + detail = resp.text[:200] log.error("openrouter http %s: %s", resp.status_code, detail) raise ProviderError(f"http {resp.status_code}") try: @@ -90,6 +91,88 @@ class OpenRouterProvider: return AssistantTurn(content=msg.get("content"), tool_calls=calls) +class GigaChatToolProvider: + """GigaChat function calling (RF provider, 152-FZ compliant). Converts the OpenAI-style history the agent keeps: + assistant `tool_calls` → `function_call`, `tool` results → role `function` with the function name. + GigaChat returns at most one function call per turn — the agent loop handles that naturally.""" + + name = "gigachat" + + def __init__(self, settings: Settings, client: Any | None = None) -> None: + self._settings = settings + self._client = client + + def _get_client(self) -> Any: + if self._client is None: + from gigachat import GigaChat + + s = self._settings + self._client = GigaChat( + credentials=s.gigachat_credentials, + scope=s.gigachat_scope, + model=s.gigachat_agent_model, + verify_ssl_certs=s.gigachat_verify_ssl, + timeout=s.chat_agent_timeout_s, + ) + return self._client + + @staticmethod + def to_messages(messages: list[dict[str, Any]]) -> list[Any]: + from gigachat.models import FunctionCall, Messages, MessagesRole + + out = [] + for m in messages: + role = m["role"] + if role == "system": + out.append(Messages(role=MessagesRole.SYSTEM, content=m["content"])) + elif role == "user": + out.append(Messages(role=MessagesRole.USER, content=m["content"])) + elif role == "assistant": + calls = m.get("tool_calls") or [] + for c in calls or [None]: + fc = None + if c is not None: + fc = FunctionCall(name=c["function"]["name"], arguments=json.loads(c["function"]["arguments"])) + out.append(Messages(role=MessagesRole.ASSISTANT, content=m.get("content") or "", function_call=fc)) + elif role == "tool": + out.append(Messages(role=MessagesRole.FUNCTION, name=m.get("name"), content=m["content"])) + return out + + @staticmethod + def to_functions(tools: list[dict[str, Any]]) -> list[Any]: + from gigachat.models import Function + + return [Function.model_validate(t["function"]) for t in tools] + + async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn: + from gigachat.models import Chat + + chat = Chat( + messages=self.to_messages(messages), + functions=self.to_functions(tools), + function_call="auto", + temperature=0.1, + max_tokens=900, + ) + try: + resp = await self._get_client().achat(chat) + except Exception as exc: # noqa: BLE001 — network/auth/HTTP errors of the SDK + raise ProviderError(type(exc).__name__) from exc + msg = resp.choices[0].message + calls = [] + if msg.function_call is not None: + args = msg.function_call.arguments or {} + if isinstance(args, str): + try: + args = json.loads(args) + except json.JSONDecodeError: + args = {} + calls.append(ToolCall(id=msg.function_call.name, name=msg.function_call.name, arguments=args)) + usage = getattr(resp, "usage", None) + log.info("gigachat agent ok calls=%d tokens=%s", len(calls), getattr(usage, "total_tokens", None)) + return AssistantTurn(content=msg.content or None, tool_calls=calls) + + # ------------------------------------------------------------------------------------------------ offline mock _DRUG_WORDS = re.compile( diff --git a/tests/test_llm_agent.py b/tests/test_llm_agent.py index daabcc7..3752f15 100644 --- a/tests/test_llm_agent.py +++ b/tests/test_llm_agent.py @@ -197,3 +197,75 @@ async def test_empty_final_answer_is_nudged(sertraline): assert ans is not None and "Алкоголь" in ans.text assert sent[0]["tool_choice"] == "required" and "tool_choice" not in sent[1] assert "сертралин" in sent[0]["messages"][0]["content"] # patient brief in the system prompt + + +async def test_region_block_string_error_degrades(): + # Seen live from the RF server: 403 {"success": false, "error": "Access denied by security policy."} + client = httpx.AsyncClient( + transport=httpx.MockTransport(lambda r: httpx.Response(403, json={"success": False, "error": "Access denied"})) + ) + provider = OpenRouterProvider(_settings(), client=client) + assert await agent.answer_chat(ANDREY, scrub("вопрос"), provider=provider, settings=_settings()) is None + + +async def test_unexpected_provider_bug_degrades(): + class Broken: + name = "broken" + + async def chat(self, messages, tools): + raise AttributeError("boom") + + assert await agent.answer_chat(ANDREY, scrub("вопрос"), provider=Broken(), settings=_settings()) is None + + +# ------------------------------------------------------------------------------------------------- gigachat + + +class _FakeGiga: + """Mimics gigachat.GigaChat.achat: first a function_call, then the final text.""" + + def __init__(self): + self.chats = [] + + async def achat(self, chat): + from types import SimpleNamespace as NS + + self.chats.append(chat) + if len(self.chats) == 1: + fc = NS(name="check_drug_for_patient", arguments={"drug": "ибупрофен"}) + msg = NS(content="", function_call=fc) + else: + msg = NS( + content="Ибупрофен вместе с сертралином повышает риск кровотечений. Обсудите с врачом.", + function_call=None, + ) + return NS(choices=[NS(message=msg)], usage=NS(total_tokens=42)) + + +async def test_gigachat_tool_loop(sertraline): + from app.llm.agent.providers import GigaChatToolProvider + + fake = _FakeGiga() + s = Settings(_env_file=None, chat_agent="gigachat", gigachat_credentials="x", chat_agent_timeout_s=5.0) + ans = await agent.answer_chat( + ANDREY, scrub("Можно ибупрофен?"), provider=GigaChatToolProvider(s, client=fake), settings=s + ) + assert ans is not None and "кровотеч" in ans.text and ans.provider == "gigachat" + second = fake.chats[1] + roles = [str(m.role.value if hasattr(m.role, "value") else m.role) for m in second.messages] + assert roles == ["system", "user", "assistant", "function"] + assert second.messages[2].function_call.name == "check_drug_for_patient" + assert second.messages[3].name == "check_drug_for_patient" + assert {f.name for f in second.functions} == { + "search_guidelines", + "get_patient_record", + "get_drug_info", + "check_drug_for_patient", + } + + +def test_build_provider_prefers_gigachat(): + from app.llm.agent.providers import GigaChatToolProvider + + s = Settings(_env_file=None, chat_agent="auto", gigachat_credentials="x", openrouter_api_key="y") + assert isinstance(agent.build_provider(s), GigaChatToolProvider)