From 1558b0a4c4f2e20190cef48f0ba5a82fb8f697e7 Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 16 Jul 2026 16:42:57 +0800 Subject: [PATCH 1/5] feat(gateway): admit non-Anthropic models into Claude Code CLI picker The CLI's gateway-discovery `/model` picker filters incoming ids by `/^(claude|anthropic)/i`, silently dropping every other model. Prepend a `claude-code:` synthetic prefix in `toClaudeCodeShape` on any id that doesn't already pass the filter, and strip it back off in `enumerateModelCandidates` when the same id comes in on any data-plane endpoint. `display_name` stays untouched so the picker keeps rendering the operator's original label. Predicate extracted verbatim from `@anthropic-ai/claude-code@2.1.211`'s Bootstrap Gateway handler; the `[1m]` suffix trick relies on the same de-collision from the CLI's built-in family map. --- .../data-plane/models/claude-code-prefix.ts | 43 ++++++++ .../gateway/src/data-plane/models/serve.ts | 25 ++++- .../src/data-plane/providers/registry.ts | 98 +++++++++++++------ 3 files changed, 131 insertions(+), 35 deletions(-) create mode 100644 packages/gateway/src/data-plane/models/claude-code-prefix.ts diff --git a/packages/gateway/src/data-plane/models/claude-code-prefix.ts b/packages/gateway/src/data-plane/models/claude-code-prefix.ts new file mode 100644 index 000000000..a97bb10d8 --- /dev/null +++ b/packages/gateway/src/data-plane/models/claude-code-prefix.ts @@ -0,0 +1,43 @@ +// 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: +// +// models.filter(m => /^(claude|anthropic)/i.test(m.id)) +// .filter(m => { const fam = knownFamily(m.id); return fam === null || fam === fable5Family; }) +// +// The second filter drops any id that exact-matches a family string the +// binary already ships built-in (haiku/sonnet/opus/etc.) — discovery is +// meant to advertise EXTRA models beyond the built-in list — with a +// carve-out for the `fable5` family. Extracted verbatim from +// `@anthropic-ai/claude-code@2.1.211`'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. +// +// 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 `[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:'; + +// 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; diff --git a/packages/gateway/src/data-plane/models/serve.ts b/packages/gateway/src/data-plane/models/serve.ts index 8f6619bb5..58fc0f7b7 100644 --- a/packages/gateway/src/data-plane/models/serve.ts +++ b/packages/gateway/src/data-plane/models/serve.ts @@ -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'; @@ -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 @@ -28,7 +29,19 @@ 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` (see ./claude-code-prefix.ts for the extracted +// predicate). 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 @@ -47,8 +60,14 @@ const toClaudeCodeShape = (response: PublicModelsResponse) => { const CREATED_AT_UNKNOWN = '1970-01-01T00:00:00Z'; const data = response.data.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, diff --git a/packages/gateway/src/data-plane/providers/registry.ts b/packages/gateway/src/data-plane/providers/registry.ts index f22471f89..f9fc8856e 100644 --- a/packages/gateway/src/data-plane/providers/registry.ts +++ b/packages/gateway/src/data-plane/providers/registry.ts @@ -3,6 +3,7 @@ import { isEqual } from 'es-toolkit'; import { unionEndpoints } from './endpoint-union.ts'; import { fetchUpstreamModelsCached } from './models-cache.ts'; import { createPerRequestFetcher } from '../../dial/per-request.ts'; +import { CLAUDE_CODE_SYNTHETIC_PREFIX } from '../models/claude-code-prefix.ts'; import { getRepo } from '../../repo/index.ts'; import type { ModelAliasRecord } from '../../repo/types.ts'; import type { BackgroundScheduler } from '@floway-dev/platform'; @@ -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 @@ -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 `` gets routed back to ``. The direct +// lookup always runs first so an admin alias literally named +// `` keeps precedence. // // The real-catalog resolver walks every visible upstream, filters by kind // inside the walk (so wrong-kind entries never become candidates), and @@ -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(); - 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(); + 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 `` 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; }; From d9fb6a5201e06eb30fecbb1022cf829e4d69eaed Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 16 Jul 2026 16:46:44 +0800 Subject: [PATCH 2/5] test(gateway): cover the claude-code: prefix rewrite and strip-retry Adds a serve_test case that confirms `toClaudeCodeShape` prefixes non-Anthropic ids for the CC picker while leaving `display_name` intact and composing cleanly with the `[1m]` suffix, and two registry_test cases that pin down the strip-retry semantics: `claude-code:` routes to the real model, and a prefixed id whose stripped form is also unknown still misses cleanly. --- .../src/data-plane/models/serve_test.ts | 79 +++++++++++++++++++ .../src/data-plane/providers/registry_test.ts | 73 +++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/packages/gateway/src/data-plane/models/serve_test.ts b/packages/gateway/src/data-plane/models/serve_test.ts index 8f6396e1a..6b337ce8d 100644 --- a/packages/gateway/src/data-plane/models/serve_test.ts +++ b/packages/gateway/src/data-plane/models/serve_test.ts @@ -976,3 +976,82 @@ 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:[1m]`). +test('/v1/models prefixes non-Anthropic ids for the Claude Code CLI picker while preserving display_name', async () => { + const { apiKey } = await setupAppTest(); + + 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, + }, + ]), + ); + } + 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-CC caller still gets the OpenAI-Anthropic superset with raw ids — + // the prefix is scoped to the CC-shape branch. + 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']); + }, + ); +}); + diff --git a/packages/gateway/src/data-plane/providers/registry_test.ts b/packages/gateway/src/data-plane/providers/registry_test.ts index 9bcc689ca..f8a97ed7f 100644 --- a/packages/gateway/src/data-plane/providers/registry_test.ts +++ b/packages/gateway/src/data-plane/providers/registry_test.ts @@ -343,6 +343,79 @@ test('enumerateModelCandidates prefers the literal dated id over the stripped ba ); }); +test('enumerateModelCandidates strips the claude-code: synthetic prefix and routes to the real model when the raw form misses', async () => { + // Counterpart to the discovery-side rewrite in ../models/serve.ts: the + // Claude Code CLI picks a non-Anthropic model as `claude-code:`, + // no upstream advertises the literal prefixed form, so the resolver + // strips the prefix and rerun (alias-then-real) on ``. + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.upstreams.save( + buildCustomUpstreamRecord({ + config: { + baseUrl: 'https://custom.example.com', + authStyle: 'bearer', + apiKey: 'sk-custom', + endpoints: { chatCompletions: {} }, + }, + }), + ); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'custom.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ object: 'list', data: [{ id: 'gpt-5' }] }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const prefixed = await enumerateModelCandidates({ upstreamIds: null, model: 'claude-code:gpt-5', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + assertEquals(prefixed.candidates.length, 1); + assertEquals(prefixed.candidates[0]?.model.id, 'gpt-5'); + assertEquals(prefixed.sawModel, true); + + // The raw id still resolves directly — the strip retry is only the + // second attempt, so no double-resolution. + const bare = await enumerateModelCandidates({ upstreamIds: null, model: 'gpt-5', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + assertEquals(bare.candidates.length, 1); + assertEquals(bare.candidates[0]?.model.id, 'gpt-5'); + }, + ); +}); + +test('enumerateModelCandidates does not spuriously hit when a claude-code: prefixed id has no real form either', async () => { + // The strip retry runs only when the first attempt reports sawModel:false. + // A prefixed id whose stripped form is also unknown must still miss. + const { repo } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.upstreams.save( + buildCustomUpstreamRecord({ + config: { + baseUrl: 'https://custom.example.com', + authStyle: 'bearer', + apiKey: 'sk-custom', + endpoints: { chatCompletions: {} }, + }, + }), + ); + + await withMockedFetch( + request => { + const url = new URL(request.url); + if (url.hostname === 'custom.example.com' && url.pathname === '/v1/models') { + return jsonResponse({ object: 'list', data: [{ id: 'gpt-5' }] }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const resolved = await enumerateModelCandidates({ upstreamIds: null, model: 'claude-code:nonexistent-model', kind: 'chat', scheduler: testScheduler, runtimeLocation: 'TEST' }); + assertEquals(resolved.candidates.length, 0); + assertEquals(resolved.sawModel, false); + }, + ); +}); + test('enumerateRealModelCandidates only loads the selected providers\' catalogs', async () => { const { repo } = await setupAppTest(); await repo.upstreams.deleteAll(); From 4f45363a6d8848aabc53ed2c21f55f83bc099ced Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 16 Jul 2026 16:50:47 +0800 Subject: [PATCH 3/5] chore(lint): fix import order and trailing blank line --- packages/gateway/src/data-plane/models/serve_test.ts | 1 - packages/gateway/src/data-plane/providers/registry.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/gateway/src/data-plane/models/serve_test.ts b/packages/gateway/src/data-plane/models/serve_test.ts index 6b337ce8d..0d0fe0bc5 100644 --- a/packages/gateway/src/data-plane/models/serve_test.ts +++ b/packages/gateway/src/data-plane/models/serve_test.ts @@ -1054,4 +1054,3 @@ test('/v1/models prefixes non-Anthropic ids for the Claude Code CLI picker while }, ); }); - diff --git a/packages/gateway/src/data-plane/providers/registry.ts b/packages/gateway/src/data-plane/providers/registry.ts index f9fc8856e..757e8f768 100644 --- a/packages/gateway/src/data-plane/providers/registry.ts +++ b/packages/gateway/src/data-plane/providers/registry.ts @@ -3,9 +3,9 @@ import { isEqual } from 'es-toolkit'; import { unionEndpoints } from './endpoint-union.ts'; import { fetchUpstreamModelsCached } from './models-cache.ts'; import { createPerRequestFetcher } from '../../dial/per-request.ts'; -import { CLAUDE_CODE_SYNTHETIC_PREFIX } from '../models/claude-code-prefix.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'; From 909485fe095366043d1c85e52b1d302dac5cd1ef Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 16 Jul 2026 17:24:54 +0800 Subject: [PATCH 4/5] fix(gateway): drop non-chat models from Claude Code CLI picker `toClaudeCodeShape` returned the full catalog (chat + embedding + image) so gpt-image-2, text-embedding-*, and friends showed up in the picker menu where the CLI can't dispatch to them anyway. Filter to chat-only at the top of the projection, matching the same `model.kind === 'chat'` narrow that ../codex/models.ts and ./gemini.ts already apply for their own discovery surfaces. --- .../gateway/src/data-plane/models/serve.ts | 6 ++- .../src/data-plane/models/serve_test.ts | 42 +++++++++++++++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/gateway/src/data-plane/models/serve.ts b/packages/gateway/src/data-plane/models/serve.ts index 58fc0f7b7..d560326cc 100644 --- a/packages/gateway/src/data-plane/models/serve.ts +++ b/packages/gateway/src/data-plane/models/serve.ts @@ -58,7 +58,11 @@ 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 diff --git a/packages/gateway/src/data-plane/models/serve_test.ts b/packages/gateway/src/data-plane/models/serve_test.ts index 0d0fe0bc5..b6302c7da 100644 --- a/packages/gateway/src/data-plane/models/serve_test.ts +++ b/packages/gateway/src/data-plane/models/serve_test.ts @@ -982,8 +982,27 @@ test('/v1/models serves Anthropic-shape rows without a [1m] suffix when no model // 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:[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 { apiKey } = await setupAppTest(); + 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 => { @@ -1021,9 +1040,17 @@ test('/v1/models prefixes non-Anthropic ids for the Claude Code CLI picker while 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 () => { @@ -1043,14 +1070,21 @@ test('/v1/models prefixes non-Anthropic ids for the Claude Code CLI picker while // 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-CC caller still gets the OpenAI-Anthropic superset with raw ids — - // the prefix is scoped to the CC-shape branch. + // 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']); + assertEquals(openAiIds, ['claude-opus-4-7', 'gpt-4o', 'gpt-5-1m', 'gpt-image-2', 'text-embedding-3-large']); }, ); }); From d62f943d75434940625cb6fdcbfb01be38ff4c6e Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 16 Jul 2026 18:21:22 +0800 Subject: [PATCH 5/5] docs(gateway): cite the model-discovery protocol page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic documents the picker's `/^(claude|anthropic)/i` filter and the built-in-collision skip (with the Fable carve-out) at code.claude.com/docs/en/llm-gateway-protocol#model-discovery. Cite the URL as the primary source-of-truth in both places that assert the filter shape — `claude-code-prefix.ts` (predicate definition) and `serve.ts` item (2) (the load-bearing claim that motivates the synthetic prefix rewrite). The binary extraction from @anthropic-ai/claude-code@2.1.211 stays as corroborating detail that pins the exact carve-out family and evaluation order the prose leaves implicit. --- .../data-plane/models/claude-code-prefix.ts | 24 +++++++++++++------ .../gateway/src/data-plane/models/serve.ts | 24 +++++++++++-------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/packages/gateway/src/data-plane/models/claude-code-prefix.ts b/packages/gateway/src/data-plane/models/claude-code-prefix.ts index a97bb10d8..0651b104c 100644 --- a/packages/gateway/src/data-plane/models/claude-code-prefix.ts +++ b/packages/gateway/src/data-plane/models/claude-code-prefix.ts @@ -1,19 +1,29 @@ // 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: +// 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; }) // -// The second filter drops any id that exact-matches a family string the -// binary already ships built-in (haiku/sonnet/opus/etc.) — discovery is -// meant to advertise EXTRA models beyond the built-in list — with a -// carve-out for the `fable5` family. Extracted verbatim from -// `@anthropic-ai/claude-code@2.1.211`'s compiled `Bootstrap Gateway +// where `knownFamily` walks the CLI's built-in id→family map. Extracted +// from `@anthropic-ai/claude-code@2.1.211`'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. +// 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: // diff --git a/packages/gateway/src/data-plane/models/serve.ts b/packages/gateway/src/data-plane/models/serve.ts index d560326cc..e5a50b8dc 100644 --- a/packages/gateway/src/data-plane/models/serve.ts +++ b/packages/gateway/src/data-plane/models/serve.ts @@ -30,16 +30,20 @@ import { ProviderModelsUnavailableError } from '@floway-dev/provider'; // Claude Code passes it through to the upstream). // // (2) The picker only accepts discovered ids matching -// `/^(claude|anthropic)/i` (see ./claude-code-prefix.ts for the extracted -// predicate). 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. +// `/^(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