stack 4/5: keep an explicit thinking disable through translation (#545) - #954
stack 4/5: keep an explicit thinking disable through translation (#545)#954lidge-jun wants to merge 3 commits into
Conversation
) Claude Desktop 3P Auto Mode sends thinking:{type:"disabled"} with max_tokens:64 and a stop sequence. Inbound translation dropped the instruction — reasoning stayed undefined, indistinguishable from a request that never mentioned thinking — so the outbound Anthropic body omitted the field entirely. For Sonnet 5 an omitted thinking field means adaptive thinking is ON, and thinking shares max_tokens, so generation ran out of budget before it could emit </block>. Claude Code then retried, up to five times per tool approval. The gate is deliberately its own predicate rather than usesAdaptiveThinking(), which answers a different question: Fable always thinks and rejects an explicit disable, while Opus 4.7/4.8 leave thinking off when the field is omitted. Widening it would trade a silent truncation for a 400. Refs #545
A modelMap entry can point at a routed destination like anthropic/claude-sonnet-5, which custom-provider routing decodes back into a slash-carrying native id. Both capability predicates anchored on ^claude-, so those requests silently missed the gate and the model thought anyway — the exact defect, just harder to see. Extracted the shared family/version parse so usesAdaptiveThinking() gets the same tolerance, and pinned all four id shapes plus a prefixed negative case. Also pins the Cursor effect: an explicit "none" now selects the lowest tier rather than the top one. Cursor has no off switch for a reasoning model, and the lowest tier is the closest honest reading of "do not think" — dropping the instruction sent these to the maximum tier, the opposite of what the caller asked for.
#545) The previous normalization took the last slash-separated segment, which fixed anthropic/claude-sonnet-5 and broke claude-sonnet-5/variant — a custom provider can expose a native id where the slash carries a vendor suffix rather than a routing prefix. That regression was worse than the bug: the adaptive-wire predicate shares this parse, so a slash-suffixed Sonnet 5 would have been sent obsolete manual thinking.enabled and 400d. Match the segment that actually begins with claude-, at either boundary, and pin both directions plus a double prefix and the adaptive-shape cases.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (2)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
Stack navigation
Review and merge bottom-up. Each PR targets the preceding stack branch, so its Files changed view contains only that layer. The layers touch disjoint files — #954 needs human security review per Carried in #953, with authorship preserved: #939, #942, #943, #944, #945, #948. |
Stack
4/4 — Claude Desktop classifier thinking round-trip
Base:
codex/carry-contributor-bugfixes(#953)Summary
Claude Desktop 3P Auto Mode sends
thinking: {type: "disabled"}withmax_tokens: 64and a</block>stop sequence. The instruction was dropped in translation, so the model thought anyway and spent the 64-token budget before it could close the tag — and Claude Code retried, up to five times per tool approval. The reporter measured 1,084 truncated requests against 143 that completed.src/claude/inbound.ts— an explicit disable is preserved as the parser's"none"sentinel instead of becomingundefinedsrc/adapters/anthropic.ts— emitthinking: {type: "disabled"}for models that both default to thinking-on and accept the explicit disableThe standing hypothesis was impossible
The recorded analysis on #545 said the prepended OAuth identity block consumed the classifier's 64 output tokens. It cannot: the identity goes into the system prompt, and
max_tokenscaps output. Different budgets. This repository had already reached that conclusion once —devlog/_fin/260728_bug_bundle_resolution/030_claude_system_dedup.mdabandoned an identity-dedup patch for exactly this reason — and I re-derived the rejected theory before testing it.The real chain is a round-trip fidelity loss:
src/claude/inbound.ts:494treateddisabledas nothing to translate, leavingreasoningundefinedtests/claude-inbound.test.tspinned both toundefinedthinkingonly for a real non-noneeffort, so the field was omitted outboundthinkingfield means adaptive thinking is ON, and thinking tokens count againstmax_tokensThe client asked for no thinking and got thinking. That also explains the shape of the reporter's data: the 143 requests that completed are the ones where thinking happened to stay short.
The gate is deliberately narrow
usesAdaptiveThinking()looks like the right predicate and is not — it answers which wire shape a family accepts, not whether omission means thinking-on. The sets differ both ways: Fable always thinks and rejects an explicit disable, while Opus 4.7/4.8 use the adaptive wire but leave thinking off when omitted. Reusing it would have required breaking a passing test (tests/anthropic-reasoning.test.tsassertsclaude-fable-5+"none"sends no thinking config) in order to ship a production 400.So
supportsExplicitThinkingDisable()is seeded withsonnet: [5,0]only. Widen it per family with vendor evidence.What the audit caught
Three rounds, two FAIL. Both findings were reproduced at runtime before fixing.
Round 1 —
anthropic/claude-sonnet-5silently missed the gate. AmodelMapentry can point at a routed destination that routing decodes back into a slash-carrying native id, and the predicate anchored on^claude-.Round 2 — my own round-1 fix was worse than the bug. Normalizing with
lastIndexOf("/")repaired the prefix case and broke the suffix case (claude-sonnet-5/variant), and because the adaptive-wire predicate shares that parse, such a model would have been sent obsolete manualthinking.enabledand 400d. A silent truncation traded for a hard failure.Both now match the segment that actually begins with
claude-, at either boundary, with all shapes pinned in the matrices — including a new adaptive-shape test covering the 400 path.Cross-provider note
A
modelMapthat routes such a request to Cursor now selects the model's lowest tier rather than its top one. Cursor has no off switch for a reasoning model, so the lowest tier is the closest honest reading of "do not think" — the previous behavior sent these to the maximum tier, the opposite of the caller's instruction. Pinned intests/cursor-effort-suffix.test.tsso it is deliberate rather than emergent.Verification
bun x tsc --noEmit— exit 0bun run test— 7711 pass, 8 skip, 0 fail, 507 filesbun run privacy:scan— passedTwo honest limits
The tests prove the wire shape, not that the retries stop. Confirming that needs a live Claude Desktop 3P + Anthropic OAuth session showing the classifier terminating on
</block>instead ofmax_tokens. I have asked the reporter rather than claiming the symptom fixed.This changes request construction on an Anthropic OAuth execution path, so
MAINTAINERS.mdrequires explicit human security review — no credential handling is touched, but the boundary is. Please do not merge on CI alone.Refs #545