Grounded chat turns now run the agent loop (app/rag/agent.py) instead
of a bare chat_stream: while the per-turn budgets last
(BOR_AGENT_LIST_CALLS / BOR_AGENT_READ_CALLS, default 1 each) the model
gets list_documents (the indexed catalog, /api/docs order) and
read_document (full text, never truncated — A7-revised contract); once
both budgets are spent the tools key is dropped from the request and
the model must answer. Rejected calls (unknown tool, unknown/missing
path, document already in context, spent budget) consume no budget.
Budgets 0/0 make exactly one tools=None request — byte-identical to
the pre-phase path (budgets-as-kill-switch). Deflected turns keep the
direct chat_stream (A8 unchanged; the LOW prompt never carries the
<tools> section).
SSE contract gains {"type":"tool","name":...,"argument":
"source/path"|null} frames ahead of the answer deltas (PLAN §4
extension, owner permission 2026-08-26); done.sources, query_log.sources
and the per-turn log line (gains tool_calls=N) report the retrieval
docs + read docs, deduped. The UI shows a "calling tool"
button/label state and one visible .tool-call line per call above the
answer; the lines persist with the chat record and re-render on
reload. chat_stream passes tools through and accumulates streaming
tool_calls deltas into ToolCallPiece (tools=None stays byte-identical).
E2E: deterministic mock tool flow ("use your tools" + <tools> marker:
list -> read first catalog line -> quoted answer) plus the story suite
(marker flow, reload re-render, plain/deflected no-tool regressions).
Docs: .env.example + README (the two tools, the budgets, the SSE tool
frame, the "calling tool" UI state).
probe: turbo tool_calls=supported 2026-08-26 (uv run python -m
scripts.llm_probe --tools — non-streaming + streaming
finish_reason=tool_calls, indexed delta.tool_calls partials)
797 lines
28 KiB
Python
797 lines
28 KiB
Python
"""Unit tests: LLMClient embeddings (batching, order, loud dim failure).
|
|
|
|
The fakes stand in at the httpx-transport layer — that is where LLMClient
|
|
actually talks to the endpoint (see ``LLMClient._embed_batch`` in
|
|
``app/rag/llm.py`` for why the openai SDK's own ``embeddings.create`` is
|
|
bypassed: it injects ``encoding_format``, which aipi's litellm proxy
|
|
rejects).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from types import SimpleNamespace
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
|
|
from app.config import Settings
|
|
from app.rag.llm import (
|
|
EmbeddingDimensionError,
|
|
EmbeddingError,
|
|
LLMClient,
|
|
LLMError,
|
|
StreamPiece,
|
|
ToolCallPiece,
|
|
)
|
|
|
|
|
|
def _settings(**kwargs: Any) -> Settings:
|
|
kwargs.setdefault("_env_file", None)
|
|
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
|
|
|
|
|
|
class _Row:
|
|
def __init__(self, index: int, embedding: list[float]) -> None:
|
|
self.index = index
|
|
self.embedding = embedding
|
|
|
|
|
|
class _Response:
|
|
def __init__(self, rows: list[_Row]) -> None:
|
|
self.data = rows
|
|
|
|
|
|
class _FakeEmbeddingsService:
|
|
"""Simulates the /embeddings endpoint; records calls; can fail."""
|
|
|
|
def __init__(
|
|
self,
|
|
dim: int = 768,
|
|
fail: Exception | None = None,
|
|
drop_index: int = -1,
|
|
http_error: int | None = None,
|
|
too_large_min: int | None = None,
|
|
) -> None:
|
|
self.dim = dim
|
|
self.fail = fail
|
|
self.drop_index = drop_index
|
|
self.http_error = http_error
|
|
self.too_large_min = too_large_min
|
|
self.calls: list[list[str]] = []
|
|
|
|
async def create(self, *, model: str, input: list[str]) -> _Response:
|
|
self.calls.append(list(input))
|
|
if self.fail is not None:
|
|
raise self.fail
|
|
return _Response(
|
|
[
|
|
_Row(i, [0.5] * self.dim)
|
|
for i in range(len(input))
|
|
if i != self.drop_index
|
|
]
|
|
)
|
|
|
|
|
|
class _FakeHttpResponse:
|
|
def __init__(
|
|
self, status_code: int, payload: dict[str, Any] | None = None, text: str = ""
|
|
) -> None:
|
|
self.status_code = status_code
|
|
self._payload = payload
|
|
self.text = text or (json.dumps(payload) if payload is not None else "boom")
|
|
|
|
def json(self) -> Any:
|
|
if self._payload is None:
|
|
raise ValueError("no json body")
|
|
return self._payload
|
|
|
|
|
|
#: The endpoint's real error phrasing (litellm) — the client keys off it.
|
|
_TOO_LARGE_TEXT = 'input (9999 tokens) is too large to process. increase the physical batch size'
|
|
|
|
|
|
class _FakeHttp:
|
|
"""Stands in for the httpx transport the openai client owns."""
|
|
|
|
def __init__(self, service: _FakeEmbeddingsService) -> None:
|
|
self.service = service
|
|
self.bodies: list[dict[str, Any]] = []
|
|
|
|
async def post(
|
|
self, url: str, *, json: dict[str, Any], headers: dict[str, str] | None = None
|
|
) -> _FakeHttpResponse:
|
|
self.bodies.append(json)
|
|
assert "Authorization" in (headers or {})
|
|
if self.service.http_error is not None:
|
|
return _FakeHttpResponse(self.service.http_error)
|
|
if (
|
|
self.service.too_large_min is not None
|
|
and len(json["input"]) >= self.service.too_large_min
|
|
):
|
|
return _FakeHttpResponse(500, None, _TOO_LARGE_TEXT)
|
|
rows = await self.service.create(model=json["model"], input=json["input"])
|
|
payload = {"data": [{"index": r.index, "embedding": r.embedding} for r in rows.data]}
|
|
return _FakeHttpResponse(200, payload)
|
|
|
|
|
|
class _FakeClient:
|
|
"""Stands in for the openai AsyncOpenAI object (only its transport is used)."""
|
|
|
|
def __init__(self, http: _FakeHttp) -> None:
|
|
self._client = http
|
|
|
|
|
|
def _make_client(service: _FakeEmbeddingsService, **kwargs: Any) -> tuple[LLMClient, _FakeHttp]:
|
|
kwargs.setdefault("embed_batch_size", 2)
|
|
llm = LLMClient(_settings(**kwargs))
|
|
http = _FakeHttp(service)
|
|
llm._client = _FakeClient(http) # pyright: ignore[reportAttributeAccessIssue]
|
|
return llm, http
|
|
|
|
|
|
def test_embed_batches_by_batch_size_and_keeps_order() -> None:
|
|
service = _FakeEmbeddingsService()
|
|
llm, http = _make_client(service)
|
|
texts = [f"t{i}" for i in range(5)]
|
|
vecs = asyncio.run(llm.embed(texts))
|
|
|
|
assert [len(c) for c in service.calls] == [2, 2, 1]
|
|
assert [t for call in service.calls for t in call] == texts
|
|
assert len(vecs) == 5
|
|
assert all(len(v) == 768 for v in vecs)
|
|
assert llm.embed_batches == 3
|
|
# aipi (litellm) rejects the SDK's injected "encoding_format" — the
|
|
# payload must stay a minimal {model, input} body.
|
|
assert all(set(b) == {"model", "input"} for b in http.bodies)
|
|
|
|
|
|
def test_embed_empty_returns_empty_without_calling_endpoint() -> None:
|
|
service = _FakeEmbeddingsService()
|
|
llm, http = _make_client(service)
|
|
assert asyncio.run(llm.embed([])) == []
|
|
assert http.bodies == []
|
|
assert service.calls == []
|
|
assert llm.embed_batches == 0
|
|
|
|
|
|
def test_embed_one_returns_single_vector() -> None:
|
|
llm, _ = _make_client(_FakeEmbeddingsService())
|
|
vec = asyncio.run(llm.embed_one("hello"))
|
|
assert len(vec) == 768
|
|
|
|
|
|
def test_dim_mismatch_fails_loudly_with_actionable_message() -> None:
|
|
llm, _ = _make_client(_FakeEmbeddingsService(dim=512))
|
|
with pytest.raises(EmbeddingDimensionError) as exc:
|
|
asyncio.run(llm.embed(["hello"]))
|
|
msg = str(exc.value)
|
|
assert "512" in msg and "768" in msg
|
|
assert "BOR_EMBEDDING_DIM" in msg
|
|
assert "llm_probe" in msg
|
|
|
|
|
|
def test_endpoint_error_is_wrapped() -> None:
|
|
llm, _ = _make_client(_FakeEmbeddingsService(fail=RuntimeError("connection refused")))
|
|
with pytest.raises(EmbeddingError, match="connection refused"):
|
|
asyncio.run(llm.embed(["hello"]))
|
|
assert llm.embed_batches == 0
|
|
|
|
|
|
def test_http_error_surfaces_status() -> None:
|
|
llm, _ = _make_client(_FakeEmbeddingsService(http_error=502))
|
|
with pytest.raises(EmbeddingError, match="HTTP 502"):
|
|
asyncio.run(llm.embed(["hello"]))
|
|
assert llm.embed_batches == 0
|
|
|
|
|
|
def test_missing_vector_row_is_rejected() -> None:
|
|
llm, _ = _make_client(_FakeEmbeddingsService(drop_index=1))
|
|
with pytest.raises(EmbeddingError, match="returned 1 vectors for 2 inputs"):
|
|
asyncio.run(llm.embed(["a", "b"]))
|
|
|
|
|
|
def test_batch_size_one_forces_one_call_per_text() -> None:
|
|
service = _FakeEmbeddingsService()
|
|
llm, _ = _make_client(service, embed_batch_size=1)
|
|
asyncio.run(llm.embed(["a", "b", "c"]))
|
|
assert [len(c) for c in service.calls] == [1, 1, 1]
|
|
|
|
|
|
def test_token_budget_limits_texts_per_request() -> None:
|
|
"""~2000-char chunks must not stack up past aipi's ~1024-token cap."""
|
|
service = _FakeEmbeddingsService()
|
|
llm, _ = _make_client(service, embed_batch_size=16) # high count cap
|
|
texts = ["x" * 2000 for _ in range(4)]
|
|
vecs = asyncio.run(llm.embed(texts))
|
|
# 2000 + 2000 chars > 3600-char (≈900-token) budget ⇒ one chunk per request
|
|
assert [len(c) for c in service.calls] == [1, 1, 1, 1]
|
|
assert len(vecs) == 4
|
|
|
|
|
|
def test_small_chunks_pack_up_to_count_cap() -> None:
|
|
service = _FakeEmbeddingsService()
|
|
llm, _ = _make_client(service, embed_batch_size=4) # count cap binds
|
|
texts = ["short text" for _ in range(9)]
|
|
vecs = asyncio.run(llm.embed(texts))
|
|
assert [len(c) for c in service.calls] == [4, 4, 1]
|
|
assert len(vecs) == 9
|
|
|
|
|
|
def test_too_large_response_halves_batch_until_it_fits() -> None:
|
|
"""The tokenizer estimate can be wrong for dense content — the client
|
|
must halve an over-large request and preserve order."""
|
|
service = _FakeEmbeddingsService(too_large_min=3)
|
|
llm, http = _make_client(service, embed_batch_size=16) # all 8 fit one request
|
|
texts = [f"t{i}" for i in range(8)]
|
|
vecs = asyncio.run(llm.embed(texts))
|
|
# http.bodies sees every request (including the rejected ones); the
|
|
# left-half recursion completes before the right half starts.
|
|
assert [len(b["input"]) for b in http.bodies] == [8, 4, 2, 2, 4, 2, 2]
|
|
assert len(vecs) == 8
|
|
assert all(len(v) == 768 for v in vecs)
|
|
|
|
|
|
def test_single_oversized_text_fails_actionably() -> None:
|
|
service = _FakeEmbeddingsService(too_large_min=1)
|
|
llm, _ = _make_client(service)
|
|
with pytest.raises(EmbeddingError, match="token cap"):
|
|
asyncio.run(llm.embed(["x" * 3000]))
|
|
assert llm.embed_batches == 0
|
|
|
|
|
|
# ---------- chat streaming (phase 03) ----------
|
|
|
|
|
|
def _tool_call(
|
|
index: int,
|
|
id: str | None = None,
|
|
name: str | None = None,
|
|
arguments: str | None = None,
|
|
):
|
|
"""One fake ``delta.tool_calls[]`` partial (openai SDK shape, phase 37).
|
|
|
|
``function`` is None when neither *name* nor *arguments* is given —
|
|
mirroring the real wire, where id-only fragments carry no function.
|
|
"""
|
|
fn = None
|
|
if name is not None or arguments is not None:
|
|
fn = SimpleNamespace(name=name, arguments=arguments)
|
|
return SimpleNamespace(index=index, id=id, function=fn)
|
|
|
|
|
|
def _chunk(
|
|
content: str | None = "text",
|
|
empty: bool = False,
|
|
reasoning: str | None = None,
|
|
tool_calls: list | None = None,
|
|
finish_reason: str | None = None,
|
|
):
|
|
"""One fake ChatCompletionChunk (``choices[].delta`` shape).
|
|
|
|
``reasoning_content``, ``tool_calls`` and ``finish_reason`` are
|
|
present only when provided — mirroring the real wire, where the
|
|
fields exist only when the model sends them.
|
|
"""
|
|
if empty:
|
|
return SimpleNamespace(choices=[])
|
|
delta: SimpleNamespace = SimpleNamespace(content=content)
|
|
if reasoning is not None:
|
|
delta.reasoning_content = reasoning
|
|
if tool_calls is not None:
|
|
delta.tool_calls = tool_calls
|
|
choice = SimpleNamespace(delta=delta)
|
|
if finish_reason is not None:
|
|
choice.finish_reason = finish_reason
|
|
return SimpleNamespace(choices=[choice])
|
|
|
|
|
|
class _FakeChatStream:
|
|
def __init__(self, chunks: list) -> None:
|
|
self._chunks = list(chunks)
|
|
|
|
def __aiter__(self):
|
|
self._i = 0
|
|
return self
|
|
|
|
async def __anext__(self):
|
|
if self._i >= len(self._chunks):
|
|
raise StopAsyncIteration
|
|
chunk = self._chunks[self._i]
|
|
self._i += 1
|
|
return chunk
|
|
|
|
|
|
class _FakeCompletion:
|
|
"""One fake non-streaming ChatCompletion (``choices[].message`` shape).
|
|
|
|
``content=None`` mirrors the real wire where the field can be absent or
|
|
empty (reasoning-only replies, provider quirks).
|
|
"""
|
|
|
|
def __init__(self, content: str | None, empty_choices: bool = False) -> None:
|
|
if empty_choices:
|
|
self.choices = []
|
|
else:
|
|
self.choices = [SimpleNamespace(message=SimpleNamespace(content=content))]
|
|
|
|
|
|
class _FakeCompletions:
|
|
def __init__(
|
|
self,
|
|
chunks: list | None = None,
|
|
fail: Exception | None = None,
|
|
completion: _FakeCompletion | None = None,
|
|
) -> None:
|
|
self.chunks = chunks or []
|
|
self.fail = fail
|
|
self.completion = completion
|
|
self.kwargs: dict | None = None
|
|
self.chat_kwargs: dict | None = None
|
|
|
|
async def create(self, **kwargs) -> _FakeChatStream | _FakeCompletion:
|
|
self.kwargs = kwargs
|
|
if self.fail is not None:
|
|
raise self.fail
|
|
if kwargs.get("stream"):
|
|
return _FakeChatStream(self.chunks)
|
|
self.chat_kwargs = kwargs
|
|
assert self.completion is not None
|
|
return self.completion
|
|
|
|
|
|
def _make_stream_client(
|
|
chunks: list | None = None,
|
|
fail: Exception | None = None,
|
|
**settings_kwargs: Any,
|
|
) -> tuple[LLMClient, _FakeCompletions]:
|
|
completions = _FakeCompletions(chunks, fail)
|
|
fake_openai = SimpleNamespace(chat=SimpleNamespace(completions=completions))
|
|
llm = LLMClient(_settings(**settings_kwargs))
|
|
llm._client = fake_openai # pyright: ignore[reportAttributeAccessIssue]
|
|
return llm, completions
|
|
|
|
|
|
async def _collect(llm: LLMClient, messages: list[dict[str, str]]) -> list[StreamPiece]:
|
|
"""Collect pieces from a tools-less stream (phase 37 task 02, test (a):
|
|
without tools, no ToolCallPiece can appear)."""
|
|
pieces = [p async for p in llm.chat_stream(messages)]
|
|
assert all(isinstance(p, StreamPiece) for p in pieces)
|
|
return cast("list[StreamPiece]", pieces)
|
|
|
|
|
|
def test_chat_stream_yields_deltas_in_order() -> None:
|
|
llm, completions = _make_stream_client(
|
|
[_chunk("Hey "), _chunk("you've "), _chunk("got this! 🧠")]
|
|
)
|
|
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
|
|
# Content-only chunks yield content pieces in wire order.
|
|
assert [(p.kind, p.text) for p in pieces] == [
|
|
("content", "Hey "),
|
|
("content", "you've "),
|
|
("content", "got this! 🧠"),
|
|
]
|
|
assert all(isinstance(p, StreamPiece) for p in pieces)
|
|
|
|
|
|
def test_chat_stream_uses_locked_generation_params() -> None:
|
|
llm, completions = _make_stream_client([_chunk("x")])
|
|
messages = [{"role": "system", "content": "s"}, {"role": "user", "content": "u"}]
|
|
asyncio.run(_collect(llm, messages))
|
|
assert completions.kwargs is not None
|
|
assert completions.kwargs["model"] == "turbo"
|
|
assert completions.kwargs["stream"] is True
|
|
assert completions.kwargs["temperature"] == 0.4
|
|
# Phase 11: the old hard 700-token cap is gone — answers may run up to
|
|
# BOR_MAX_OUTPUT_TOKENS (default 32 768) so they are not cut off.
|
|
assert completions.kwargs["max_tokens"] == 32_768
|
|
assert completions.kwargs["messages"] == messages
|
|
# Phase 37: no tools passed ⇒ no `tools` key at all (byte-identical
|
|
# request to pre-phase-37).
|
|
assert "tools" not in completions.kwargs
|
|
|
|
|
|
def test_chat_stream_max_tokens_comes_from_settings() -> None:
|
|
"""The output cap is operator-configurable, not a client constant."""
|
|
llm, completions = _make_stream_client(
|
|
[_chunk("x")], max_output_tokens=1234 # pyright: ignore[reportArgumentType]
|
|
)
|
|
asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
|
|
assert completions.kwargs is not None
|
|
assert completions.kwargs["max_tokens"] == 1234
|
|
|
|
|
|
def test_chat_stream_skips_empty_deltas_and_choiceless_chunks() -> None:
|
|
llm, _ = _make_stream_client([_chunk("a"), _chunk(empty=True), _chunk(None), _chunk("b")])
|
|
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
|
|
assert [(p.kind, p.text) for p in pieces] == [("content", "a"), ("content", "b")]
|
|
|
|
|
|
def test_chat_stream_maps_reasoning_content_to_thinking_pieces() -> None:
|
|
"""The verified aipi wire field (``delta.reasoning_content``) maps to
|
|
``thinking`` pieces; content chunks are untouched by the presence of
|
|
reasoning elsewhere in the stream."""
|
|
llm, _ = _make_stream_client(
|
|
[
|
|
_chunk("", reasoning="Step 1: parse the question."),
|
|
_chunk("", reasoning="Step 2: cite the doc."),
|
|
_chunk("Talos."),
|
|
]
|
|
)
|
|
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
|
|
assert [(p.kind, p.text) for p in pieces] == [
|
|
("thinking", "Step 1: parse the question."),
|
|
("thinking", "Step 2: cite the doc."),
|
|
("content", "Talos."),
|
|
]
|
|
|
|
|
|
def test_chat_stream_falls_back_to_reasoning_field() -> None:
|
|
"""Future-proofing: a bare ``delta.reasoning`` field (no
|
|
``reasoning_content``) is picked up by the fallback getattr."""
|
|
chunk = SimpleNamespace(
|
|
choices=[
|
|
SimpleNamespace(delta=SimpleNamespace(content="ans", reasoning="why not"))
|
|
]
|
|
)
|
|
llm, _ = _make_stream_client([chunk])
|
|
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
|
|
assert [(p.kind, p.text) for p in pieces] == [
|
|
("thinking", "why not"),
|
|
("content", "ans"),
|
|
]
|
|
|
|
|
|
def test_chat_stream_thinking_yields_before_content_in_chunk() -> None:
|
|
"""One chunk carrying both fields yields the thinking piece first."""
|
|
llm, _ = _make_stream_client([_chunk("answer", reasoning="hmm")])
|
|
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
|
|
assert [(p.kind, p.text) for p in pieces] == [
|
|
("thinking", "hmm"),
|
|
("content", "answer"),
|
|
]
|
|
|
|
|
|
def test_chat_stream_interleaved_thinking_and_content_order_preserved() -> None:
|
|
"""The piece sequence must match the chunk sequence exactly — a late
|
|
or interleaved thinking chunk is emitted at its wire position."""
|
|
llm, _ = _make_stream_client(
|
|
[
|
|
_chunk("", reasoning="t1"),
|
|
_chunk("c1"),
|
|
_chunk("", reasoning="t2"),
|
|
_chunk("c2"),
|
|
]
|
|
)
|
|
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
|
|
assert [(p.kind, p.text) for p in pieces] == [
|
|
("thinking", "t1"),
|
|
("content", "c1"),
|
|
("thinking", "t2"),
|
|
("content", "c2"),
|
|
]
|
|
|
|
|
|
def test_chat_stream_wraps_failures_as_llm_error() -> None:
|
|
llm, _ = _make_stream_client(fail=RuntimeError("connection reset by peer"))
|
|
|
|
async def drain() -> None:
|
|
async for _ in llm.chat_stream([{"role": "user", "content": "q"}]):
|
|
pass
|
|
|
|
with pytest.raises(LLMError, match="connection reset by peer"):
|
|
asyncio.run(drain())
|
|
|
|
|
|
def test_chat_stream_llm_error_passes_through_unwrapped() -> None:
|
|
llm, _ = _make_stream_client(fail=LLMError("already wrapped"))
|
|
with pytest.raises(LLMError, match="already wrapped"):
|
|
asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
|
|
|
|
|
|
# ---------- tool-call streaming (phase 37, task 02) ----------
|
|
|
|
#: The agent's tool list (phase 37) — the exact wire shape AGENT_TOOLS will
|
|
#: pass through (the names are whatever the caller's tools list names).
|
|
_AGENT_TOOLS: list[dict[str, Any]] = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "list_documents",
|
|
"description": "List the indexed documents.",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "read_document",
|
|
"description": "Add one indexed document's full text to the context.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"source": {"type": "string"},
|
|
"path": {"type": "string"},
|
|
},
|
|
"required": ["source", "path"],
|
|
},
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def _collect_with_tools(
|
|
llm: LLMClient, messages: list[dict[str, str]], tools: list[dict[str, Any]]
|
|
) -> list[StreamPiece | ToolCallPiece]:
|
|
async def run() -> list[StreamPiece | ToolCallPiece]:
|
|
return [p async for p in llm.chat_stream(messages, tools=tools)]
|
|
|
|
return asyncio.run(run())
|
|
|
|
|
|
def test_chat_stream_passes_tools_when_given() -> None:
|
|
"""(e) A non-None tools list is forwarded verbatim to create()."""
|
|
llm, completions = _make_stream_client([_chunk("ok")], llm_chat_model="turbo")
|
|
_collect_with_tools(llm, [{"role": "user", "content": "q"}], _AGENT_TOOLS)
|
|
assert completions.kwargs is not None
|
|
assert completions.kwargs["tools"] == _AGENT_TOOLS
|
|
|
|
|
|
def test_chat_stream_accumulates_tool_call_across_chunk_partials() -> None:
|
|
"""(b) name on the first partial, arguments in fragments — merged into
|
|
one ToolCallPiece with the concatenated JSON, at finish_reason."""
|
|
llm, _ = _make_stream_client(
|
|
[
|
|
_chunk(
|
|
None,
|
|
tool_calls=[
|
|
_tool_call(
|
|
0,
|
|
id="call_abc",
|
|
name="read_document",
|
|
arguments='{"source": "Homelab", "pa',
|
|
)
|
|
],
|
|
),
|
|
_chunk(None, tool_calls=[_tool_call(0, arguments='th": "kubernetes.md"}')]),
|
|
_chunk(None, finish_reason="tool_calls"),
|
|
]
|
|
)
|
|
pieces = _collect_with_tools(
|
|
llm, [{"role": "user", "content": "q"}], _AGENT_TOOLS
|
|
)
|
|
assert pieces == [
|
|
ToolCallPiece(
|
|
id="call_abc",
|
|
name="read_document",
|
|
arguments={"source": "Homelab", "path": "kubernetes.md"},
|
|
)
|
|
]
|
|
|
|
|
|
def test_chat_stream_two_tool_calls_yielded_in_index_order() -> None:
|
|
"""(c) Indices 0 and 1, interleaved partials (index 1 seen first) —
|
|
both calls, in index order, each merged from its own fragments."""
|
|
llm, _ = _make_stream_client(
|
|
[
|
|
_chunk(
|
|
None,
|
|
tool_calls=[
|
|
_tool_call(1, id="call_b", name="read_document", arguments='{"sou')
|
|
],
|
|
),
|
|
_chunk(
|
|
None,
|
|
tool_calls=[
|
|
_tool_call(0, id="call_a", name="list_documents"),
|
|
_tool_call(1, arguments='rce": "Homelab", "path": "a.md"}')
|
|
],
|
|
),
|
|
_chunk(None, finish_reason="tool_calls"),
|
|
]
|
|
)
|
|
pieces = _collect_with_tools(
|
|
llm, [{"role": "user", "content": "q"}], _AGENT_TOOLS
|
|
)
|
|
assert pieces == [
|
|
ToolCallPiece(id="call_a", name="list_documents", arguments={}),
|
|
ToolCallPiece(
|
|
id="call_b",
|
|
name="read_document",
|
|
arguments={"source": "Homelab", "path": "a.md"},
|
|
),
|
|
]
|
|
|
|
|
|
def test_chat_stream_tool_calls_yielded_at_stream_end_without_finish_reason() -> None:
|
|
"""The spec's other emission point: stream ends without a
|
|
finish_reason="tool_calls" chunk — pieces still materialize."""
|
|
llm, _ = _make_stream_client(
|
|
[
|
|
_chunk(
|
|
None,
|
|
tool_calls=[_tool_call(0, id="call_z", name="list_documents")],
|
|
)
|
|
]
|
|
)
|
|
pieces = _collect_with_tools(
|
|
llm, [{"role": "user", "content": "q"}], _AGENT_TOOLS
|
|
)
|
|
assert pieces == [ToolCallPiece(id="call_z", name="list_documents", arguments={})]
|
|
|
|
|
|
def test_chat_stream_synthesizes_call_id_when_absent() -> None:
|
|
"""Wire never carried the call id ⇒ synthesized "call_<index>"."""
|
|
llm, _ = _make_stream_client(
|
|
[
|
|
_chunk(None, tool_calls=[_tool_call(2, name="read_document", arguments="{}")]),
|
|
_chunk(None, finish_reason="tool_calls"),
|
|
]
|
|
)
|
|
pieces = _collect_with_tools(
|
|
llm, [{"role": "user", "content": "q"}], _AGENT_TOOLS
|
|
)
|
|
assert pieces == [
|
|
ToolCallPiece(
|
|
id="call_2",
|
|
name="read_document",
|
|
arguments={},
|
|
)
|
|
]
|
|
|
|
|
|
def test_chat_stream_null_arguments_become_empty_dict() -> None:
|
|
"""JSON "null" (and, by the same branch, absent arguments) ⇒ {}."""
|
|
llm, _ = _make_stream_client(
|
|
[
|
|
_chunk(
|
|
None,
|
|
tool_calls=[
|
|
_tool_call(0, id="call_n", name="list_documents", arguments="null")
|
|
],
|
|
),
|
|
_chunk(None, finish_reason="tool_calls"),
|
|
]
|
|
)
|
|
pieces = _collect_with_tools(
|
|
llm, [{"role": "user", "content": "q"}], _AGENT_TOOLS
|
|
)
|
|
assert pieces == [ToolCallPiece(id="call_n", name="list_documents", arguments={})]
|
|
|
|
|
|
def test_chat_stream_malformed_tool_arguments_raise_llm_error() -> None:
|
|
"""(d) A silently dropped tool call would corrupt the loop — malformed
|
|
arguments JSON must fail loudly."""
|
|
llm, _ = _make_stream_client(
|
|
[
|
|
_chunk(
|
|
None,
|
|
tool_calls=[
|
|
_tool_call(
|
|
0,
|
|
id="call_x",
|
|
name="read_document",
|
|
arguments='{"source": "Homelab",',
|
|
)
|
|
],
|
|
),
|
|
_chunk(None, finish_reason="tool_calls"),
|
|
]
|
|
)
|
|
|
|
async def drain() -> None:
|
|
async for _ in llm.chat_stream(
|
|
[{"role": "user", "content": "q"}], tools=_AGENT_TOOLS
|
|
):
|
|
pass
|
|
|
|
with pytest.raises(LLMError, match="malformed tool-call arguments"):
|
|
asyncio.run(drain())
|
|
|
|
|
|
def test_chat_stream_non_object_tool_arguments_raise_llm_error() -> None:
|
|
"""The OpenAI contract says arguments is a JSON *object* — a bare array
|
|
is malformed too."""
|
|
llm, _ = _make_stream_client(
|
|
[
|
|
_chunk(
|
|
None,
|
|
tool_calls=[
|
|
_tool_call(0, id="call_y", name="read_document", arguments='[1, 2]')
|
|
],
|
|
),
|
|
_chunk(None, finish_reason="tool_calls"),
|
|
]
|
|
)
|
|
|
|
async def drain() -> None:
|
|
async for _ in llm.chat_stream(
|
|
[{"role": "user", "content": "q"}], tools=_AGENT_TOOLS
|
|
):
|
|
pass
|
|
|
|
with pytest.raises(LLMError, match="non-object tool-call arguments"):
|
|
asyncio.run(drain())
|
|
|
|
|
|
# ---------- one-shot chat: LLMClient.chat (phase 30, task 01) ----------
|
|
|
|
|
|
def _make_chat_client(
|
|
completion: _FakeCompletion | None = None,
|
|
fail: Exception | None = None,
|
|
**settings_kwargs: Any,
|
|
) -> tuple[LLMClient, _FakeCompletions]:
|
|
completions = _FakeCompletions(fail=fail, completion=completion)
|
|
fake_openai = SimpleNamespace(chat=SimpleNamespace(completions=completions))
|
|
llm = LLMClient(_settings(**settings_kwargs))
|
|
llm._client = fake_openai # pyright: ignore[reportAttributeAccessIssue]
|
|
return llm, completions
|
|
|
|
|
|
def test_chat_returns_trimmed_content_with_locked_params() -> None:
|
|
"""Default model is ``lite`` (BOR_LLM_SUMMARY_MODEL), non-streaming,
|
|
low temperature, fixed 2048-token budget — summaries are short."""
|
|
llm, completions = _make_chat_client(_FakeCompletion(" Summary text.\n"))
|
|
messages = [{"role": "system", "content": "s"}, {"role": "user", "content": "u"}]
|
|
out = asyncio.run(llm.chat(messages))
|
|
assert out == "Summary text."
|
|
assert completions.chat_kwargs is not None
|
|
assert completions.chat_kwargs["model"] == "lite"
|
|
assert completions.chat_kwargs["stream"] is False
|
|
assert completions.chat_kwargs["temperature"] == 0.2
|
|
assert completions.chat_kwargs["max_tokens"] == 2048
|
|
assert completions.chat_kwargs["messages"] == messages
|
|
|
|
|
|
def test_chat_default_model_comes_from_llm_summary_model_setting() -> None:
|
|
llm, completions = _make_chat_client(
|
|
_FakeCompletion("x"), llm_summary_model="tiny"
|
|
)
|
|
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
|
|
assert completions.chat_kwargs is not None
|
|
assert completions.chat_kwargs["model"] == "tiny"
|
|
|
|
|
|
def test_chat_explicit_model_overrides_the_default() -> None:
|
|
llm, completions = _make_chat_client(
|
|
_FakeCompletion("x"), llm_summary_model="tiny"
|
|
)
|
|
asyncio.run(llm.chat([{"role": "user", "content": "q"}], model="special"))
|
|
assert completions.chat_kwargs is not None
|
|
assert completions.chat_kwargs["model"] == "special"
|
|
|
|
|
|
def test_chat_transport_failure_wrapped_as_llm_error_with_base_url() -> None:
|
|
"""HTTP/transport failures (incl. >=400 surfaced by the SDK) are wrapped
|
|
with the base URL in the message — same style as chat_stream."""
|
|
llm, _ = _make_chat_client(fail=RuntimeError("HTTP 502 Bad Gateway"))
|
|
with pytest.raises(LLMError, match="HTTP 502") as exc:
|
|
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
|
|
assert "aipi.reeseapps.com" in str(exc.value)
|
|
|
|
|
|
def test_chat_llm_error_passes_through_unwrapped() -> None:
|
|
llm, _ = _make_chat_client(fail=LLMError("already wrapped"))
|
|
with pytest.raises(LLMError, match="already wrapped"):
|
|
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
|
|
|
|
|
|
def test_chat_empty_choices_raises_llm_error() -> None:
|
|
llm, _ = _make_chat_client(_FakeCompletion(None, empty_choices=True))
|
|
with pytest.raises(LLMError, match="no choices"):
|
|
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
|
|
|
|
|
|
def test_chat_missing_content_raises_llm_error() -> None:
|
|
"""A silent empty summary must never be stored — None content fails."""
|
|
llm, _ = _make_chat_client(_FakeCompletion(None))
|
|
with pytest.raises(LLMError, match="empty content"):
|
|
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
|
|
|
|
|
|
def test_chat_whitespace_only_content_raises_llm_error() -> None:
|
|
llm, _ = _make_chat_client(_FakeCompletion(" \n\t "))
|
|
with pytest.raises(LLMError, match="empty content"):
|
|
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
|