diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index ff299a2b440..59a33d18192 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -13,6 +13,16 @@ Routing turns the model id sent by a client into one concrete provider and upstr | `combos?` | `Record` | `{}` | Virtual `combo/` models built from ordered provider/model targets. | | `routingProfiles?` | `Record` | `{}` | Virtual `policy/` models that select among an explicit candidate allowlist using hard capability requirements and deterministic scoring. | +### Model alias discovery + +Set `preferModelAliasIds: true` to publish configured or enabled built-in model aliases +as the `id` in `GET /v1/models`. The default is `false`. Promoted rows retain their +previous qualified ID in `native_id` and keep `alias_of`; both IDs remain routable. +Models without aliases keep their existing IDs. If a short alias is ambiguous across +providers or is shadowed by another route, the row keeps its qualified ID so selecting +it still reaches the intended model. The qualified ID also stays when a built-in short +alias would produce an unparseable synthetic Fast selector. Codex's own model catalog is unchanged. + ### Codex Pool low-quota protection `codexPool.lowQuotaProtection` applies only to stored Codex Pool accounts. The Desktop/main diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 22640c7d5b2..f722de4f5d6 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json"], - "explicit": { "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", + "explicit": { "prefer-model-alias-ids.test.ts": "server", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "owner-registry-isolation.test.ts": "config", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 01c53522a53..52f092be756 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1160,6 +1160,11 @@ async function fetchExposedModelIds(live: LiveProxy, fetchFn: ExposedModelsFetch if (id.length === 0) continue; if (id.length > EXPOSED_MODEL_ID_MAX_LENGTH) return null; ids.add(id); + const nativeId = (row as { native_id?: unknown }).native_id; + if (nativeId !== undefined) { + if (typeof nativeId !== "string" || nativeId.length > EXPOSED_MODEL_ID_MAX_LENGTH) return null; + if (nativeId.length > 0) ids.add(nativeId); + } } return ids; } catch { diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 36a09ba78f9..f49e8efc790 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -207,6 +207,7 @@ export const configSchema = z.object({ .catch(undefined), defaultProvider: z.string().min(1).default("openai"), defaultModelAliases: z.boolean().optional(), + preferModelAliasIds: z.boolean().optional().catch(false), // Malformed hand edits disable this opt-in projection without rejecting providers. cursorEffortRows: z.boolean().optional().catch(false), // Fast selectors default on; malformed hand edits disable them without rejecting providers. diff --git a/src/server/index/model-alias-id.ts b/src/server/index/model-alias-id.ts new file mode 100644 index 00000000000..e4204f53a50 --- /dev/null +++ b/src/server/index/model-alias-id.ts @@ -0,0 +1,36 @@ +import { fastRowBases } from "../fast-row"; +import { previewRouteModel } from "../../router"; +import type { OcxConfig } from "../../types"; + +/** + * Build the alias promoter for one model listing. The Fast-base inventory walks + * the configured catalog, so it is computed at most once per listing instead of + * once per aliased row. + */ +export function modelAliasIdPromoter(config: OcxConfig) { + let isFastBase: ((id: string) => boolean) | undefined; + const fastBases = () => (isFastBase ??= fastRowBases(config)); + return (provider: string, model: string, alias: string, fastEligible: boolean) => + preferredModelAliasId(config, provider, model, alias, fastEligible, fastBases); +} + +/** Promote only a short alias whose ordinary route is this exact upstream model. */ +export function preferredModelAliasId( + config: OcxConfig, provider: string, model: string, alias: string, fastEligible: boolean, + fastBases: () => (id: string) => boolean = () => fastRowBases(config), +): string | undefined { + if (config.preferModelAliasIds !== true) return undefined; + // Built-in bare aliases are not stable Fast bases. Preserve the qualified ID + // when promotion would publish a synthetic sibling that ingress cannot parse. + if (config.fastRows !== false && fastEligible && !fastBases()(alias)) return undefined; + try { + // Preview cannot consume a combo's round-robin selection state. A policy/combo, + // reserved native name or another provider's native id must keep its own meaning. + const route = previewRouteModel(config, alias); + return route.routeReason === "model-alias" && route.providerName === provider && route.modelId === model + ? alias : undefined; + } catch { + // An ambiguous alias stays available through the existing qualified public id. + return undefined; + } +} diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 0159c536f8b..72af800ba1a 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -1,4 +1,5 @@ import type { Server, ServerWebSocket } from "bun"; +import { modelAliasIdPromoter } from "./model-alias-id"; import type { StartServerDeps } from "./startup-warnings"; import { isLinkManagementPath } from "../../link/management-routes"; import { @@ -1252,6 +1253,7 @@ export function createServeOptions(ctx: ServeOptionsContext) { effortRowKnownIds, )); }; + const promoteAliasId = modelAliasIdPromoter(config); const routedRows = await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered) .filter(m => listAllows(m.provider, m.id)) .map(async m => { @@ -1269,8 +1271,11 @@ export function createServeOptions(ctx: ServeOptionsContext) { knownModelIdsForProvider(m.provider, provider, config), ).get(m.id) : undefined; + const effectiveId = !isCombo && !fastModelId && effective + ? promoteAliasId(m.provider, m.id, effective.alias, catalogRowFastEligible(m)) ?? publicId + : publicId; const row = { - id: publicId, + id: effectiveId, object: "model", created: 0, // This endpoint is an OpenAI-compatible inbound contract. Some clients use @@ -1279,6 +1284,9 @@ export function createServeOptions(ctx: ServeOptionsContext) { owned_by: isCombo ? "openai" : (m.owned_by ?? m.provider), ...(isCombo ? { is_combo: true } : {}), ...(effective ? { alias_of: `${provider?.alias || m.provider}/${effective.alias}` } : {}), + // When preferModelAliasIds promotes the alias to `id`, retain the original + // provider/nativeId so clients that need the upstream identity can read it. + ...(effectiveId !== publicId ? { native_id: publicId } : {}), ...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort), ...modelCapabilityFields({ reasoningEfforts: m.reasoningEfforts, diff --git a/src/types/config.ts b/src/types/config.ts index 9b56063c760..2746a26395b 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -569,6 +569,21 @@ export interface OcxConfig { }; /** Enable the shipped model alias patterns for providers without an override. */ defaultModelAliases?: boolean; + /** + * When `true`, the raw OpenAI-compatible `/v1/models` list publishes a safe alias (e.g. + * `SEK-glm-4.7-flash`) as the row `id` instead of `provider/nativeId` + * (e.g. `Seek/glm-4.7-flash`). The original `provider/nativeId` moves to a + * new `native_id` field so clients that need it can still read it. + * + * **Default: `false`** — the historical `provider/nativeId` form remains the + * published `id`, and the alias is in the informational `alias_of` field. + * + * Codex (`client_version`) and Anthropic-specific discovery retain their own + * identities and are unchanged. Ambiguous or shadowed short aliases retain the + * qualified ID, as do aliases with unparseable synthetic Fast siblings. + * Existing requests using `provider/nativeId` continue to work unchanged. + */ + preferModelAliasIds?: boolean; /** * Opt-in Cursor Private Inference compatibility rows. When true, `/v1/models` * adds `--` selectors for reasoning-capable model ids absent diff --git a/structure/catalog.md b/structure/catalog.md index 65a9ef46769..243c8a95c74 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -295,7 +295,7 @@ a label edit refresh Codex output. Raw `/v1/models` rows advertise positive safe capacity values in both Cursor's nested `capabilities` object and top-level discovery fields used by other clients. A model with a larger -opt-in context tier uses that effective long window in both shapes; invalid values are omitted. For `github-copilot`, `modelContextTiers` raises a selected long-context window only when an exact `modelContextWindows` value supplies per-model evidence; that value wins before the provider cap, while unknown models retain observed metadata. The gather fingerprint includes the map. +opt-in context tier uses that effective long window in both shapes; invalid values are omitted. For `github-copilot`, `modelContextTiers` raises a selected long-context window only when an exact `modelContextWindows` value supplies per-model evidence; that value wins before the provider cap, while unknown models retain observed metadata. The gather fingerprint includes the map. `src/server/index/model-alias-id.ts` applies opt-in `preferModelAliasIds` to `/v1/models` only: short aliases are promoted only when a read-only route preview identifies the same upstream by `model-alias`; ambiguous or shadowed aliases and aliases with unparseable synthetic Fast siblings retain qualified IDs; Cursor Fast remaps also keep their advertised identity. Promoted rows keep their original ID in `native_id` and retain `alias_of`. Codex catalog gathering is unchanged. `src/cli/doctor.ts` includes retained `native_id` values in model-exposure checks. Supported bare native GPT rows also consume `providers.openai.modelDisplayNames`. Retained sync and convergence pass the same map to the observed-state merge. After native normalization and diff --git a/structure/config.md b/structure/config.md index 07b41f1e312..e0d1b8deaa1 100644 --- a/structure/config.md +++ b/structure/config.md @@ -28,7 +28,7 @@ the [source-owned credential contract](codex-home.md#orca-source-owned-account-i ## Config surface -Custom Responses providers can explicitly enable `preserveResponsesInputItemIds` and `preserveResponsesMessageMetadata`; both default off and accept only booleans. The [replay sanitization contract](transports/responses-wire-shapes.md) keeps ordinary destinations unchanged and preserves xAI custom-call ID repair. `src/config/schema/compaction-recovery.ts` strictly validates opt-in `compactionRecovery`; invalid disk values disable it with a warning, while candidate writes reject them. `src/config/schema/blocked-model-redirects.ts` applies the same read-degrade/write-reject boundary to malformed `blockedModelRedirects` maps. The [failure-only contract](transports/responses-failover.md) leaves provider identity, accounts and client compaction unchanged. Optional `desktopCompatibility: { startOnProxyStart: boolean }` likewise rejects invalid writes and degrades invalid disk values to off; it saves neither certificate consent nor correction mode. Its field-scoped dashboard writer adopts committed state into the live comparison baseline; see [Codex Desktop startup](clients/codex-desktop.md#proxy-startup-preference). `src/cli/config-command.ts` accepts one leading UTF-8 BOM when parsing validate/import input from a file or stdin. JSON syntax and schema validation still run before persistence; BOM characters inside string values remain data. +`src/config/schema/config-schema.ts` validates the optional `preferModelAliasIds` flag as a boolean; malformed disk values disable this raw-list projection without rejecting providers. It defaults off. Custom Responses providers can explicitly enable `preserveResponsesInputItemIds` and `preserveResponsesMessageMetadata`; both default off and accept only booleans. The [replay sanitization contract](transports/responses-wire-shapes.md) keeps ordinary destinations unchanged and preserves xAI custom-call ID repair. `src/config/schema/compaction-recovery.ts` strictly validates opt-in `compactionRecovery`; invalid disk values disable it with a warning, while candidate writes reject them. `src/config/schema/blocked-model-redirects.ts` applies the same read-degrade/write-reject boundary to malformed `blockedModelRedirects` maps. The [failure-only contract](transports/responses-failover.md) leaves provider identity, accounts and client compaction unchanged. Optional `desktopCompatibility: { startOnProxyStart: boolean }` likewise rejects invalid writes and degrades invalid disk values to off; it saves neither certificate consent nor correction mode. Its field-scoped dashboard writer adopts committed state into the live comparison baseline; see [Codex Desktop startup](clients/codex-desktop.md#proxy-startup-preference). `src/cli/config-command.ts` accepts one leading UTF-8 BOM when parsing validate/import input from a file or stdin. JSON syntax and schema validation still run before persistence; BOM characters inside string values remain data. `skills.catalog_refresh` in the proxy JSON configuration accepts `per_session` (the runtime default when absent) or `per_turn`. The former retains received skills instructions for a conversation; the latter passes through the current catalog. This is separate from Codex's `skills.include_instructions` TOML switch and does not change the live dashboard probe. See the [Responses snapshot contract](transports/responses.md#responses-httpsse). diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e7b826a030f..b0f7f39cda1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { + "prefer-model-alias-ids.test.ts": "server", "messages-native-empty-inline.test.ts": "claude-integration", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", diff --git a/tests/server/prefer-model-alias-ids.test.ts b/tests/server/prefer-model-alias-ids.test.ts new file mode 100644 index 00000000000..557a1e337de --- /dev/null +++ b/tests/server/prefer-model-alias-ids.test.ts @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { parseFastRowId, fastRowBases } from "../../src/server/fast-row"; +import { knownEffortRowIds } from "../../src/server/effort-row"; +import { collectDefaultModelExposure } from "../../src/cli/doctor"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { routeModel } from "../../src/router"; +import { gatherRoutedModels } from "../../src/codex/catalog/provider-fetch"; +import { startServer } from "../../src/server"; +import type { OcxConfig } from "../../src/types"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * `preferModelAliasIds` changes the `/v1/models` row `id` from `provider/nativeId` to the + * effective model alias when one exists. This lets every downstream harness (Codex CLI, + * Command Code, Claude Code, Hermes) discover operator-chosen short names directly from the + * API, eliminating per-harness synchronization scripts. + * + * Each case starts a real server with `startServer(0)`, hits `GET /v1/models`, and inspects + * the JSON output — the same integration style used by Cursor local-models and Grok discovery. + */ +setDefaultTimeout(SERVER_BUDGET_MS); + +const previousHome = process.env.OPENCODEX_HOME; +let testHome = ""; + +function aliasConfig(prefer: boolean): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "seek", + ...(prefer ? { preferModelAliasIds: true } : {}), + providers: { + seek: { + adapter: "openai-chat", + baseUrl: "https://seek.example/v1", + alias: "SEK", + liveModels: false, + models: ["glm-4.7-flash"], + modelAliases: { + "glm-4.7-flash": "SEK-glm-4.7-flash", + }, + }, + }, + }; +} + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), "ocx-prefer-alias-")); + process.env.OPENCODEX_HOME = testHome; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testHome) removeTreeWithRetry(testHome); + testHome = ""; +}); + +describe("preferModelAliasIds on /v1/models", () => { + test("default (off): row id is provider/nativeId, no native_id field", async () => { + saveConfig(aliasConfig(false)); + const server = startServer(0); + try { + const res = await fetch(new URL("/v1/models", server.url)); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { id: string; alias_of?: string; native_id?: string }[] }; + const seekRow = body.data.find(m => m.id.includes("glm") || m.id.includes("SEK")); + expect(seekRow).toBeDefined(); + // Without the flag, id is the traditional provider/nativeId form + expect(seekRow!.id).toContain("/"); + // native_id is only added when alias is promoted + expect(seekRow!.native_id).toBeUndefined(); + // alias_of is present because we have modelAliases + expect(seekRow!.alias_of).toBeDefined(); + } finally { + server.stop(true); + } + }); + + test("on: row id becomes the effective alias, native_id preserves original", async () => { + saveConfig(aliasConfig(true)); + const server = startServer(0); + try { + const res = await fetch(new URL("/v1/models", server.url)); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { id: string; alias_of?: string; native_id?: string }[] }; + const seekRow = body.data.find(m => m.id === "SEK-glm-4.7-flash"); + expect(seekRow).toBeDefined(); + // alias is now the id + expect(seekRow!.id).toBe("SEK-glm-4.7-flash"); + // native_id preserves the old form + expect(seekRow!.native_id).toBeDefined(); + expect(seekRow!.native_id).toContain("glm-4.7-flash"); + expect(seekRow!.native_id).toContain("/"); + // alias_of still present for backward compatibility + expect(seekRow!.alias_of).toBeDefined(); + } finally { + server.stop(true); + } + }); + + test("on: models without modelAliases keep their provider/nativeId as id", async () => { + const config = aliasConfig(true); + // Add a second provider without modelAliases + config.providers.plain = { + adapter: "openai-chat", + baseUrl: "https://plain.example/v1", + liveModels: false, + models: ["test-model"], + }; + saveConfig(config); + const server = startServer(0); + try { + const res = await fetch(new URL("/v1/models", server.url)); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { id: string; alias_of?: string; native_id?: string }[] }; + const plainRow = body.data.find(m => m.id.includes("test-model")); + expect(plainRow).toBeDefined(); + // No alias → id stays as provider/nativeId + expect(plainRow!.id).toBe("plain/test-model"); + // No native_id added when no alias swap happened + expect(plainRow!.native_id).toBeUndefined(); + } finally { + server.stop(true); + } + }); +}); + + +test("shared aliases remain qualified, unique and routable", async () => { + const config = aliasConfig(true); + config.providers.other = { ...config.providers.seek!, alias: "OTH" }; + saveConfig(config); + const server = startServer(0); + try { + const body = await (await fetch(new URL("/v1/models", server.url))).json() as { data: { id: string }[] }; + const ids = body.data.map(row => row.id); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).not.toContain("SEK-glm-4.7-flash"); + expect(ids).toContain("seek/glm-4.7-flash"); + expect(ids).toContain("other/glm-4.7-flash"); + for (const id of ids) expect(() => routeModel(config, id)).not.toThrow(); + } finally { server.stop(true); } +}); + +test("a shadowed alias never advertises a different provider's route", async () => { + const config = aliasConfig(true); + config.providers.seek!.modelAliases = { "glm-4.7-flash": "plain-native" }; + config.providers.plain = { adapter: "openai-chat", baseUrl: "https://plain.example/v1", liveModels: false, models: ["plain-native"] }; + saveConfig(config); + const server = startServer(0); + try { + const body = await (await fetch(new URL("/v1/models", server.url))).json() as { data: { id: string; owned_by: string }[] }; + const row = body.data.find(row => row.owned_by === "seek")!; + expect(routeModel(config, row.id)).toMatchObject({ providerName: "seek", modelId: "glm-4.7-flash" }); + } finally { server.stop(true); } +}); + +test("preferred and native IDs share a route; Codex gather is unchanged", async () => { + const config = aliasConfig(true); + expect(routeModel(config, "SEK-glm-4.7-flash")).toMatchObject({ providerName: "seek", modelId: "glm-4.7-flash" }); + expect(routeModel(config, "seek/glm-4.7-flash")).toMatchObject({ providerName: "seek", modelId: "glm-4.7-flash" }); + const before = await gatherRoutedModels({ ...config, preferModelAliasIds: false }); + const after = await gatherRoutedModels(config); + expect(after.map(m => [m.id, m.alias])).toEqual(before.map(m => [m.id, m.alias])); + expect(after.filter(m => m.provider === "seek")).toHaveLength(1); +}); + + +test("built-in aliases preserve routable Fast siblings", async () => { + const config = aliasConfig(true); + config.defaultModelAliases = true; + config.providers.seek!.models = ["claude-haiku-4-5"]; + config.providers.seek!.modelAliases = {}; + config.providers.seek!.supportsServiceTier = true; + saveConfig(config); + const server = startServer(0); + try { + const body = await (await fetch(new URL("/v1/models", server.url))).json() as { data: { id: string }[] }; + const fast = body.data.filter(row => row.id.endsWith("--fast")); + expect(fast).toHaveLength(1); + for (const row of fast) { + const parsed = parseFastRowId(row.id, config, knownEffortRowIds(config), fastRowBases(config)); + expect(parsed).not.toBeNull(); + expect(routeModel(config, parsed!.baseId)).toMatchObject({ providerName: "seek", modelId: "claude-haiku-4-5" }); + } + } finally { server.stop(true); } +}); + + +test("Codex and Anthropic discovery retain one unchanged identity per model", async () => { + const snapshots: string[][][] = []; + for (const prefer of [false, true]) { + saveConfig(aliasConfig(prefer)); + const server = startServer(0); + try { + const codex = await (await fetch(new URL("/v1/models?client_version=0.130.0", server.url))).json() as { models: { slug: string }[] }; + const anthropic = await (await fetch(new URL("/v1/models", server.url), { headers: { "anthropic-version": "2023-06-01", "user-agent": "claude-code/2.1.207" } })).json() as { data: { id: string }[] }; + const ids = [codex.models.map(row => row.slug), anthropic.data.map(row => row.id)]; + for (const list of ids) { + expect(list.length).toBeGreaterThan(0); + expect(new Set(list).size).toBe(list.length); + } + snapshots.push(ids.map(list => list.toSorted())); + } finally { server.stop(true); } + } + expect(snapshots[1]).toEqual(snapshots[0]); +}); + + +test("Cursor Fast listing keeps the advertised Fast identity", async () => { + const config = aliasConfig(true); + config.fastMode = true; + config.defaultProvider = "cursor"; + config.providers = { cursor: { adapter: "openai-chat", baseUrl: "https://cursor.example/v1", liveModels: false, models: ["claude-opus-5"], modelAliases: { "claude-opus-5": "opus-pick" } } }; + saveConfig(config); + const server = startServer(0); + try { + const body = await (await fetch(new URL("/v1/models", server.url))).json() as { data: { id: string; native_id?: string }[] }; + expect(body.data.map(row => row.id)).toContain("cursor/claude-opus-5-thinking-fast"); + expect(body.data.map(row => row.id)).not.toContain("opus-pick"); + } finally { server.stop(true); } +}); + +test("the config schema validates the alias-ID opt-in independently", () => { + const config = { ...aliasConfig(true), port: 18276 }; + expect(configSchema.parse(config).preferModelAliasIds).toBe(true); + expect(configSchema.parse({ ...config, preferModelAliasIds: false }).preferModelAliasIds).toBe(false); + expect(configSchema.parse({ ...config, preferModelAliasIds: "true" }).preferModelAliasIds).toBe(false); +}); + +test("doctor recognizes the native ID retained on an alias row", async () => { + const result = await collectDefaultModelExposure({ + readConfiguredModelFn: () => "seek/glm-4.7-flash", + readCatalogModelsFn: () => null, + live: { pid: 4321, port: 18277, source: "config" }, + fetchFn: async () => new Response(JSON.stringify({ data: [{ id: "SEK-glm-4.7-flash", native_id: "seek/glm-4.7-flash" }] })), + }); + expect(result.status).toBe("exposed"); + expect(result.source).toBe("proxy"); +});