diff --git a/changelog.d/fixes/10119-claude-haiku-45-capability-flags.md b/changelog.d/fixes/10119-claude-haiku-45-capability-flags.md new file mode 100644 index 0000000000..799486ffb0 --- /dev/null +++ b/changelog.d/fixes/10119-claude-haiku-45-capability-flags.md @@ -0,0 +1 @@ +- fix(sse): downgrade client-supplied `thinking:{type:"adaptive"}` to `enabled` and gate the `context-1m-2025-08-07` beta on model eligibility when a combo/fallback re-routes a request to a non-adaptive/non-1M model like claude-haiku-4-5 (avoids "adaptive thinking is not supported on this model" and "long context beta is not yet available" 400s, #10119) \ No newline at end of file diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts index cf6710c4fe..a030cd1c4a 100644 --- a/open-sse/config/anthropicHeaders.ts +++ b/open-sse/config/anthropicHeaders.ts @@ -6,6 +6,7 @@ import { CLAUDE_CODE_SDK_PACKAGE_VERSION, getClaudeCodeUserAgent, } from "@/shared/constants/claudeCodeClient"; +import { modelSupportsContext1mBeta } from "../config/context1m.ts"; export const ANTHROPIC_VERSION_HEADER = "2023-06-01"; @@ -70,11 +71,21 @@ export const FORWARDABLE_CLIENT_BETAS = Object.freeze([ * case-insensitive). The client beta is added only if it is on `allow`, so this * never forces betas the client did not request nor leaks betas the backend * rejects. See #3974 (tool-search-tool dropped on the Claude OAuth path). + * + * `model` (optional) gate: when a resolved upstream model is supplied and it does + * NOT support the long-context beta, `context-1m-2025-08-07` is dropped from the + * merged allowlist instead of being forwarded blind. Combo/fallback + * can re-route a request whose client negotiated `[1m]` for a more capable sibling + * onto a model that does not qualify (e.g. a Haiku) — Anthropic rejects the beta + * there with "long context beta is not yet available for this subscription" + * (#10119). When no model is supplied (legacy callers without model resolution), + * the prior forwarding behavior is preserved. */ export function mergeClientAnthropicBeta( base: string, clientBeta: string | null | undefined, - allow: readonly string[] = FORWARDABLE_CLIENT_BETAS + allow: readonly string[] = FORWARDABLE_CLIENT_BETAS, + model?: string | null ): string { const baseList = base .split(",") @@ -82,7 +93,14 @@ export function mergeClientAnthropicBeta( .filter(Boolean); if (typeof clientBeta !== "string" || !clientBeta.trim()) return baseList.join(","); const seen = new Set(baseList.map((s) => s.toLowerCase())); - const allowSet = new Set(allow.map((s) => s.toLowerCase())); + const allowList = allow + .map((s) => s.toLowerCase()) + .filter((lower) => { + if (lower !== "context-1m-2025-08-07") return true; + if (model === undefined || model === null || model === "") return true; + return modelSupportsContext1mBeta(model); + }); + const allowSet = new Set(allowList); for (const token of clientBeta .split(",") .map((s) => s.trim()) diff --git a/open-sse/config/context1m.ts b/open-sse/config/context1m.ts new file mode 100644 index 0000000000..dab5c405b1 --- /dev/null +++ b/open-sse/config/context1m.ts @@ -0,0 +1,39 @@ +/** + * Model eligibility for the `context-1m-2025-08-07` long-context `anthropic-beta`. + * + * Only a subset of Claude models qualify for the 1M-context beta. Forwarding the + * beta to a non-qualifying model (e.g. claude-haiku-4-5-20251001) is a hard 400 + * from the Messages API: "long context beta is not yet available for this + * subscription". A client can negotiate the beta for one member of a combo and + * have the SAME request re-routed (combo/fallback) to a less capable sibling, so + * beta forwarding must be gated on the RESOLVED target model — never blind. + * + * Neutral module (no imports) so both `anthropicHeaders.ts` (the merge path) and + * `claudeCodeCompatible.ts` (the `[1m]`-suffix path) share one source of truth + * without importing each other. + */ +export const CONTEXT_1M_SUPPORTED_MODELS = [ + "claude-fable-5", + "claude-sonnet-5", + "claude-sonnet-4-6", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", +] as const; + +/** + * True when the (resolved upstream) model qualifies for the long-context beta. + * Normalizes case and strips a trailing dated alias (`-20251001`) so both bare and + * dated model ids match. SHA-256 of the reference implementation in + * `claudeCodeCompatible.ts` (moved here). + */ +export function modelSupportsContext1mBeta(model: string | null | undefined): boolean { + const normalizedModel = String(model || "") + .trim() + .toLowerCase() + .replace(/-\d{8}$/, ""); + + return CONTEXT_1M_SUPPORTED_MODELS.some( + (supported) => normalizedModel === supported || normalizedModel.startsWith(`${supported}-`) + ); +} \ No newline at end of file diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 8fe668b622..b8094e7182 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1180,7 +1180,12 @@ export class BaseExecutor { // rejected; selectBetaFlags still gates thinking/effort per #3415. "anthropic-beta": mergeClientAnthropicBeta( selectBetaFlags(tb, null, clientAnthropicBeta), - clientAnthropicBeta + clientAnthropicBeta, + undefined, + // Gate the client-negotiated context-1m beta on the RESOLVED target: + // combo/fallback can route a request negotiated for a [1m] sibling onto a + // model that does not qualify (e.g. Haiku), which Anthropic rejects (#10119). + model ), "anthropic-dangerous-direct-browser-access": "true", "x-app": "cli", diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 79d2779881..12480187e2 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -56,14 +56,6 @@ const CLAUDE_CODE_COMPATIBLE_DEFAULT_SYSTEM_BLOCKS = [ text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.", }, ]; -const CONTEXT_1M_SUPPORTED_MODELS = [ - "claude-fable-5", - "claude-sonnet-5", - "claude-sonnet-4-6", - "claude-opus-4-8", - "claude-opus-4-7", - "claude-opus-4-6", -]; export const CLAUDE_CODE_COMPATIBLE_STAINLESS_TIMEOUT_SECONDS = getStainlessTimeoutSeconds( process.env ); @@ -168,16 +160,9 @@ export function appendAnthropicBetaHeader( } } -export function modelSupportsContext1mBeta(model: string | null | undefined): boolean { - const normalizedModel = String(model || "") - .trim() - .toLowerCase() - .replace(/-\d{8}$/, ""); - - return CONTEXT_1M_SUPPORTED_MODELS.some( - (supported) => normalizedModel === supported || normalizedModel.startsWith(`${supported}-`) - ); -} +// Re-exported from the shared context1m module so existing importers of this +// helper (base.ts) keep working; the eligibility list now has one source of truth. +export { modelSupportsContext1mBeta } from "../config/context1m.ts"; export function buildClaudeCodeCompatibleHeaders( apiKey: string, diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 7fb844bda9..ff07029e61 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -7,7 +7,7 @@ import { sanitizeToolId } from "../helpers/schemaCoercion.ts"; import { safeParseJSON } from "../helpers/jsonUtil.ts"; import { applyKimiCodingThinking } from "../helpers/claudeHelper.ts"; import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; -import { isAdaptiveThinkingOnly } from "../../../src/shared/constants/modelSpecs.ts"; +import { getDefaultThinkingBudget, isAdaptiveThinkingOnly } from "../../../src/shared/constants/modelSpecs.ts"; import { fitThinkingToMaxTokens } from "./openai-to-claude/thinkingBudget.ts"; import { enforceToolResultAdjacency } from "./openai-to-claude/toolResultAdjacency.ts"; import { sanitizeToolResultId } from "./openai-to-claude/sanitizeToolResultId.ts"; @@ -16,6 +16,12 @@ import { sanitizeToolResultId } from "./openai-to-claude/sanitizeToolResultId.ts // adaptive-only Claude models (Opus 4.7+/Fable 5) without ever emitting a manual budget. const ADAPTIVE_EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]); +// Safe manual budget when `thinking:{type:"adaptive"}` must be downgraded to the +// compatible manual `type:"enabled"` form for a model that does not support adaptive +// thinking (#10119). 1024 is both Anthropic's MIN thinking budget (thinkingBudget.ts) +// and the `low` effort bucket — conservative for small-context models like Haiku. +const ADAPTIVE_DOWNGRADE_BUDGET = 1024; + // Prefix for Claude OAuth tool names to avoid conflicts // Can be disabled per-request via body._disableToolPrefix = true export const CLAUDE_OAUTH_TOOL_PREFIX = "proxy_"; @@ -179,11 +185,27 @@ export function openaiToClaudeRequest(model, body, stream, credentials = null) { if (isKimiCoding) { applyKimiCodingThinking(result, body); } else if (body.thinking) { - result.thinking = { - type: body.thinking.type || "enabled", - ...(body.thinking.budget_tokens && { budget_tokens: body.thinking.budget_tokens }), - ...(body.thinking.max_tokens && { max_tokens: body.thinking.max_tokens }), - }; + const thinkingType = body.thinking.type || "enabled"; + if (thinkingType === "adaptive" && !isAdaptiveThinkingOnly(model)) { + // Downgrade guard (#10119): a request can carry `thinking:{type:"adaptive"}` — the + // shape built for an adaptive-only sibling (Opus 4.7+/Sonnet-5) in a combo — and be + // re-routed by combo/fallback to a model that only accepts manual extended thinking + // (e.g. claude-haiku-4-5-20251001). Anthropic rejects `adaptive` on those models with + // "adaptive thinking is not supported on this model". Convert to the compatible manual + // `enabled` form with a safe budget instead of forwarding an incompatible type. + const callerBudget = Number(body.thinking.budget_tokens); + const safeBudget = + (Number.isFinite(callerBudget) && callerBudget > 0 ? callerBudget : 0) || + getDefaultThinkingBudget(model) || + ADAPTIVE_DOWNGRADE_BUDGET; + result.thinking = { type: "enabled", budget_tokens: safeBudget }; + } else { + result.thinking = { + type: thinkingType, + ...(body.thinking.budget_tokens && { budget_tokens: body.thinking.budget_tokens }), + ...(body.thinking.max_tokens && { max_tokens: body.thinking.max_tokens }), + }; + } } else if (body.reasoning_effort) { // Convert OpenAI reasoning_effort to Claude thinking format (#627) // Clients like OpenCode send reasoning_effort via @ai-sdk/openai-compatible diff --git a/tests/unit/claude-context-1m-supported-models.test.ts b/tests/unit/claude-context-1m-supported-models.test.ts index 202f90ba63..176eecdcfe 100644 --- a/tests/unit/claude-context-1m-supported-models.test.ts +++ b/tests/unit/claude-context-1m-supported-models.test.ts @@ -11,7 +11,9 @@ function parseModelList(constantName: string): string[] { const sourceFile = constantName === "CONTEXT_1M_NATIVE_MODELS" ? "open-sse/config/claudeCodeCompatibleIdentity.ts" - : "open-sse/services/claudeCodeCompatible.ts"; + : constantName === "CONTEXT_1M_SUPPORTED_MODELS" + ? "open-sse/config/context1m.ts" + : "open-sse/services/claudeCodeCompatible.ts"; const src = fs.readFileSync(path.join(REPO_ROOT, sourceFile), "utf8"); // Strip type annotations before matching to handle `const X: string[] = [...]` const match = src diff --git a/tests/unit/repro-10119-claude-context1m-beta-gated.test.ts b/tests/unit/repro-10119-claude-context1m-beta-gated.test.ts new file mode 100644 index 0000000000..2485006acf --- /dev/null +++ b/tests/unit/repro-10119-claude-context1m-beta-gated.test.ts @@ -0,0 +1,43 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { mergeClientAnthropicBeta, FORWARDABLE_CLIENT_BETAS } from "../../open-sse/config/anthropicHeaders.ts"; + +// Issue #10119: a client can negotiate `anthropic-beta: context-1m-2025-08-07` for ONE member +// of a combo and have the SAME request re-routed (combo/fallback) to a model that does not +// qualify for the long-context beta (e.g. claude-haiku-4-5-20251001). Previously +// mergeClientAnthropicBeta() forwarded any client-negotiated beta on the allowlist with ZERO +// model eligibility check, so the qualifying beta reached Haiku too and Anthropic rejected it +// with "long context beta is not yet available for this subscription". The merge must now drop +// context-1m-2025-08-07 when a resolved model is supplied and does not support the beta. + +const CONTEXT_1M = "context-1m-2025-08-07"; +const BASE = "claude-code-20250219,oauth-2025-04-20"; + +test("drops client-negotiated context-1m beta when the model does not support it", () => { + const out = mergeClientAnthropicBeta(BASE, CONTEXT_1M, undefined, "claude-haiku-4-5-20251001"); + assert.ok( + !out.split(",").includes(CONTEXT_1M), + "context-1m must not be forwarded to a Haiku target" + ); + assert.equal(out, BASE, "the base beta set is otherwise preserved"); +}); + +test("keeps context-1m beta when the model supports it", () => { + const out = mergeClientAnthropicBeta(BASE, CONTEXT_1M, undefined, "claude-sonnet-5"); + assert.ok( + out.split(",").includes(CONTEXT_1M), + "a context-1m-eligible model keeps the client's [1m] negotiation" + ); +}); + +test("preserves client-negotiated context-1m when no model context is supplied", () => { + // Callers that do not thread a resolved model (legacy signature) keep the prior + // forwarding behavior — gating only engages when a model is explicitly resolved. + const out = mergeClientAnthropicBeta(BASE, CONTEXT_1M); + assert.ok(out.split(",").includes(CONTEXT_1M), "no-model merge keeps prior forwarding behavior"); +}); + +test("context-1m remains on the forwardable allowlist", () => { + assert.ok(FORWARDABLE_CLIENT_BETAS.includes(CONTEXT_1M)); +}); \ No newline at end of file diff --git a/tests/unit/repro-10119-claude-haiku-adaptive-downgrade.test.ts b/tests/unit/repro-10119-claude-haiku-adaptive-downgrade.test.ts new file mode 100644 index 0000000000..a9bbe3ef30 --- /dev/null +++ b/tests/unit/repro-10119-claude-haiku-adaptive-downgrade.test.ts @@ -0,0 +1,42 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { openaiToClaudeRequest } from "../../open-sse/translator/request/openai-to-claude.ts"; + +// Issue #10119: a request that already carries `thinking:{type:"adaptive"}` (the shape +// OmniRoute builds for an adaptive-only sibling like Opus 4.7+/Sonnet-5 in a combo) is +// forwarded verbatim when combo/fallback routing re-targets the SAME request to a model +// that only supports manual extended thinking (e.g. claude-haiku-4-5-20251001). Anthropic +// rejects `type:"adaptive"` on Haiku with "adaptive thinking is not supported on this +// model". The translator must downgrade `adaptive` -> `enabled` (with a safe budget) for +// any non-`isAdaptiveThinkingOnly` Claude model instead of forwarding an incompatible type. + +function toClaude(model: string, thinking: Record) { + return openaiToClaudeRequest( + model, + { model, thinking, messages: [{ role: "user", content: "hi" }] }, + false, + null + ); +} + +test("downgrades client-supplied adaptive thinking to manual enabled for Haiku 4.5", () => { + const out = toClaude("claude-haiku-4-5-20251001", { type: "adaptive" }); + assert.equal(out.thinking?.type, "enabled", "adaptive must not survive on a Haiku target"); + assert.ok( + Number(out.thinking?.budget_tokens) >= 1024, + "downgraded enabled thinking must carry a safe non-zero budget" + ); +}); + +test("downgrades adaptive thinking for any non-adaptive-only Claude model generically", () => { + // claude-sonnet-4-5 is NOT adaptive-only (unlike Opus 4.7+/Sonnet-5), so the same + // downgrade must apply — the guard is keyed on isAdaptiveThinkingOnly(), not the Haiku id. + const out = toClaude("claude-sonnet-4-5", { type: "adaptive" }); + assert.equal(out.thinking?.type, "enabled", "non-adaptive-only models must not receive adaptive"); +}); + +test("preserves adaptive thinking for an adaptive-only model (Opus 4.7+)", () => { + const out = toClaude("claude-opus-4-7", { type: "adaptive" }); + assert.equal(out.thinking?.type, "adaptive", "adaptive-only models keep adaptive thinking"); +}); \ No newline at end of file