From 756304322a22400e651f5be7ba1ccd294dd77ad7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 00:57:41 +0800 Subject: [PATCH] feat(llm-pi-ai): modelOverrides reshapes catalog models without replacing the catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A route's modelOverrides dict customizes individual installed-catalog models — key = catalog model id, value = the same fields a models entry takes — while the rest of the catalog keeps serving, which a models list cannot express because declaring one replaces the served set. An override becomes the catalog entry's configuration and resolves through the existing entry path, so capacities, reasoningEfforts, compat, and request-default semantics are identical to a models entry's. Unlike Pi's config layer, which ignores unknown ids, every override that lands nowhere is refused at the write that produced it: beside a models list, on a hand-declared route, naming a model the catalog does not describe, or smuggling an id through the schema's unknown-key tolerance. --- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 13 +++- packages/llm/llm-pi-ai/README.zh.md | 13 +++- packages/llm/llm-pi-ai/src/catalog.ts | 39 ++++++++++- packages/llm/llm-pi-ai/src/config.ts | 30 ++++++++- packages/llm/llm-pi-ai/src/index.ts | 1 + packages/llm/llm-pi-ai/tests/catalog.spec.ts | 71 ++++++++++++++++++++ 7 files changed, 164 insertions(+), 7 deletions(-) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 69efba1977..c8ae1899bd 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: 894aecc720f0a7616c0127d439b41129d94ef667 -README.zh.md: 63464f80ee3036ddec3fb6828ecccc68c5524478 +README.md: f208f553ab3a1f80c5b71f4792e5fc80459f9fa5 +README.zh.md: 24ae4b0e2021eeea373eacb8cc1dfc39063fee8b diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 894aecc720..f208f553ab 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -35,6 +35,15 @@ Configure credentials, the model catalog, and deployment-specific transport sett models: - id: claude-sonnet-4-5 contextWindow: 200000 + # Catalog route with one model reshaped in place; the rest of the + # catalog keeps serving (a models list would replace it instead). + deepseek: + apiKeyEnv: DEEPSEEK_API_KEY + modelOverrides: + deepseek-v4-pro: + reasoningEfforts: + off: + high: high # Hand-declared route: pi-ai ships nothing under this key, so the profile # supplies the whole provider. acme-gateway: @@ -68,6 +77,8 @@ The dict shape makes duplicate routes unrepresentable, and the pre-release array A profile's `models` list *replaces* the route's installed catalog rather than extending it; omitting it (or leaving it empty) serves that catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a catalog route to two models, correcting one capacity, or adding a model newer than the installed catalog are all one-line edits — but declaring any `models` list means every model the route should keep serving must appear in it, an entry of nothing but `id` being enough. The configurable entry fields are `id`, `name`, `contextWindow`, `maxTokens`, `reasoningEfforts`, and `compat`. Pricing and input modalities have no harness consumer and ride the installed entry or are absent. +`modelOverrides` reshapes individual installed-catalog models without that cost: each key is a catalog model id, each value the same fields a `models` entry takes with the id living in the key, and the rest of the catalog keeps serving untouched — "correct one model, keep the other thirty-seven" as a three-line edit. An override becomes that catalog entry's configuration, so capacities, efforts, and compat resolve through the same path with the same diagnostics and the same request-default semantics as a `models` entry. Overrides are only meaningful on a catalog route serving its catalog: one set beside a `models` list (which already replaces the catalog), on a hand-declared route (whose models are fully spelled in `models`), or naming a model the catalog does not describe is refused rather than skipped, because a silently unchanged model is a typo someone would otherwise hunt for. + ### Per-model reasoning efforts `reasoningEfforts` declares a model's selectable thinking levels: each key is a level selectors offer, its value the spelling dispatch sends on the wire, so `high: high` passes the canonical name through while `max: ultra` renames it for a gateway with its own vocabulary. Keys come from pi-ai's level set (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`); a level not declared is not offered. Omitting the field keeps the installed catalog entry's capability (a hand-declared model has none and does not reason); `false` declares a non-reasoning model, which is how a profile strips reasoning from a catalog model its gateway cannot serve; an empty declaration is refused rather than guessing between those two meanings. @@ -98,7 +109,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 63464f80ee..24ae4b0e20 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -35,6 +35,15 @@ models: - id: claude-sonnet-4-5 contextWindow: 200000 + # Catalog route with one model reshaped in place; the rest of the + # catalog keeps serving (a models list would replace it instead). + deepseek: + apiKeyEnv: DEEPSEEK_API_KEY + modelOverrides: + deepseek-v4-pro: + reasoningEfforts: + off: + high: high # Hand-declared route: pi-ai ships nothing under this key, so the profile # supplies the whole provider. acme-gateway: @@ -68,6 +77,8 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩充它;省略它(或留空)则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑——但一旦声明了 `models` 列表,该路由要继续服务的每个模型就都必须出现在其中,条目哪怕只写一个 `id` 也足够。可配置的条目字段是 `id`、`name`、`contextWindow`、`maxTokens`、`reasoningEfforts` 与 `compat`。定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席。 +`modelOverrides` 无需这份代价就能就地重塑单个已安装 catalog 模型:每个键是一个 catalog 模型 id,每个值可写 `models` 条目接受的同一批字段,只是 id 落在键上,而 catalog 的其余部分原样继续服务——「改一个模型、其余三十七个原样保留」只是一次三行编辑。一条覆盖会成为该 catalog 条目的配置,因此容量、档位与 compat 沿与 `models` 条目相同的路径解析,携带相同的诊断与相同的请求默认值语义。覆盖只在正服务自身 catalog 的 catalog 路由上才有意义:与 `models` 列表并存的一份(该列表本就替换了 catalog)、落在手工声明路由上的一份(其模型已在 `models` 中完整写出),或点名了 catalog 未描述模型的一份,都会被拒绝而非跳过,因为一个静默保持原样的模型,就是一个否则要有人费力追查的笔误。 + ### 按模型的推理档位 `reasoningEfforts` 声明模型可选的思考级别:每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写,因此 `high: high` 原样透传规范名称,而 `max: ultra` 则为使用自有词汇的网关改名。键取自 pi-ai 的档位集合(`off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`);未声明的档位不会被提供。省略该字段会保留已安装 catalog 条目的能力(手工声明的模型没有这份能力,也不推理);`false` 声明一个不具备推理能力的模型,profile 正是以此从其网关无法服务的 catalog 模型上剥除推理;空声明会被拒绝,而不是在这两种含义之间去猜。 @@ -98,7 +109,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index e3c9207927..3285d1595a 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -184,6 +184,15 @@ export interface PiAiModelProfile { compat?: PiAiCompatProfile } +/** + * Customization of one installed catalog model, keyed by its id in the + * route's `modelOverrides` dict — the same fields a `models` entry may set, + * with the id living in the key. Unlike a `models` list, overrides leave the + * rest of the catalog serving untouched, which is what makes "correct one + * model, keep the other thirty-seven" a three-line edit. + */ +export type PiAiModelOverride = Omit + /** The route-level facts model materialization reads. */ export interface RouteCatalogRequest { /** Provider route key, stamped onto every materialized model. */ @@ -194,6 +203,8 @@ export interface RouteCatalogRequest { baseURL?: string /** Configured catalog; absent means the whole installed catalog for this route. */ models?: readonly PiAiModelProfile[] + /** Installed-catalog customizations by model id; only meaningful while `models` is absent. */ + modelOverrides?: Readonly> /** Reasoning-dispatch switches for every `openai-completions` model on the route; entries override per field. */ compat?: PiAiCompatProfile /** Context capacity for a model neither the entry nor the catalog sizes. */ @@ -381,9 +392,35 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { // schema materializes `[]` for the absent case, and an empty catalog could // serve no request anyway, so both mean "serve the installed catalog". const configured = request.models ?? [] + const overrides = request.modelOverrides ?? {} + // Every miss is refused, never skipped: an override that lands nowhere is a + // typo someone would otherwise hunt for in a silently unchanged model. + for (const [id, override] of Object.entries(overrides)) { + if (id.length === 0) invalid(provider, 'has a modelOverrides entry with an empty model id') + if (defaults.size === 0) { + invalid(provider, `sets modelOverrides for "${id}", but the installed catalog does not describe this route;` + + ' a declared route spells every model out in its models list') + } + if (configured.length > 0) { + invalid(provider, `sets modelOverrides for "${id}" beside a models list; models already replaces the served` + + ' catalog, so declare the fields on its entries') + } + if (!defaults.has(id)) { + invalid(provider, `modelOverrides names "${id}", which the installed catalog does not describe`) + } + // The id lives in the dict key; a value carrying its own would quietly + // rename the model it meant to customize. The static shape already omits + // it — this guards the schema boundary, which passes unknown keys through. + if ('id' in override) { + invalid(provider, `modelOverrides entry "${id}" sets "id", which is the dict key`) + } + } + // An override becomes the catalog entry's configuration, so everything a + // models entry may declare — capacities, efforts, compat — resolves through + // the same path with the same diagnostics and request-default semantics. const entries: readonly PiAiModelProfile[] = configured.length > 0 ? configured - : [...defaults.values()].map(model => ({ id: model.id })) + : [...defaults.values()].map(model => ({ id: model.id, ...overrides[model.id] })) if (entries.length === 0) { invalid(provider, 'resolves no models; the installed catalog does not describe this route, so its models' + ' must be listed in configuration') diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 9d4cca089c..d93f68bcd9 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -22,7 +22,7 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { resolveRouteModels, SUPPORTED_THINKING_FORMATS, THINKING_LEVELS } from './catalog.ts' -import type { PiAiCompatProfile, PiAiModelProfile, PiAiReasoningEfforts } from './catalog.ts' +import type { PiAiCompatProfile, PiAiModelOverride, PiAiModelProfile, PiAiReasoningEfforts } from './catalog.ts' import { buildProvider, supportedProtocols } from './provider.ts' /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -34,7 +34,13 @@ export const DEFAULT_CONTEXT_WINDOW = 262_144 /** Output capability assumed for a model neither configuration nor the catalog sizes. */ export const DEFAULT_MAX_TOKENS = 32_768 -export type { PiAiCompatProfile, PiAiModelProfile, PiAiReasoningEfforts, PiAiThinkingFormat } from './catalog.ts' +export type { + PiAiCompatProfile, + PiAiModelOverride, + PiAiModelProfile, + PiAiReasoningEfforts, + PiAiThinkingFormat, +} from './catalog.ts' /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { @@ -62,6 +68,15 @@ export interface PiAiProviderProfile { * unset fields from the installed model of the same id. */ models?: PiAiModelProfile[] + /** + * Installed-catalog customizations by model id: each entry reshapes that + * one model with the same fields a {@link models} entry takes, while the + * rest of the catalog keeps serving untouched. Only meaningful on a catalog + * route with no `models` list — `models` already replaces the catalog, so + * an override beside it, on a route the catalog does not ship, or naming a + * model the catalog does not describe is refused rather than skipped. + */ + modelOverrides?: Record /** * Reasoning-dispatch switches for every `openai-completions` model on this * route; each model's own `compat` overrides per field. What neither sets @@ -176,6 +191,15 @@ const modelProfile: z = z.object({ compat: compatProfile, }) +/** A {@link modelProfile} whose id lives in the `modelOverrides` dict key. */ +const modelOverride: z = z.object({ + name: z.string(), + contextWindow: z.number().step(1).min(1), + maxTokens: z.number().step(1).min(1), + reasoningEfforts: z.union([z.const(false), reasoningEfforts]), + compat: compatProfile, +}) + const profile = z.object({ apiKey: z.string().role('secret'), apiKeyEnv: z.string().role('credential-ref'), @@ -183,6 +207,7 @@ const profile = z.object({ api: z.union(supportedProtocols()), baseURL: z.string(), models: z.array(modelProfile), + modelOverrides: z.dict(modelOverride), compat: compatProfile, defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW), defaultMaxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS), @@ -291,6 +316,7 @@ export function resolveProfiles( ...source.api === undefined ? {} : { api: source.api }, ...source.baseURL === undefined ? {} : { baseURL: source.baseURL }, ...source.models === undefined ? {} : { models: source.models }, + ...source.modelOverrides === undefined ? {} : { modelOverrides: source.modelOverrides }, ...source.compat === undefined ? {} : { compat: source.compat }, defaultContextWindow: source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW, defaultMaxTokens: source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS, diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index ea81f66fec..e00b9f3c2a 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -70,6 +70,7 @@ export type { PiAiAdapterOptions } from './adapter.ts' export { Config } from './config.ts' export type { PiAiCompatProfile, + PiAiModelOverride, PiAiModelProfile, PiAiProviderProfile, PiAiReasoningEfforts, diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index fbfcd653cf..de806558c5 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -573,6 +573,77 @@ describe('per-model reasoning efforts', () => { }) }) +describe('modelOverrides', () => { + const deepseekModel = (): Model => { + const [model] = getBuiltinModels('deepseek') + if (model === undefined) throw new Error('the installed catalog ships no deepseek model') + return model + } + + it('reshapes one catalog model while the rest of the catalog keeps serving', () => { + const catalogSize = getBuiltinModels('deepseek').length + const target = deepseekModel() + const resolved = resolveProfiles({ + deepseek: { + modelOverrides: { + [target.id]: { + name: 'DeepSeek (proxied)', + maxTokens: 4096, + reasoningEfforts: { off: null, high: 'high' }, + }, + }, + }, + }) + const models = resolved.get('deepseek')?.piProvider.getModels() ?? [] + const reshaped = models.find(model => model.id === target.id) + if (reshaped === undefined) throw new Error('the overridden model vanished from the route') + + // The whole catalog still serves — that is the difference from `models`, + // which replaces it. + expect(models).toHaveLength(catalogSize) + expect(reshaped.name).toBe('DeepSeek (proxied)') + expect(getSupportedThinkingLevels(reshaped)).toEqual(['off', 'high']) + // An override's cap is explicit configuration, so it becomes the request + // default exactly as a models entry's would. + expect(resolved.get('deepseek')?.configuredMaxTokens.get(target.id)).toBe(4096) + // A sibling the overrides do not name is byte-identical to the catalog. + const sibling = models.find(model => model.id !== target.id) + expect(sibling?.maxTokens).toBe(getBuiltinModels('deepseek').find(model => model.id === sibling?.id)?.maxTokens) + }) + + it('refuses every override that lands nowhere instead of skipping it', () => { + expect(() => resolveProfiles({ + deepseek: { modelOverrides: { 'no-such-model': { name: 'ghost' } } }, + })).toThrow(/which the installed catalog does not describe/) + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ id: 'm' }], + modelOverrides: { m: { name: 'renamed' } }, + }, + })).toThrow(/a declared route spells every model out/) + const declaredOnly = deepseekModel() + expect(() => resolveProfiles({ + deepseek: { + models: [{ id: declaredOnly.id }], + modelOverrides: { [declaredOnly.id]: { name: 'renamed' } }, + }, + })).toThrow(/models already replaces the served catalog/) + expect(() => resolveProfiles({ + deepseek: { modelOverrides: { '': { name: 'nameless' } } }, + })).toThrow(/empty model id/) + // The dict key is the id; a value smuggling its own would quietly rename + // the model it meant to customize. The schema passes unknown keys + // through, so resolution is the boundary that refuses it — the variable + // indirection mirrors that boundary by sidestepping the literal check. + const smuggled = { name: 'x', id: 'other' } + expect(() => resolveProfiles({ + deepseek: { modelOverrides: { [deepseekModel().id]: smuggled } }, + })).toThrow(/sets "id", which is the dict key/) + }) +}) + describe('reasoning-dispatch compat switches', () => { /** The materialized models of one route, keyed by id. */ function modelsOf(providers: Record, route: string): Map> {