diff --git a/crates/agent-gateway/web/src/lib/models/catalog.generated.ts b/crates/agent-gateway/web/src/lib/models/catalog.generated.ts index 6ee9ae42f..3a0b1a88e 100644 --- a/crates/agent-gateway/web/src/lib/models/catalog.generated.ts +++ b/crates/agent-gateway/web/src/lib/models/catalog.generated.ts @@ -1,5 +1,5 @@ // Generated by scripts/generate-model-catalog.mjs — DO NOT EDIT. -// Source: https://models.dev/api.json (providers: anthropic, google, openai, xai) +// Source: https://models.dev/api.json (sections: anthropic, google, openai, xai, deepseek, zhipuai, moonshotai, minimax, stepfun, xiaomi, longcat, alibaba, tencent) // Refresh: make update-model-catalog export type CatalogModelEntry = { @@ -8,7 +8,7 @@ export type CatalogModelEntry = { maxOutputToken: number; }; -export type CatalogProviderId = "anthropic" | "google" | "openai" | "xai"; +export type CatalogProviderId = "anthropic" | "google" | "openai" | "xai" | "deepseek" | "zhipuai" | "moonshotai" | "minimax" | "stepfun" | "xiaomi" | "longcat" | "alibaba" | "tencent"; export const MODEL_CATALOG_SNAPSHOT_DATE = "2026-07-24"; @@ -126,4 +126,150 @@ export const MODEL_CATALOG: Record { let index = catalogIndexByProvider.get(catalogProvider); if (!index) { - index = new Map(MODEL_CATALOG[catalogProvider].map((entry) => [entry.id, entry])); + index = new Map(); + for (const entry of MODEL_CATALOG[catalogProvider]) { + index.set(entry.id, entry); + // 目录含混合大小写 id(MiniMax-M2/LongCat-2.0 等):补小写别名,让候选链 + // 的 lower 候选可命中;生成期按小写去重保证别名不会跨条目歧义。 + const lower = entry.id.toLowerCase(); + if (!index.has(lower)) index.set(lower, entry); + } catalogIndexByProvider.set(catalogProvider, index); } return index; @@ -95,10 +102,12 @@ export function findCatalogModel( return undefined; } -// 中转聚合常把 A 家模型挂在 B 家供应商类型下(如 Anthropic 兼容中转供 grok), -// 供应商作用域查不到时按 id 跨供应商回查,避免真实限额被本供应商兜底值顶掉。 -// 目录 id 无跨供应商重名(目录不变量测试锁死);候选链放外层——更精确的 id -// 形态优先于供应商声明序。 +// 中转聚合常把 A 家模型挂在 B 家供应商类型下(grok/deepseek/glm/qwen 等挂在 +// Anthropic/OpenAI 兼容中转),供应商作用域查不到时按 id 跨供应商回查,避免 +// 真实限额被本供应商兜底值顶掉。国内厂商分区(deepseek/zhipuai/alibaba 等) +// 没有对应的应用供应商类型,只经这里消费。目录 id 全局小写唯一(生成期跨 +// 分区去重+目录不变量测试锁死);候选链放外层——更精确的 id 形态优先于 +// 供应商声明序。 const CATALOG_PROVIDER_IDS = Object.keys(MODEL_CATALOG) as CatalogProviderId[]; export function findCatalogModelAcrossProviders( diff --git a/crates/agent-gui/src/lib/models/catalog.generated.ts b/crates/agent-gui/src/lib/models/catalog.generated.ts index 6ee9ae42f..3a0b1a88e 100644 --- a/crates/agent-gui/src/lib/models/catalog.generated.ts +++ b/crates/agent-gui/src/lib/models/catalog.generated.ts @@ -1,5 +1,5 @@ // Generated by scripts/generate-model-catalog.mjs — DO NOT EDIT. -// Source: https://models.dev/api.json (providers: anthropic, google, openai, xai) +// Source: https://models.dev/api.json (sections: anthropic, google, openai, xai, deepseek, zhipuai, moonshotai, minimax, stepfun, xiaomi, longcat, alibaba, tencent) // Refresh: make update-model-catalog export type CatalogModelEntry = { @@ -8,7 +8,7 @@ export type CatalogModelEntry = { maxOutputToken: number; }; -export type CatalogProviderId = "anthropic" | "google" | "openai" | "xai"; +export type CatalogProviderId = "anthropic" | "google" | "openai" | "xai" | "deepseek" | "zhipuai" | "moonshotai" | "minimax" | "stepfun" | "xiaomi" | "longcat" | "alibaba" | "tencent"; export const MODEL_CATALOG_SNAPSHOT_DATE = "2026-07-24"; @@ -126,4 +126,150 @@ export const MODEL_CATALOG: Record { let index = catalogIndexByProvider.get(catalogProvider); if (!index) { - index = new Map(MODEL_CATALOG[catalogProvider].map((entry) => [entry.id, entry])); + index = new Map(); + for (const entry of MODEL_CATALOG[catalogProvider]) { + index.set(entry.id, entry); + // 目录含混合大小写 id(MiniMax-M2/LongCat-2.0 等):补小写别名,让候选链 + // 的 lower 候选可命中;生成期按小写去重保证别名不会跨条目歧义。 + const lower = entry.id.toLowerCase(); + if (!index.has(lower)) index.set(lower, entry); + } catalogIndexByProvider.set(catalogProvider, index); } return index; @@ -95,10 +102,12 @@ export function findCatalogModel( return undefined; } -// 中转聚合常把 A 家模型挂在 B 家供应商类型下(如 Anthropic 兼容中转供 grok), -// 供应商作用域查不到时按 id 跨供应商回查,避免真实限额被本供应商兜底值顶掉。 -// 目录 id 无跨供应商重名(目录不变量测试锁死);候选链放外层——更精确的 id -// 形态优先于供应商声明序。 +// 中转聚合常把 A 家模型挂在 B 家供应商类型下(grok/deepseek/glm/qwen 等挂在 +// Anthropic/OpenAI 兼容中转),供应商作用域查不到时按 id 跨供应商回查,避免 +// 真实限额被本供应商兜底值顶掉。国内厂商分区(deepseek/zhipuai/alibaba 等) +// 没有对应的应用供应商类型,只经这里消费。目录 id 全局小写唯一(生成期跨 +// 分区去重+目录不变量测试锁死);候选链放外层——更精确的 id 形态优先于 +// 供应商声明序。 const CATALOG_PROVIDER_IDS = Object.keys(MODEL_CATALOG) as CatalogProviderId[]; export function findCatalogModelAcrossProviders( diff --git a/crates/agent-gui/test/models/model-catalog.test.mjs b/crates/agent-gui/test/models/model-catalog.test.mjs index e737be981..c3565e218 100644 --- a/crates/agent-gui/test/models/model-catalog.test.mjs +++ b/crates/agent-gui/test/models/model-catalog.test.mjs @@ -5,18 +5,38 @@ import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; const loader = createTsModuleLoader(); const catalog = loader.loadModule("src/lib/models/modelCatalog.ts"); -const PROVIDERS = ["anthropic", "google", "openai", "xai"]; -// 与 scripts/generate-model-catalog.mjs 的质量门同值:上游被截断时刷新会硬错, -// 这里锁住已入库快照的完整性。 -const MIN_MODELS_PER_PROVIDER = { anthropic: 8, google: 15, openai: 20, xai: 3 }; +// 与 scripts/generate-model-catalog.mjs 的 SECTIONS 同值(键、序、质量门): +// 上游被截断时刷新会硬错,这里锁住已入库快照的完整性。前四家是应用供应商 +// 类型的原生目录;其余为国内厂商分区,只经跨供应商回查消费。 +const MIN_MODELS_PER_PROVIDER = { + anthropic: 8, + google: 15, + openai: 20, + xai: 3, + deepseek: 3, + zhipuai: 10, + moonshotai: 8, + minimax: 5, + stepfun: 4, + xiaomi: 4, + longcat: 1, + alibaba: 40, + tencent: 4, +}; +const PROVIDERS = Object.keys(MIN_MODELS_PER_PROVIDER); test("generated catalog upholds the data invariants", () => { - // 跨供应商回查(findCatalogModelAcrossProviders)依赖 id 全目录唯一, - // 否则同名模型在不同供应商下会产生歧义命中。 + assert.deepEqual( + Object.keys(catalog.MODEL_CATALOG), + PROVIDERS, + "catalog sections must match the generator's SECTIONS (keys and order)", + ); + // 跨供应商回查(findCatalogModelAcrossProviders)与索引的小写别名依赖 + // id 全目录按小写唯一,否则同名模型在不同分区下会产生歧义命中。 const allIds = PROVIDERS.flatMap((providerId) => - catalog.MODEL_CATALOG[providerId].map((entry) => entry.id), + catalog.MODEL_CATALOG[providerId].map((entry) => entry.id.toLowerCase()), ); - assert.equal(new Set(allIds).size, allIds.length, "ids must be unique across providers"); + assert.equal(new Set(allIds).size, allIds.length, "ids must be lowercase-unique across sections"); for (const providerId of PROVIDERS) { const entries = catalog.MODEL_CATALOG[providerId]; assert.ok( @@ -102,6 +122,14 @@ test("cross-provider lookup resolves models configured under a foreign provider" maxOutputToken: 32_000, }); assert.equal(catalog.resolveModelLimitsAcrossProviders("model-not-in-catalog"), undefined); + // 国内厂商分区(无对应应用供应商类型)经跨供应商回查可命中。 + assert.equal(catalog.findCatalogModelAcrossProviders("deepseek-chat")?.id, "deepseek-chat"); + assert.equal(catalog.findCatalogModelAcrossProviders("glm-4.6")?.id, "glm-4.6"); + assert.equal(catalog.findCatalogModelAcrossProviders("qwen-max")?.id, "qwen-max"); + assert.equal(catalog.findCatalogModelAcrossProviders("kimi-k2.5")?.id, "kimi-k2.5"); + // 混合大小写目录 id(MiniMax/LongCat):小写配置经索引别名命中,返回原始 id。 + assert.equal(catalog.findCatalogModelAcrossProviders("minimax-m2.5")?.id, "MiniMax-M2.5"); + assert.equal(catalog.findCatalogModelAcrossProviders("longcat-2.0")?.id, "LongCat-2.0"); }); test("repairStaleCrossProviderLimits replaces only stale provider-fallback pairs", () => { diff --git a/crates/agent-gui/test/settings/normalization.test.mjs b/crates/agent-gui/test/settings/normalization.test.mjs index 212301e4a..8e7d3a75d 100644 --- a/crates/agent-gui/test/settings/normalization.test.mjs +++ b/crates/agent-gui/test/settings/normalization.test.mjs @@ -2419,6 +2419,16 @@ test("cross-provider models resolve real catalog limits instead of provider fall settings.getProviderModelDefaults("claude_code", "GROK-4.5@prod").contextWindow, 500_000, ); + // 国内厂商模型(deepseek/glm/qwen/kimi/MiniMax 等分区)没有自己的应用供应商 + // 类型,配在任一类型下都经跨供应商回查取真实限额。 + const deepseekUnderClaude = settings.getProviderModelDefaults("claude_code", "deepseek-chat"); + assert.equal(deepseekUnderClaude.contextWindow, 1_000_000); + assert.equal(deepseekUnderClaude.maxOutputToken, 384_000); + const glmUnderCodex = settings.getProviderModelDefaults("codex", "glm-4.7"); + assert.equal(glmUnderCodex.contextWindow, 204_800); + assert.equal(glmUnderCodex.maxOutputToken, 131_072); + // 混合大小写目录 id(MiniMax-M2.1):小写配置经索引小写别名命中。 + assert.equal(settings.getProviderModelDefaults("codex", "minimax-m2.1").contextWindow, 204_800); // 全目录未收录的模型仍吃本供应商兜底值。 assert.equal( settings.getProviderModelDefaults("claude_code", "some-custom-model").contextWindow, diff --git a/scripts/generate-model-catalog.mjs b/scripts/generate-model-catalog.mjs index bbc3d6f8d..9362ab626 100644 --- a/scripts/generate-model-catalog.mjs +++ b/scripts/generate-model-catalog.mjs @@ -24,15 +24,50 @@ const OUTPUT_PATHS = [ ]; const DEFAULT_SOURCE = "https://models.dev/api.json"; -// Fixed provider order: these are the native catalogs behind the app's four -// provider types (claude_code→anthropic, gemini→google, codex→openai, xai). -const PROVIDERS = ["anthropic", "google", "openai", "xai"]; -// Guard against a truncated upstream response replacing a healthy snapshot. -const MIN_MODELS_PER_PROVIDER = { anthropic: 8, google: 15, openai: 20, xai: 3 }; -// Models that must exist; their absence signals an upstream schema change. + +// Catalog sections. Each section unions one or more upstream models.dev +// provider keys (first source wins on a same-id conflict — used to prefer the +// China endpoint's limits for vendors that publish both). +// +// The first four sections are the native catalogs behind the app's provider +// types (claude_code→anthropic, gemini→google, codex→openai, xai); scoped +// lookup (findCatalogModel) only ever reads these. The remaining sections are +// mainland-China vendors with no app provider type of their own: they are +// consumed exclusively through findCatalogModelAcrossProviders, so models +// served through claude_code/codex-compatible relays resolve real limits. +// +// Section order is also the adjudication order for duplicate ids across +// sections: ids are deduplicated case-insensitively, first section wins. +// Pure vendor-official catalogs therefore come first; "alibaba" (Bailian) and +// "tencent" (coding plan) host third-party models (glm/kimi/MiniMax/deepseek +// deployments with platform-clamped limits), so they come last and their +// copies of another vendor's models are dropped in favor of the official ones. +const SECTIONS = [ + { key: "anthropic", sources: ["anthropic"], min: 8 }, + { key: "google", sources: ["google"], min: 15 }, + { key: "openai", sources: ["openai"], min: 20 }, + { key: "xai", sources: ["xai"], min: 3 }, + { key: "deepseek", sources: ["deepseek"], min: 3 }, + // zai (Z.AI, international brand) is a superset of zhipuai with identical + // ids and limits for the overlap; keep the domestic brand as the key. + { key: "zhipuai", sources: ["zai", "zhipuai"], min: 10 }, + { key: "moonshotai", sources: ["moonshotai-cn", "moonshotai"], min: 8 }, + { key: "minimax", sources: ["minimax-cn", "minimax"], min: 5 }, + { key: "stepfun", sources: ["stepfun"], min: 4 }, + { key: "xiaomi", sources: ["xiaomi"], min: 4 }, + { key: "longcat", sources: ["longcat"], min: 1 }, + { key: "alibaba", sources: ["alibaba-cn", "alibaba"], min: 40 }, + { key: "tencent", sources: ["tencent-coding-plan"], min: 4 }, +]; + +// Models that must exist; their absence signals an upstream schema change +// (or, for unioned sections, a source-key rename). const SENTINELS = [ ["anthropic", "claude-sonnet-4-6"], ["openai", "gpt-5"], + ["deepseek", "deepseek-chat"], + ["zhipuai", "glm-4.6"], + ["alibaba", "qwen-max"], ]; // Single semantic rule shared with lib/models/modelCatalog.ts (bound together @@ -91,35 +126,54 @@ async function loadUpstream(source) { return undefined; } -function extractModels(providerId, providerData) { - const rawModels = providerData?.models; - if (!rawModels || typeof rawModels !== "object") { - fail(`provider ${providerId}: missing models map`); - } +// claimedLower: lowercased id -> owning section key. Lowercase-unique ids +// across the whole catalog are what make cross-provider lookup unambiguous +// and let the runtime index add case-insensitive aliases; the invariant is +// re-asserted by test/models/model-catalog.test.mjs. +function extractSection(section, upstream, claimedLower) { const entries = []; - const seen = new Set(); - for (const [id, model] of Object.entries(rawModels)) { - const contextWindow = model?.limit?.context; - const rawOutput = model?.limit?.output; - if (!model?.modalities?.output?.includes?.("text")) { - console.error(`skip ${providerId}/${id} (non-text output)`); - continue; - } - if (!Number.isInteger(contextWindow) || contextWindow <= 0) { - console.error(`skip ${providerId}/${id} (invalid limit.context)`); - continue; + for (const source of section.sources) { + const providerData = upstream?.[source]; + if (!providerData) fail(`section ${section.key}: source ${source} missing from upstream data`); + const rawModels = providerData.models; + if (!rawModels || typeof rawModels !== "object") { + fail(`section ${section.key}: source ${source} missing models map`); } - if (!Number.isInteger(rawOutput) || rawOutput <= 0) { - console.error(`skip ${providerId}/${id} (invalid limit.output)`); - continue; + for (const [id, model] of Object.entries(rawModels)) { + // Aggregator-namespaced deployments (e.g. Bailian's "siliconflow/…", + // "kimi/…") are not vendor model ids; relays never serve them verbatim. + if (id.includes("/")) { + console.error(`skip ${source}/${id} (aggregator-prefixed id)`); + continue; + } + const contextWindow = model?.limit?.context; + const rawOutput = model?.limit?.output; + if (!model?.modalities?.output?.includes?.("text")) { + console.error(`skip ${source}/${id} (non-text output)`); + continue; + } + if (!Number.isInteger(contextWindow) || contextWindow <= 0) { + console.error(`skip ${source}/${id} (invalid limit.context)`); + continue; + } + if (!Number.isInteger(rawOutput) || rawOutput <= 0) { + console.error(`skip ${source}/${id} (invalid limit.output)`); + continue; + } + const lower = id.toLowerCase(); + const claimedBy = claimedLower.get(lower); + if (claimedBy === section.key) continue; // CN/global union overlap: first source wins. + if (claimedBy) { + console.error(`skip ${source}/${id} (id claimed by section ${claimedBy})`); + continue; + } + claimedLower.set(lower, section.key); + entries.push({ + id, + contextWindow, + maxOutputToken: normalizeMaxOutputToken(contextWindow, rawOutput), + }); } - if (seen.has(id)) fail(`provider ${providerId}: duplicate model id ${id}`); - seen.add(id); - entries.push({ - id, - contextWindow, - maxOutputToken: normalizeMaxOutputToken(contextWindow, rawOutput), - }); } entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); return entries; @@ -130,9 +184,10 @@ function renderEntry(entry) { } function renderCatalog(catalog, snapshotDate) { + const keys = SECTIONS.map((section) => section.key); const lines = [ "// Generated by scripts/generate-model-catalog.mjs — DO NOT EDIT.", - "// Source: https://models.dev/api.json (providers: anthropic, google, openai, xai)", + `// Source: https://models.dev/api.json (sections: ${keys.join(", ")})`, "// Refresh: make update-model-catalog", "", "export type CatalogModelEntry = {", @@ -141,15 +196,15 @@ function renderCatalog(catalog, snapshotDate) { " maxOutputToken: number;", "};", "", - `export type CatalogProviderId = ${PROVIDERS.map((p) => JSON.stringify(p)).join(" | ")};`, + `export type CatalogProviderId = ${keys.map((key) => JSON.stringify(key)).join(" | ")};`, "", `export const MODEL_CATALOG_SNAPSHOT_DATE = "${snapshotDate}";`, "", "export const MODEL_CATALOG: Record = {", ]; - for (const providerId of PROVIDERS) { - lines.push(` ${providerId}: [`); - for (const entry of catalog[providerId]) lines.push(renderEntry(entry)); + for (const key of keys) { + lines.push(` ${key}: [`); + for (const entry of catalog[key]) lines.push(renderEntry(entry)); lines.push(" ],"); } lines.push("};", ""); @@ -172,21 +227,20 @@ const args = parseArgs(process.argv); const upstream = await loadUpstream(args.source); const catalog = {}; -for (const providerId of PROVIDERS) { - const providerData = upstream?.[providerId]; - if (!providerData) fail(`provider ${providerId}: missing from upstream data`); - const entries = extractModels(providerId, providerData); - if (entries.length < MIN_MODELS_PER_PROVIDER[providerId]) { +const claimedLower = new Map(); +for (const section of SECTIONS) { + const entries = extractSection(section, upstream, claimedLower); + if (entries.length < section.min) { fail( - `provider ${providerId}: only ${entries.length} models after filtering ` + - `(expected >= ${MIN_MODELS_PER_PROVIDER[providerId]}); upstream data looks truncated`, + `section ${section.key}: only ${entries.length} models after filtering ` + + `(expected >= ${section.min}); upstream data looks truncated`, ); } - catalog[providerId] = entries; + catalog[section.key] = entries; } -for (const [providerId, modelId] of SENTINELS) { - if (!catalog[providerId].some((entry) => entry.id === modelId)) { - fail(`sentinel model ${providerId}/${modelId} missing; upstream schema may have changed`); +for (const [sectionKey, modelId] of SENTINELS) { + if (!catalog[sectionKey].some((entry) => entry.id === modelId)) { + fail(`sentinel model ${sectionKey}/${modelId} missing; upstream schema may have changed`); } } @@ -218,5 +272,5 @@ if (unchanged) { for (const path of OUTPUT_PATHS) writeFileSync(path, nextContent); const [guiBytes, webBytes] = OUTPUT_PATHS.map((path) => readFileSync(path)); if (!guiBytes.equals(webBytes)) fail("post-write self-check failed: outputs differ"); -const total = PROVIDERS.reduce((sum, providerId) => sum + catalog[providerId].length, 0); +const total = SECTIONS.reduce((sum, section) => sum + catalog[section.key].length, 0); console.log(`catalog updated (${total} models, snapshot ${today})`);