From 912ed295db3153f01f44f3e56f93fa4616b69607 Mon Sep 17 00:00:00 2001 From: izzzzzi Date: Thu, 17 Sep 2026 17:52:47 +0700 Subject: [PATCH] fix(deepseek,model-catalog): vision for V4.1-Flash ids, scope synced catalog to gateways - Declare deepseek-v4.1-flash and deepseek-flash as vision-capable in MODEL_CAPABILITIES - Share installed catalogSource across route chunks via globalThis.__9rCatalogSource - Scope catalog modality keys by provider:model to prevent cross-gateway collisions - Upgrade catalog format to v2 with automatic rebuild of older schemas --- open-sse/providers/capabilities.js | 32 ++++- open-sse/providers/catalogOverride.js | 22 +++- src/lib/modelCatalog/sync.js | 105 +++++++++------ tests/unit/capabilities.test.js | 18 +++ tests/unit/model-catalog-scope.test.js | 174 +++++++++++++++++++++++++ 5 files changed, 299 insertions(+), 52 deletions(-) create mode 100644 tests/unit/model-catalog-scope.test.js diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 177db4a7..eac9089e 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -116,6 +116,16 @@ export const MODEL_CAPABILITIES = { // DeepSeek's first V4 model with image input; text limits match V4-Flash. "deepseek-v4-flash-vision-exp": { vision: true, reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 384000 }, + // DeepSeek V4.1-Flash is natively multimodal — models.dev lists + // opencode-go/deepseek-v4.1-flash with modalities.input ["text","image"] — and upstream + // the retired v4-flash / vision-exp ids route to it, so the live V4.1 ids carry the + // same image capability as the exp id above. "deepseek-flash" is the GA id on the + // DeepSeek API; it previously fell through to the generic *deepseek* pattern, whose + // 128K/64K limits are kept here. The repeated fields are deliberate: an exact entry + // short-circuits the pattern table, so a vision-only delta would drop them. + "deepseek-v4.1-flash": { vision: true, reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 384000 }, + "deepseek-flash": { vision: true, reasoning: true, thinkingFormat: "deepseek", contextWindow: 128000, maxOutput: 64000 }, + // Qwen plain coder/text (no vision) — registry "vision-model" / "coder-model" aliases "vision-model": { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 }, "coder-model": { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 }, @@ -431,14 +441,27 @@ const MODALITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"]; // Catalog lookups, installed by the server at startup. Left as no-ops in the // browser bundle, where there is no file to read. +// +// The server bundles this module into every route chunk that needs it, and each +// copy carries its own module state, so an install landing in the copy the +// startup hook imported stays invisible to the copy resolving requests. The slot +// lives on globalThis instead; the local binding is the fast path. let catalogSource = null; /** * Install the synced catalog reader (server only). - * @param {{ getModalities: Function, getLimits: Function } | null} source + * @param {{ getModalities: (provider: string, model: string) => object|null, + * getLimits: (provider: string, model: string) => object|null } | null} source */ export function setCatalogSource(source) { catalogSource = source; + if (typeof globalThis !== "undefined") globalThis.__9rCatalogSource = source; +} + +function getCatalogSource() { + if (catalogSource) return catalogSource; + if (typeof globalThis === "undefined") return null; + return (catalogSource = globalThis.__9rCatalogSource || null); } // Apply the synced catalog + name heuristic on top of a table-resolved result. @@ -447,15 +470,16 @@ export function setCatalogSource(source) { function refine(base, provider, model) { const result = { ...DEFAULT_CAPABILITIES, ...base }; - if (catalogSource) { - const modalities = catalogSource.getModalities(model); + const source = getCatalogSource(); + if (source) { + const modalities = source.getModalities(provider, model); if (modalities) { for (const key of MODALITY_KEYS) { if (modalities[key] === true) result[key] = true; } } - const limits = catalogSource.getLimits(provider, model); + const limits = source.getLimits(provider, model); if (limits) { if (limits.contextWindow > 0) result.contextWindow = limits.contextWindow; if (limits.maxOutput > 0) result.maxOutput = limits.maxOutput; diff --git a/open-sse/providers/catalogOverride.js b/open-sse/providers/catalogOverride.js index 12914b7d..c34576d9 100644 --- a/open-sse/providers/catalogOverride.js +++ b/open-sse/providers/catalogOverride.js @@ -13,6 +13,11 @@ export const CATALOG_FILE = path.join(DATA_DIR, "model-catalog.json"); // Trimmed upstream catalog, read by the add-models skill (not by the router). export const CATALOG_RAW_FILE = path.join(DATA_DIR, "model-catalog-raw.json"); +// Schema of the file this module reads. The writer stamps it; a file carrying an +// older value predates provider-scoped modality keys, and its flat keys are not +// looked up here, so the sync rebuilds it instead of asking upstream for a 304. +export const CATALOG_VERSION = 2; + const EMPTY = { models: {}, providers: {} }; let cache = EMPTY; let cachedMtime = -1; @@ -45,14 +50,19 @@ function load() { return cache; } -// Modality is a property of the model itself — any gateway serving it inherits -// the same image/video/pdf support, so this is keyed by model id alone. -export function getCatalogModalities(model) { - return load().models[baseId(model)] || null; +// Modalities are recorded per gateway upstream, and gateways disagree about the +// same weights — some do not proxy images at all — so the key is provider + +// model, in the local provider id space, exactly like the limits below. Keying +// by model id alone made short ids collide across vendors: "auto", "free" and +// "efficient" are router modes in one catalog and model names in another, and a +// request to the router mode inherited a stranger's vision. +export function getCatalogModalities(provider, model) { + if (!provider) return null; + return load().models[`${provider}:${baseId(model)}`] || null; } -// Context and output limits are a property of the gateway, not the model: each -// one truncates differently, so these stay keyed by provider + model. +// Context and output limits are a property of the gateway too: each one +// truncates differently, so these stay keyed by provider + model. export function getCatalogLimits(provider, model) { const byProvider = provider && load().providers[provider]; if (!byProvider) return null; diff --git a/src/lib/modelCatalog/sync.js b/src/lib/modelCatalog/sync.js index 0d49c48e..973c18b3 100644 --- a/src/lib/modelCatalog/sync.js +++ b/src/lib/modelCatalog/sync.js @@ -6,7 +6,7 @@ import fs from "node:fs"; import path from "node:path"; -import { CATALOG_FILE, CATALOG_RAW_FILE, invalidateCatalog, installCatalogSource } from "open-sse/providers/catalogOverride.js"; +import { CATALOG_FILE, CATALOG_RAW_FILE, CATALOG_VERSION, invalidateCatalog, installCatalogSource } from "open-sse/providers/catalogOverride.js"; const CATALOG_URL = "https://models.dev/api.json"; const FETCH_TIMEOUT_MS = 60000; @@ -16,16 +16,14 @@ const STARTUP_DELAY_MS = 60 * 1000; // let the server boot and serve first req const RETRY_DELAY_MS = 30 * 60 * 1000; const MODALITY_BY_INPUT = { image: "vision", pdf: "pdf", audio: "audioInput", video: "videoInput" }; -// Gateways disagree about the same model, so a modality needs a majority of -// them to declare it — one reseller mislabelling a text model must not win. -const MIN_SHARE = 0.5; // Ignore limit differences below this: gateways round 200000 vs 202752. const LIMIT_TOLERANCE = 0.1; -// 9router provider id -> models.dev provider id, for context/maxOutput only. -// Providers absent here keep whatever the local pattern table resolves; names -// that already match are resolved automatically. -const PROVIDER_ALIASES = { +// 9router provider id -> models.dev provider id: the same gateway under another +// name. Both halves of the catalog are stored against the local id, so this runs +// while building rather than on every lookup. Providers absent here keep whatever +// the local pattern table resolves; names that already match need no entry. +export const PROVIDER_ALIASES = { "glm": "zai", "glm-cn": "zhipuai", "claude": "anthropic", @@ -40,7 +38,7 @@ const PROVIDER_ALIASES = { "cloudflare-ai": "cloudflare-workers-ai", }; -let state = { running: false, lastSync: null, lastError: null, lastResult: null, etag: null }; +let state = { running: false, lastSync: null, lastError: null, lastResult: null, etag: null, fileVersion: null }; let timer = null; export function getSyncState() { @@ -78,40 +76,57 @@ function slim(catalog) { return out; } -function build(catalog, entries) { - // Index once: per provider for limits, and tallied across all of them for - // modalities. - const byProvider = {}; - const tally = {}; - for (const [providerId, provider] of Object.entries(catalog)) { - const models = {}; - const counted = new Set(); - for (const [modelId, model] of Object.entries(provider?.models || {})) { - const id = baseId(modelId); - models[id] = model; - // One vote per provider: several ids can normalize to the same model - // (claude-opus-4-thinking:1024, :8192, :32768 …) and must not stack. - if (counted.has(id)) continue; - counted.add(id); - const counts = tally[id] || (tally[id] = { total: 0 }); - counts.total++; - for (const input of model?.modalities?.input || []) { - const key = MODALITY_BY_INPUT[input]; - if (key) counts[key] = (counts[key] || 0) + 1; - } - } - byProvider[providerId] = models; +export function build(catalog, entries) { + // Upstream provider id -> the local ids it belongs to, taken from the registry + // snapshot so a gateway listed upstream under another name is still filed + // under the name requests arrive with. One upstream name can back more than one + // local id (glm-cn and zhipu are both zhipuai) and each has to resolve; the + // snapshot only covers the built-in registry, so an upstream provider it does + // not mention keeps its own name. + const localIds = new Map(); + for (const { provider } of entries) { + const upstreamId = PROVIDER_ALIASES[provider] || provider; + let locals = localIds.get(upstreamId); + if (!locals) localIds.set(upstreamId, (locals = [])); + if (!locals.includes(provider)) locals.push(provider); } - // Modalities belong to the model — every gateway serving it has the same - // weights — so they are keyed by model id and shared across providers. + // Index once: the raw upstream record per provider+model for limits, and the + // modalities each gateway declares for it. + const byProvider = {}; + // Modalities are recorded per gateway upstream and gateways disagree about the + // same weights — some do not proxy images at all — so the key is provider + + // model. Keying by model id alone let short ids collide across vendors: "auto", + // "free" and "efficient" are router modes in one catalog and model names in + // another, so a router mode inherited a stranger's vision. const models = {}; - for (const [id, counts] of Object.entries(tally)) { - const declared = {}; - for (const key of Object.values(MODALITY_BY_INPUT)) { - if ((counts[key] || 0) / counts.total >= MIN_SHARE) declared[key] = true; + for (const [providerId, provider] of Object.entries(catalog)) { + const locals = localIds.get(providerId) || [providerId]; + const modelsById = {}; + const seen = new Set(); + for (const [modelId, model] of Object.entries(provider?.models || {})) { + const id = baseId(modelId); + modelsById[id] = model; + // One entry per provider+model: several upstream ids can normalize to the + // same model (claude-opus-4-thinking:1024, :8192, :32768 …) and must not + // stack their modalities. + if (seen.has(id)) continue; + seen.add(id); + const declared = {}; + for (const input of model?.modalities?.input || []) { + const key = MODALITY_BY_INPUT[input]; + if (key) declared[key] = true; + } + if (Object.keys(declared).length) { + // Filed under every local id requests arrive with, and under the upstream + // id too: a custom provider node can carry the upstream name without + // appearing in the registry snapshot, and nothing else would resolve for + // it. The reader takes whichever key it is handed. + for (const local of locals) models[`${local}:${id}`] = declared; + if (!locals.includes(providerId)) models[`${providerId}:${id}`] = declared; + } } - if (Object.keys(declared).length) models[id] = declared; + byProvider[providerId] = modelsById; } // Limits belong to the gateway — each truncates differently — so only the @@ -172,7 +187,9 @@ export async function syncModelCatalog() { state.running = true; try { const headers = { accept: "application/json" }; - if (state.etag) headers["if-none-match"] = state.etag; + // A file written by an older schema has to be rebuilt even when upstream is + // unchanged, so only ask upstream for a 304 when the file is current. + if (state.etag && state.fileVersion === CATALOG_VERSION) headers["if-none-match"] = state.etag; const response = await fetch(CATALOG_URL, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); let result; @@ -187,12 +204,13 @@ export async function syncModelCatalog() { const etag = response.headers.get("etag") || null; const entries = await collectEntries(); const { models, providers } = build(catalog, entries); - const serialized = JSON.stringify({ v: 1, etag, syncedAt: Date.now(), models, providers }); + const serialized = JSON.stringify({ v: CATALOG_VERSION, etag, syncedAt: Date.now(), models, providers }); writeAtomic(CATALOG_FILE, serialized); writeAtomic(CATALOG_RAW_FILE, JSON.stringify(slim(catalog))); state.etag = etag; + state.fileVersion = CATALOG_VERSION; invalidateCatalog(); result = { status: "updated", @@ -223,10 +241,13 @@ export async function syncModelCatalog() { // of re-downloading 4.3MB to be told nothing changed. function restoreEtag() { try { - state.etag = JSON.parse(fs.readFileSync(CATALOG_FILE, "utf8")).etag || null; + const parsed = JSON.parse(fs.readFileSync(CATALOG_FILE, "utf8")); + state.etag = parsed.etag || null; + state.fileVersion = parsed.v || 1; state.lastSync = fs.statSync(CATALOG_FILE).mtimeMs; } catch { state.etag = null; + state.fileVersion = null; } } diff --git a/tests/unit/capabilities.test.js b/tests/unit/capabilities.test.js index cde8e9b4..84a8643c 100644 --- a/tests/unit/capabilities.test.js +++ b/tests/unit/capabilities.test.js @@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest"; import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js"; describe("getCapabilitiesForModel", () => { + + it("reports DeepSeek V4.1-Flash ids as vision-capable without dropping their thinking/context", () => { + const v41 = { vision: true, reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 384000 }; + expect(getCapabilitiesForModel(undefined, "deepseek-v4.1-flash")).toMatchObject(v41); + expect(getCapabilitiesForModel("opencode-go", "deepseek-v4.1-flash")).toMatchObject(v41); + expect(getCapabilitiesForModel("openrouter", "deepseek/deepseek-v4.1-flash")).toMatchObject(v41); + // "deepseek-flash" is the GA id for V4.1-Flash on the DeepSeek API; the pattern it + // used to fall through to gives it 128K/64K, which the exact entry keeps. + expect(getCapabilitiesForModel("opencode-go", "deepseek-flash")).toMatchObject({ + vision: true, + reasoning: true, + thinkingFormat: "deepseek", + contextWindow: 128000, + maxOutput: 64000, + }); + // the superseded text-only Flash id stays text-only + expect(getCapabilitiesForModel("opencode-go", "deepseek-v4-flash").vision).toBe(false); + }); const claudeSonnet5Expected = { contextWindow: 1000000, maxOutput: 128000, diff --git a/tests/unit/model-catalog-scope.test.js b/tests/unit/model-catalog-scope.test.js new file mode 100644 index 00000000..c582d28f --- /dev/null +++ b/tests/unit/model-catalog-scope.test.js @@ -0,0 +1,174 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Both modules read their file path from DATA_DIR at import time, so the temp +// data dir has to be in place before the first import. +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "9r-catalog-")); +process.env.DATA_DIR = dataDir; +const catalogFile = path.join(dataDir, "model-catalog.json"); + +// One upstream record per gateway: the same short id means different things to +// different vendors, which is what used to leak capabilities across providers. +const upstream = { + zai: { models: { "glm-4.6v": { modalities: { input: ["text", "image"] } } } }, + // two local ids alias this one upstream provider + zhipuai: { models: { "glm-5-canary": { modalities: { input: ["text", "image", "pdf"] } } } }, + moonshotai: { models: { "kimi-k3": { modalities: { input: ["text"] } } } }, + kilo: { models: { "kilo-auto/efficient": { modalities: { input: ["text", "image"] } } } }, +}; +// The registry snapshot the sync feeds build(): local ids, with the capabilities +// the tables resolve on their own. +const entries = [ + { provider: "glm", model: "glm-4.6v", current: { contextWindow: 200000, maxOutput: 128000 } }, + { provider: "glm-cn", model: "glm-5-canary", current: { contextWindow: 200000, maxOutput: 128000 } }, + { provider: "zhipu", model: "glm-5-canary", current: { contextWindow: 200000, maxOutput: 128000 } }, + { provider: "kimi", model: "kimi-k3", current: { contextWindow: 128000, maxOutput: 32000 } }, +]; + +let build, getCatalogModalities, invalidateCatalog, syncModelCatalog, startModelCatalogSync, capabilities; + +beforeAll(async () => { + ({ build, syncModelCatalog, startModelCatalogSync } = await import("../../src/lib/modelCatalog/sync.js")); + // the builder is exercised directly; a missing export must fail loudly here + // rather than skip every case below + expect(typeof build).toBe("function"); + const { models, providers } = build(upstream, entries); + fs.writeFileSync(catalogFile, JSON.stringify({ v: 2, models, providers })); + ({ getCatalogModalities, invalidateCatalog } = await import("../../open-sse/providers/catalogOverride.js")); + capabilities = await import("../../open-sse/providers/capabilities.js"); +}); + +afterAll(() => { + fs.rmSync(dataDir, { recursive: true, force: true }); +}); + +describe("model catalog", () => { + it("keys modalities by gateway and writes no model-only key", () => { + const { models } = build(upstream, entries); + // upstream "zai" is filed under the local id requests arrive with... + expect(models["glm:glm-4.6v"]).toEqual({ vision: true }); + // ...and under its upstream name, because a custom provider node can carry + // that name without being in the registry snapshot + expect(models["zai:glm-4.6v"]).toEqual({ vision: true }); + expect(models["kilo:efficient"]).toEqual({ vision: true }); + // the vendor-stripped key is what used to be shared with every other gateway + expect(models["glm-4.6v"]).toBeUndefined(); + expect(models["efficient"]).toBeUndefined(); + // a gateway that only declares text has nothing to contribute + expect(models["kimi:kimi-k3"]).toBeUndefined(); + }); + + it("files an upstream provider under every local id that aliases it", () => { + const { models } = build(upstream, entries); + // glm-cn and zhipu are both zhipuai upstream; neither may be dropped + expect(models["glm-cn:glm-5-canary"]).toEqual({ vision: true, pdf: true }); + expect(models["zhipu:glm-5-canary"]).toEqual({ vision: true, pdf: true }); + expect(models["zhipuai:glm-5-canary"]).toEqual({ vision: true, pdf: true }); + expect(getCatalogModalities("glm-cn", "glm-5-canary")).toEqual({ vision: true, pdf: true }); + expect(getCatalogModalities("zhipu", "glm-5-canary")).toEqual({ vision: true, pdf: true }); + }); + + it("does not hand a router mode another vendor's modalities", () => { + // kilo's "efficient" is a real model; another gateway's "efficient" is a mode + expect(getCatalogModalities("kilo", "kilo-auto/efficient")).toEqual({ vision: true }); + expect(getCatalogModalities("kilo-gateway", "kilo-auto/efficient")).toBeNull(); + expect(getCatalogModalities("qoder", "efficient")).toBeNull(); + }); + + it("resolves a gateway the file was written for, and nobody else", () => { + expect(getCatalogModalities("glm", "glm-4.6v")).toEqual({ vision: true }); + expect(getCatalogModalities("zai", "glm-4.6v")).toEqual({ vision: true }); + expect(getCatalogModalities("unrelated", "glm-4.6v")).toBeNull(); + expect(getCatalogModalities(undefined, "glm-4.6v")).toBeNull(); + }); + + it("passes the gateway to the catalog reader when refining", () => { + const seen = []; + capabilities.setCatalogSource({ + getModalities: (provider) => { + seen.push(provider); + return provider === "gateway-a" ? { vision: true } : null; + }, + getLimits: () => null, + }); + try { + // "*laguna*" resolves from the pattern table, so refine() runs + expect(capabilities.getCapabilitiesForModel("gateway-a", "laguna-9-preview").vision).toBe(true); + expect(capabilities.getCapabilitiesForModel("gateway-b", "laguna-9-preview").vision).toBe(false); + expect(seen).toContain("gateway-a"); + } finally { + capabilities.setCatalogSource(null); + } + }); + + it("shares the installed source with every copy of the module", async () => { + const source = { + getModalities: (provider) => (provider === "gateway-a" ? { vision: true } : null), + getLimits: () => null, + }; + capabilities.setCatalogSource(source); + try { + // The server bundles this module into more than one chunk and the startup + // hook only runs in one of them, so the slot has to be process-wide. + expect(globalThis.__9rCatalogSource).toBe(source); + const other = await import("../../open-sse/providers/capabilities.js?copy=2"); + expect(other.getCapabilitiesForModel).not.toBe(capabilities.getCapabilitiesForModel); + // ...and that second copy resolves through the source it never installed + expect(other.getCapabilitiesForModel("gateway-a", "laguna-9-preview").vision).toBe(true); + } finally { + capabilities.setCatalogSource(null); + } + expect(globalThis.__9rCatalogSource).toBeNull(); + }); +}); + +describe("catalog schema", () => { + it("ignores a file written before the keys were scoped", () => { + const scoped = fs.readFileSync(catalogFile); + // v1: flat model keys, which is exactly the shape that collided + fs.writeFileSync(catalogFile, JSON.stringify({ v: 1, models: { "kimi-k3": { vision: true } }, providers: {} })); + invalidateCatalog(); + expect(getCatalogModalities("kimi", "kimi-k3")).toBeNull(); + fs.writeFileSync(catalogFile, scoped); + invalidateCatalog(); + }); + + it("rebuilds an older-schema file instead of trusting its etag", async () => { + fs.writeFileSync(catalogFile, JSON.stringify({ v: 1, etag: 'W/"old"', models: {}, providers: {} })); + invalidateCatalog(); + startModelCatalogSync(); // picks the file's etag + schema version back up + + const sent = []; + const realFetch = globalThis.fetch; + globalThis.fetch = async (_url, options) => { + sent.push(options?.headers || {}); + return { ok: true, status: 200, headers: new Map([["etag", 'W/"new"']]), json: async () => upstream }; + }; + try { + expect((await syncModelCatalog()).status).toBe("updated"); + } finally { + globalThis.fetch = realFetch; + } + expect(sent[0]["if-none-match"]).toBeUndefined(); + const written = JSON.parse(fs.readFileSync(catalogFile, "utf8")); + expect(written.v).toBe(2); + expect(written.models["glm:glm-4.6v"]).toEqual({ vision: true }); + }); + + it("asks upstream for a 304 once the file is current", async () => { + const sent = []; + const realFetch = globalThis.fetch; + globalThis.fetch = async (_url, options) => { + sent.push(options?.headers || {}); + return { ok: false, status: 304, headers: new Map(), json: async () => ({}) }; + }; + try { + expect((await syncModelCatalog()).status).toBe("unchanged"); + } finally { + globalThis.fetch = realFetch; + } + expect(sent[0]["if-none-match"]).toBe('W/"new"'); + }); +});