diff --git a/apps/desktop/electron/main/ipc/provider-ipc.ts b/apps/desktop/electron/main/ipc/provider-ipc.ts index 9926a58316..101b007fe8 100644 --- a/apps/desktop/electron/main/ipc/provider-ipc.ts +++ b/apps/desktop/electron/main/ipc/provider-ipc.ts @@ -451,8 +451,12 @@ export function registerProviderIpc({ modelId: model.modelId, displayName: info.displayName || modelConfig.name, providerId: provider?.id ?? "", - contextWindow: operationMetadata?.contextWindow ?? modelConfig.contextWindow, - maxTokens: operationMetadata?.maxTokens ?? modelConfig.maxTokens, + // The service's own model-list row states what it actually gives; + // an endpoint that reports capacity outranks both the catalog and + // any stored value it carries (PiDeck parity: reported > catalog > + // nothing — an omission is never a guess). + contextWindow: model.contextWindow ?? operationMetadata?.contextWindow ?? modelConfig.contextWindow, + maxTokens: model.maxTokens ?? operationMetadata?.maxTokens ?? modelConfig.maxTokens, // Published modalities, taken before the binding is applied. This // record is what the settings panel compares its checkboxes against, // so letting a stored override shape it would make the override its diff --git a/apps/desktop/electron/main/model-discovery.ts b/apps/desktop/electron/main/model-discovery.ts index edc54c3815..1ba9f5c9b3 100644 --- a/apps/desktop/electron/main/model-discovery.ts +++ b/apps/desktop/electron/main/model-discovery.ts @@ -24,6 +24,14 @@ import { export type DiscoveredModel = { modelId: string; displayName: string; + /** + * Capacity the service itself reported on its model-list row + * (`context_window` / `context_length` / `inputTokenLimit` / …). Endpoint- + * reported values outrank catalog enrichment; absent means the service + * stated nothing, so nothing is guessed. + */ + contextWindow?: number; + maxTokens?: number; }; export const DISCOVERY_TIMEOUT_MS = 10_000; @@ -84,6 +92,42 @@ function dedupeSort(models: DiscoveredModel[]): DiscoveredModel[] { .slice(0, MAX_MODELS); } +/** + * The first positive safe integer a row of a model list states for a capacity, + * reading the OpenAI/Anthropic and Google/Gemini spellings side by side the + * way the service's own console does. Zero, fractional and missing values + * state nothing: an endpoint that omits a capacity is not evidence it is + * small, so nothing is guessed in its place. + */ +function firstPositiveInteger(row: Record | null | undefined, keys: readonly string[]): number | undefined { + if (!row) return undefined; + for (const key of keys) { + const value = row[key]; + if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) return value; + } + return undefined; +} + +/** Context capacity the service itself stated on the row, or nothing. */ +function reportedContextWindow(row: Record | null): number | undefined { + return firstPositiveInteger(row, [ + "context_window", + "context_length", + "inputTokenLimit", + "contextWindow", + ]); +} + +/** Output capacity the service itself stated on the row, or nothing. */ +function reportedMaxTokens(row: Record | null): number | undefined { + return firstPositiveInteger(row, [ + "max_output_tokens", + "max_tokens", + "outputTokenLimit", + "maxTokens", + ]); +} + /** * Normalize a model-list response body. Pure. * @@ -110,7 +154,16 @@ export function normalizeModelList( typeof item?.displayName === "string" && item.displayName ? item.displayName : modelId; - return [{ modelId, displayName }]; + const contextWindow = reportedContextWindow(item); + const maxTokens = reportedMaxTokens(item); + return [ + { + modelId, + displayName, + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(maxTokens !== undefined ? { maxTokens } : {}), + }, + ]; }), ); } @@ -132,7 +185,16 @@ export function normalizeModelList( typeof item?.display_name === "string" && item.display_name ? item.display_name : modelId; - return [{ modelId, displayName }]; + const contextWindow = reportedContextWindow(item); + const maxTokens = reportedMaxTokens(item); + return [ + { + modelId, + displayName, + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(maxTokens !== undefined ? { maxTokens } : {}), + }, + ]; }), ); } diff --git a/apps/desktop/electron/main/models-dev-catalog.ts b/apps/desktop/electron/main/models-dev-catalog.ts index a8a6b310f0..8130761e37 100644 --- a/apps/desktop/electron/main/models-dev-catalog.ts +++ b/apps/desktop/electron/main/models-dev-catalog.ts @@ -17,6 +17,7 @@ import { stripReleaseSuffix, stripVariantSuffix, } from "@pi-desktop/shared"; +import { RELAY_FAMILY_PUBLISHERS } from "./pi-model-metadata.ts"; import type { ModelCost, ModelCostTier, @@ -107,6 +108,13 @@ export type ModelsDevModel = { cost?: ModelCost; interleaved?: ModelInterleaved; status?: string; + /** + * The catalog's internal name for this model's current generation + * (models.dev `canonical_model_id`, e.g. `deepseek/deepseek-v4.1-flash`). + * A relay may serve this canonical name directly; only the owner's own + * records answer it. + */ + canonicalModelId?: string; experimental?: ModelExperimentalMetadata; provider?: ModelProviderMetadata; }; @@ -500,6 +508,9 @@ function modelFromRaw( ? { interleaved: parseInterleaved(raw.interleaved) } : {}), ...(nonEmptyString(raw.status) ? { status: nonEmptyString(raw.status) } : {}), + ...(nonEmptyString(raw.canonical_model_id) + ? { canonicalModelId: nonEmptyString(raw.canonical_model_id) } + : {}), ...(experimental !== undefined ? { experimental } : {}), ...(providerMetadata !== undefined ? { provider: providerMetadata } : {}), ...(modelApi !== undefined ? { modelApi } : {}), @@ -1084,27 +1095,6 @@ function sameModelSpelling(catalogId: string, requested: string): boolean { ); } -/** - * Record to use when nothing identifies the row's provider. - * - * A relay or a gateway the catalog cannot place still serves models the catalog - * knows, and asking for one of them by id used to answer nothing at all. What - * every publisher of that model agrees on can be claimed instead: the same - * intersection and median `borrowedModel` computes for an anchored row. - * - * One thing is deliberately not claimed. Which *wire shape* a deployment accepts - * for reasoning is a property of that deployment — the same model behind an - * OpenAI-compatible gateway and behind Anthropic's own API takes different - * reasoning fields — so a record borrowed this way drops its `reasoningOptions` - * rather than speaking for an endpoint no publisher describes. `catalogModelConfigFor` - * still applies Anthropic's own shape to an Anthropic Messages row that is left - * without one. - */ -function unanchoredConsensus(entries: readonly ModelsDevModel[]): ModelsDevModel | undefined { - const consensus = borrowedModel(entries); - return consensus ? { ...consensus, reasoningOptions: undefined } : undefined; -} - /** * Which publisher's records answer for an id the row's own publisher lacks. * @@ -1193,6 +1183,80 @@ function dropDeploymentMarker(value: string): string { return DEPLOYMENT_MARKER_SUFFIXES.has(match[2].toLowerCase()) ? match[1] : value; } +/** The last `/`-segment of a normalized id; the whole id when it has none. */ +function leafOf(normalized: string): string { + return normalized.slice(normalized.lastIndexOf("/") + 1); +} + +/** + * The narrow deployment labels the legacy relay lookup was allowed to strip to + * reach a published base ID. Model variants such as `thinking`, `preview`, + * dated releases, `asr` or `pro` name distinct models and are deliberately + * absent: they must keep their own records or stay unmatched. + */ +const RELAY_DEPLOYMENT_SUFFIXES: ReadonlySet = new Set([ + "test", + "staging", + "canary", + "dev", + "1m", + "2m", + "4m", + "32k", + "64k", + "128k", + "200k", + "256k", + "512k", +]); + +/** The id without one whitelisted trailing label, or undefined when it has none. */ +function relayDeploymentSuffixStrip(value: string): string | undefined { + const match = /^(.*)[-_:]([a-z0-9]+)$/i.exec(value); + if (!match || !RELAY_DEPLOYMENT_SUFFIXES.has(match[2].toLowerCase())) return undefined; + return match[1]; +} + +/** + * Notation aliases of a versioned leaf: `4.5` reads as `4-5` and back. A relay + * may name a generation in either spelling; each alias is checked as a whole + * published id, so an alias that the catalog does not publish simply stays + * unmatched. + */ +function dotsToDashes(value: string): string { + return value.includes(".") ? value.replace(/\./g, "-") : value; +} + +function dashesToDots(value: string): string { + // Only the dashes between two digits form a version (`3-1` reads as `3.1`); + // the dashes that join semantic parts stay in place. + return value.replace(/(?<=\d)-(?=\d)/g, "."); +} + +/** + * The relay id's leaves to check against the owner's published records: the + * leaf itself, its dotted/dashed notation aliases, and — after one whitelisted + * deployment label is stripped — those aliases again. No alias chain is + * followed and the wire id is never rewritten. + */ +function relayNotationCandidates(requested: string): string[] { + const out: string[] = []; + const push = (value?: string) => { + if (value && !out.includes(value)) out.push(value); + }; + const leaf = leafOf(requested); + push(leaf); + push(dotsToDashes(leaf)); + push(dashesToDots(leaf)); + const base = relayDeploymentSuffixStrip(leaf); + if (base) { + push(base); + push(dotsToDashes(base)); + push(dashesToDots(base)); + } + return out; +} + /** * Published ids to read when nothing the catalog publishes matches the id the * service serves: the id behind a route prefix, and that id without one @@ -1546,20 +1610,19 @@ export class ModelsDevCatalog { const deploymentFallback = result ? undefined : this.modelForDeploymentMarker(input.modelId, preferredProvider); - /* The row's own catalog provider did not publish this id. Borrowing needs a - known provider identity to anchor on: the row resolved to a catalog - provider whose own records are authoritative, so anything missing from it - can be checked against the other publishers of the exact id instead of - dropping the model to the generic shape. - - This only fills a miss the lookup already had — a record the preferred - provider does publish stays authoritative. Without that anchor the scores - prove nothing about identity, and an unknown endpoint keeps the existing - behaviour: a unique unambiguous match, official disambiguation, shared - capabilities, or nothing. Deployment-marker / variant-suffix fallbacks - are intentionally not applied here (approved #1047 matching rules). */ - const borrowed = result ?? deploymentFallback ?? - (preferredProvider ? this.borrowedAcrossProviders(input) : undefined); + /* The row's own catalog provider stays authoritative. When an unanchored + relay serves a known model family and the weight owner publishes a + matching record, prefer that record over reseller consensus. An + ambiguous owner answer stays generic; without an owner match, existing + shared-record matching continues to apply. */ + const relayOwnerKey = preferredProvider + ? undefined + : RELAY_FAMILY_PUBLISHERS[leafOf(requested).split(/[-.]/)[0] ?? ""]; + const ownerRecord = relayOwnerKey ? this.unanchoredOwnerRecord(requested) : undefined; + const borrowed = ownerRecord === null + ? undefined + : ownerRecord ?? result ?? deploymentFallback ?? + (preferredProvider ? this.borrowedAcrossProviders(input) : undefined); // Cache the result (a miss included) so a repeated miss is also O(1) and // cannot grow the candidate index with query-dependent keys. this.lookupMemo.set(memoKey, borrowed); @@ -1668,6 +1731,105 @@ export class ModelsDevCatalog { return borrowedModel(borrowPool(matches, requested)); } + /** + * Unanchored relay fallback: the endpoint's own publisher is unknown, so + * nothing owns a negative answer for this id. Resolve it the way the + * pre-catalog relay lookup did — the model's own family names the + * publisher that owns its weights, and that owner's published record is + * what describes the model a relay serves, not a reseller's flags about + * its own deployment. A relay-only `deepseek-v4-flash` reads DeepSeek's + * record and keeps its window and output visible instead of dropping to + * the generic 128k text-only seed. + * + * The rules that keep the result honest: + * + * - Only the owner's own records answer. Reseller copies and route + * prefixes never participate, and a split owner answer still states + * nothing. + * - The wire ID is never rewritten: at most one whitelisted deployment + * label (`-1m`, `-test`, …) is stripped, and a dotted/dashed notation + * alias (`4.5` reads as `4-5`) is tried, to reach the owner's + * published ID; model variants such as `-thinking` or dated releases + * are distinct models and stay unmatched. + * - When the relay names the owner's canonical generation id (the + * catalog's internal name, e.g. `deepseek-v4.1-flash`), the owner's + * own live record for that canonical id answers. + */ + private unanchoredOwnerRecord(requested: string): ModelsDevModel | null | undefined { + const ownerKey = RELAY_FAMILY_PUBLISHERS[leafOf(requested).split(/[-.]/)[0] ?? ""]; + if (!ownerKey) return undefined; + const ownerKeys = new Set(providerKeyCandidates(ownerKey)); + const ownerHits = (id: string): ModelsDevModel[] => + (this.lookupIndex?.candidates(id) ?? []) + .filter(({ model, provider }) => + normalizedModelId(model.modelId) === id && + ownerKeys.has(normalizedProviderKey(provider.providerKey)), + ) + .map((entry) => entry.model); + const pick = (hits: ModelsDevModel[]): ModelsDevModel | null | undefined => { + if (hits.length === 0) return undefined; + if (hits.length === 1) return hits[0]; + if (hits.length > 1) { + const signature = capabilitySignature(hits[0]); + const limit = JSON.stringify(hits[0].limit); + if ( + hits.every( + (model) => + capabilitySignature(model) === signature && + JSON.stringify(model.limit) === limit, + ) + ) { + return hits[0]; + } + } + return null; + }; + // Notation aliases and one whitelisted deployment label, each checked as + // a whole published id; the wire id is never rewritten. + const candidates = relayNotationCandidates(requested); + for (const candidate of candidates) { + const hit = pick(ownerHits(candidate)); + if (hit !== undefined) return hit; + } + // The relay named the owner's canonical generation id; the owner's own + // live record for it answers. + return this.canonicalOwnerRecord(ownerKeys, candidates); + } + + /** + * The owner's record whose canonical id (models.dev `canonical_model_id`, + * owner-prefixed) is one of the requested leaves. A non-deprecated record + * wins; when the owner's aliases for one canonical model disagree on shape + * nothing is claimed. Reseller copies never enter this set: only the + * weight owner's records may answer a canonical name. + */ + private canonicalOwnerRecord(ownerKeys: Set, targets: string[]): ModelsDevModel | null | undefined { + if (targets.length === 0) return undefined; + const wanted = new Set(targets.map((id) => normalizedModelId(leafOf(id)))); + const records = new Map(); + for (const provider of this.providers.values()) { + if (!ownerKeys.has(normalizedProviderKey(provider.providerKey))) continue; + for (const model of provider.models) { + const canonical = model.canonicalModelId; + if (!canonical) continue; + if (!wanted.has(normalizedModelId(leafOf(canonical)))) continue; + records.set(`${provider.providerKey}\u0000${model.modelId}`, model); + } + } + const candidates = [...records.values()]; + if (candidates.length === 0) return undefined; + const active = candidates.filter((model) => model.status?.toLowerCase() !== "deprecated"); + const pool = active.length > 0 ? active : candidates; + if (pool.length === 1) return pool[0]; + const first = pool[0]; + const agreed = pool.every( + (model) => + capabilitySignature(model) === capabilitySignature(first) && + JSON.stringify(model.limit) === JSON.stringify(first.limit), + ); + return agreed ? pool[0] : null; + } + /** * Thinking wire metadata Anthropic publishes for exactly this id, or nothing. * Aliases and other publishers' copies never answer: only Anthropic's own diff --git a/apps/desktop/electron/main/pi-model-metadata.ts b/apps/desktop/electron/main/pi-model-metadata.ts index aa20fa1c64..af518549a0 100644 --- a/apps/desktop/electron/main/pi-model-metadata.ts +++ b/apps/desktop/electron/main/pi-model-metadata.ts @@ -17,7 +17,7 @@ export const PI_VENDOR_ALIASES: Record = { * the configured wire ID is never rewritten, and identified providers remain * authoritative. */ -const publishers: Record = { +export const RELAY_FAMILY_PUBLISHERS: Record = { claude: "anthropic", gpt: "openai", o1: "openai", o3: "openai", o4: "openai", gemini: "google", grok: "xai", // Open-weight families whose owner publishes the IDs a relay actually serves. @@ -59,7 +59,7 @@ export function relayChatMetadata(models: Models, id: string): Model | unde hits = findHits(lookupId); } if (!hits.length) return undefined; - const publisher = publishers[lookupId.split(/[-.]/)[0] ?? ""]; + const publisher = RELAY_FAMILY_PUBLISHERS[lookupId.split(/[-.]/)[0] ?? ""]; const official = hits.filter(model => model.provider === publisher); if (official.length === 1) return official[0]; return hits.every(model => capabilities(model) === capabilities(hits[0])) ? hits[0] : undefined; diff --git a/apps/desktop/test/model-discovery.test.mjs b/apps/desktop/test/model-discovery.test.mjs index 5917782c9b..1033465202 100644 --- a/apps/desktop/test/model-discovery.test.mjs +++ b/apps/desktop/test/model-discovery.test.mjs @@ -37,7 +37,7 @@ test("a models[] response with slug rows is read as a list too", () => { { slug: "glm-5.3" }, ], }), - [{ modelId: "glm-5.3", displayName: "glm-5.3" }], + [{ modelId: "glm-5.3", displayName: "glm-5.3", contextWindow: 1_048_576 }], ); // The two id keys are mutually exclusive; `id` wins if a gateway publishes both. assert.deepEqual(normalizeModelList("chat_completions", { models: [{ id: "m", slug: "s" }] }), [ @@ -64,16 +64,53 @@ test("anthropic lists keep display names", () => { test("google lists strip the models/ prefix", () => { const models = normalizeModelList("google_generative_ai", { models: [ - { name: "models/gemini-2.5-pro", displayName: "Gemini 2.5 Pro" }, + { + name: "models/gemini-2.5-pro", + displayName: "Gemini 2.5 Pro", + inputTokenLimit: 1_048_576, + outputTokenLimit: 65_536, + }, { name: "models/gemini-2.5-flash" }, ], }); assert.deepEqual(models, [ { modelId: "gemini-2.5-flash", displayName: "gemini-2.5-flash" }, - { modelId: "gemini-2.5-pro", displayName: "Gemini 2.5 Pro" }, + { + modelId: "gemini-2.5-pro", + displayName: "Gemini 2.5 Pro", + contextWindow: 1_048_576, + maxTokens: 65_536, + }, ]); }); +test("a model list's own capacity report is kept, nothing is guessed when absent", () => { + const models = normalizeModelList("chat_completions", { + data: [ + { id: "with-capacity", context_window: 1_000_000, max_output_tokens: 64_000 }, + { id: "openrouter-style", context_length: 200_000, max_tokens: 8_192 }, + { id: "gemini-style", inputTokenLimit: 1_048_576, outputTokenLimit: 65_536 }, + { id: "camel-style", contextWindow: 32_000, maxTokens: 4_096 }, + { id: "silent" }, + { id: "zero", context_window: 0 }, + { id: "unsafe", context_window: Number.MAX_SAFE_INTEGER + 1 }, + ], + }); + const byId = new Map(models.map((model) => [model.modelId, model])); + assert.equal(byId.get("with-capacity").contextWindow, 1_000_000); + assert.equal(byId.get("with-capacity").maxTokens, 64_000); + assert.equal(byId.get("openrouter-style").contextWindow, 200_000); + assert.equal(byId.get("openrouter-style").maxTokens, 8_192); + assert.equal(byId.get("gemini-style").contextWindow, 1_048_576); + assert.equal(byId.get("gemini-style").maxTokens, 65_536); + assert.equal(byId.get("camel-style").contextWindow, 32_000); + // An omitted or non-positive capacity states nothing: no guessed default. + assert.equal(byId.get("silent").contextWindow, undefined); + assert.equal(byId.get("silent").maxTokens, undefined); + assert.equal(byId.get("zero").contextWindow, undefined); + assert.equal(byId.get("unsafe").contextWindow, undefined); +}); + test("requests use per-style endpoints and auth headers", () => { const openai = modelListRequest({ baseUrl: "https://api.example.com/v1/", diff --git a/apps/desktop/test/models-dev-catalog.test.mjs b/apps/desktop/test/models-dev-catalog.test.mjs index 7d79bf3e82..505d219972 100644 --- a/apps/desktop/test/models-dev-catalog.test.mjs +++ b/apps/desktop/test/models-dev-catalog.test.mjs @@ -742,7 +742,9 @@ test("resellers still answer for an id no shipped publisher states", async (t) = test("route prefixes and official deployment markers resolve without rewriting served IDs", async (t) => { // Exact last-segment match allows `test/mimo-v2.5` → `mimo-v2.5`. - // Markers like `-thinking` / `-test` are part of the leaf and do not strip. + // Model variants like `-thinking` / `-asr` are parts of the leaf and never + // strip; a whitelisted deployment label (`-test`, `-1m`) still reduces to + // the owner's published base ID. const catalog = await loadFixtureCatalog(t, { gateway: { api: "https://gateway.example/v1", models: {} }, google: { models: { @@ -773,7 +775,7 @@ test("route prefixes and official deployment markers resolve without rewriting s assert.ok(info.capabilities.includes("vision")); assert.equal(relay("mimo-v2.5-thinking"), undefined); - assert.equal(relay("test/mimo-v2.5-pro-test"), undefined); + assert.equal(relay("test/mimo-v2.5-pro-test")?.modelId, "mimo-v2.5-pro"); assert.equal(relay("mimo-v2.5-asr"), undefined); assert.equal(relay("gemini-2.5-pro-1m")?.modelId, "gemini-2.5-pro"); }); @@ -805,9 +807,12 @@ test("a shipped publisher answers first for a row anchored to a reseller", async assert.equal(match.limit.output, 500_000); }); -test("ambiguous same-leaf spellings with conflicting modalities stay unmatched", async (t) => { +test("an unanchored relay reads its family owner's record over a conflicting reseller spelling", async (t) => { // `mimo-v2.5` and `XiaomiMiMo/MiMo-V2.5` share a case-insensitive leaf but - // disagree on modalities, and neither publisher is an official/source family. + // disagree on modalities, so the shared-capabilities enrichment misses. The + // weight owner's exact record still answers an unidentified relay — the way + // the pre-catalog relay lookup did: a reseller's narrower copy never decides + // the window or modalities. const catalog = await loadFixtureCatalog(t, { gateway: { api: "https://gateway.example/v1", models: {} }, xiaomi: { models: { "mimo-v2.5": { @@ -821,11 +826,59 @@ test("ambiguous same-leaf spellings with conflicting modalities stay unmatched", limit: { context: 1_048_576, output: 131_072 }, } } }, }); - assert.equal(catalog.findModel({ + const match = catalog.findModel({ vendorKey: "custom", baseUrl: "https://relay.example/v1", modelId: "mimo-v2.5", - }), undefined); + }); + assert.equal(match?.providerKey, "xiaomi", "the owner's record wins the unanchored relay"); + assert.deepEqual(match?.modalities.input, ["text", "image", "audio"]); + assert.equal(match?.limit.context, 1_048_576); +}); + +test("an unanchored relay prefers its owner's limits over matching reseller copies", async (t) => { + const catalog = await loadFixtureCatalog(t, { + gateway: { api: "https://gateway.example/v1", models: {} }, + xiaomi: { models: { "mimo-v2.5": { + id: "mimo-v2.5", tool_call: true, + modalities: { input: ["text", "image"], output: ["text"] }, + limit: { context: 1_048_576, output: 131_072 }, + } } }, + reseller: { models: { "mimo-v2.5": { + id: "mimo-v2.5", tool_call: true, + modalities: { input: ["text", "image"], output: ["text"] }, + limit: { context: 262_144, output: 16_384 }, + } } }, + }); + + const match = catalog.findModel({ + vendorKey: "custom", + baseUrl: "https://relay.example/v1", + modelId: "mimo-v2.5", + }); + assert.equal(match?.providerKey, "xiaomi"); + assert.equal(match?.limit.context, 1_048_576); + assert.equal(match?.limit.output, 131_072); +}); + +test("an unanchored relay stays generic when owner aliases disagree on capacity", async (t) => { + const catalog = await loadFixtureCatalog(t, { + gateway: { api: "https://gateway.example/v1", models: {} }, + minimax: { models: { "MiniMax-M2.7": { + id: "MiniMax-M2.7", tool_call: true, + limit: { context: 204_800, output: 131_072 }, + } } }, + "minimax-cn": { models: { "MiniMax-M2.7": { + id: "MiniMax-M2.7", tool_call: true, + limit: { context: 128_000, output: 65_536 }, + } } }, + }); + + assert.equal(catalog.findModel({ + vendorKey: "custom", + baseUrl: "https://relay.example/v1", + modelId: "MiniMax-M2.7", + }), undefined, "conflicting owner limits must not be chosen by catalog order"); }); test("unique leaf enrichment coexists with exact-id borrowing and unknown misses", async (t) => { @@ -1842,3 +1895,64 @@ test("a host two publishers share leaves conflicting leaves unmatched", async (t assert.equal(alpha?.providerKey, "alpha"); assert.equal(alpha?.limit.context, 1_000_000); }); + +test("a relay's dotted version name reads the owner's dashed published record", async (t) => { + // `claude-sonnet-4.5` is Claude's `claude-sonnet-4-5`: a notation difference, + // not a distinct model, so the owner's own published record answers. + const catalog = await loadFixtureCatalog(t, { + gateway: { api: "https://gateway.example/v1", models: {} }, + anthropic: { models: { "claude-sonnet-4-5": { + id: "claude-sonnet-4-5", tool_call: true, limit: { context: 200_000, output: 64_000 }, + } } }, + }); + const match = catalog.findModel({ + vendorKey: "custom", baseUrl: "https://relay.example/v1", modelId: "claude-sonnet-4.5", + }); + assert.ok(match, "a dotted version alias must reach the owner's published record"); + assert.equal(match.providerKey, "anthropic"); + assert.equal(match.modelId, "claude-sonnet-4-5", "the wire id is never rewritten"); + assert.equal(match.limit.context, 200_000); + assert.equal(match.limit.output, 64_000); +}); + +test("a relay's canonical generation name reads the owner's live record", async (t) => { + // The relay serves `deepseek-v4.1-flash` — models.dev's canonical id for + // DeepSeek's current flash model, which DeepSeek publishes only under version + // aliases. The live (non-deprecated) alias is what answers. + const catalog = await loadFixtureCatalog(t, { + gateway: { api: "https://gateway.example/v1", models: {} }, + deepseek: { models: { + "deepseek-flash": { + id: "deepseek-flash", tool_call: true, + canonical_model_id: "deepseek/deepseek-v4.1-flash", + limit: { context: 1_000_000, output: 393_216 }, + }, + "deepseek-v4-flash": { + id: "deepseek-v4-flash", tool_call: true, status: "deprecated", + canonical_model_id: "deepseek/deepseek-v4.1-flash", + limit: { context: 1_000_000, output: 393_216 }, + }, + } }, + }); + const match = catalog.findModel({ + vendorKey: "custom", baseUrl: "https://relay.example/v1", modelId: "deepseek-v4.1-flash", + }); + assert.ok(match, "the owner's canonical id must not drop to the generic seed"); + assert.equal(match.providerKey, "deepseek"); + assert.equal(match.modelId, "deepseek-flash", "the live alias answers, not the deprecated one"); + assert.equal(match.limit.context, 1_000_000); +}); + +test("a generation name the catalog never publishes stays unmatched", async (t) => { + // `gemini-3-flash` is a distinct (non-preview) model the catalog does not + // publish; a preview record must not answer for it. + const catalog = await loadFixtureCatalog(t, { + gateway: { api: "https://gateway.example/v1", models: {} }, + google: { models: { "gemini-3-flash-preview": { + id: "gemini-3-flash-preview", tool_call: true, limit: { context: 1_000_000 }, + } } }, + }); + assert.equal(catalog.findModel({ + vendorKey: "custom", baseUrl: "https://relay.example/v1", modelId: "gemini-3-flash", + }), undefined); +}); diff --git a/apps/desktop/test/provider-endpoint-metadata.test.mjs b/apps/desktop/test/provider-endpoint-metadata.test.mjs index 2ff6e1b7ef..ac40141778 100644 --- a/apps/desktop/test/provider-endpoint-metadata.test.mjs +++ b/apps/desktop/test/provider-endpoint-metadata.test.mjs @@ -4,9 +4,10 @@ * Two reported failures live here. A custom row pointed at * `https://open.bigmodel.cn/api/v1` (Zhipu's OpenAI Responses endpoint) listed * its models but showed a generic 128k / 8k text-only row for every one of them, - * because the catalog only accepted that host's own published path. And a row on - * a relay the catalog cannot place got nothing at all for models the catalog - * describes, because one publisher's record must never answer for another. + * because the catalog only accepted that host's own published path. A row on a + * relay the catalog cannot place used to get nothing at all; it now still reads + * the weight owner's published record for ids the owner states, so a relay-only + * `deepseek-v4-flash` keeps DeepSeek's window instead of the generic seed. * * This drives the real handlers with the bundled snapshot, so it pins the whole * chain: discovery, endpoint resolution, catalog anchoring, and the hand-typed @@ -141,6 +142,22 @@ test("a relay's list reads the shipped publisher's record for a known id", async assert.deepEqual(unknown.capabilities, ["text"]); }); +test("a model-list capacity report takes precedence over catalog limits", async (t) => { + const row = rowOf({ + id: "row-reported-capacity", + name: "Relay", + baseUrl: "https://relay.example/v1", + }); + const { result } = await handlersFor(t, row, { + data: [{ id: "claude-sonnet-4-5", context_length: 321_000, max_tokens: 12_345 }], + }); + + const [model] = result.models; + assert.equal(model.contextWindow, 321_000, "the endpoint reports its actual context window"); + assert.equal(model.maxTokens, 12_345, "the endpoint reports its actual output limit"); + assert.equal(model.catalogSource, "models.dev", "catalog metadata still identifies the model"); +}); + test("a hand-typed id on the same relay answers with the same record", async (t) => { const row = rowOf({ id: "row-3", @@ -184,15 +201,20 @@ test("a relay enriches official IDs, strips safe deployment labels, and preserve const byId = new Map(result.models.map((model) => [model.modelId, model])); - // No official publisher states this leaf, so a reseller must not decide it. - assert.equal(byId.get("deepseek-v4-flash").catalogSource, undefined); - // Reseller copies disagree; an unanchored relay stays on the generic shape. + // The weight owner (DeepSeek) publishes this id, so an unanchored relay reads + // the owner's record instead of dropping to the generic seed. + const deepseek = byId.get("deepseek-v4-flash"); + assert.equal(deepseek.catalogSource, "models.dev"); + assert.equal(deepseek.contextWindow, 1_000_000); + assert.equal(deepseek.maxTokens, 393_216); + // Xiaomi publishes the base id, so both the plain and the `-1m` alias read + // the owner's record without rewriting the served wire ID. for (const id of ["mimo-v2.5-pro", "mimo-v2.5-pro-1m"]) { const mimo = byId.get(id); assert.equal(mimo.modelId, id); // lookup never rewrites the served wire ID - assert.equal(mimo.catalogSource, undefined); - assert.equal(mimo.contextWindow, 128_000); - assert.equal(mimo.maxTokens, 8_192); + assert.equal(mimo.catalogSource, "models.dev"); + assert.equal(mimo.contextWindow, 1_048_576); + assert.equal(mimo.maxTokens, 131_072); } // Matching Xiaomi reseller records agree on the published speech limits. diff --git a/docs/spec/03-runtime/13-model-catalog-and-selection.md b/docs/spec/03-runtime/13-model-catalog-and-selection.md index 1be12ad238..e6df84ac28 100644 --- a/docs/spec/03-runtime/13-model-catalog-and-selection.md +++ b/docs/spec/03-runtime/13-model-catalog-and-selection.md @@ -276,11 +276,23 @@ remote catalog and uses no ambient credentials. `providers.refreshModelCatalog` refreshes models.dev explicitly; settings metadata lookups themselves are local. For keyed endpoints, live discovery supplies IDs and models.dev enriches their -metadata. For OAuth, the live account endpoint supplies entitled IDs, which are -then enriched from models.dev. Successful account lists are authoritative; a -failed list preserves the last usable IDs. A live-only ID with no models.dev -match stays generic; pi-ai sibling metadata never supplies its chat limits, -reasoning or price. +metadata. A positive safe integer context/input or output limit reported on a live +model-list row takes precedence over the catalog for that field; each field +falls back independently to catalog metadata when the endpoint omits it. An +omitted or non-positive value is not guessed. For OAuth, the live account +endpoint supplies entitled IDs, which are then enriched from models.dev. +Successful account lists are authoritative; a failed list preserves the last +usable IDs. A live-only ID with no service or catalog capacity stays generic; +pi-ai sibling metadata never supplies its chat limits, reasoning or price. + +For an unanchored OpenAI-compatible relay, a known model family may use the +weight owner's published record when that owner names the model. This record +outranks shared reseller matches without changing the configured wire ID. +Lookup accepts dotted/dashed version notation, one whitelisted deployment +label, and the owner's published canonical generation ID; semantic variants +and unpublished generations remain distinct. If owner aliases disagree on +capabilities or limits, no owner record is borrowed. When the owner publishes +no matching record, the existing shared catalog matching rules still apply. `source: "cache"` reads Host caches and configured bindings without network. `source: "refresh"` probes the selected endpoint/account and decorates returned diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 998884b51d..e242190b6f 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -1065,11 +1065,17 @@ identify the platform validation still needed. - **Expected**: `providers.lookupModel` answers a hand-typed id from the local snapshot only — no provider network request and no host call — and a hit seeds the binding the way a picked model is seeded (published context window, max output tokens, thinking levels, `contextWindowSource: "catalog"`) while the stored id stays exactly what the user typed. A miss, a failed call, or an id the current discovery already described leaves the previous behavior intact: one usable row, the generic seed, and no stalled or duplicated list. A record published under another spelling of that id — a route prefix, a dated stamp, a marker the deployment appends — is a hit too: the row is upgraded in - place as soon as the answer arrives, without waiting for a save. + place as soon as the answer arrives, without waiting for a save. In a live + discovered row, a positive safe integer context/input or output limit reported by + the endpoint takes precedence over the catalog for that field; omitted fields + independently use catalog metadata. For an unanchored relay, a published + owner record outranks reseller matches; dotted/dashed version aliases and one + whitelisted deployment label keep the served ID intact, and conflicting + owner records do not supply guessed limits. - **Specs linked**: `03-runtime/12-provider-config-schema.md`, `03-runtime/13-model-catalog-and-selection.md` - **Acceptance**: B (multi-model provider configuration) - **Milestone**: M2 -- **Status**: Documented; covered by `apps/desktop/test/model-custom-lookup.test.mjs` and `apps/desktop/test/provider-lookup-model-handler.test.mjs` +- **Status**: Documented; covered by `apps/desktop/test/model-custom-lookup.test.mjs`, `apps/desktop/test/provider-lookup-model-handler.test.mjs`, and `apps/desktop/test/provider-endpoint-metadata.test.mjs` #### E2E-PROVIDER-configured-models-search: The chosen pane's search narrows the configured list diff --git a/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md b/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md index 012733b60f..84f3e50aee 100644 --- a/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md +++ b/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md @@ -55,6 +55,7 @@ AI 服务和 OAuth 厂商账户编辑器共用已选模型面板。每个已选 - 提供商名称 - 能力徽章 - 可选的上下文窗口 +- 服务模型列表为某字段报告正安全整数上下文/输入或输出上限时,该值优先于目录;未报告的字段独立回退到目录值。缺失或非正数不作猜测。 - 上限值与用量计数经同一个紧凑格式化函数(`formatCompactTokenCount`)渲染: `M` 量级最多两位小数、`K` 量级一位小数,去掉末尾零;`K` 尾数若进位到 1000 则提升到 `M` 量级。1M 附近的已发布窗口因此保持可区分(1000000 显示 `1M`, diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index d94cff5832..63ead52c49 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -391,10 +391,11 @@ task-candidate E2E 从请求工作树运行,但使用主工作区已经准备 - **预期**:`providers.lookupModel` 只从本地快照回答手输 id —— 不访问提供商网络、不调用主机 —— 命中时按「被勾选的模型」同样的口径播种绑定(已发布的上下文长度、最大输出、思考等级,`contextWindowSource: "catalog"`),而存储的 id 保持用户输入的原样。未命中、调用失败,或该 id 已被本次发现结果描述过时,行为与之前一致:一行可用,通用种子,列表既不卡住也不重复。 该 id 以另一种拼写发布(路由前缀、日期戳、部署自己追加的标记)同样算命中: 答复到达即就地升级该行,不必等到保存。 +- 实时发现的模型行若由服务报告正安全整数上下文/输入或输出上限,则该字段优先使用服务值;未报告字段独立回退到目录元数据。对于未锚定的中继,若模型所有者发布了匹配记录,则优先使用所有者记录;带点/连字符版本别名和一个白名单部署标签不会改写服务端 ID,所有者记录冲突时不会猜测限额。 - **链接规格**:`03-runtime/12-provider-config-schema.md`、`03-runtime/13-model-catalog-and-selection.md` - **接受**:B(多模型提供商配置) - **里程碑**:M2 -- **状态**:已文档化;由 `apps/desktop/test/model-custom-lookup.test.mjs` 与 `apps/desktop/test/provider-lookup-model-handler.test.mjs` 覆盖 +- **状态**:已文档化;由 `apps/desktop/test/model-custom-lookup.test.mjs`、`apps/desktop/test/provider-lookup-model-handler.test.mjs` 与 `apps/desktop/test/provider-endpoint-metadata.test.mjs` 覆盖 #### E2E-005H:从较长的服务模型列表一次全选当前可见模型