Skip to content
Open
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
53 changes: 53 additions & 0 deletions packages/gateway/src/data-plane/models/claude-code-prefix.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// The Claude Code CLI's gateway-discovery model picker (enabled by the
// `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` env var) applies two
// filters to the `/v1/models` payload before populating its `/model`
// menu. Anthropic documents both filters at
// https://code.claude.com/docs/en/llm-gateway-protocol#model-discovery:
//
// > Claude Code reads `id` and the optional `display_name` from each
// > entry in the response's `data` array, and ignores entries whose
// > `id` doesn't begin with `claude` or `anthropic`.
//
// > A discovered ID is skipped when it exactly matches a row already
// > in the picker, or when both the discovered and existing IDs
// > resolve to Fable.
//
// The compiled implementation matches the docs verbatim; the shape is
//
// models.filter(m => /^(claude|anthropic)/i.test(m.id))
// .filter(m => { const fam = knownFamily(m.id); return fam === null || fam === fable5Family; })
//
// where `knownFamily` walks the CLI's built-in id→family map. Extracted
// from `@anthropic-ai/[email protected]`'s compiled `Bootstrap Gateway
// /v1/models` handler, captured 2026-07-16 by grepping the Bun-compiled
// darwin-arm64 binary around the `[Bootstrap] Gateway /v1/models`
// telemetry strings; the docs are the primary source-of-truth and the
// binary extraction pins the exact carve-out (`fable5`) and evaluation
// order the prose leaves implicit.
//
// Consequences for gateway callers:
//
// - `label: display_name ?? id` — the picker renders `display_name` to
// the user; the id itself is only shown on the wire. Rewriting the
// id is invisible in the UI.
// - The synthetic prefix used here (`claude-code:`) starts with
// `claude`, so any non-Anthropic id we advertise passes the first
// filter. It never exact-matches a built-in family string, so the
// second filter also passes (`knownFamily('claude-code:gpt-5')`
// returns null). This mirrors the same de-collision trick that
// lets `<id>[1m]` variants surface — the bracket suffix guarantees
// the synthesized id can never coincide with a built-in family
// string.
//
// The prefix is a Floway-owned convention: `claude-code:` was chosen
// over `claude-` because a bare `claude-` prefix would masquerade a
// non-Anthropic model as a Claude family member in any log / trace /
// error the user sees; the `code:` segment plus the colon separator
// make it visually obvious to anyone reading the wire that the id is
// a gateway alias, not an upstream-native Anthropic name.
export const CLAUDE_CODE_SYNTHETIC_PREFIX = 'claude-code:';

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on hold since this PR manipulated the model resolution


// Ids the CLI's `/^(claude|anthropic)/i` picker filter accepts without
// prefixing. Kept as a single shared regex so `toClaudeCodeShape` (which
// decides whether to prepend) and any future consumer stay in lockstep.
export const CLAUDE_CODE_PICKER_ID_ACCEPT = /^(claude|anthropic)/i;
35 changes: 31 additions & 4 deletions packages/gateway/src/data-plane/models/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import type { Context } from 'hono';

import { CLAUDE_CODE_PICKER_ID_ACCEPT, CLAUDE_CODE_SYNTHETIC_PREFIX } from './claude-code-prefix.ts';
import { loadModels } from './load.ts';
import { MODEL_LISTING_FAILURE_MESSAGE } from './shared.ts';
import { createPerRequestFetcher } from '../../dial/per-request.ts';
Expand All @@ -16,7 +17,7 @@ import { ProviderModelsUnavailableError } from '@floway-dev/provider';

// Anthropic's official /v1/models shape — `{data, first_id, has_more,
// last_id}` with `ModelInfo` rows — served to Claude Code CLI's `/model`
// picker. Two picker mechanics dictate the fields below.
// picker. Three picker mechanics dictate the fields below.
//
// (1) The CLI's `[1m]` suffix convention — append `[1m]` to a model id and
// the CLI switches that pick to the 1M-context window — only reaches the
Expand All @@ -28,7 +29,23 @@ import { ProviderModelsUnavailableError } from '@floway-dev/provider';
// which providers already honor (Copilot's `context1m` variant selector;
// Claude Code passes it through to the upstream).
//
// (2) Mirroring the official shape (instead of the OpenAI-Anthropic
// (2) The picker only accepts discovered ids matching
// `/^(claude|anthropic)/i` — Anthropic documents this at
// https://code.claude.com/docs/en/llm-gateway-protocol#model-discovery
// ("ignores entries whose `id` doesn't begin with `claude` or
// `anthropic`"); see ./claude-code-prefix.ts for the extracted predicate
// and the second, built-in-family-collision filter it pairs with. Any
// non-Anthropic model advertised through gateway discovery is silently
// dropped from the menu unless its id starts with one of those two
// prefixes. We prepend `CLAUDE_CODE_SYNTHETIC_PREFIX` on those ids so
// the picker admits them; because the picker renders `display_name`
// (with id as a fallback), the original label the operator configured
// is what the user sees. The prefix is stripped back off in
// `enumerateModelCandidates` when the same id comes in on `/v1/messages`
// (or any other data-plane endpoint) so routing lands on the real
// model.
//
// (3) Mirroring the official shape (instead of the OpenAI-Anthropic
// superset the handler serves everyone else) also lets any future
// Anthropic-native picker consume the payload verbatim. `capabilities`
// is nullable per the SDK type; we do not track every dimension the
Expand All @@ -45,10 +62,20 @@ import { ProviderModelsUnavailableError } from '@floway-dev/provider';
// https://github.com/anthropics/anthropic-sdk-typescript/blob/main/src/resources/models.ts
const toClaudeCodeShape = (response: PublicModelsResponse) => {
const CREATED_AT_UNKNOWN = '1970-01-01T00:00:00Z';
const data = response.data.map(model => {
// The CLI's `/model` picker is a chat surface — embedding and image models
// in the response only clutter the menu. Mirrors the same chat-only narrow
// already done by the Codex CLI discovery handler at ../codex/models.ts
// and by `loadGeminiModels` at ./gemini.ts.
const data = response.data.filter(model => model.kind === 'chat').map(model => {
const max = model.limits.max_context_window_tokens;
// Prefix decision runs on the raw id (so a real `claude-*` never gets
// double-prefixed), then [1m] is appended to the possibly-prefixed
// form so the CLI's suffix strip lands cleanly on either shape.
const accepted = CLAUDE_CODE_PICKER_ID_ACCEPT.test(model.id);
const withPrefix = accepted ? model.id : `${CLAUDE_CODE_SYNTHETIC_PREFIX}${model.id}`;
const withSuffix = max !== undefined && max >= 1_000_000 ? `${withPrefix}[1m]` : withPrefix;
return {
id: max !== undefined && max >= 1_000_000 ? `${model.id}[1m]` : model.id,
id: withSuffix,
type: 'model' as const,
display_name: model.display_name,
created_at: model.created_at ?? CREATED_AT_UNKNOWN,
Expand Down
112 changes: 112 additions & 0 deletions packages/gateway/src/data-plane/models/serve_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -976,3 +976,115 @@ test('/v1/models serves Anthropic-shape rows without a [1m] suffix when no model
},
);
});

// Non-Anthropic ids get a `claude-code:` synthetic prefix so the CLI's
// `/^(claude|anthropic)/i` picker filter admits them. `display_name`
// stays untouched because the picker renders `display_name ?? id`, so the
// operator-configured label reaches the user unchanged. The `[1m]` suffix
// composes on the possibly-prefixed form (`claude-code:<id>[1m]`).
// Embedding and image models are dropped upstream of the prefix rewrite —
// the picker is a chat surface, matching the same chat-only narrow the
// Codex and Gemini discovery handlers already apply.
test('/v1/models prefixes non-Anthropic ids for the Claude Code CLI picker while preserving display_name', async () => {
const { repo, apiKey } = await setupAppTest();

// Custom upstream carrying an image model (Copilot's fixture only emits
// chat and embedding kinds; image classification comes from the id-tier
// heuristic on a non-Copilot upstream — see the '/models superset' test
// for the same setup).
await repo.upstreams.save(buildCustomUpstreamRecord({
id: 'up_images_proj',
name: 'Image Provider',
sortOrder: 100,
config: {
baseUrl: 'https://images-proj.example.com',
authStyle: 'bearer',
apiKey: 'sk-images-proj',
endpoints: { },
},
}));

await withMockedFetch(
request => {
const url = new URL(request.url);
if (url.hostname === 'update.code.visualstudio.com') return jsonResponse(['1.110.1']);
if (url.pathname === '/copilot_internal/v2/token') {
return jsonResponse({
token: 'copilot-access-token',
expires_at: 4102444800,
refresh_in: 3600,
endpoints: { api: 'https://api.individual.githubcopilot.com' },
});
}
if (url.pathname === '/models' && url.hostname === 'api.individual.githubcopilot.com') {
return jsonResponse(
copilotModels([
{
id: 'claude-opus-4.7',
display_name: 'Claude Opus 4.7',
supported_endpoints: ['/v1/messages'],
maxContextWindowTokens: 1_000_000,
maxOutputTokens: 128_000,
},
{
id: 'gpt-4o',
display_name: 'GPT-4o',
supported_endpoints: ['/chat/completions'],
maxContextWindowTokens: 128_000,
maxOutputTokens: 16_384,
},
{
id: 'gpt-5-1m',
display_name: 'GPT-5 (1M)',
supported_endpoints: ['/chat/completions'],
maxContextWindowTokens: 1_000_000,
maxOutputTokens: 128_000,
},
{
id: 'text-embedding-3-large',
display_name: 'Text Embedding 3 Large',
supported_endpoints: ['/embeddings'],
},
]),
);
}
if (url.hostname === 'images-proj.example.com' && url.pathname === '/v1/models') {
return jsonResponse({ object: 'list', data: [{ id: 'gpt-image-2' }] });
}
throw new Error(`Unhandled fetch ${request.url}`);
},
async () => {
const claudeCodeResp = await requestApp('/v1/models', {
headers: { 'x-api-key': apiKey.key, 'user-agent': 'claude-code/2.1.211' },
});
assertEquals(claudeCodeResp.status, 200);
const claudeCodeBody = (await claudeCodeResp.json()) as {
data: Array<{ id: string; display_name: string }>;
};
const byDisplayName = new Map(claudeCodeBody.data.map(m => [m.display_name, m.id]));

// Real Anthropic id passes the picker filter as-is; [1m] still lands.
assertEquals(byDisplayName.get('Claude Opus 4.7'), 'claude-opus-4-7[1m]');
// Non-Anthropic id gets the synthetic prefix so it reaches the menu.
assertEquals(byDisplayName.get('GPT-4o'), 'claude-code:gpt-4o');
// Prefix composes with the [1m] suffix on 1M-capable non-Anthropic models.
assertEquals(byDisplayName.get('GPT-5 (1M)'), 'claude-code:gpt-5-1m[1m]');

// Non-chat kinds never reach the picker — they would only clutter
// a chat-only surface, and the CLI can't dispatch to them anyway.
const ids = claudeCodeBody.data.map(m => m.id);
assertEquals(ids.includes('claude-code:text-embedding-3-large'), false);
assertEquals(ids.includes('claude-code:gpt-image-2'), false);
assertEquals(claudeCodeBody.data.length, 3);

// Non-CC caller still gets the OpenAI-Anthropic superset — full catalog
// with all kinds, raw ids, no prefix applied.
const openAiResp = await requestApp('/v1/models', {
headers: { 'x-api-key': apiKey.key, 'user-agent': 'openai-python/1.42.0' },
});
const openAiBody = (await openAiResp.json()) as { data: Array<{ id: string }> };
const openAiIds = openAiBody.data.map(m => m.id).sort();
assertEquals(openAiIds, ['claude-opus-4-7', 'gpt-4o', 'gpt-5-1m', 'gpt-image-2', 'text-embedding-3-large']);
},
);
});
98 changes: 66 additions & 32 deletions packages/gateway/src/data-plane/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { fetchUpstreamModelsCached } from './models-cache.ts';
import { createPerRequestFetcher } from '../../dial/per-request.ts';
import { getRepo } from '../../repo/index.ts';
import type { ModelAliasRecord } from '../../repo/types.ts';
import { CLAUDE_CODE_SYNTHETIC_PREFIX } from '../models/claude-code-prefix.ts';
import type { BackgroundScheduler } from '@floway-dev/platform';
import { type ModelKind, kindForEndpoints } from '@floway-dev/protocols/common';
import { isAbortError, type Fetcher, type FlagDefaults, type InternalModel, type ModelCandidate, type Provider, type ProviderModel, type ProviderModule, type UpstreamProviderKind, type UpstreamRecord } from '@floway-dev/provider';
Expand Down Expand Up @@ -435,7 +436,8 @@ const orderAliasTargets = (alias: ModelAliasRecord): readonly ModelAliasRecord['
return shuffled;
};

// Per-request model resolution. Two-branch chain:
// Per-request model resolution. Two-branch chain, then one prefix-strip
// fallback:
//
// 1. Look the inbound id up in the alias repo. When the id names an
// alias, walk every target in `selection`-mode order, delegate to the
Expand All @@ -449,6 +451,14 @@ const orderAliasTargets = (alias: ModelAliasRecord): readonly ModelAliasRecord['
// failing at the first target.
// 2. Otherwise (no alias match at all) run the real-catalog resolver
// directly on the inbound id.
// 3. If the whole chain missed AND the inbound id starts with
// `CLAUDE_CODE_SYNTHETIC_PREFIX` (see ../models/claude-code-prefix.ts),
// strip the prefix and rerun (1)+(2). This is the counterpart to the
// discovery-side rewrite `toClaudeCodeShape` applies when serving the
// Claude Code CLI's `/model` picker: a non-Anthropic model advertised
// as `<prefix><real-id>` gets routed back to `<real-id>`. The direct
// lookup always runs first so an admin alias literally named
// `<prefix><name>` keeps precedence.
//
// The real-catalog resolver walks every visible upstream, filters by kind
// inside the walk (so wrong-kind entries never become candidates), and
Expand Down Expand Up @@ -490,38 +500,62 @@ export const enumerateModelCandidates = async ({
const fetcherForUpstream = await createPerRequestFetcher(runtimeLocation);
const providers = await listModelProviders(upstreamIds);

const alias = await getRepo().modelAliases.getByName(model);
if (alias === null) {
return await resolveRealCandidates(model, kind, providers, fetcherForUpstream, scheduler);
}
const attempt = async (m: string): Promise<{
readonly candidates: readonly ModelCandidate[];
readonly sawModel: boolean;
readonly failedUpstreams: readonly string[];
}> => {
const alias = await getRepo().modelAliases.getByName(m);
if (alias === null) {
return await resolveRealCandidates(m, kind, providers, fetcherForUpstream, scheduler);
}

// Walk every target, tag each returned candidate with the target's rule
// overlay, then flatten (target order preserved), and dedup by
// (modelId, upstreamId, rules). Different rules against the same
// (model, upstream) stay as distinct entries so the operator can pin the
// same physical binding under two rule variants.
const aggregatedFailed = new Set<string>();
let sawAny = false;
const flat: ModelCandidate[] = [];
for (const target of orderAliasTargets(alias)) {
const result = await resolveRealCandidates(target.target_model_id, kind, providers, fetcherForUpstream, scheduler);
for (const name of result.failedUpstreams) aggregatedFailed.add(name);
if (result.sawModel) sawAny = true;
for (const candidate of result.candidates) {
flat.push({ ...candidate, rules: target.rules });
// Walk every target, tag each returned candidate with the target's rule
// overlay, then flatten (target order preserved), and dedup by
// (modelId, upstreamId, rules). Different rules against the same
// (model, upstream) stay as distinct entries so the operator can pin the
// same physical binding under two rule variants.
const aggregatedFailed = new Set<string>();
let sawAny = false;
const flat: ModelCandidate[] = [];
for (const target of orderAliasTargets(alias)) {
const result = await resolveRealCandidates(target.target_model_id, kind, providers, fetcherForUpstream, scheduler);
for (const name of result.failedUpstreams) aggregatedFailed.add(name);
if (result.sawModel) sawAny = true;
for (const candidate of result.candidates) {
flat.push({ ...candidate, rules: target.rules });
}
}
}
const deduped: ModelCandidate[] = [];
for (const candidate of flat) {
const duplicate = deduped.some(existing =>
existing.model.id === candidate.model.id
&& existing.provider.upstream === candidate.provider.upstream
&& isEqual(existing.rules, candidate.rules));
if (!duplicate) deduped.push(candidate);
}
return {
candidates: deduped,
sawModel: sawAny,
failedUpstreams: [...aggregatedFailed],
const deduped: ModelCandidate[] = [];
for (const candidate of flat) {
const duplicate = deduped.some(existing =>
existing.model.id === candidate.model.id
&& existing.provider.upstream === candidate.provider.upstream
&& isEqual(existing.rules, candidate.rules));
if (!duplicate) deduped.push(candidate);
}
return {
candidates: deduped,
sawModel: sawAny,
failedUpstreams: [...aggregatedFailed],
};
};

// Symmetric with the discovery-side rewrite in ../models/serve.ts: a
// Claude Code CLI caller sending back a synthetic-prefixed id (see
// CLAUDE_CODE_SYNTHETIC_PREFIX) never resolves on the first try because
// no upstream carries that literal string. Strip the prefix and try
// again; the direct-lookup path always runs first so an admin who ever
// creates a real alias literally named `<prefix><name>` keeps precedence.
// Unconditional (no UA gate) because the CLI's discovery UA
// (`claude-code/*`) diverges from the Messages UA (`claude-cli/*`) it
// sends inference through, and other clients that reuse the discovered
// list also benefit from the same round-trip working.
const primary = await attempt(model);
if (!primary.sawModel && model.startsWith(CLAUDE_CODE_SYNTHETIC_PREFIX)) {
const stripped = model.slice(CLAUDE_CODE_SYNTHETIC_PREFIX.length);
const retry = await attempt(stripped);
if (retry.sawModel) return retry;
}
return primary;
};
Loading