Repository navigation
fix: hide Gemma 4 reasoning channel from CLI output - #889
Conversation
Gemma 4 checkpoints open a `<|channel>thought ... <channel|>` reasoning channel that the model emits inline with the answer. The CLI detokenizer decodes with `skip_special_tokens = false`, so the raw channel markers and the whole chain-of-thought printed to the terminal as if they were the answer (issue #884). The server chat-completions path already routed this into `reasoning_content`; only the CLI output path was unhandled. Add `mlxcel::reasoning_stream`, a small streaming reasoning-channel splitter driven by the tokenizer's resolved `ThinkingMarkers` (`<|channel>thought` / `<channel|>` for Gemma 4, `<think>` / `</think>` for Qwen-style models). It is the CLI counterpart to the server's `StreamFilter` but deliberately narrower: it splits only the reasoning channel and leaves every other byte untouched, so an answer that opens no channel passes through byte-identically (no tool-call or turn-marker handling). Because the open marker is the full `<|channel>thought`, the `thought` channel argument is consumed as part of the marker and never surfaces in the reasoning text. A marker that straddles two decoded fragments is held back until the next fragment resolves it, and the end-of-stream flush keeps a never-closed thought block hidden without losing or duplicating any text. Wire the splitter into both CLI output paths: the interactive REPL streaming callback in `commands::chat` (fed token-by-token, with the detokenizer tail flushed through the filter) and the one-shot `commands::generate` flow (fed the whole decoded reply before printing). A non-thinking model resolves no markers, so the filter is an inert passthrough and output is byte-identical to the pre-fix path. Add a `--show-reasoning` flag (default off) on the shared generation options so it reaches both `mlxcel run` and `mlxcel generate`, plus the chat REPL via `ChatOptions`. When off, the reasoning channel is hidden and only the final answer prints; when on, the reasoning is also printed (dimmed on a TTY). The raw channel markers never print either way. Validation: `cargo test --lib reasoning_stream` (14 unit tests) covers thought-then-answer, answer-only passthrough, an unclosed thought, token-by-token feeding with markers split across fragments, and the `--show-reasoning` surface. `cargo check --lib`, `cargo check --bin mlxcel`, and `cargo clippy --lib --tests` are clean. End-to-end model-run verification on the GB10 CUDA host is left to the orchestrator. Refs #884
The CLI enables thinking for Qwen-style models by rendering a prompt that ends with an open marker (`<think>\n`, or `<|channel>thought\n` for a thinking-on Gemma-4 channel). That open marker is part of the prompt, not the generated tokens, so the generated text begins already inside the reasoning block and the first marker the model emits is the close (`</think>` / `<channel|>`). The `ReasoningFilter` always started in `Content`, so it never matched the absent open marker and printed the whole chain-of-thought plus the raw close marker to the terminal, the same leak issue #884 fixes for a model-opened channel. Add `ReasoningFilter::new_primed_open_thinking` (starts in the reasoning state) and the `prompt_primed_open_thinking` suffix check, mirroring the server's `is_prompt_primed_open_thinking` / `StreamFilter::new_primed_open_thinking`. The interactive REPL (`stream_turn`) and the one-shot `generate` path (`filter_reasoning_for_display`, both print sites) now detect a primed-open prompt and start the filter inside the channel, so the primed reasoning and its raw close marker stay off the terminal by default and surface only under `--show-reasoning`. Gemma-4 thinking-OFF prompts end with the closed scaffold and are not detected as primed, so the primary #884 path is unchanged. Adds seven unit tests covering primed-open split, unclosed hide, token-by-token close, the Gemma channel, the non-thinking passthrough, suffix detection, and `render_full`. Refs #884
Implementation Review SummaryIntent
The core Gemma-4 fix (issue #884's primary target) is correct, complete, and well-tested. Review turned up one real leak in the PR's own claimed Qwen-style support, which I auto-fixed on the branch. Findings Addressed (auto-fixed, commit
|
Security & performance review (auto-fix)Scope: Result: no CRITICAL / HIGH / MEDIUM findings. No changes required. 1. Unbounded memory / buffer growth: safe
2. UTF-8 boundary safety: safeNo slice can land mid-codepoint. Fragments reach the filter only from 3. No output loss / duplication: safeThe LOW (informational, not changed)
|
Add two unit tests to src/reasoning_stream.rs: a stream with two separate reasoning blocks (the state machine must re-enter Thinking on a second open marker, not just once per filter lifetime), and a flush() call after a partial close marker is held back, verifying a second flush() is a no-op rather than replaying text. Document the CLI --show-reasoning flag and the default reasoning-hidden behavior in README.md (Run a model section) and docs/supported-models.md (new "CLI reasoning display" subsection alongside the existing server-side thinking/reasoning_content docs), since the flag previously had no documentation outside --help. Validation: - cargo test --lib reasoning_stream: 23 passed (was 21) - cargo check --lib --tests --bin mlxcel: clean - cargo clippy --lib --tests -- -D warnings: clean Refs #884, #889
PR Finalization CompleteSummary
All checks passing. Ready for merge. |
Summary
Gemma 4 checkpoints open a
<|channel>thought ... <channel|>reasoning channel inline with the answer. The CLI detokenizer decodes withskip_special_tokens = false, so the raw channel markers and the entire chain-of-thought printed straight to the terminal as if they were the answer (issue #884). The server chat-completions path already routed this intoreasoning_content; only the CLI output path was unhandled. This PR hides the reasoning channel from the visible CLI output by default and adds an opt-in flag to surface it.What changed
src/reasoning_stream.rs(mlxcel::reasoning_stream): a small streaming reasoning-channel splitter driven by the tokenizer's resolvedThinkingMarkers(<|channel>thought/<channel|>for Gemma 4,<think>/</think>for Qwen-style models). It is the CLI counterpart to the server'sStreamFilterbut deliberately narrower: it splits only the reasoning channel and leaves every other byte untouched, so an answer that opens no channel passes through byte-identically (no tool-call suppression, no turn-marker stripping).<|channel>thought, thethoughtchannel argument is consumed as part of the marker and never surfaces in the reasoning text (issue-requiredthoughttrim).src/commands/chat.rs: the interactive REPL streaming callback now feeds each decoded fragment through the splitter; the detokenizer tail is flushed through the filter too, replacing the oldreply.strip_prefixtail mechanism.src/commands/generate.rs: the one-shotgenerateflow feeds the whole decoded reply through the splitter before printing, at every autoregressive print site.src/main.rs: adds the--show-reasoningflag (default off) on the shared generation options so it reaches bothmlxcel runandmlxcel generate;ChatOptionscarries it into the REPL.Behavior
<|channel>/<channel|>/thoughttext appears. A direct answer that opens no channel is byte-identical to before (the filter is an inert passthrough for non-thinking models).--show-reasoning: the reasoning is also printed (dimmed on a TTY). The raw channel markers never print either way.New CLI flag
--show-reasoning(default off), on bothmlxcel runandmlxcel generate.Test plan
cargo test --lib reasoning_stream(14 unit tests): thought-then-answer (only the answer visible, reasoning captured), answer-only passthrough, an unclosed thought (reasoning hidden, no marker leak), token-by-token feeding with markers split across fragments, and the--show-reasoningsurface without raw markers.cargo check --libandcargo check --bin mlxcel: clean.cargo clippy --lib --tests: clean.gemma-4-e4b-it-qat-4bit) left to the orchestrator, per the build constraints on this host.Scope note
This PR addresses the CLI output leak, which is the primary bug. The minor server-side
reasoning_contentthoughtprefix noted in the issue is left untouched to avoid changing the server'sStreamFilterreasoning contract and its existing tests.Closes #884