mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
@@ -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) {
|
||||
|
||||
@@ -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(",");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
50
tests/unit/claude-beta-flags-2454.test.ts
Normal file
50
tests/unit/claude-beta-flags-2454.test.ts
Normal 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"));
|
||||
});
|
||||
73
tests/unit/claude-passthrough-thinking-2454.test.ts
Normal file
73
tests/unit/claude-passthrough-thinking-2454.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user