feat: live agent answers (SSE streaming) + «Чат» tab on phones
CI / ruff · pytest (pull_request) Successful in 2m43s
CI / ruff · pytest (pull_request) Successful in 2m43s
- POST /dialog/{token}/events/stream: Server-Sent Events with the agent's steps («Смотрю вашу карту ЕМИАС…»,
«Ищу в клинических рекомендациях…»), the answer as the model writes it (OpenRouter stream=true), then the
final replies (same as /events, incl. the forbidden-output filter and sources). X-Accel-Buffering: no.
- Web: free-text messages use the stream — a live bubble with steps, typing dots and a cursor; falls back to
the plain request if the stream breaks.
- Phones: the bottom tab bar gets «Чат» (it had no way back to the chat).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import httpx
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import Settings
|
||||
from app.llm.agent import stream
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,6 +54,8 @@ class OpenRouterProvider:
|
||||
self._client = client
|
||||
|
||||
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
|
||||
if stream.active():
|
||||
return await self._chat_stream(messages, tools)
|
||||
body: dict[str, Any] = {"model": self._model, "messages": messages, "tools": tools, "temperature": 0.1}
|
||||
body["max_tokens"] = 900
|
||||
if not any(m["role"] == "tool" for m in messages):
|
||||
@@ -174,6 +177,78 @@ class GigaChatToolProvider:
|
||||
return AssistantTurn(content=msg.content or None, tool_calls=calls)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ streaming
|
||||
|
||||
|
||||
async def _iter_sse(resp: httpx.Response):
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.startswith("data:"):
|
||||
continue # comments («: OPENROUTER PROCESSING») and blank lines
|
||||
data = line[5:].strip()
|
||||
if data == "[DONE]":
|
||||
return
|
||||
try:
|
||||
yield json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
async def _openrouter_stream(self: "OpenRouterProvider", messages: list[dict[str, Any]], tools: list[dict[str, Any]]):
|
||||
"""Same request with `stream: true`: content deltas go to the live channel, tool calls are assembled."""
|
||||
body: dict[str, Any] = {
|
||||
"model": self._model,
|
||||
"messages": messages,
|
||||
"tools": tools,
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 900,
|
||||
"stream": True,
|
||||
}
|
||||
if not any(m["role"] == "tool" for m in messages):
|
||||
body["tool_choice"] = "required"
|
||||
headers = {"Authorization": f"Bearer {self._key}", "X-Title": "PTSRaiAgent prototype (synthetic data)"}
|
||||
content: list[str] = []
|
||||
calls: dict[int, dict[str, Any]] = {}
|
||||
try:
|
||||
client = self._client or httpx.AsyncClient(timeout=self._timeout, proxy=self._proxy)
|
||||
try:
|
||||
async with client.stream("POST", self._url, json=body, headers=headers, timeout=self._timeout) as resp:
|
||||
if resp.status_code != 200:
|
||||
await resp.aread()
|
||||
raise ProviderError(f"http {resp.status_code}")
|
||||
async for chunk in _iter_sse(resp):
|
||||
choice = (chunk.get("choices") or [{}])[0]
|
||||
delta = choice.get("delta") or {}
|
||||
if delta.get("content"):
|
||||
content.append(delta["content"])
|
||||
stream.emit("delta", delta["content"])
|
||||
for tc in delta.get("tool_calls") or []:
|
||||
slot = calls.setdefault(tc.get("index", 0), {"id": "", "name": "", "args": ""})
|
||||
slot["id"] = tc.get("id") or slot["id"]
|
||||
fn = tc.get("function") or {}
|
||||
slot["name"] += fn.get("name") or ""
|
||||
slot["args"] += fn.get("arguments") or ""
|
||||
finally:
|
||||
if self._client is None:
|
||||
await client.aclose()
|
||||
except httpx.HTTPError as exc:
|
||||
raise ProviderError(type(exc).__name__) from exc
|
||||
out = []
|
||||
for i in sorted(calls):
|
||||
c = calls[i]
|
||||
try:
|
||||
args = json.loads(c["args"] or "{}")
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
out.append(ToolCall(id=c["id"] or c["name"] or f"call{i}", name=c["name"], arguments=args))
|
||||
if out and content:
|
||||
stream.emit("reset", None) # a preface before tool calls is not the answer
|
||||
log.info("openrouter stream ok calls=%d chars=%d", len(out), sum(map(len, content)))
|
||||
return AssistantTurn(content="".join(content) or None, tool_calls=out)
|
||||
|
||||
|
||||
OpenRouterProvider._chat_stream = _openrouter_stream # type: ignore[attr-defined]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ offline mock
|
||||
|
||||
_DRUG_WORDS = re.compile(
|
||||
|
||||
Reference in New Issue
Block a user