Skip to content

P0: streaming /v1/chat/completions never emits tool_calls — tool calls arrive as raw <tool_call> text in delta.content, finish_reason "stop" #4918

Closed

Description

@alfredodeza

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)

  1. Tool-call parsing runs only on the non-streaming path. build_chat_response (crates/aprender-serve/src/api/openai_handlers.rs:698) calls build_tool_calling_message (:621) when request.tools is 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.
  2. Neither SSE builder receives request.tools. true_streaming_sse_response (openai_handlers.rs:959; used by the qwen35, CUDA, CPU-quantized and MoE arms) and pregenerated_sse_response (:842; used by the GPU, cached, APR, registry and MoE-GPU arms) write every decoded delta as content. They pick the terminal finish_reason with FinishReason::from_generation, which can only return stop or length.
  3. The streaming wire type has nowhere to put a call. ChatDelta (crates/aprender-serve/src/api/mod_create_demo.rs:801) has only role and content, with no tool_calls field.
  4. 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 to true_streaming_sse_response (:163) and only the non-streaming branch to build_chat_response (:221).

Impact

Fix shape

  • Apply tool-call detection to both SSE builders, after the existing ChatStopFilter, and only when the request declares tools and tool_choice is 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_message the 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_calls chunk (index, id, type: "function", function.name, function.arguments as a JSON string), and set the terminal chunk's finish_reason to "tool_calls".
  • If the captured text parses to no call (an undeclared tool, or a mere mention of the tag), flush it as content so 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 no content delta carries any part of the call, that exactly one delta.tool_calls[0] arrives with name == "bash" and arguments {"command":"ls -la ~/tmp/"}, and that the terminal finish_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/completions request with tools, against a Qwen3.5 GGUF on CUDA, returns delta.tool_calls and finish_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.

Refs #4650, #4866, #2086 (PMAT-801).

Activity

  1. added
    bugSomething isn't working
    P0Critical priority
    on Oct 8, 2026
  2. added this to the 0.71.0 milestone on Oct 8, 2026
  3. noahgift commented on Oct 9, 2026

    @noahgift
    Member

    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.1 tag 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 --locked from crates.io → apr 0.70.3 (95f64ba14)
    • apr serve run Qwen3.5-4B-Q4_K_M.gguf --gpu-layers all on an RTX 4090 (CUDA backend, 32/32 layers on GPU)
    • the request from this issue with "stream": true: 1 delta.tool_calls frame (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 a make t10-live target to re-run it) in a PR to main once the release branch is merged back.

  4. modified the milestones: 0.71.0, 0.70.3 on Oct 9, 2026
  5. alfredodeza commented on Oct 14, 2026

    @alfredodeza
    ContributorAuthor

    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?

  6. added
    P1High priority
    and removed
    P0Critical priority
    on Oct 14, 2026
  7. noahgift commented on Oct 14, 2026

    @noahgift
    Member

    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.

  8. modified the milestones: 0.70.3, 0.73.0 on Oct 14, 2026
  9. noahgift commented on Oct 22, 2026

    @noahgift
    Member

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High prioritybugSomething isn't workingsize:MK̂ 3-4 h

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions