Assistant streams its answer token by token

ai/client.py streams the completion and puts the chunks back together
(Assembler), covering OpenAI's piecewise tool-call arguments, Ollama's whole
calls, OpenRouter's comment lines, unnumbered calls and in-stream errors.
The turn emits a delta event per piece and a message per round as before,
and the chat grows the last answer as deltas arrive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-15 17:37:56 +02:00
co-authored by Claude Opus 5.5
parent 429100a0c7
commit 58b92da8b0
8 changed files with 243 additions and 32 deletions
+119 -5
View File
@@ -1,14 +1,14 @@
"""The assistant: who may set it up, and that it acts as the person chatting."""
import json
from collections.abc import Iterator
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from fluksio.ai import agent
from fluksio.ai import agent, client
from fluksio.core.config import settings
@@ -94,12 +94,16 @@ def test_a_turn_runs_the_tools_as_the_person_chatting(
]
)
async def fake_completion(**kwargs: Any) -> dict[str, Any]:
async def fake_completion(**kwargs: Any) -> AsyncIterator[str | dict[str, Any]]:
seen.append(kwargs["messages"])
assert any(t["function"]["name"] == "save_flow" for t in kwargs["tools"])
return {"choices": [{"message": next(answers)}]}
answer = next(answers)
if answer["content"]:
yield "Counted "
yield "them."
yield answer
monkeypatch.setattr(agent.client, "chat_completion", fake_completion)
monkeypatch.setattr(agent.client, "stream_completion", fake_completion)
with client.stream(
"POST",
@@ -114,6 +118,8 @@ def test_a_turn_runs_the_tools_as_the_person_chatting(
"start",
"tool_call",
"tool_result",
"delta",
"delta",
"message",
"done",
]
@@ -136,3 +142,111 @@ def test_a_turn_runs_the_tools_as_the_person_chatting(
}
client.delete("/api/v1/ai/chat", headers=superuser_token_headers)
assert client.get("/api/v1/ai/chat", headers=superuser_token_headers).json() == []
# One answer per provider as it streams it. Ollama's text is recorded from a
# local server, the rest follows the shape each endpoint documents.
STREAMS = {
"ollama": """
data: {"id":"chatcmpl-888","object":"chat.completion.chunk","model":"gemma3:4b-it-qat","system_fingerprint":"fp_ollama","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}
data: {"id":"chatcmpl-888","object":"chat.completion.chunk","model":"gemma3:4b-it-qat","system_fingerprint":"fp_ollama","choices":[{"index":0,"delta":{"role":"assistant","content":" there"},"finish_reason":null}]}
data: {"id":"chatcmpl-888","object":"chat.completion.chunk","model":"gemma3:4b-it-qat","system_fingerprint":"fp_ollama","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop"}]}
data: [DONE]
""",
"ollama-tools": """
data: {"id":"chatcmpl-12","object":"chat.completion.chunk","model":"qwen3","system_fingerprint":"fp_ollama","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":"The user wants"},"finish_reason":null}]}
data: {"id":"chatcmpl-12","object":"chat.completion.chunk","model":"qwen3","system_fingerprint":"fp_ollama","choices":[{"index":0,"delta":{"role":"assistant","content":"","tool_calls":[{"id":"call_k2","index":0,"type":"function","function":{"name":"get_flow","arguments":"{\\"name\\":\\"demo\\"}"}}]},"finish_reason":null}]}
data: {"id":"chatcmpl-12","object":"chat.completion.chunk","model":"qwen3","system_fingerprint":"fp_ollama","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"tool_calls"}]}
data: [DONE]
""",
"openai": """
data: {"id":"chatcmpl-9","object":"chat.completion.chunk","model":"gpt-4.1","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"get_flow","arguments":""}}],"refusal":null},"finish_reason":null}]}
data: {"id":"chatcmpl-9","object":"chat.completion.chunk","model":"gpt-4.1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"name\\""}}]},"finish_reason":null}]}
data: {"id":"chatcmpl-9","object":"chat.completion.chunk","model":"gpt-4.1","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_b","type":"function","function":{"name":"list_flows","arguments":""}}]},"finish_reason":null}]}
data: {"id":"chatcmpl-9","object":"chat.completion.chunk","model":"gpt-4.1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":":\\"demo\\"}"}}]},"finish_reason":null}]}
data: {"id":"chatcmpl-9","object":"chat.completion.chunk","model":"gpt-4.1","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{}"}}]},"finish_reason":null}]}
data: {"id":"chatcmpl-9","object":"chat.completion.chunk","model":"gpt-4.1","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}
data: {"id":"chatcmpl-9","object":"chat.completion.chunk","model":"gpt-4.1","choices":[],"usage":{"prompt_tokens":9,"completion_tokens":12,"total_tokens":21}}
data: [DONE]
""",
"anthropic": """
data: {"id":"msg_01","object":"chat.completion.chunk","model":"claude-sonnet-4-5","choices":[{"index":0,"delta":{"role":"assistant","content":"Reading "},"finish_reason":null}]}
data: {"id":"msg_01","object":"chat.completion.chunk","model":"claude-sonnet-4-5","choices":[{"index":0,"delta":{"content":"it."},"finish_reason":null}]}
data: {"id":"msg_01","object":"chat.completion.chunk","model":"claude-sonnet-4-5","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"toolu_01","type":"function","function":{"name":"get_flow","arguments":""}}]},"finish_reason":null}]}
data: {"id":"msg_01","object":"chat.completion.chunk","model":"claude-sonnet-4-5","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"name\\": \\"de"}}]},"finish_reason":null}]}
data: {"id":"msg_01","object":"chat.completion.chunk","model":"claude-sonnet-4-5","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"mo\\"}"}}]},"finish_reason":null}]}
data: {"id":"msg_01","object":"chat.completion.chunk","model":"claude-sonnet-4-5","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}
data: [DONE]
""",
"openrouter": """
: OPENROUTER PROCESSING
data: {"id":"gen-1","provider":"Google","model":"google/gemini-2.5-flash","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":"Short answer."},"finish_reason":null}]}
: OPENROUTER PROCESSING
data: {"id":"gen-1","provider":"Google","model":"google/gemini-2.5-flash","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}
data: {"id":"gen-1","provider":"Google","model":"google/gemini-2.5-flash","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":" there"},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":2,"total_tokens":6}}
data: [DONE]
""",
# Gemini's compatibility endpoint sends each call whole and unnumbered.
"gemini": """
data: {"choices":[{"delta":{"role":"assistant","tool_calls":[{"id":"c1","type":"function","function":{"name":"get_flow","arguments":"{\\"name\\":\\"demo\\"}"}},{"id":"c2","type":"function","function":{"name":"list_flows","arguments":"{}"}}]},"finish_reason":"tool_calls","index":0}],"object":"chat.completion.chunk"}
data: [DONE]
""",
}
GET_DEMO = {"name": "get_flow", "arguments": '{"name":"demo"}'}
LIST = {"name": "list_flows", "arguments": "{}"}
@pytest.mark.parametrize(
("provider", "content", "calls"),
[
("ollama", "Hello there", []),
("ollama-tools", None, [GET_DEMO]),
("openai", None, [GET_DEMO, LIST]),
("anthropic", "Reading it.", [{**GET_DEMO, "arguments": '{"name": "demo"}'}]),
("openrouter", "Hello there", []),
("gemini", None, [GET_DEMO, LIST]),
],
)
def test_a_streamed_answer_comes_back_whole(
provider: str, content: str | None, calls: list[dict[str, str]]
) -> None:
assembler = client.Assembler()
pieces = [assembler.feed(line) for line in STREAMS[provider].splitlines()]
message = assembler.message()
assert "".join(pieces) == (content or "")
assert message["content"] == content
assert [c["function"] for c in message["tool_calls"] or []] == calls
assert all(c["id"] and c["type"] == "function" for c in message["tool_calls"] or [])
def test_an_error_inside_the_stream_stops_it() -> None:
assembler = client.Assembler()
line = 'data: {"error":{"code":502,"message":"Provider disconnected"},"choices":[{"index":0,"delta":{"content":""},"finish_reason":"error"}]}'
with pytest.raises(client.ProviderError, match="Provider disconnected"):
assembler.feed(line)