diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 6fa9f258d8..392ddc1a7b 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -715,7 +715,9 @@ export class BaseExecutor { // Default CC logic when no override headers are present const isHaiku = typeof tb.model === "string" && tb.model.includes("haiku"); if (isHaiku) { - delete tb.thinking; + // Keep tb.thinking — real Claude Desktop keeps thinking enabled for Haiku + // (issue #2454). Only strip output_config (effort) which Haiku rejects; + // context_management is re-paired with the preserved thinking below. delete tb.output_config; delete tb.context_management; } else if (tb.thinking === undefined && tb.output_config === undefined) { diff --git a/open-sse/executors/claudeIdentity.ts b/open-sse/executors/claudeIdentity.ts index 30e081acd9..4b4ea9bf5d 100644 --- a/open-sse/executors/claudeIdentity.ts +++ b/open-sse/executors/claudeIdentity.ts @@ -284,12 +284,34 @@ export function parseUpstreamMetadataUserId( // ---------- anthropic-beta selector -------------------------------------- +/** + * Models that support the heavy-agent beta tier (context-1m, effort, + * advanced-tool-use). Only Opus/Sonnet are eligible — Haiku with OAuth + * authentication rejects `context-1m-2025-08-07` with + * "This authentication style is incompatible with the long context beta header" + * (issue #2454). Matches real Claude Desktop, which omits these flags for Haiku. + */ +const HEAVY_AGENT_BETA_MODEL_PREFIXES = ["claude-opus", "claude-sonnet"]; + +function isHeavyAgentModel(model: unknown): boolean { + if (typeof model !== "string") return false; + const normalized = model.toLowerCase(); + return HEAVY_AGENT_BETA_MODEL_PREFIXES.some((prefix) => normalized.includes(prefix)); +} + /** * Pick the anthropic-beta flag set that matches the request shape. Real CLI * uses three patterns: minimal probe, structured-output, and full agent. * Sending the full set on every shape is itself a fingerprint. + * + * The heavy-agent flags (context-1m, effort, advanced-tool-use) are gated on + * the model as well as the shape: Haiku must never receive `context-1m`, which + * Anthropic rejects under OAuth authentication. */ -export function selectBetaFlags(body: Record | null | undefined): string { +export function selectBetaFlags( + body: Record | null | undefined, + model?: string | null +): string { const b = body || {}; const hasSystem = !!b.system && @@ -301,11 +323,13 @@ export function selectBetaFlags(body: Record | null | undefined !!(outputCfg && (outputCfg.format as { type?: string } | undefined)?.type === "json_schema") || !!(b.response_format as { type?: string } | undefined)?.type; const isFullAgent = hasTools && hasSystem; + const effectiveModel = model ?? (typeof b.model === "string" ? b.model : ""); + const isHeavyAgent = isFullAgent && isHeavyAgentModel(effectiveModel); const flags: string[] = []; if (isFullAgent) flags.push("claude-code-20250219"); flags.push("oauth-2025-04-20"); - if (isFullAgent) flags.push("context-1m-2025-08-07"); + if (isHeavyAgent) flags.push("context-1m-2025-08-07"); flags.push( "interleaved-thinking-2025-05-14", "redact-thinking-2026-02-12", @@ -314,12 +338,11 @@ export function selectBetaFlags(body: Record | null | undefined ); if (hasStructuredOutput || isFullAgent) flags.push("advisor-tool-2026-03-01"); if (hasStructuredOutput && !isFullAgent) flags.push("structured-outputs-2025-12-15"); - if (isFullAgent) { - flags.push( - "advanced-tool-use-2025-11-20", - "effort-2025-11-24", - "extended-cache-ttl-2025-04-11" - ); + // extended-cache-ttl is sent for all full-agent shapes (incl. Haiku); the + // heavier advanced-tool-use / effort flags are Opus/Sonnet-only. + if (isFullAgent) flags.push("extended-cache-ttl-2025-04-11"); + if (isHeavyAgent) { + flags.push("advanced-tool-use-2025-11-20", "effort-2025-11-24"); } return flags.join(","); } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 74a059e2e3..d6e59665b5 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -17,6 +17,7 @@ import { addBufferToUsage, filterUsageForFormat, estimateUsage } from "../utils/ import { refreshWithRetry, isUnrecoverableRefreshError } from "../services/tokenRefresh.ts"; import { createRequestLogger } from "../utils/requestLogger.ts"; import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts"; +import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../config/defaultThinkingSignature.ts"; import { getStripTypesForProviderModel, stripIncompatibleMessageContent, @@ -434,6 +435,36 @@ export function shouldUseNativeCodexPassthrough({ return segments.includes("responses"); } +/** + * Convert all historical `thinking` / `redacted_thinking` blocks in assistant + * messages to `redacted_thinking` carrying a synthetic default signature. + * + * A thinking block's `signature` is cryptographically bound to the auth token + * that generated it. In Anthropic-native Claude OAuth passthrough, when a session + * starts on one model (token A) and then switches model or falls over (token B), + * Anthropic rejects every historical signature with 400 "Invalid signature in + * thinking block" (issue #2454). `redacted_thinking` bypasses signature validation. + * + * ALL assistant turns are converted, including the last — under a different token + * every signature is invalid, so there is no "preserve latest" exception. Returns a + * new messages array (original is not mutated) only touching messages that changed. + */ +export function redactPassthroughThinkingSignatures(messages: unknown, signature: string): unknown { + if (!Array.isArray(messages)) return messages; + return (messages as Record[]).map((msg) => { + if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) return msg; + let modified = false; + const newContent = (msg.content as Record[]).map((block) => { + if (block && (block.type === "thinking" || block.type === "redacted_thinking")) { + modified = true; + return { type: "redacted_thinking", data: signature }; + } + return block; + }); + return modified ? { ...msg, content: newContent } : msg; + }); +} + export function isClaudeCodeSemanticPassthroughRequest({ provider, sourceFormat, @@ -2742,6 +2773,17 @@ export async function handleChatCore({ // regardless of combo strategy or cache_control settings. translatedBody = { ...body }; translatedBody._disableToolPrefix = true; + + // Sanitize historical thinking-block signatures for Anthropic-native Claude OAuth. + // Only Anthropic's first-party API validates these signatures (token-bound); third-party + // Claude-shape providers do not. See redactPassthroughThinkingSignatures + issue #2454. + if (provider === "claude") { + translatedBody.messages = redactPassthroughThinkingSignatures( + translatedBody.messages, + DEFAULT_THINKING_CLAUDE_SIGNATURE + ) as typeof translatedBody.messages; + } + if (!isClaudeCodeSemanticPassthrough) { normalizeClaudeUpstreamMessages(translatedBody, { preserveToolResultBlocks: true }); } else { diff --git a/tests/unit/claude-beta-flags-2454.test.ts b/tests/unit/claude-beta-flags-2454.test.ts new file mode 100644 index 0000000000..3dbd3e4e9d --- /dev/null +++ b/tests/unit/claude-beta-flags-2454.test.ts @@ -0,0 +1,50 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { selectBetaFlags } = await import("../../open-sse/executors/claudeIdentity.ts"); + +// Regression for #2454: Haiku with OAuth rejects `context-1m-2025-08-07` with +// 400 "This authentication style is incompatible with the long context beta header". +// The heavy-agent beta tier (context-1m, effort, advanced-tool-use) must be gated on +// Opus/Sonnet only; Haiku still receives the general full-agent flags. + +function fullAgentBody(model: string) { + return { + model, + system: "You are a coding agent.", + tools: [{ name: "read_file", description: "x", input_schema: { type: "object" } }], + }; +} + +test("#2454 Haiku full-agent omits heavy-agent beta flags", () => { + const flags = selectBetaFlags(fullAgentBody("claude-haiku-4-5-20251001")); + assert.ok(!flags.includes("context-1m-2025-08-07"), "Haiku must NOT receive context-1m"); + assert.ok(!flags.includes("effort-2025-11-24"), "Haiku must NOT receive effort"); + assert.ok( + !flags.includes("advanced-tool-use-2025-11-20"), + "Haiku must NOT receive advanced-tool-use" + ); + // General full-agent flags are still present for Haiku. + assert.ok(flags.includes("oauth-2025-04-20")); + assert.ok(flags.includes("interleaved-thinking-2025-05-14")); + assert.ok(flags.includes("claude-code-20250219")); + assert.ok(flags.includes("extended-cache-ttl-2025-04-11")); +}); + +test("#2454 Sonnet full-agent includes heavy-agent beta flags", () => { + const flags = selectBetaFlags(fullAgentBody("claude-sonnet-4-6")); + assert.ok(flags.includes("context-1m-2025-08-07"), "Sonnet should receive context-1m"); + assert.ok(flags.includes("effort-2025-11-24")); + assert.ok(flags.includes("advanced-tool-use-2025-11-20")); +}); + +test("#2454 Opus full-agent includes heavy-agent beta flags", () => { + const flags = selectBetaFlags(fullAgentBody("claude-opus-4-7")); + assert.ok(flags.includes("context-1m-2025-08-07"), "Opus should receive context-1m"); +}); + +test("#2454 explicit model arg overrides body.model for tiering", () => { + // body says sonnet, but the resolved upstream model is haiku → must omit context-1m + const flags = selectBetaFlags(fullAgentBody("claude-sonnet-4-6"), "claude-haiku-4-5-20251001"); + assert.ok(!flags.includes("context-1m-2025-08-07")); +}); diff --git a/tests/unit/claude-passthrough-thinking-2454.test.ts b/tests/unit/claude-passthrough-thinking-2454.test.ts new file mode 100644 index 0000000000..7878ce7e68 --- /dev/null +++ b/tests/unit/claude-passthrough-thinking-2454.test.ts @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { redactPassthroughThinkingSignatures } = await import("../../open-sse/handlers/chatCore.ts"); + +const SIG = "SYNTHETIC_SIGNATURE_FIXTURE"; + +// Regression for #2454 (Error 2): historical thinking signatures are bound to the +// original auth token; after a model switch the proxy uses a different token and +// Anthropic rejects them. Convert ALL thinking/redacted_thinking blocks (incl. last). + +test("#2454 converts all thinking blocks to redacted_thinking with synthetic signature", () => { + const messages = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "reasoning", signature: "TOKEN_A_SIG_1" }, + { type: "text", text: "answer 1" }, + ], + }, + { role: "user", content: [{ type: "text", text: "more" }] }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "reasoning 2", signature: "TOKEN_A_SIG_2" }, + { type: "text", text: "answer 2" }, + ], + }, + ]; + + const out = redactPassthroughThinkingSignatures(messages, SIG) as any[]; + + for (const msg of out) { + if (msg.role !== "assistant") continue; + for (const block of msg.content) { + if (block.type === "redacted_thinking") { + assert.equal(block.data, SIG, "redacted_thinking carries the synthetic signature"); + assert.equal(block.signature, undefined, "original signature is dropped"); + assert.equal(block.thinking, undefined, "plaintext thinking is dropped"); + } + assert.notEqual(block.type, "thinking", "no raw thinking block must survive (incl. last)"); + } + } + // text blocks are preserved verbatim + assert.equal(out[1].content[1].text, "answer 1"); + assert.equal(out[3].content[1].text, "answer 2"); +}); + +test("#2454 leaves messages without thinking untouched (same reference)", () => { + const messages = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { role: "assistant", content: [{ type: "text", text: "plain answer" }] }, + ]; + const out = redactPassthroughThinkingSignatures(messages, SIG) as any[]; + assert.equal(out[1], messages[1], "unchanged assistant message keeps its reference"); +}); + +test("#2454 already-redacted thinking blocks are re-stamped with the synthetic signature", () => { + const messages = [ + { + role: "assistant", + content: [{ type: "redacted_thinking", data: "STALE_TOKEN_B_DATA" }], + }, + ]; + const out = redactPassthroughThinkingSignatures(messages, SIG) as any[]; + assert.equal(out[0].content[0].data, SIG); +}); + +test("#2454 non-array messages pass through", () => { + assert.equal(redactPassthroughThinkingSignatures(undefined, SIG), undefined); + assert.equal(redactPassthroughThinkingSignatures(null, SIG), null); +});