Compare commits

...

1 Commits

Author SHA1 Message Date
adevwithpurpose
3ea8cd8140 fix(sse): downgrade adaptive thinking and gate context-1m beta on model eligibility (#10119) 2026-08-15 13:03:16 -03:00
9 changed files with 185 additions and 28 deletions

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -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,

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown>) {
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");
});