feat(bot): GigaChat function-calling provider for the chat agent; never 500 on provider errors
CI / ruff · pytest (pull_request) Successful in 2m15s
CI / ruff · pytest (pull_request) Successful in 2m15s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user