Files
brain-of-reese/tests/e2e/mock_llm.py
T
ducoterra 15c1272828 feat(rag): agent document tools — list/read tools with env-tuned budgets, SSE tool events + "calling tool" UI
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)
2026-08-26 22:39:14 -04:00

645 lines
28 KiB
Python

"""Deterministic OpenAI-compatible mock for E2E tests (aipi stand-in).
Implements just enough of the aipi surface:
* ``GET /v1/models``
* ``POST /v1/embeddings`` — real bag-of-words vectors (768-dim, L2-normed).
Because similarity is *genuine token overlap*, the relevance threshold
behaves the same way it will in production: related questions score high,
unrelated ones score low and trigger honest deflection.
* ``POST /v1/chat/completions`` — streaming (SSE) or not. The content keys
off markers in the system prompt:
- user message containing ``write a long answer`` -> a ~900-word
deterministic numbered answer (long-answers story, phase 11)
- ``SUMMARY_MODE`` -> the deterministic summary digest: the first 24
tokens of the user message (the summarizer puts the capped document
content there) — byte-stable for a given fixture (document summaries,
phase 30)
- ``KB_OVERVIEW_MODE`` -> the deterministic outline: the first 8 tokens
of the user message (the generator puts the document list there) —
byte-stable for a given KB (KB overview, phase 31)
- ``DEFLECT_MODE`` -> honest "I haven't done anything like that" answer
- otherwise -> upbeat answer quoting the provided document context
- user message containing ``pretend to think slowly`` -> 3s warm-up delay
(used by the loading-feedback story).
- user message containing ``think out loud`` -> the answer is preceded by
~2 700 chars of deterministic ``reasoning_content`` chunks (the
thinking-display story, phase 17; lengthened in phase 21 so the
rendered scratchpad overflows the 320px ``.thinking-text`` window)
- user message containing ``think out loud then hesitate`` -> the
``think out loud`` stream, then a 4s pause before the first content
frame (the sources-midstream story, phase 20 — a deterministic
"leave during pure thinking" navigation window).
- system prompt containing ``<tuning>`` (phase 15, steering notes) ->
the composed answer ends with `` (tuning: <first note line>)`` —
makes prompt injection observable in the UI deterministically.
- system prompt containing ``<knowledge_base>`` (phase 31, KB overview)
-> the composed answer ends with `` (kb: <first bullet line>)`` —
the same echo convention for the overview's prompt injection.
- user message containing ``show the end of your notes`` (phase 24,
whole-document context) -> the answer quotes the **last 160 chars of
the ``<documents>`` block** — a tail echo, byte-stable across runs, so
a sentinel placed at the *end* of a document appears in the rendered
answer iff the whole document was in the prompt. (Phase 37: the HIGH
prompt now ends with a ``<tools>`` section after ``</documents>``, so
the echo targets the block itself; its tail still includes the
closing tag — same sentinel semantics.)
- user message containing ``use your tools`` (phase 37, agent document
tools) **and** the system prompt carries the ``<tools>`` section ->
the deterministic tool-calling flow, discriminated statelessly from
the messages + the ``tools`` parameter:
* request 1 (``tools`` offered, no tool results yet): stream ONLY
``tool_calls`` deltas — ``list_documents`` (synthetic id
``call_0``, no arguments), ``finish_reason: "tool_calls"``, no
content;
* request 2 (a ``tool``-role catalog result in the messages):
parse the FIRST catalog line (``source/path — title`` → split on
``" — "`` → ``rsplit("/", 1)``) and stream a ``tool_calls`` delta
calling ``read_document`` on it (id ``call_1``);
* request 3 (the read result in the messages, no ``tools``
parameter): a content answer, deterministic: ``Read
<source/path>. <first 80 chars of the read document's content>``
— so a suite can assert the read document reached the model and
landed in the answer.
All other requests (including the marker without a ``<tools>``
section, or with the tool conversation not yet started and no tools
offered — e.g. budgets 0/0) behave exactly as today. ``E2E_REAL_LLM=1``
ignores the mock entirely (the real model does what it does).
``max_tokens`` is honored deterministically (token ≈ whitespace word),
like a real endpoint: an answer longer than the cap is truncated. This
is what makes the phase-11 truncation regression observable.
"""
from __future__ import annotations
import hashlib
import math
import os
import re
import signal
import threading
import time
import uuid
from typing import Any
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
DIM = 768
TOKEN_RE = re.compile(r"[a-z0-9]+")
def embed_text(text: str) -> list[float]:
vec = [0.0] * DIM
for tok in TOKEN_RE.findall(text.lower()):
idx = int(hashlib.md5(tok.encode()).hexdigest(), 16) % DIM
vec[idx] += 1.0
norm = math.sqrt(sum(v * v for v in vec)) or 1.0
return [v / norm for v in vec]
def _messages(body: dict[str, Any]) -> list[dict[str, str]]:
return body.get("messages", [])
def _system(body: dict[str, Any]) -> str:
return " ".join(m.get("content", "") for m in _messages(body) if m.get("role") == "system")
def _user(body: dict[str, Any]) -> str:
parts = [m.get("content", "") for m in _messages(body) if m.get("role") == "user"]
return parts[-1] if parts else ""
def _context(body: dict[str, Any]) -> str:
"""The document context is the longest system/user message in practice."""
msgs = _messages(body)
return max((m.get("content", "") for m in msgs), key=len)
LONG_ANSWER_TRIGGER = "write a long answer"
#: ~920 words — comfortably past the old hard 700-token cap (where the
#: tail would be cut) yet short enough to stream in ~8s at the mock's
#: per-chunk pacing.
LONG_ANSWER_LINES = 40
LONG_ANSWER_END = "LONG-ANSWER-END"
#: Phase 17 (thinking-display story): a user message containing this
#: substring (case-insensitive) is answered with a deterministic
#: ``reasoning_content`` stream ahead of the content — same convention as
#: the other user-message triggers above. Existing E2E questions do not
#: contain the substring, so every other suite is unaffected.
THINKING_TRIGGER = "think out loud"
#: Phase 20 (sources-midstream bug): a user message containing this
#: substring (case-insensitive) gets the phase-17 thinking stream followed
#: by a multi-second pause before the FIRST content frame — the
#: navigation window for the "leave during pure thinking" scenario
#: (owner-confirmed A1.2: nothing brain-side may be persisted then).
#: Strictly longer than ``THINKING_TRIGGER``, so the phase-17 suite's
#: questions are unaffected.
SLOW_PRETOKEN_TRIGGER = "think out loud then hesitate"
PRE_CONTENT_PAUSE_S = 4.0
#: Phase 24 (whole-document-context story): a user message containing this
#: substring (case-insensitive) gets an answer quoting the TAIL of the
#: document context (see the module docstring). Verified 2026-08-24: no
#: existing E2E question or fixture file contains the phrase, so every
#: other suite is unaffected.
END_OF_NOTES_TRIGGER = "show the end of your notes"
#: The ``<documents>`` block of the system prompt (phase 37: the HIGH
#: prompt ends with the ``<tools>`` section after ``</documents>``, so the
#: phase-24 tail echo targets the block, not the raw message tail).
_DOCUMENTS_BLOCK_RE = re.compile(r"<documents>.*?</documents>", re.S)
#: Phase 37 (agent-document-tools story): a user message containing this
#: substring (case-insensitive) — combined with the ``<tools>`` section
#: in the system prompt — drives the deterministic tool flow documented
#: in the module docstring (list_documents → read_document on the first
#: catalog line → the quoted answer). Existing E2E questions do not
#: contain the phrase, so every other suite is unaffected.
TOOLS_TRIGGER = "use your tools"
#: The agent's ``read_document`` tool-result prefix (app.rag.agent
#: ``_execute_tool``): ``"Document <source/path>:\n<content>"``.
_READ_RESULT_PREFIX = "Document "
def _tool_flow(body: dict[str, Any]) -> tuple[str, str, str] | None:
"""Classify a marker request into one step of the tool flow (phase 37).
Returns one of:
* ``("list", "", "")`` — ``tools`` are offered and no tool results
are in the messages yet: the model lists the catalog.
* ``("read", source, path)`` — a ``tool``-role catalog result is in
the messages: the model reads its FIRST ``source/path — title``
line (split on ``" — "``, then ``rsplit("/", 1)``).
* ``("answer", "source/path", content)`` — a ``tool``-role read
result (``"Document <source/path>:\n<content>"``) is in the
messages: the model answers, quoting the read document.
* ``None`` — not the marker flow: the request behaves exactly as
today (marker absent, no ``<tools>`` section, or a no-tools first
request — the budgets-0/0 path).
"""
if TOOLS_TRIGGER not in _user(body).lower():
return None
if "<tools>" not in _system(body):
return None
tool_msgs = [m for m in _messages(body) if m.get("role") == "tool"]
for m in tool_msgs: # a read result means the forced-answer request
content = str(m.get("content") or "")
if content.startswith(_READ_RESULT_PREFIX):
# The header is "Document <source/path>:" — drop the prefix
# AND the trailing colon so the answer quotes a clean path.
header, _, doc_content = content.partition("\n")
sp = header[len(_READ_RESULT_PREFIX):].strip().removesuffix(":")
return ("answer", sp, doc_content)
if not body.get("tools"):
return None
for m in tool_msgs: # a catalog result means the read request
content = str(m.get("content") or "")
for line in content.splitlines():
head = line.split(" — ", 1)[0].strip()
if "/" in head:
source, _, path = head.rpartition("/")
if source and path:
return ("read", source, path)
return ("list", "", "")
def long_answer() -> str:
"""~900-word deterministic walkthrough (phase 11): numbered steps plus
a unique final line that must survive the stream untruncated."""
lines = [
f"{i}. Step {i}: configure node-{i} with the homelab defaults and "
f"verify that step {i} of the long walkthrough is complete before moving on."
for i in range(1, LONG_ANSWER_LINES + 1)
]
lines.append(LONG_ANSWER_END)
return "\n".join(lines)
#: First numbered note line of a ``<tuning>`` section (phase 15).
_TUNING_BLOCK_RE = re.compile(r"<tuning>\n(.*?)\n</tuning>", re.S)
_NOTE_LINE_RE = re.compile(r"^\d+\.\s*(.+)$")
def first_tuning_note(system: str) -> str | None:
"""The first steering note in the system prompt, or ``None``.
The prompt numbers notes 1..N oldest-first (see
``app.rag.prompts.build_steering_section``); the mock echoes the first
one into its answer so prompt injection is observable in the UI.
"""
block = _TUNING_BLOCK_RE.search(system)
if not block:
return None
for line in block.group(1).splitlines():
m = _NOTE_LINE_RE.match(line.strip())
if m:
return m.group(1).strip()
return None
#: First ``-`` bullet line of a ``<knowledge_base>`` section (phase 31).
_KB_BLOCK_RE = re.compile(r"<knowledge_base>\n(.*?)\n</knowledge_base>", re.S)
_KB_BULLET_RE = re.compile(r"^-(?:\s+(.*))?$")
def first_kb_bullet(system: str) -> str | None:
"""The first outline bullet in the system prompt, or ``None``.
The stored outline (phase 31) is ``-`` bullet lines (see
``app.rag.overview.OVERVIEW_INSTRUCTION``); the mock echoes the first
one into its answer as `` (kb: <bullet>)`` — the exact
:func:`first_tuning_note` convention, so prompt injection of the
``<knowledge_base>`` section is observable in the UI.
"""
block = _KB_BLOCK_RE.search(system)
if not block:
return None
for line in block.group(1).splitlines():
m = _KB_BULLET_RE.match(line.strip())
if m:
return (m.group(1) or "").strip()
return None
def compose_answer(body: dict[str, Any]) -> str:
system = _system(body)
user = _user(body)
if LONG_ANSWER_TRIGGER in user.lower():
answer = long_answer()
elif "SUMMARY_MODE" in system:
# Document summaries (phase 30): the ``lite`` stand-in returns a
# deterministic digest — the first 24 tokens of the user message
# (the summarizer puts the capped document content there). Byte-
# stable for a given fixture, so the summary chunk's retrieval
# rank is a pure function of the fixture text. Checked BEFORE the
# DEFLECT_MODE branch (task 06) so a deflection prompt that ever
# carries the marker cannot shadow the summary call.
answer = (
f"This document covers "
f"{' '.join(TOKEN_RE.findall(user.lower())[:24])}."
)
elif "KB_OVERVIEW_MODE" in system:
# KB overview (phase 31): the ``lite`` stand-in returns the
# deterministic outline — the first 8 tokens of the user message
# (the generator puts the document list there). Byte-stable for a
# given KB, so the stored row is a pure function of the fixture.
# Checked BEFORE the DEFLECT_MODE branch, like SUMMARY_MODE, so a
# prompt that ever carries both markers cannot shadow the
# overview call.
answer = "Knowledge base outline:\n- " + " ".join(
TOKEN_RE.findall(user.lower())[:8]
)
elif "DEFLECT_MODE" in system:
answer = (
"Ah — I haven't done anything like that, so I don't want to make stuff up! "
"You're thinking bigger than my notes for a second. Try asking about "
"kubernetes, backups, or deploying a new service — I know those inside out. "
"You've got this!"
)
elif END_OF_NOTES_TRIGGER in user.lower():
# Whole-document-context story (phase 24): echo the tail of the
# document context. Byte-stable across runs — a sentinel on the
# document's last line appears in the answer iff the whole
# document was in the prompt. (The tail includes the closing
# </documents> — harmless for the E2E sentinel assertions.)
# Phase 37: the HIGH prompt now ends with the <tools> section
# after </documents>, so the echo targets the <documents> block
# itself — the sentinel semantics are unchanged.
block = _DOCUMENTS_BLOCK_RE.search(_system(body))
tail_source = block.group(0) if block else _context(body)
answer = (
f"…and the very end of my notes reads: “{tail_source[-160:]}” "
"(Deterministic mock answer for E2E.)"
)
else:
ctx = _context(body)
snippet = ctx[:220].replace("\n", " ").strip()
answer = (
f"Great question — you've absolutely got this! Here's what my notes say about "
f"“{user.strip()[:80]}”: {snippet}… That's the gist from the docs; happy to "
"dig into any of it. (Deterministic mock answer for E2E.)"
)
# Steering (phase 15): when the system prompt carries <tuning>, the
# answer ends with the first note — deterministically observable.
note = first_tuning_note(system)
if note:
answer = f"{answer} (tuning: {note})"
# KB overview (phase 31): when the system prompt carries
# <knowledge_base>, the answer ends with the first outline bullet —
# mirrors the steering echo exactly (appended after it, so the kb
# suffix is the last thing rendered).
bullet = first_kb_bullet(system)
if bullet:
answer = f"{answer} (kb: {bullet})"
return answer
def compose_thinking(body: dict[str, Any]) -> str:
"""Deterministic reasoning scratchpad (thinking-display story, phase 17;
lengthened in phase 21).
A fixed "Step 1… Step 4" template interleaved with a "Scratch" deep-dive
block, quoting the first ~60 chars of the user question: unique per
question, byte-stable across runs, ~2 700 chars total (≈ 230 frames at
the mock's 12-char/0.02s pacing). The length is deliberate (phase 21,
thinking-no-scroll story): rendered in the 320px ``.thinking-text``
window it overflows by ~2x, so the live-tail clip and the no-user-scroll
contract are observable in E2E. The ``Step 2: Check my notes`` line
fragment (phase 17) and the ``nothing is invented`` tail (phase 20's
THINKING_TAIL) are what the E2E assertions key off — both are preserved.
"""
q = _user(body).strip()[:60]
return (
f"Step 1: Read the question carefully — “{q}” — and figure out what kind of "
"answer it wants (a how-to, a lookup, or a design decision) before touching "
"the docs, so I don't over- or under-answer.\n"
"Step 2: Check my notes for the closest match. The homelab kubernetes file "
"is the obvious candidate, but I should also consider whether a deployments "
"note covers the same ground better.\n"
"Scratch 1: the kubernetes file is organized by component — control plane, "
"worker nodes, ingress, storage — so I can map each part of the question to "
"a section instead of summarizing the whole file at once, and keep the "
"answer anchored to the structure the notes actually use.\n"
"Scratch 2: I should check whether the deployments note duplicates any of "
"that ground; if it does, I will prefer the homelab file because the "
"question is phrased around the cluster itself, and I will say which file "
"each fact came from so the citation is honest.\n"
"Scratch 3: versions and ports are the facts most likely to be stale in my "
"memory — the etcd backup schedule, the ingress controller port, the "
"registry mirror address — so I will re-read those lines verbatim before "
"writing a single one of them into the answer.\n"
"Scratch 4: if the answer needs a sequence, for example how a node joins the "
"cluster or how the load balancer fronts the control plane, I will keep the "
"order exactly as the notes write it rather than re-deriving it from general "
"kubernetes knowledge that may not match this setup.\n"
"Scratch 5: anything I cannot find in the notes — a host I do not recognize, "
"a version I am not sure about, a schedule I cannot place — gets left out of "
"the answer instead of guessed, because the honesty rule beats a longer "
"answer every single time.\n"
"Scratch 6: one more pass over the question wording to make sure I am "
"answering the cluster setup, not some other homelab topic that shares the "
"same vocabulary, and I will stay on the specific the question asked about.\n"
"Scratch 7: I will also verify that the file describes the current setup — "
"if the notes mention a migration from an older cluster, I should answer "
"from the post-migration section and not mix in the old host names or the "
"old port numbers that no longer apply.\n"
"Scratch 8: final shape check before I commit — short paragraphs, a few "
"bullets at most, the document path cited where the fact came from, and no "
"invented facts anywhere in the draft.\n"
"Step 3: Re-read the relevant sections top to bottom so every specific — "
"hosts, versions, ports, schedules — is exact as written rather than "
"remembered, and note which document each fact comes from.\n"
"Step 4: Draft the answer around those specifics, keep it tight with short "
"paragraphs and bullets where it helps, cite the documents by path, and "
"double-check that nothing is invented."
)
@app.post("/__shutdown__")
def shutdown() -> dict[str, Any]:
"""Test hook (loading-feedback story): terminate this mock process to
simulate an LLM outage. The E2E fixture restores a fresh instance on
the same port afterwards, so the rest of the session keeps working."""
def _die() -> None:
time.sleep(0.1) # let the HTTP response flush before we exit
os.kill(os.getpid(), signal.SIGTERM)
threading.Thread(target=_die, daemon=True).start()
return {"status": "shutting down"}
@app.get("/v1/models")
def models() -> dict[str, Any]:
return {
"object": "list",
"data": [
{"id": "turbo", "object": "model"},
{"id": "embed", "object": "model"},
{"id": "lite", "object": "model"},
],
}
@app.post("/v1/embeddings")
def embeddings(body: dict[str, Any]) -> dict[str, Any]:
raw = body.get("input")
if isinstance(raw, str):
raw = [raw]
inputs: list[Any] = list(raw) if isinstance(raw, list) else []
data = [
{"object": "embedding", "index": i, "embedding": embed_text(t)}
for i, t in enumerate(inputs)
]
return {
"object": "list",
"data": data,
"model": body.get("model", "embed"),
"usage": {"prompt_tokens": 8, "total_tokens": 8},
}
def _sse_stream(
answer: str, delay: float, thinking: str = "", pre_content_delay: float = 0.0
) -> Any:
"""SSE frames for one chat completion (phase 17: + reasoning).
When ``thinking`` is non-empty its 12-char slices go out FIRST as
``delta.reasoning_content`` frames — same 0.02s cadence and envelope
as the content frames, the aipi wire convention (reasoning before
content). Without ``thinking`` the output is byte-identical to the
content-only stream, so the other story suites are unaffected.
``pre_content_delay`` (phase 20) inserts a silence gap between the end
of the thinking stream and the first content frame — the client stays
in its pre-token "thinking" state the whole time (0.02s cadence and
frame shapes are unchanged, so 0.0 is byte-identical to before).
"""
model = "turbo"
chunk_id = f"chatcmpl-{uuid.uuid4()}"
if delay:
time.sleep(delay)
for piece in re.findall(r".{1,12}", thinking, re.S):
payload = {
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [
{"index": 0, "delta": {"reasoning_content": piece}, "finish_reason": None}
],
}
yield f"data: {json_dumps(payload)}\n\n"
time.sleep(0.02)
if pre_content_delay:
time.sleep(pre_content_delay)
for piece in re.findall(r".{1,12}", answer, re.S):
payload = {
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{"index": 0, "delta": {"content": piece}, "finish_reason": None}],
}
yield f"data: {json_dumps(payload)}\n\n"
time.sleep(0.02)
yield (
"data: "
+ json_dumps(
{
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
}
)
+ "\n\n"
)
yield "data: [DONE]\n\n"
def json_dumps(obj: dict[str, Any]) -> str:
import json
return json.dumps(obj)
def _apply_max_tokens(answer: str, max_tokens: Any) -> str:
"""Deterministic stand-in for the endpoint's output cap: one token ≈
one whitespace-separated word. Answers within the cap pass through
byte-identical, so existing (short) answers are unaffected."""
if not isinstance(max_tokens, int) or max_tokens <= 0:
return answer
words = answer.split()
if len(words) <= max_tokens:
return answer
return " ".join(words[:max_tokens])
def _tool_call_stream(name: str, arguments: dict[str, Any], call_id: str) -> Any:
"""SSE frames for one tool-call-only chat completion (phase 37).
The OpenAI wire convention the app accumulates (``app/rag/llm.py``):
the first partial of index 0 carries ``id`` + ``type`` +
``function.name`` plus the first ``function.arguments`` fragment;
the remaining fragments (deterministic 16-char split — so the
multi-fragment accumulation path is exercised) arrive on later
chunks; the final chunk carries ``finish_reason: "tool_calls"``.
No ``content`` / ``reasoning_content`` frames — the turn asked for a
tool instead of answering.
Pacing: 0.1 s per frame — deliberately SLOWER than the content
stream's 0.02 s, so the UI's transient "calling tool" state (held
from the first ``tool`` frame until the first answer ``delta``) is a
comfortable observation window for the story E2E (~1 s across the
two tool requests).
"""
model = "turbo"
chunk_id = f"chatcmpl-{uuid.uuid4()}"
raw_args = json_dumps(arguments) if arguments else "{}"
frags = [raw_args[i : i + 16] for i in range(0, len(raw_args), 16)] or ["{}"]
for i, frag in enumerate(frags):
tc: dict[str, Any] = {"index": 0, "function": {"arguments": frag}}
delta: dict[str, Any] = {"tool_calls": [tc]}
if i == 0:
tc = {
"index": 0,
"id": call_id,
"type": "function",
"function": {"name": name, "arguments": frag},
}
delta = {"role": "assistant", "tool_calls": [tc]}
payload = {
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{"index": 0, "delta": delta, "finish_reason": None}],
}
yield f"data: {json_dumps(payload)}\n\n"
time.sleep(0.1)
yield (
"data: "
+ json_dumps(
{
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
}
)
+ "\n\n"
)
yield "data: [DONE]\n\n"
@app.post("/v1/chat/completions")
def chat_completions(body: dict[str, Any]) -> Any:
user_lower = _user(body).lower()
# Phase 37 (agent document tools): the deterministic marker flow.
# The app's chat path is the only streaming consumer of this mock, so
# the flow handles streaming requests; a non-streaming marker request
# (never issued by the app) falls through to the regular answer.
if body.get("stream"):
flow = _tool_flow(body)
if flow is not None:
if flow[0] == "list":
stream = _tool_call_stream("list_documents", {}, "call_0")
elif flow[0] == "read":
stream = _tool_call_stream(
"read_document",
{"source": flow[1], "path": flow[2]},
"call_1",
)
else: # "answer" — quote the read document (first 80 chars)
answer = _apply_max_tokens(
f"Read {flow[1]}. {flow[2][:80]}", body.get("max_tokens")
)
stream = _sse_stream(answer, 0.0)
return StreamingResponse(
stream,
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
answer = _apply_max_tokens(compose_answer(body), body.get("max_tokens"))
delay = 3.0 if "pretend to think slowly" in _user(body) else 0.0
thinking = compose_thinking(body) if THINKING_TRIGGER in user_lower else ""
pre_content = (
PRE_CONTENT_PAUSE_S if SLOW_PRETOKEN_TRIGGER in user_lower else 0.0
)
if not body.get("stream"):
message: dict[str, Any] = {"role": "assistant", "content": answer}
if thinking:
# Harmless future-proofing: the app only uses streaming, but a
# non-streaming client that reads the field gets the reasoning.
message["reasoning_content"] = thinking
return {
"id": f"chatcmpl-{uuid.uuid4()}",
"object": "chat.completion",
"created": int(time.time()),
"model": body.get("model", "turbo"),
"choices": [
{"index": 0, "message": message, "finish_reason": "stop"}
],
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
}
return StreamingResponse(
_sse_stream(answer, delay, thinking=thinking, pre_content_delay=pre_content),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)