fix(claude): per-model beta gating + passthrough thinking sanitization (#2454)

selectBetaFlags now gates the heavy-agent betas (context-1m, effort,
advanced-tool-use) on Opus/Sonnet only; Haiku with OAuth was rejecting
context-1m with 400 'incompatible with the long context beta header'. base.ts
stops deleting Haiku's thinking config (real Claude Desktop keeps it). chatCore
passthrough converts historical thinking/redacted_thinking blocks to
redacted_thinking with a synthetic signature, fixing 400 'Invalid signature in
thinking block' on mid-session model switches. Co-authored analysis by havockdev.
This commit is contained in:
diegosouzapw
2026-05-21 04:01:11 -03:00
parent 016deae964
commit 6fe366db54
5 changed files with 199 additions and 9 deletions

View File

@@ -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) {

View File

@@ -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<string, unknown> | null | undefined): string {
export function selectBetaFlags(
body: Record<string, unknown> | null | undefined,
model?: string | null
): string {
const b = body || {};
const hasSystem =
!!b.system &&
@@ -301,11 +323,13 @@ export function selectBetaFlags(body: Record<string, unknown> | 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<string, unknown> | 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(",");
}

View File

@@ -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<string, unknown>[]).map((msg) => {
if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) return msg;
let modified = false;
const newContent = (msg.content as Record<string, unknown>[]).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 {

View File

@@ -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"));
});

View File

@@ -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);
});