mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-16 04:03:02 +03:00
Compare commits
1 Commits
fix/10314-
...
fix/10119-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ea8cd8140 |
@@ -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)
|
||||
@@ -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())
|
||||
|
||||
39
open-sse/config/context1m.ts
Normal file
39
open-sse/config/context1m.ts
Normal 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}-`)
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
43
tests/unit/repro-10119-claude-context1m-beta-gated.test.ts
Normal file
43
tests/unit/repro-10119-claude-context1m-beta-gated.test.ts
Normal 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));
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user