Repository navigation
P0: streaming /v1/chat/completions never emits tool_calls — tool calls arrive as raw <tool_call> text in delta.content, finish_reason "stop" #4918
Description
Severity: P0
Tool calling over /v1/chat/completions works only when "stream": false. With "stream": true, apr sends the model's tool call to the client as ordinary text: the <tool_call>…</tool_call> markup arrives in delta.content and the stream ends with finish_reason: "stop". Agent clients (OpenCode, and in practice any agent harness) always stream, so they show the raw call to the user and never run it.
Reproduction (apr 0.70.2 89e261cda, CUDA build, RTX 4090)
apr serve run ~/Downloads/Qwen3.5-4B-Q4_K_M.gguf --gpu # chat template: Qwen3NoThink{"model":"qwen3-coder",
"messages":[{"role":"user","content":"what are the directories in ~/tmp/"}],
"tools":[{"type":"function","function":{"name":"bash","description":"Run a shell command",
"parameters":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}}}],
"max_tokens":200,"temperature":0}"stream": false: correct.
"message": {"role":"assistant","content":"",
"tool_calls":[{"id":"call_0","type":"function",
"function":{"name":"bash","arguments":"{\"command\":\"ls -la ~/tmp/\"}"}}]},
"finish_reason": "tool_calls""stream": true: broken. The same generation (30 completion tokens) comes back as content deltas:
data: {..."delta":{"role":"assistant"}...,"stream_mode":"live"}
data: {..."delta":{"content":"<tool_call>"}...}
data: {..."delta":{"content":"\n"}...}
data: {..."delta":{"content":"<function"}...}
data: {..."delta":{"content":"=b"}...}
data: {..."delta":{"content":"ash"}...}
… <parameter=command>\nls -la ~/tmp/\n</parameter>\n</function> …
data: {..."delta":{"content":"\n</tool_call>"}...}
data: {..."delta":{},"finish_reason":"stop"}
data: [DONE]
In OpenCode the assistant turn renders as the literal text <tool_call>\n<function=bash>\n<parameter=command>…, and no tool runs.
Root cause (traced in code on main @ 828ccb845)
- Tool-call parsing runs only on the non-streaming path.
build_chat_response(crates/aprender-serve/src/api/openai_handlers.rs:698) callsbuild_tool_calling_message(:621) whenrequest.toolsis set. PMAT-801 (feat(serve): wire tool/function-calling into OpenAI /v1/chat/completions (non-streaming) — PMAT-801 #2086) was explicitly scoped "(non-streaming)", and P0: apr serve run never passes tools into the Qwen3.5 chat template — tool calling silently dead for GGUF hybrid sessions #4650 / fix(serve): request tools reach the chat template; Qwen3.5 XML tool calls parse (#4650) #4866 added Qwen3.5<function=…>XML parsing to that same non-streaming path only. - Neither SSE builder receives
request.tools.true_streaming_sse_response(openai_handlers.rs:959; used by the qwen35, CUDA, CPU-quantized and MoE arms) andpregenerated_sse_response(:842; used by the GPU, cached, APR, registry and MoE-GPU arms) write every decoded delta ascontent. They pick the terminalfinish_reasonwithFinishReason::from_generation, which can only returnstoporlength. - The streaming wire type has nowhere to put a call.
ChatDelta(crates/aprender-serve/src/api/mod_create_demo.rs:801) has onlyroleandcontent, with notool_callsfield. - For the reported model, the request goes through
try_qwen35_backend(crates/aprender-serve/src/api/qwen35_chat_backend.rs:85). It sends the streaming branch totrue_streaming_sse_response(:163) and only the non-streaming branch tobuild_chat_response(:221).
Impact
- P0: apr serve run never passes tools into the Qwen3.5 chat template — tool calling silently dead for GGUF hybrid sessions #4650 / fix(serve): request tools reach the chat template; Qwen3.5 XML tool calls parse (#4650) #4866 made the model emit correct tool calls (tools now reach the Qwen3.5 template). Streaming clients still can't receive them, so for OpenCode-style agents, tool calling through
apr servedoesn't work end to end. - It fails silently: the server returns HTTP 200 with well-formed SSE, and the defect only shows up as wrong client behaviour.
- The path is shared, so this affects every backend arm that streams, not just Qwen3.5.
Fix shape
- Apply tool-call detection to both SSE builders, after the existing
ChatStopFilter, and only when the request declarestoolsandtool_choiceis not"none". - Stream text live, but hold back any tail that could still grow into a
<tool_call>or<function=marker. Once a marker appears, capture the rest of the output. - At end of stream, parse the captured text with the same
build_tool_calling_messagethe non-streaming path uses, so the two paths can't disagree about what counts as a call. - Emit the calls as an OpenAI streaming
delta.tool_callschunk (index,id,type: "function",function.name,function.argumentsas a JSON string), and set the terminal chunk'sfinish_reasonto"tool_calls". - If the captured text parses to no call (an undeclared tool, or a mere mention of the tag), flush it as
contentso nothing is lost. - Without tools, or with
tool_choice: "none", the stream must stay byte-identical to today's.
Acceptance / falsifiers
- A test replays the exact delta sequence above (the markers split across tokens) through
true_streaming_sse_response. It asserts that nocontentdelta carries any part of the call, that exactly onedelta.tool_calls[0]arrives withname == "bash"and arguments{"command":"ls -la ~/tmp/"}, and that the terminalfinish_reason == "tool_calls". Making the detector a pass-through must turn this test RED. - With tools declared, a plain-text answer still streams token by token and finishes
"stop". - Live check: a streaming
/v1/chat/completionsrequest with tools, against a Qwen3.5 GGUF on CUDA, returnsdelta.tool_callsandfinish_reason: "tool_calls".
Out of scope
The non-standard /v1/chat/completions/stream endpoint (chat_completions_stream.rs) and the Ollama-compat /api/chat stream (see #4182) have the same gap. Either track them here as follow-ups or split them into their own issues.
Why P0
OpenCode and other agentic clients default to streaming against OpenAI-compatible endpoints. #4650 was P0 because tool calling was silently dead for Qwen3.5 GGUF sessions; this is the other half of that defect, and until it's fixed the #4650 fix has no effect for every streaming client.
Activity
release(0.70.3): crux-smoke emergency scope and carried prompt certif…
release(0.70.3): release notes with the known-failures table
chore(release): bump workspace to 0.70.3; CHANGELOG [0.70.3]
noahgift commentedon Oct 9, 2026
Refs #4918 — status on 0.70.3 (live on crates.io and as a GitHub release, tag
v0.70.3= 95f64ba).Release candidate (rc-cut): no candidate was cut. rc-cut run 37888579970 (fired from green CI run 37882586765 on the release PR head) reached "cut v0.70.3-rc.1" with fleet cells ok, then got HTTP 422 "Reference update failed" when creating the tag. No
v0.70.3-rc.1tag or release exists; the final was tagged directly from the same commit.T10 (FALSIFY-SSTC-010) on the published crate: PASS, 2026-10-09T08:47Z.
cargo install aprender --version =0.70.3 --features cuda --lockedfrom crates.io →apr 0.70.3 (95f64ba14)apr serve run Qwen3.5-4B-Q4_K_M.gguf --gpu-layers allon an RTX 4090 (CUDA backend, 32/32 layers on GPU)"stream": true: 1delta.tool_callsframe (bash,ls -la ~/tmp/),finish_reason: "tool_calls", 0 content deltas containing<tool_call>The raw SSE, request and receipt land in
evidence/release/0.70.3/t10/(with amake t10-livetarget to re-run it) in a PR to main once the release branch is merged back.release: 0.70.3 — streaming tool calls (paiml#4927)
feat(ttft): the V1 TTFT ratio verdict, the TTFT split by phase, and a…
alfredodeza commentedon Oct 14, 2026
I think this ticket can be closed, verified on my end. Or perhaps we need some sort of an answer that explains why this isn't closed, maybe it is missing something else?
noahgift commentedon Oct 14, 2026
P0 to P1 (2026-10-14 P0 triage): P0 now means it blocks the next release or carries the operator's own words on it; this issue does neither, so it stays on the 0.70.3 milestone at P1.
noahgift commentedon Oct 22, 2026
Solved on main: release 0.70.3 (acd599e, #4927): crates/aprender-serve/src/api/stream_tool_calls.rs streams tool_calls with finish_reason tool_calls; CHANGELOG 'Refs #4918'; reporter verified. Triage 2026-10-22.