feat: surface reasoning-only truncation on the chat completions API - #1746
Conversation
A reasoning model whose generation exhausts max_tokens or its reasoning_budget before closing <think> leaves `content` empty with no signal that anything else was coming. lablup#1721 named this condition for the CLI's generate/chat REPL; lablup#467 logs it server-side, but only for prompts that primed an open thinking block. Neither reaches the HTTP API, which is what most real integrations actually hit. Add reasoning_only: Option<bool> to ChatMessage and Delta, additive and omitted unless true, set via a generalized check (any request, primed or not) that reuses reasoning_stream::is_reasoning_only. Closes lablup#1745 Refs lablup#467 Refs lablup#1721 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fmmt7cCRCmiwmTbWrEq8x
f4c8b34 to
f6295ec
Compare
The new field asserts that a generation's output stayed in the reasoning channel, but both paths decided it from `is_reasoning_only` alone, which only means "raw text non-empty and shaped content empty". Two ordinary responses match that with no reasoning having happened. A streamed tool call is the bigger one. `FilterState::ToolCall` suppresses the whole payload from `delta.content`, so a model answering with nothing but a tool call, the ordinary shape, ends with `saw_content` false and a non-empty `result.text`. The terminal chunk then carried `reasoning_only: true` beside `finish_reason: "tool_calls"`. The non-streaming path already excluded its tool-calls arm by hand, so the two surfaces disagreed on the same turn. The second is output of nothing but structural markers, which `clean_structural_tokens` reduces to empty content with no thinking block to extract: `reasoning_content` absent, yet still reported reasoning-only. Gemma 4 emits exactly this when a request carries no tools. Both paths now require that reasoning reached the client. The non-streaming check gates on the shaped `reasoning_content`; the streaming side tracks `saw_reasoning_content` over the same finalized chunk batch as `saw_content`, flush included, and the finish-time decision moves into `stream_reasoning_only` so its three conditions are testable without a live model. This cannot suppress a true positive: `deepseek` and `auto` populate `reasoning_content`, while `none` and `deepseek-legacy` keep thoughts in `content`, which is then non-empty and fails the emptiness check anyway. Reverting each gate separately fails exactly its own test and no others. fmt and clippy pass. Refs lablup#1745, lablup#1746
Hardening pass on this PRReviewed against #1745 (and the #467 / #1721 framing). The design holds: an additive Streaming tool calls were flagged as reasoning-only
Marker-only output claimed a channel it never used
FixBoth paths now require that reasoning reached the client: non-streaming gates on the shaped Validation31 targeted tests pass. Reverting each gate separately fails exactly its own test and no others. Not re-run here: your real-checkpoint check against Security and performance: no auth, input-validation, or allocation behavior touched; the added work is one bool per chunk batch on a path that already scanned it. Refs #1745 |
Summary
Adds an additive
reasoning_only: truefield to chat completion responses (both streaming and non-streaming) when a generation produced tokens butcontentcame back empty because everything stayed in the reasoning channel — today that case is silent and indistinguishable from a clean, intentionally empty response.Related issues
Closes #1745
Refs #467
Refs #1721
(Neither #467 nor #1721 is a full duplicate: #467's fix only logs server-side, and only when the prompt itself primed an open thinking block; #1721 only fixed the CLI's
generate/chatdisplay. This generalizes past both to cover the far more common shape — a plain, unprimed request that opens its own<think>block and exhausts its token budget — and surfaces it to the actual API response, not just a log or a terminal.)Type of change
feat— new user-visible featureTest plan
cargo fmt --all -- --checkcargo clippy --workspace --all-targets --features metal,accelerate -- -D warningscargo test --workspace --profile test-fast --features metal,accelerate --no-fail-fast -- --test-threads=1(full workspace: 10,605 passed, 0 failed)cargo deny checkmlx-community/Qwen3.5-4B-MLX-4bit. Built baseline and patched release binaries from the same commit (stash/build/pop, mirroringab_output_equality.sh's own pattern) and compared live server responses:content:"", finish_reason:"length", no signal. After → same, plus"reasoning_only": true.delta:{}on the finish chunk. After →delta:{"reasoning_only":true}.--reasoning-budget 40, reachesfinish_reason:"stop"): field correctly absent in both paths, before and after — no regression on the common case.Notes for reviewers
finish_reason, matching the precedent already set forreasoning_content— existing OpenAI-schema clients that validatefinish_reasonas an enum are unaffected.reasoning_stream::is_reasoning_only(added for feat(cli): say when a generation went entirely to the reasoning channel #1721) is reused as-is withshow_reasoning: falsefixed at both server call sites — the server never suppresses reasoning into a hidden channel, so passingfalsecorrectly fires whenevercontentis empty and generation happened, independent of priming.contentis actually anomalous; deliberately not attached in the tool-calls-succeeded arm, where emptycontentis normal/expected by OpenAI convention.StreamCallbackState::saw_content, updated from a single check over the finalized per-token chunk batch (chunk_carries_content) plus the end-of-stream flush, read once at the terminal chunk.primed_thinking_unclosedkeeps its original job (drivingstrip_unclosed_primed_thinking's content-emptying) — only the log that used to key off it directly was generalized.Checklist
feat:)docs/: N/A — no existing doc describes the chat completions response schema field-by-field// Used by: ...— extendedis_reasoning_only's doc comment to name the new server call site.envfiles committed