Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/claude/model-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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-<provider>--<model>`, so it
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions tests/claude-integration/claude-model-info.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
41 changes: 41 additions & 0 deletions tests/claude-integration/claude-native-passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading