diff --git a/src/claude/model-info.ts b/src/claude/model-info.ts index cbecf8c60a..bcd047a281 100644 --- a/src/claude/model-info.ts +++ b/src/claude/model-info.ts @@ -143,14 +143,19 @@ export function buildAnthropicModelInfos( // the auto-context widening that let a 372K route carry the marker (and be // over-filled) is the #854 defect and does not come back. Guards (audit R1#11): // same dedupe set, never double-suffix. - const push1mVariant = (base: AnthropicModelInfo, contextWindow: number | undefined, maxInputTokens?: number) => { + const push1mVariant = ( + base: AnthropicModelInfo, + contextWindow: number | undefined, + maxInputTokens?: number, + selectorId?: string, + ) => { // The [1m] marker makes Claude Code account 1e6 tokens for the row, so it // may only name models whose AUTHORITATIVE effective window is >= 1M — // never the auto-context widening, which would mark a 372K route and have // Claude Code over-fill it (the #854 defect). if (contextWindow === undefined || contextWindow < ONE_MILLION) return; if (base.id.includes("[1m]")) return; - const id = `${base.id}[1m]`; + const id = selectorId ?? `${base.id}[1m]`; if (seen.has(id)) return; seen.add(id); // The marker fixes Claude Code's accounting at 1e6, but a model may accept less input @@ -220,7 +225,15 @@ export function buildAnthropicModelInfos( out.push(info); // Anthropic passthrough guard (audit 021 #3): never auto-widen canonical claude // routes — only a genuine >=1M window earns the variant row there. - push1mVariant(info, m.contextWindow, routedMaxInput); + // Claude Code groups canonical Fable ids before it compares the [1m] marker. This + // reversible alias only separates picker families; it is not an OpenAI-native route. + // The Messages ingress restores the canonical Anthropic id before passthrough. + const oneMillionSelector = idStyle === "readable" + && m.provider === "anthropic" + && listedModelId.startsWith("claude-fable-") + ? `${claudeCodeNativeAlias(listedModelId)}[1m]` + : undefined; + push1mVariant(info, m.contextWindow, routedMaxInput, oneMillionSelector); // The whole model is passed, not a (provider, id) pair: a combo row lives in its own // namespace with no config.providers entry, so the caller classifies it from the // aggregated supportsServiceTier the row already carries. diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index bf01cae50e..0f11fd8343 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -12,6 +12,7 @@ import { enforceAnthropicImageLimits, sniffImageDimensions } from "../adapters/a import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"; import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxEffortDirective, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; import { resolveDesktop3pAlias } from "../claude/desktop-3p"; +import { claudeCodeNativeAlias } from "../claude/alias"; import { recordDesktopRequest } from "../claude/desktop-health"; import { stripOneMillionMarker } from "../claude/context-windows"; import { captureClaudeInbound } from "../claude/inbound-debug"; @@ -79,6 +80,13 @@ function decodeClaudeFastSelector(raw: string, cc?: OcxConfig["claudeCode"]): st return decodedBase === bare ? exact : `${decodedBase}--fast`; } +/** Restore the reversible Fable picker alias before Anthropic passthrough checks. */ +function decodeFablePickerAlias(raw: string, cc?: OcxConfig["claudeCode"]): string { + const decoded = resolveInboundModel(raw, cc); + if (!decoded.startsWith("claude-fable-")) return raw; + return claudeCodeNativeAlias(decoded) === raw ? decoded : raw; +} + function isRec(v: unknown): v is Rec { return !!v && typeof v === "object" && !Array.isArray(v); } @@ -649,6 +657,9 @@ async function handleClaudeMessagesWithBudget( effortOverride = extractOcxEffortDirective(anthropicBody); } } + if (isRec(anthropicBody) && typeof anthropicBody.model === "string") { + anthropicBody.model = decodeFablePickerAlias(anthropicBody.model, config.claudeCode); + } if (isRec(anthropicBody) && typeof anthropicBody.model === "string") { requestedModel = anthropicBody.model; // Decode for Fast only. A Claude alias is `claude-ocx---`, so it @@ -1079,6 +1090,8 @@ export async function handleClaudeCountTokens( model = stripOneMillionMarker(countRoute); raw.model = model; } + model = decodeFablePickerAlias(model, config.claudeCode); + raw.model = model; // Fast-only: count_tokens never parsed an effort row, so it must not start. It returns a // token estimate and sends no tier, so only the IDENTITY is corrected - without this the // synthetic id reaches native passthrough as a model Anthropic has never heard of. diff --git a/tests/claude-integration/claude-model-info.test.ts b/tests/claude-integration/claude-model-info.test.ts index 0a2d151a1b..b9a23342af 100644 --- a/tests/claude-integration/claude-model-info.test.ts +++ b/tests/claude-integration/claude-model-info.test.ts @@ -44,6 +44,22 @@ describe("anthropic-flavor ModelInfo discovery entries (devlog 130 B4b)", () => expect(info!.capabilities.effort.max.supported).toBe(true); }); + test("readable Fable rows keep base and 1M selections distinct in Claude Code", () => { + const infos = buildAnthropicModelInfos([], [{ + provider: "anthropic", + id: "claude-fable-5-1", + contextWindow: 1_000_000, + maxInputTokens: 1_000_000, + }], undefined, "readable"); + + expect(infos.map(info => info.id)).toEqual([ + "claude-fable-5-1", + "claude-ocx-native--claude-fable-5-1[1m]", + ]); + expect(infos[1]!.display_name).toBe("claude-fable-5-1 (anthropic) · 1M"); + expect(infos[1]!.max_input_tokens).toBe(1_000_000); + }); + test("native effective ladder only advertises clamp-identity rungs (audit R4#1)", () => { for (const slug of ["gpt-5.5", "gpt-5.4", "gpt-5.6-sol"]) { for (const rung of nativeEffectiveLadder(slug)) { diff --git a/tests/claude-integration/claude-native-passthrough.test.ts b/tests/claude-integration/claude-native-passthrough.test.ts index c8c798ac9a..af87aa83cb 100644 --- a/tests/claude-integration/claude-native-passthrough.test.ts +++ b/tests/claude-integration/claude-native-passthrough.test.ts @@ -206,6 +206,47 @@ test("count_tokens passes through with native credentials", async () => { } }); +test("Fable 1M picker alias preserves native passthrough on both Messages endpoints", async () => { + const captured: Captured[] = []; + const upstream = mockAnthropicUpstream(captured); + saveConfig(cfg(upstream.url.toString().replace(/\/$/, ""))); + const server = startServer(0); + const pickerModel = "claude-ocx-native--claude-fable-5-1"; + try { + const messagesWithoutMarker = await fetch(new URL("/v1/messages", server.url), { + method: "POST", + headers: OAUTH_HEADERS, + body: JSON.stringify({ ...claudeBody(), model: pickerModel }), + }); + expect(messagesWithoutMarker.status).toBe(200); + await messagesWithoutMarker.text(); + + const messagesWithMarker = await fetch(new URL("/v1/messages", server.url), { + method: "POST", + headers: OAUTH_HEADERS, + body: JSON.stringify({ ...claudeBody(), model: `${pickerModel}[1m]` }), + }); + expect(messagesWithMarker.status).toBe(200); + await messagesWithMarker.text(); + + const countTokens = await fetch(new URL("/v1/messages/count_tokens", server.url), { + method: "POST", + headers: OAUTH_HEADERS, + body: JSON.stringify({ model: `${pickerModel}[1m]`, messages: [{ role: "user", content: "hi" }] }), + }); + expect(countTokens.status).toBe(200); + expect(await countTokens.json()).toEqual({ input_tokens: 4242 }); + + expect(captured).toHaveLength(3); + expect(captured[0]!.body.model).toBe("claude-fable-5-1"); + expect(captured[1]!.body.model).toBe("claude-fable-5-1"); + expect(captured[2]!.body.model).toBe("claude-fable-5-1"); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + test("exposed native passthrough requires dedicated admission and never forwards admission credentials", async () => { const admissionSecret = "sk-ant-api03-key"; const providerBearer = "sk-ant-oat01-provider";