"""SSE parser: keep-alive comments, fragmented tool_calls, usage chunk, finish_reason.""" import json import httpx import pytest import respx from app.core.config import Settings from app.llm.openrouter import LLMClient CHUNKS = [ ": OPENROUTER PROCESSING", "", 'data: {"id":"gen-1","model":"anthropic/claude-sonnet-4.6","choices":[{"index":0,"delta":{"content":"Bonjour"},"finish_reason":null}]}', 'data: {"id":"gen-1","model":"anthropic/claude-sonnet-4.6","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"financial_calc","arguments":"{\\"function\\": \\"fv_l"}}]},"finish_reason":null}]}', ": OPENROUTER PROCESSING", 'data: {"id":"gen-1","model":"anthropic/claude-sonnet-4.6","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ump\\", \\"params\\": {\\"rate\\": 0.05}}"}}]},"finish_reason":null}]}', 'data: {"id":"gen-1","model":"anthropic/claude-sonnet-4.6","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}', 'data: {"id":"gen-1","model":"anthropic/claude-sonnet-4.6","choices":[],"usage":{"prompt_tokens":120,"completion_tokens":30,"cost":0.00123}}', "data: [DONE]", ] @pytest.mark.asyncio async def test_parse_fragmented_tool_calls() -> None: settings = Settings(OPENROUTER_API_KEY="k", OPENROUTER_BASE_URL="https://or.test/api/v1") client = LLMClient(settings) body = "\n".join(CHUNKS) + "\n" with respx.mock(base_url="https://or.test/api/v1") as mock: mock.post("/chat/completions").mock( return_value=httpx.Response(200, content=body.encode(), headers={"content-type": "text/event-stream"})) events = [ev async for ev in client.stream_chat( [{"role": "user", "content": "hi"}], None, ["anthropic/claude-sonnet-4.6", "openai/gpt-5.5"])] sent = json.loads(mock.calls[0].request.content) assert sent["models"] == ["anthropic/claude-sonnet-4.6", "openai/gpt-5.5"] assert sent["usage"] == {"include": True} assert mock.calls[0].request.headers["X-Title"] == "UQO-Chat" types = [e.type for e in events] assert types[0] == "text_delta" and events[0].data["delta"] == "Bonjour" end = next(e for e in events if e.type == "tool_call_end") assert end.data["name"] == "financial_calc" assert json.loads(end.data["arguments"]) == {"function": "fv_lump", "params": {"rate": 0.05}} usage = next(e for e in events if e.type == "usage") assert usage.data["input_tokens"] == 120 and usage.data["cost_usd"] == 0.00123 done = events[-1] assert done.type == "done" and done.data["finish_reason"] == "tool_calls" await client.aclose() @pytest.mark.asyncio async def test_retry_then_error() -> None: settings = Settings(OPENROUTER_API_KEY="k", OPENROUTER_BASE_URL="https://or.test/api/v1") client = LLMClient(settings) with respx.mock(base_url="https://or.test/api/v1") as mock: mock.post("/chat/completions").mock(return_value=httpx.Response(500, text="boom")) with pytest.raises(Exception): # noqa: B017 _ = [ev async for ev in client.stream_chat([{"role": "user", "content": "x"}], None, ["m"])] assert len(mock.calls) == 3 await client.aclose()