From d1a525dcb3daf2d746c1b5223fef7db788ef11ca Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 4 Aug 2026 01:09:31 +0900 Subject: [PATCH 1/3] fix(claude): keep an explicit thinking disable through translation (#545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 . 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 --- src/adapters/anthropic.ts | 35 ++++++++++++++++++- src/claude/inbound.ts | 8 ++++- tests/anthropic-reasoning.test.ts | 57 +++++++++++++++++++++++++++++++ tests/claude-inbound.test.ts | 11 ++++-- 4 files changed, 106 insertions(+), 5 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 5aff9c4a0..80232e417 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -420,6 +420,32 @@ function usesAdaptiveThinking(modelId: string): boolean { return major > minimum[0] || (major === minimum[0] && minor >= minimum[1]); } +/** + * Claude families that (a) think by DEFAULT when the request omits `thinking`, + * and (b) accept an explicit `thinking: {type: "disabled"}` to turn it off. + * + * Deliberately NOT `usesAdaptiveThinking()`, which answers a different question + * (which wire shape a family accepts). The two sets differ in both directions: + * Fable always thinks and REJECTS an explicit disable, while Opus 4.7/4.8 use + * the adaptive wire but leave thinking off when the field is omitted, so they + * need no disable at all. Seeded with the family where the defect reproduces + * (#545); widen only with vendor evidence, since a wrong entry here turns a + * silent truncation into a 400. + */ +const EXPLICIT_THINKING_DISABLE_FAMILY_MINIMUMS: Record = { + sonnet: [5, 0], +}; + +function supportsExplicitThinkingDisable(modelId: string): boolean { + const match = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/.exec(modelId); + if (!match) return false; + const minimum = EXPLICIT_THINKING_DISABLE_FAMILY_MINIMUMS[match[1]]; + if (!minimum) return false; + const major = Number(match[2]); + const minor = match[3] === undefined ? 0 : Number(match[3]); + return major > minimum[0] || (major === minimum[0] && minor >= minimum[1]); +} + /** `output_config.effort` accepts low|medium|high|xhigh|max — "minimal" is rejected with a 400. */ function adaptiveEffort(effort: string): string { return effort === "minimal" ? "low" : effort; @@ -766,7 +792,14 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // `reasoning` is a Codex effort string; "none" is the disable sentinel (see parser.ts // REASONING_EFFORTS). A bare truthy check would treat "none" as truthy and wrongly enable // extended thinking (and strip temperature/top_p), so gate on a real, non-disable effort. - if (typeof parsed.options.reasoning === "string" && parsed.options.reasoning !== "none") { + // + // "none" is not the same as absent. Omitting `thinking` lets a default-on model think + // anyway, and thinking shares the caller's `max_tokens` — which truncates a small-budget + // request before it can emit its stop sequence (#545). Say "disabled" out loud where the + // model both defaults to thinking and accepts being told not to. + if (parsed.options.reasoning === "none" && supportsExplicitThinkingDisable(parsed.modelId)) { + body.thinking = { type: "disabled" }; + } else if (typeof parsed.options.reasoning === "string" && parsed.options.reasoning !== "none") { if (usesAdaptiveThinking(parsed.modelId)) { // Adaptive-thinking models replace the token budget with an effort knob and reject // `thinking.type: "enabled"` outright. `max_tokens` still caps thinking plus visible diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 86cdfc104..cda3799d7 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -498,7 +498,13 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode const thinking = raw.thinking; const outputConfigEffort = effortFromOutputConfig(raw.output_config); const thinkingDisabled = isRec(thinking) && thinking.type === "disabled"; - if (!thinkingDisabled && (isRec(thinking) || outputConfigEffort !== undefined)) { + if (thinkingDisabled) { + // An explicit "disabled" is an instruction, not an absence. Dropping it made this + // indistinguishable from a request that never mentioned thinking — and for models that + // think by default, omission means thinking is ON, sharing the caller's max_tokens (#545). + // "none" is the parser's disable sentinel (parser.ts REASONING_EFFORTS). + body.reasoning = { effort: "none", summary: "none" }; + } else if (isRec(thinking) || outputConfigEffort !== undefined) { const reasoning: Rec = { summary: "auto" }; if (outputConfigEffort !== undefined) { // Adaptive wire: /effort arrives as output_config.effort (devlog 080). diff --git a/tests/anthropic-reasoning.test.ts b/tests/anthropic-reasoning.test.ts index aa8704344..1b358ff57 100644 --- a/tests/anthropic-reasoning.test.ts +++ b/tests/anthropic-reasoning.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { createAnthropicAdapter as createAnthropicAdapterProduction } from "../src/adapters/anthropic"; import { parseRequest } from "../src/responses/parser"; +import { anthropicToResponsesBody } from "../src/claude/inbound"; import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; import { withTestTranslatorBudget } from "./helpers/translator-budget"; @@ -143,6 +144,39 @@ describe("anthropic extended-thinking gate", () => { expect(b.temperature).toBe(0.3); }); + // #545: Claude Desktop's Auto Mode classifier sends thinking:{type:"disabled"} with + // max_tokens:64. Omitting the field lets a default-on model think anyway, and thinking + // shares that 64-token budget — so generation stopped before the stop sequence and the + // client retried. Say "disabled" out loud, but only where the vendor accepts it. + test("Sonnet 5 + reasoning 'none' sends an explicit thinking disable (#545)", async () => { + const b = await bodyOf(parsed("none", { maxOutputTokens: 64, stopSequences: [""] }, "claude-sonnet-5")); + expect(b.thinking).toEqual({ type: "disabled" }); + expect(b.output_config).toBeUndefined(); + // The caller's own limits must survive untouched — they were never the defect. + expect(b.max_tokens).toBe(64); + expect(b.stop_sequences).toEqual([""]); + }); + + test("Sonnet 5 with reasoning OMITTED still omits thinking (#545)", async () => { + // Absence is not a disable instruction: only an explicit "none" earns the explicit field. + const b = await bodyOf(parsed(undefined, {}, "claude-sonnet-5")); + expect(b.thinking).toBeUndefined(); + }); + + test.each([ + "claude-fable-5", + "claude-opus-4-7", + "claude-opus-4-8", + "claude-haiku-4-5", + "claude-sonnet-4-6", + ])("%s + 'none' sends NO explicit disable (#545 gate stays narrow)", async (modelId) => { + // Fable always thinks and rejects an explicit disable; the Opus 4.7/4.8 adaptive wire + // leaves thinking off when omitted. Widening the gate to every adaptive family would + // trade a silent truncation for a 400. + const b = await bodyOf(parsed("none", {}, modelId)); + expect(b.thinking).toBeUndefined(); + }); + test("drops reconstructed Responses reasoning signatures when switching into Anthropic", async () => { const b = await bodyOf(parseRequest({ model: "anthropic/claude-sonnet-4.5", @@ -169,3 +203,26 @@ describe("anthropic extended-thinking gate", () => { expect(messages).toEqual([{ role: "user", content: "continue on anthropic" }]); }); }); + +describe("Claude Desktop classifier round trip (#545)", () => { + test("thinking:disabled survives inbound translation to the outbound Anthropic body", async () => { + // The reporter's exact shape: a permission classifier with a 64-token budget that must + // close its XML tag. Before the fix, "disabled" was dropped at the inbound hop and the + // outbound request omitted `thinking` entirely, so Sonnet 5 thought anyway and spent the + // budget before emitting . Claude Code then retried, up to five times. + const inbound = anthropicToResponsesBody({ + model: "claude-sonnet-5", + max_tokens: 64, + stop_sequences: [""], + thinking: { type: "disabled" }, + system: "decide whether this tool call is allowed", + messages: [{ role: "user", content: "ls" }], + }); + + const body = await bodyOf(parseRequest(inbound)); + + expect(body.thinking).toEqual({ type: "disabled" }); + expect(body.max_tokens).toBe(64); + expect(body.stop_sequences).toEqual([""]); + }); +}); diff --git a/tests/claude-inbound.test.ts b/tests/claude-inbound.test.ts index 2edb35ca1..5049609d6 100644 --- a/tests/claude-inbound.test.ts +++ b/tests/claude-inbound.test.ts @@ -94,7 +94,9 @@ describe("claude inbound translation", () => { test("thinking variants", () => { const base = { model: "m", max_tokens: 10, messages: [{ role: "user", content: "hi" }] }; expect((anthropicToResponsesBody({ ...base, thinking: { type: "adaptive" } }) as any).reasoning).toEqual({ summary: "auto" }); - expect((anthropicToResponsesBody({ ...base, thinking: { type: "disabled" } }) as any).reasoning).toBeUndefined(); + // "disabled" and omitted must NOT collapse to the same state: for a model that thinks by + // default, omission means thinking is ON and shares the caller's max_tokens (#545). + expect((anthropicToResponsesBody({ ...base, thinking: { type: "disabled" } }) as any).reasoning).toEqual({ effort: "none", summary: "none" }); // justified: sibling assertions in this test use the same cast expect((anthropicToResponsesBody(base) as any).reasoning).toBeUndefined(); expect(effortForThinkingBudget(1024)).toBe("low"); expect(effortForThinkingBudget(8192)).toBe("medium"); @@ -126,10 +128,13 @@ describe("claude inbound translation", () => { thinking: { type: "enabled", budget_tokens: 1024 }, output_config: { effort: "xhigh" }, }))).toEqual({ summary: "auto", effort: "xhigh" }); - // disabled thinking suppresses effort entirely (subagent wire, claude-code#65863) + // disabled thinking suppresses effort entirely (subagent wire, claude-code#65863). + // Still suppressed — "high" never reaches the wire — but now stated explicitly as the + // "none" disable sentinel instead of by absence, so a default-on model is told to stop + // rather than left to think anyway (#545). expect(reasoningOf(anthropicToResponsesBody({ ...base, thinking: { type: "disabled" }, output_config: { effort: "high" }, - }))).toBeUndefined(); + }))).toEqual({ effort: "none", summary: "none" }); // unknown effort strings are dropped so downstream defaults win expect(reasoningOf(anthropicToResponsesBody({ ...base, thinking: { type: "adaptive" }, output_config: { effort: "turbo" }, From 930efdf60ae63b1c0dabc74406f5e54549b58d27 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 4 Aug 2026 01:17:56 +0900 Subject: [PATCH 2/3] fix(claude): match the thinking gate on prefixed model ids too (#545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/adapters/anthropic.ts | 53 +++++++++++++++++++++--------- tests/anthropic-reasoning.test.ts | 13 ++++++-- tests/cursor-effort-suffix.test.ts | 12 +++++++ 3 files changed, 60 insertions(+), 18 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 80232e417..0304eea3b 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -408,16 +408,43 @@ const ADAPTIVE_THINKING_FAMILY_MINIMUMS: Record, +): boolean { + const parsed = claudeFamilyVersion(modelId); + if (!parsed) return false; + const minimum = minimums[parsed.family]; if (!minimum) return false; - const major = Number(match[2]); - const minor = match[3] === undefined ? 0 : Number(match[3]); - return major > minimum[0] || (major === minimum[0] && minor >= minimum[1]); + return parsed.major > minimum[0] || (parsed.major === minimum[0] && parsed.minor >= minimum[1]); +} + +function usesAdaptiveThinking(modelId: string): boolean { + return meetsFamilyMinimum(modelId, ADAPTIVE_THINKING_FAMILY_MINIMUMS); } /** @@ -437,13 +464,7 @@ const EXPLICIT_THINKING_DISABLE_FAMILY_MINIMUMS: Record minimum[0] || (major === minimum[0] && minor >= minimum[1]); + return meetsFamilyMinimum(modelId, EXPLICIT_THINKING_DISABLE_FAMILY_MINIMUMS); } /** `output_config.effort` accepts low|medium|high|xhigh|max — "minimal" is rejected with a 400. */ diff --git a/tests/anthropic-reasoning.test.ts b/tests/anthropic-reasoning.test.ts index 1b358ff57..97eed6319 100644 --- a/tests/anthropic-reasoning.test.ts +++ b/tests/anthropic-reasoning.test.ts @@ -148,8 +148,16 @@ describe("anthropic extended-thinking gate", () => { // max_tokens:64. Omitting the field lets a default-on model think anyway, and thinking // shares that 64-token budget — so generation stopped before the stop sequence and the // client retried. Say "disabled" out loud, but only where the vendor accepts it. - test("Sonnet 5 + reasoning 'none' sends an explicit thinking disable (#545)", async () => { - const b = await bodyOf(parsed("none", { maxOutputTokens: 64, stopSequences: [""] }, "claude-sonnet-5")); + test.each([ + "claude-sonnet-5", + "claude-sonnet-5-20260101", + "claude-sonnet-5[1m]", + // A modelMap entry can point at a routed destination, which custom-provider routing + // decodes back into a slash-carrying native id. An id-shape miss here is silent: the + // request simply goes out without the disable and the model thinks anyway. + "anthropic/claude-sonnet-5", + ])("%s + reasoning 'none' sends an explicit thinking disable (#545)", async (modelId) => { + const b = await bodyOf(parsed("none", { maxOutputTokens: 64, stopSequences: [""] }, modelId)); expect(b.thinking).toEqual({ type: "disabled" }); expect(b.output_config).toBeUndefined(); // The caller's own limits must survive untouched — they were never the defect. @@ -169,6 +177,7 @@ describe("anthropic extended-thinking gate", () => { "claude-opus-4-8", "claude-haiku-4-5", "claude-sonnet-4-6", + "anthropic/claude-fable-5", ])("%s + 'none' sends NO explicit disable (#545 gate stays narrow)", async (modelId) => { // Fable always thinks and rejects an explicit disable; the Opus 4.7/4.8 adaptive wire // leaves thinking off when omitted. Widening the gate to every adaptive family would diff --git a/tests/cursor-effort-suffix.test.ts b/tests/cursor-effort-suffix.test.ts index 6d30084e3..218a06eef 100644 --- a/tests/cursor-effort-suffix.test.ts +++ b/tests/cursor-effort-suffix.test.ts @@ -48,6 +48,18 @@ describe("Cursor per-model reasoning-effort suffix", () => { expect(modelIdFor("cursor/claude-4.6-opus")).toBe("claude-4.6-opus-max"); }); + // #545 made Claude Desktop's `thinking:{type:"disabled"}` survive translation as the "none" + // sentinel instead of being dropped. For a modelMap that routes such a request to Cursor, + // that changes the selected tier — pin it so the cross-provider effect is deliberate. + // + // Cursor has no "off" for a reasoning model, so the lowest tier is the closest honest + // reading of "do not think". Dropping the instruction sent these to the model's TOP tier, + // which is the opposite of what the caller asked for. + test("an explicit 'none' picks the lowest tier, not the top one (#545)", () => { + expect(modelIdFor("cursor/claude-opus-4-8", "none")).toBe("claude-opus-4-8-low"); + expect(modelIdFor("cursor/claude-opus-4-8")).toBe("claude-opus-4-8-max"); + }); + test("single-tier models always use their one tier", () => { expect(modelIdFor("cursor/gpt-5.5-extra", "low")).toBe("gpt-5.5-extra-high"); expect(modelIdFor("cursor/claude-4.6-sonnet", "high")).toBe("claude-4.6-sonnet-medium"); From f728dc0fb8280ce63fa28d3fa4774073ea276ca2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 4 Aug 2026 01:21:22 +0900 Subject: [PATCH 3/3] fix(claude): find the claude- segment instead of assuming its position (#545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/adapters/anthropic.ts | 19 ++++++++++++------- tests/anthropic-reasoning.test.ts | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 0304eea3b..4fa629e4c 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -411,19 +411,24 @@ const ADAPTIVE_THINKING_FAMILY_MINIMUMS: Record { expect(b.output_config).toBeUndefined(); }); + // The adaptive-wire predicate shares the id parse with the #545 disable gate, so a + // slash-carrying id must still pick the ADAPTIVE shape. Getting this wrong sends obsolete + // manual `thinking.enabled` to a model that rejects it — a 400, not a silent truncation. + test.each([ + "anthropic/claude-sonnet-5", + "claude-sonnet-5/variant", + "claude-opus-4-8/vendor-suffix", + ])("adaptive-thinking model %s keeps the adaptive wire shape", async (modelId) => { + const b = await bodyOf(parsed("high", {}, modelId)); + expect(b.thinking).toEqual({ type: "adaptive" }); + expect(b.output_config).toEqual({ effort: "high" }); + }); + test("adaptive-thinking model with reasoning 'none' sends no thinking config", async () => { const b = await bodyOf(parsed("none", { temperature: 0.3 }, "claude-fable-5")); expect(b.thinking).toBeUndefined(); @@ -156,6 +169,10 @@ describe("anthropic extended-thinking gate", () => { // decodes back into a slash-carrying native id. An id-shape miss here is silent: the // request simply goes out without the disable and the model thinks anyway. "anthropic/claude-sonnet-5", + "openrouter/anthropic/claude-sonnet-5", + // The slash can also carry a vendor SUFFIX rather than a routing prefix, so the family + // segment is not reliably first or last. Both directions are real routed shapes. + "claude-sonnet-5/variant", ])("%s + reasoning 'none' sends an explicit thinking disable (#545)", async (modelId) => { const b = await bodyOf(parsed("none", { maxOutputTokens: 64, stopSequences: [""] }, modelId)); expect(b.thinking).toEqual({ type: "disabled" }); @@ -178,6 +195,8 @@ describe("anthropic extended-thinking gate", () => { "claude-haiku-4-5", "claude-sonnet-4-6", "anthropic/claude-fable-5", + "claude-fable-5/foo", + "not-a-claude-model", ])("%s + 'none' sends NO explicit disable (#545 gate stays narrow)", async (modelId) => { // Fable always thinks and rejects an explicit disable; the Opus 4.7/4.8 adaptive wire // leaves thinking off when omitted. Widening the gate to every adaptive family would