From 8aca201d2b64a9e743d8ce32a3e6e0714aa998f3 Mon Sep 17 00:00:00 2001 From: Nick <31124010+FudanSE-Nick@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:12:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(llm-pi-ai):=20=E7=9B=AE=E5=BD=95=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E7=9A=84=E6=A8=A1=E5=9E=8B=E5=8F=91=E7=8E=B0=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=90=88=E5=B9=B6=E7=BA=BF=E4=B8=8A=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「获取可用模型」对 pi-ai 内置目录路由(如 OpenCode Go)永远不发网络请求, 只回放打包时的静态快照,因此订阅网关在 pi-ai 两次发版之间新增的模型永远 无法出现——而这正是用户点这个按钮的目的。实测 opencode-go 快照为 23 个模型 (pi-ai 0.84.3,生成于 2026-08-24),而 https://opencode.ai/zen/go/v1/models 实时返回 35 个,缺 glm-5.3-flash、grok-4.6、omen-alpha、qwen3.8-flash 等 13 个,同时还保留了线上已下架的 ox-alpha-free。 即便手动把新模型写进 settings.yaml 也无法使用:resolveRouteModels 依赖 sharedCatalogApi 的「全体一致」判断来为快照外的模型补 api,而 opencode-go 横跨三种协议,该判断返回 undefined,于是整条路由解析失败并报 「model "…" needs an api」。唯一的出路是设置路由级 api,但它会覆盖所有同级 模型,反而弄坏另外两种协议的模型。 两层一起修: - 目录路由现在同时读取快照与自身端点,二者合并。快照携带清单不披露的容量, 因此在两边都描述某模型时快照优先;端点顺序决定新模型的位置。端点不可达、 凭据被拒、协议无可读清单时仍回落到快照——离线可用是这个动作原有的能力。 调用方主动取消(ABORTED)不会被回落成陈旧列表。 - sharedCatalogApi 换成 catalogEndpoint,按「多数」而非「全体一致」得出协议 及其配套 baseUrl。多数协议下的新模型因此能自动继承端点,而少数协议的同级 模型仍各自保留自己的 api/baseUrl,不会被拖到多数协议上。 按多数推断有猜错的可能,但备选是路由无法添加任何新模型,除非设路由级 api 弄坏同级模型;猜错的代价是该模型首次使用时被端点明确拒绝、有名有姓、可恢复。 草稿值、路由级 api/baseURL、以及目录未描述的自定义路由行为均保持不变。 --- ...deepseek-ai+dsh-llm-pi-ai+0.1.2-rc.1.patch | 203 +++++++++++- test/model-discovery-catalog-patch.test.ts | 303 ++++++++++++++++++ 2 files changed, 504 insertions(+), 2 deletions(-) create mode 100644 test/model-discovery-catalog-patch.test.ts diff --git a/patches/@deepseek-ai+dsh-llm-pi-ai+0.1.2-rc.1.patch b/patches/@deepseek-ai+dsh-llm-pi-ai+0.1.2-rc.1.patch index 8fc031b24..c2df765c7 100644 --- a/patches/@deepseek-ai+dsh-llm-pi-ai+0.1.2-rc.1.patch +++ b/patches/@deepseek-ai+dsh-llm-pi-ai+0.1.2-rc.1.patch @@ -1,8 +1,90 @@ diff --git a/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js b/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js -index 5c89b80..193f5b7 100644 +index c25e5c7..8a52d95 100644 --- a/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js +++ b/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js -@@ -1278,8 +1278,9 @@ function mapUsage(usage) { +@@ -511,17 +511,49 @@ function invalid(provider, detail) { + throw new Error(`llm-pi-ai: provider "${provider}" ${detail}`); + } + /** +-* The one wire protocol a catalog route's shipped models agree on. This is what +-* lets a deployment add a model the installed catalog has not caught up with — +-* a provider's newest release — without restating the protocol its siblings +-* already use. A route whose shipped models disagree (an OpenAI-style catalog +-* spanning Responses and Chat Completions) has no such answer, so a model it +-* does not describe must name its protocol at the route. +-*/ +-function sharedCatalogApi(defaults) { +- const apis = /* @__PURE__ */ new Set(); +- for (const model of defaults.values()) apis.add(model.api); +- return apis.size === 1 ? [...apis][0] : void 0; ++* The endpoint a catalog route's shipped models are dominated by: the wire ++* protocol the most of them speak, paired with the baseUrl those models agree ++* on. This is what lets a deployment add a model the installed catalog has not ++* caught up with — a provider's newest release — without restating endpoint ++* facts its siblings already carry. ++* ++* Dominance rather than unanimity, because a route whose shipped models ++* disagree (a subscription gateway spanning Chat Completions, Responses, and ++* Anthropic Messages) otherwise has no answer at all — and a `models` entry ++* cannot name its own protocol, so the only recourse left was a route-level ++* `api`, which wins over *every* sibling's and so breaks the ones speaking ++* another. Between guessing the plurality protocol for one new model and making ++* the route unable to name a new model without corrupting its siblings, the ++* plurality is the lesser error and the recoverable one: a model whose protocol ++* was guessed wrong is refused by its endpoint, named, on first use. ++* ++* @param defaults - the installed catalog models for the route. ++* @param listable - when given, only protocols in this set are considered. ++* @returns the dominant `api` and its `baseUrl`; empty when nothing qualifies. ++*/ ++function catalogEndpoint(defaults, listable) { ++ const tally = /* @__PURE__ */ new Map(); ++ for (const model of defaults.values()) { ++ if (listable !== void 0 && !listable.has(model.api)) continue; ++ const key = `${model.api}\n${model.baseUrl ?? ""}`; ++ tally.set(key, (tally.get(key) ?? 0) + 1); ++ } ++ let winner; ++ let winningCount = 0; ++ /* Insertion order is catalog order and `>` keeps the incumbent, so a tie ++ resolves to whichever pair the catalog lists first — stable across runs. */ ++ for (const [key, count] of tally) if (count > winningCount) { ++ winner = key; ++ winningCount = count; ++ } ++ if (winner === void 0) return {}; ++ const separator = winner.indexOf("\n"); ++ const api = winner.slice(0, separator); ++ const baseUrl = winner.slice(separator + 1); ++ return { ++ api, ++ ...baseUrl.length === 0 ? {} : { baseUrl } ++ }; + } + /** + * Resolve one model's reasoning capability from its declared efforts. +@@ -628,7 +660,7 @@ function resolveRouteModels(request) { + ...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"); +- const routeApi = sharedCatalogApi(defaults); ++ const routeEndpoint = catalogEndpoint(defaults); + assertOfferedCompatFields(provider, "route", request.compat); + for (const entry of entries) assertOfferedCompatFields(provider, `model "${entry.id}"`, entry.compat); + const seen = /* @__PURE__ */ new Set(); +@@ -638,9 +670,9 @@ function resolveRouteModels(request) { + if (seen.has(entry.id)) invalid(provider, `lists model "${entry.id}" more than once`); + seen.add(entry.id); + const base = defaults.get(entry.id); +- const api = request.api ?? base?.api ?? routeApi; ++ const api = request.api ?? base?.api ?? routeEndpoint.api; + if (api === void 0) invalid(provider, `model "${entry.id}" needs an api; the installed catalog does not describe it, so set the route's api to the wire protocol its endpoint speaks`); +- const baseUrl = request.baseURL ?? base?.baseUrl ?? providerBaseUrl; ++ const baseUrl = request.baseURL ?? base?.baseUrl ?? routeEndpoint.baseUrl ?? providerBaseUrl; + if (baseUrl === void 0) invalid(provider, `model "${entry.id}" needs a baseURL; the installed catalog does not describe this route`); + const contextWindow = entry.contextWindow ?? base?.contextWindow ?? request.defaultContextWindow; + if (!Number.isInteger(contextWindow) || contextWindow <= 0) invalid(provider, `model "${entry.id}" contextWindow must be a positive integer`); +@@ -1289,8 +1321,9 @@ function mapUsage(usage) { }; } function classifyPiAiError(message) { @@ -13,3 +95,120 @@ index 5c89b80..193f5b7 100644 if (/\b429\b|rate.?limit/i.test(message)) return "RATE_LIMIT"; if (/\b413\b|failed to buffer the request body:\s*length limit exceeded|payload too large|request body too large/i.test(message)) return "INVALID_REQUEST"; if (/\b400\b|invalid.?request/i.test(message)) return "INVALID_REQUEST"; +@@ -2017,11 +2050,20 @@ function authContextFrom(ctx) { + * Answering "which models can this provider serve?" for the configuration + * surface's "fetch available models" action. + * +-* A route the installed pi-ai catalog ships is answered **from that catalog**, +-* with no network call at all: pi-ai's registry is the authoritative list for +-* its own providers, and it carries the capacities a listing endpoint would +-* not disclose. Only a route the catalog does not describe — a gateway, a +-* self-hosted server — is interrogated over the wire. ++* A route the installed pi-ai catalog ships is answered from that catalog **and ++* from its endpoint**, the two merged. The catalog carries the capacities a ++* listing endpoint does not disclose, so it wins wherever both describe a ++* model; but the catalog is a build-time snapshot and a subscription gateway ++* adds models between pi-ai releases. Answering from the snapshot alone left ++* this action unable to ever report a model newer than the installed pi-ai — ++* the listing the user pressed the button for. A route the catalog does not ++* describe — a gateway, a self-hosted server — is answered from its endpoint ++* alone, as before. ++* ++* A catalog route whose endpoint cannot be read still answers from the ++* snapshot: an unreachable network, a refused key, or a protocol with no ++* readable listing leaves the shipped catalog as the better answer, and this ++* action has always been able to answer a catalog route offline. + * + * Neither path is a catalog refresh. Nothing here is stored: the request + * carries a draft the user is still editing, and the reply is candidate +@@ -2157,19 +2199,52 @@ function usableProbeKey(raw) { + * refuses or fails the request, or the reply is not a model listing. + */ + async function discoverModels(request, storedProfile) { +- if (request.provider !== void 0) { +- const installed = catalogModels(request.provider); +- if (installed.size > 0) return [...installed.values()].map((model) => ({ +- id: model.id, +- name: model.name, +- contextWindow: model.contextWindow, +- maxTokens: model.maxTokens +- })); ++ const installed = request.provider === void 0 ? /* @__PURE__ */ new Map() : catalogModels(request.provider); ++ const shipped = () => [...installed.values()].map((model) => ({ ++ id: model.id, ++ name: model.name, ++ contextWindow: model.contextWindow, ++ maxTokens: model.maxTokens ++ })); ++ if (installed.size === 0) return listEndpointModels(request, storedProfile, request.baseURL, request.api); ++ /* A catalog route's own endpoint facts, so the listing reaches the gateway ++ the route actually serves without the draft having to restate them. The ++ draft still wins: it is what the user is editing. */ ++ const endpoint = catalogEndpoint(installed, LISTABLE_PROTOCOLS); ++ const baseURL = request.baseURL ?? endpoint.baseUrl; ++ const api = request.api ?? endpoint.api; ++ if (baseURL === void 0 || baseURL.length === 0 || api === void 0) return shipped(); ++ let advertised; ++ try { ++ advertised = await listEndpointModels(request, storedProfile, baseURL, api); ++ } catch (error) { ++ /* Cancellation is the caller's own decision and must not be answered ++ with a stale list; every other failure — unreachable, refused, or ++ unreadable — leaves the shipped catalog as the better answer. */ ++ if (error instanceof LlmError && error.code === "ABORTED") throw error; ++ return shipped(); + } +- if (request.baseURL === void 0 || request.baseURL.length === 0) throw new LlmError(`pi-ai ships no catalog for provider "${request.provider ?? ""}", so its models can only come from its endpoint; set a baseURL, or enter this provider's models by hand`, "DISCOVERY_FAILED"); +- const api = request.api ?? "openai-completions"; ++ /* Catalog first: it carries capacities the listing omits. Endpoint order ++ decides where a model the catalog has not caught up with appears. */ ++ const merged = new Map(advertised.map((model) => [model.id, model])); ++ for (const model of shipped()) merged.set(model.id, model); ++ return [...merged.values()]; ++} ++/** ++* Read one endpoint's OpenAI-compatible model listing. ++* @param request - the draft being edited, for its credential and cancellation. ++* @param storedProfile - host-owned headers and lazy credential resolution. ++* @param baseURL - the endpoint to interrogate. ++* @param api - the wire protocol it speaks. ++* @returns the advertised models in endpoint order. ++* @throws LlmError when the protocol has no readable listing, the endpoint ++* refuses or fails the request, or the reply is not a model listing. ++*/ ++async function listEndpointModels(request, storedProfile, baseURL, api$1) { ++ if (baseURL === void 0 || baseURL.length === 0) throw new LlmError(`pi-ai ships no catalog for provider "${request.provider ?? ""}", so its models can only come from its endpoint; set a baseURL, or enter this provider's models by hand`, "DISCOVERY_FAILED"); ++ const api = api$1 ?? "openai-completions"; + if (!LISTABLE_PROTOCOLS.has(api)) throw new LlmError(`pi-ai protocol "${api}" has no model listing this build can read; enter this provider's models by hand`, "DISCOVERY_UNSUPPORTED"); +- const url = listingUrl(request.baseURL); ++ const url = listingUrl(baseURL); + const stored = storedProfile?.(); + const supplied = request.apiKey ?? await stored?.resolveApiKey(); + const apiKey = supplied === void 0 ? void 0 : usableProbeKey(supplied); +diff --git a/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/types/discovery.d.ts b/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/types/discovery.d.ts +index 97c5944..a6d4130 100644 +--- a/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/types/discovery.d.ts ++++ b/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/types/discovery.d.ts +@@ -2,11 +2,17 @@ + * Answering "which models can this provider serve?" for the configuration + * surface's "fetch available models" action. + * +- * A route the installed pi-ai catalog ships is answered **from that catalog**, +- * with no network call at all: pi-ai's registry is the authoritative list for +- * its own providers, and it carries the capacities a listing endpoint would +- * not disclose. Only a route the catalog does not describe — a gateway, a +- * self-hosted server — is interrogated over the wire. ++ * A route the installed pi-ai catalog ships is answered from that catalog **and ++ * from its endpoint**, the two merged. The catalog carries the capacities a ++ * listing endpoint does not disclose, so it wins wherever both describe a ++ * model; but the catalog is a build-time snapshot and a subscription gateway ++ * adds models between pi-ai releases, so the endpoint is what reports a model ++ * newer than the installed pi-ai. A route the catalog does not describe — a ++ * gateway, a self-hosted server — is answered from its endpoint alone. ++ * ++ * A catalog route whose endpoint cannot be read still answers from the ++ * snapshot: an unreachable network, a refused key, or a protocol with no ++ * readable listing leaves the shipped catalog as the better answer. + * + * Neither path is a catalog refresh. Nothing here is stored: the request + * carries a draft the user is still editing, and the reply is candidate diff --git a/test/model-discovery-catalog-patch.test.ts b/test/model-discovery-catalog-patch.test.ts new file mode 100644 index 000000000..f0e24ed9b --- /dev/null +++ b/test/model-discovery-catalog-patch.test.ts @@ -0,0 +1,303 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { projectRoot } from './patch-path' + +const piAiIndex = path.join( + projectRoot, + 'node_modules', + '@deepseek-ai', + 'dsh-llm-pi-ai', + 'lib', + 'index.js' +) + +interface CatalogModel { + id: string + name: string + api: string + baseUrl: string + contextWindow: number + maxTokens: number + input: string[] +} + +interface DiscoveredModel { + id: string + name?: string + contextWindow?: number + maxTokens?: number +} + +interface ResolvedModel { + id: string + api: string + baseUrl: string + contextWindow: number + maxTokens: number +} + +/** + * Lift one whole function out of the bundled adapter. These are module-private + * and the bundle is generated, so a brace scan is the only stable way to take a + * complete body: line offsets move with every upstream release. + */ +function extractFunction(source: string, signature: string): string { + let start = source.indexOf(`function ${signature}`) + expect(start, `missing function ${signature}`).toBeGreaterThanOrEqual(0) + if (source.slice(start - 6, start) === 'async ') start -= 6 + + let depth = 0 + let entered = false + for (let index = source.indexOf('{', start); index < source.length; index++) { + if (source[index] === '{') { + depth++ + entered = true + continue + } + if (source[index] === '}') { + depth-- + if (entered && depth === 0) return source.slice(start, index + 1) + } + } + throw new Error(`unbalanced function ${signature}`) +} + +class StubLlmError extends Error { + readonly code: string + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, options) + this.code = code + } +} + +/** The real opencode-go snapshot shape: three protocols, two base URLs. */ +const ZEN_V1 = 'https://opencode.ai/zen/go/v1' +const ZEN_ROOT = 'https://opencode.ai/zen/go' + +function snapshot(): Map { + const models = new Map() + const add = (model: CatalogModel): void => void models.set(model.id, model) + add({ + id: 'minimax-m3', + name: 'MiniMax M3', + api: 'anthropic-messages', + baseUrl: ZEN_ROOT, + contextWindow: 200000, + maxTokens: 8192, + input: ['text'] + }) + for (const id of ['deepseek-v4-pro', 'glm-5.3', 'kimi-k3']) { + add({ + id, + name: `${id} (catalog)`, + api: 'openai-completions', + baseUrl: ZEN_V1, + contextWindow: 1000000, + maxTokens: 384000, + input: ['text'] + }) + } + add({ + id: 'gpt-5.6-luna', + name: 'GPT 5.6 Luna', + api: 'openai-responses', + baseUrl: ZEN_V1, + contextWindow: 272000, + maxTokens: 128000, + input: ['text'] + }) + return models +} + +/** What the gateway advertises: the shipped ids plus ones newer than the snapshot. */ +const LIVE_IDS = [ + ...snapshot().keys(), + 'omen-alpha', + 'qwen3.8-flash', + 'grok-4.6' +] + +function listingReply(ids: readonly string[]): unknown { + const body = JSON.stringify({ + object: 'list', + data: ids.map((id) => ({ id, object: 'model' })) + }) + return { + ok: true, + status: 200, + headers: new Headers({ 'content-length': String(body.length) }), + body: new Blob([body]).stream() + } +} + +type Discover = (request: { provider?: string }) => Promise + +/** Build the discovery half of the adapter over a stubbed catalog and endpoint. */ +async function loadDiscovery( + reply: () => Promise +): Promise { + const source = await readFile(piAiIndex, 'utf8') + const factory = new Function( + 'LlmError', + 'catalogModels', + 'fetch', + 'attributionHeaders', + 'normalizeApiKey', + 'MAX_RESPONSE_BYTES', + ` + const LISTABLE_PROTOCOLS = new Set(["openai-completions", "openai-responses"]); + function usableProbeKey(raw) { return raw.trim() } + ${extractFunction(source, 'capacity(...candidates)')} + ${extractFunction(source, 'label(...candidates)')} + ${extractFunction(source, 'listingUrl(baseURL)')} + ${extractFunction(source, 'readBounded(response, url)')} + ${extractFunction(source, 'readListing(body)')} + ${extractFunction(source, 'catalogEndpoint(defaults, listable)')} + ${extractFunction(source, 'discoverModels(request, storedProfile)')} + ${extractFunction( + source, + 'listEndpointModels(request, storedProfile, baseURL, api$1)' + )} + return discoverModels + ` + ) as ( + error: typeof StubLlmError, + catalogModels: (provider: string) => Map, + fetchImpl: () => Promise, + attributionHeaders: () => Record, + normalizeApiKey: (value: string) => { ok: true; value: string }, + maxBytes: number + ) => Discover + + return factory( + StubLlmError, + (provider) => (provider === 'opencode-go' ? snapshot() : new Map()), + reply, + () => ({}), + (value: string) => ({ ok: true as const, value }), + 4 * 1024 * 1024 + ) +} + +/** Build the catalog-resolution half of the adapter over a stubbed catalog. */ +async function loadResolution(): Promise< + (request: Record) => { models: ResolvedModel[] } +> { + const source = await readFile(piAiIndex, 'utf8') + const factory = new Function( + 'catalogModels', + 'catalogProvider', + ` + const NO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + function invalid(provider, detail) { + throw new Error('llm-pi-ai: provider "' + provider + '" ' + detail) + } + function assertOfferedCompatFields() {} + function configuredCompatEntries() { return [] } + function compatProtocols() { return [] } + function declaredInput(value) { + return value === undefined || value.length === 0 ? undefined : value + } + function resolveModelReasoning() { return {} } + function resolveModelCompat() { return {} } + ${extractFunction(source, 'catalogEndpoint(defaults, listable)')} + ${extractFunction(source, 'resolveRouteModels(request)')} + return resolveRouteModels + ` + ) as ( + catalogModels: () => Map, + // The real opencode-go provider ships no provider-level baseUrl, so a model + // outside the snapshot has nothing to inherit but the catalog's entries. + catalogProvider: () => { id: string } + ) => (request: Record) => { models: ResolvedModel[] } + + return factory(snapshot, () => ({ id: 'opencode-go' })) +} + +const ROUTE_DEFAULTS = { + provider: 'opencode-go', + defaultContextWindow: 262144, + defaultMaxTokens: 32768, + defaultInput: ['text'] +} + +describe('catalog-route model discovery', () => { + it('reports gateway models the installed catalog has not caught up with', async () => { + const discoverModels = await loadDiscovery(async () => + listingReply(LIVE_IDS) + ) + + const found = await discoverModels({ provider: 'opencode-go' }) + const ids = found.map((model) => model.id) + + // The catalog is a build-time snapshot; answering from it alone made this + // action unable to ever report a model newer than the installed pi-ai. + expect(ids).toContain('omen-alpha') + expect(ids).toContain('qwen3.8-flash') + expect(ids).toContain('grok-4.6') + for (const shipped of snapshot().keys()) expect(ids).toContain(shipped) + // The catalog carries capacities a listing does not disclose, so it wins. + expect(found.find((model) => model.id === 'deepseek-v4-pro')).toMatchObject({ + name: 'deepseek-v4-pro (catalog)', + contextWindow: 1000000, + maxTokens: 384000 + }) + }) + + it('answers from the shipped catalog when the endpoint cannot be read', async () => { + const discoverModels = await loadDiscovery(async () => { + throw new TypeError('network down') + }) + + // Answering a catalog route offline has always been possible; an + // unreachable endpoint must not turn into an error or an empty list. + await expect( + discoverModels({ provider: 'opencode-go' }) + ).resolves.toHaveLength(snapshot().size) + }) +}) + +describe('catalog-route model resolution', () => { + it('serves a model the installed catalog does not describe', async () => { + const resolveRouteModels = await loadResolution() + + const { models } = resolveRouteModels({ + ...ROUTE_DEFAULTS, + models: [{ id: 'deepseek-v4-pro' }, { id: 'omen-alpha' }] + }) + + // Without inherited endpoint facts this route refused to resolve at all + // ("needs an api"), so a model adopted from the gateway was unusable. + expect(models.find((model) => model.id === 'omen-alpha')).toMatchObject({ + api: 'openai-completions', + baseUrl: ZEN_V1, + contextWindow: 262144, + maxTokens: 32768 + }) + }) + + it('does not drag a sibling onto the dominant protocol', async () => { + const resolveRouteModels = await loadResolution() + + const { models } = resolveRouteModels({ + ...ROUTE_DEFAULTS, + models: [ + { id: 'minimax-m3' }, + { id: 'gpt-5.6-luna' }, + { id: 'omen-alpha' } + ] + }) + + // The endpoint is inferred by plurality, so the guard that matters is that + // a minority-protocol sibling keeps its own api and baseUrl. + expect(models.find((model) => model.id === 'minimax-m3')).toMatchObject({ + api: 'anthropic-messages', + baseUrl: ZEN_ROOT + }) + expect(models.find((model) => model.id === 'gpt-5.6-luna')).toMatchObject({ + api: 'openai-responses', + baseUrl: ZEN_V1 + }) + }) +})