From aa35d460dce7b19e91e7ee9b680b7f34fde07525 Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Mon, 7 Sep 2026 08:00:58 -0400 Subject: [PATCH] fix(catalog): union picker customModels into the dispatch-time live catalog (#12597) (#12934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437 (ambos sob a baseline), ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes. A assimetria era exatamente o defeito: o REST do picker já mesclava `customModels`, o despacho não, e o operador via o modelo na tela e tomava 400 na inferência. O overlay só de campos definidos é o detalhe que impede uma escrita esparsa do picker de apagar metadata de capacidade que veio do sync. Nota de integração: este arquivo colidiu com o #12866, que extraiu o mesmo bloco para `loadConnectionCatalog` e uniu os catálogos irmãos agy/antigravity. Integrei os dois na worktree combinada — a união de irmãos primeiro, o `unionCustomModels` por cima — e a resolução vai junto no merge do #12866. --- .../fixes/12934-custom-models-live-catalog.md | 1 + src/lib/db/models.ts | 15 +- src/lib/db/models/activeSyncedCatalog.ts | 51 ++++++- .../custom-models-live-catalog-12597.test.ts | 136 ++++++++++++++++++ 4 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fixes/12934-custom-models-live-catalog.md create mode 100644 tests/unit/custom-models-live-catalog-12597.test.ts diff --git a/changelog.d/fixes/12934-custom-models-live-catalog.md b/changelog.d/fixes/12934-custom-models-live-catalog.md new file mode 100644 index 0000000000..879b498b59 --- /dev/null +++ b/changelog.d/fixes/12934-custom-models-live-catalog.md @@ -0,0 +1 @@ +- **fix(catalog):** Picker-added `customModels` enter the dispatch-time live catalog so combo members and bare inference no longer 400 ([#12934](https://github.com/diegosouzapw/OmniRoute/pull/12934)) — thanks @HouMinXi diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 4ec8b75069..b716d88173 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -580,9 +580,22 @@ export async function getActiveProvidersWithSyncedModel(modelId: string): Promis json_extract(synced_model.value, '$.id'), json_extract(synced_model.value, '$.name'), json_extract(synced_model.value, '$.model') + ) = ? + UNION + SELECT DISTINCT pc.provider AS provider + FROM provider_connections pc + JOIN key_value kv + ON kv.namespace = 'customModels' + AND kv.key = pc.provider + JOIN json_each(CASE WHEN json_valid(kv.value) THEN kv.value ELSE '[]' END) custom_model + WHERE pc.is_active = 1 + AND COALESCE( + json_extract(custom_model.value, '$.id'), + json_extract(custom_model.value, '$.name'), + json_extract(custom_model.value, '$.model') ) = ?` ) - .all(modelId) as Array<{ provider?: unknown }>; + .all(modelId, modelId) as Array<{ provider?: unknown }>; return rows .map((row) => row.provider) diff --git a/src/lib/db/models/activeSyncedCatalog.ts b/src/lib/db/models/activeSyncedCatalog.ts index d20e361695..443b37f894 100644 --- a/src/lib/db/models/activeSyncedCatalog.ts +++ b/src/lib/db/models/activeSyncedCatalog.ts @@ -2,10 +2,12 @@ import { providerUsesAuthoritativeLiveCatalog } from "@omniroute/open-sse/config import { PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels.ts"; import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog"; import { + getCustomModels, getSyncedAvailableModels, getSyncedAvailableModelsByConnection, type SyncedAvailableModel, } from "../models"; +import { normalizeSyncedAvailableModels } from "./synced"; import { getRawProviderConnections } from "../providers"; export type ActiveSyncedCatalog = { @@ -108,6 +110,43 @@ function enrichCursorCatalog( : models; } +/** + * #12597: picker-added `customModels` are already merged on GET /api/providers/{id}/models. + * Dispatch-time live catalog must union the same rows or combo / bare inference 400. + * Same-id custom metadata overlays the synced row (name, vision, …). + */ +async function unionCustomModels( + providerId: string, + models: SyncedAvailableModel[] +): Promise { + let customRows: SyncedAvailableModel[] = []; + try { + customRows = normalizeSyncedAvailableModels(await getCustomModels(providerId), providerId); + } catch { + // Fail open: a customModels read/parse miss must not empty the synced catalog. + return models; + } + if (customRows.length === 0) return models; + + const merged = new Map(); + for (const model of models) { + if (model?.id) merged.set(model.id, model); + } + for (const model of customRows) { + if (!model?.id) continue; + const existing = merged.get(model.id); + if (!existing) { + merged.set(model.id, model); + continue; + } + const overlay = Object.fromEntries( + Object.entries(model).filter(([, value]) => value !== undefined) + ) as Partial; + merged.set(model.id, { ...existing, ...overlay, id: model.id }); + } + return Array.from(merged.values()); +} + /** * Return the unioned synced catalog belonging only to active connections. * @@ -139,7 +178,10 @@ export async function getActiveSyncedCatalog(providerId: string): Promise 0) { return { @@ -164,7 +206,7 @@ export async function getActiveSyncedCatalog(providerId: string): Promise 0) { diff --git a/tests/unit/custom-models-live-catalog-12597.test.ts b/tests/unit/custom-models-live-catalog-12597.test.ts new file mode 100644 index 0000000000..4e6bb35068 --- /dev/null +++ b/tests/unit/custom-models-live-catalog-12597.test.ts @@ -0,0 +1,136 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// #12597: picker-added customModels must enter the dispatch-time live catalog. +// GET /api/providers/{id}/models already merges customModels; getActiveSyncedCatalog +// and getActiveProvidersWithSyncedModel did not, so combo + bare inference 400'd. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-12597-custom-live-")); + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "custom-live-12597-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const { addCustomModel, replaceSyncedAvailableModelsForConnection, getActiveProvidersWithSyncedModel } = + await import("../../src/lib/db/models.ts"); +const { + getActiveSyncedCatalog, + catalogContainsModel, + reconcileProvidersWithActiveSyncedCatalog, +} = await import("../../src/lib/db/models/activeSyncedCatalog.ts"); + +const PROVIDER = "github"; +const CONNECTION_ID = "github-live-catalog-12597"; +const SYNCED_MODEL = "gpt-4.1"; +const PICKER_MODEL = "nvidia/deepseek-ai/deepseek-r1"; + +async function seedActiveSyncedCatalog(modelIds: string[] = [SYNCED_MODEL]) { + const db = core.getDbInstance(); + const now = new Date().toISOString(); + db.prepare( + `INSERT OR REPLACE INTO provider_connections + (id, provider, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)` + ).run(CONNECTION_ID, PROVIDER, 1, now, now); + await replaceSyncedAvailableModelsForConnection( + PROVIDER, + CONNECTION_ID, + modelIds.map((id) => ({ id, name: id, source: "imported" as const })) + ); +} + +test.beforeEach(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + await seedActiveSyncedCatalog(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("#12597 picker-only model is in the authoritative live catalog", async () => { + await addCustomModel(PROVIDER, PICKER_MODEL, "DeepSeek R1 via picker"); + + const catalog = await getActiveSyncedCatalog(PROVIDER); + assert.equal(catalog.authoritative, true); + assert.ok( + catalog.models.some((model) => model.id === PICKER_MODEL), + "picker-added customModels must union into getActiveSyncedCatalog" + ); + assert.ok( + catalog.models.some((model) => model.id === SYNCED_MODEL), + "synced rows must still be present" + ); + assert.equal(catalogContainsModel(catalog, PICKER_MODEL), true); +}); + +test("#12597 reconcile must not exclude a provider for a picker-only model", async () => { + await addCustomModel(PROVIDER, PICKER_MODEL, "DeepSeek R1 via picker"); + + const { providers, excludedProviders } = await reconcileProvidersWithActiveSyncedCatalog( + [PROVIDER], + PICKER_MODEL + ); + assert.deepEqual(providers, [PROVIDER]); + assert.deepEqual(excludedProviders, []); +}); + +test("#12597 getActiveProvidersWithSyncedModel finds a picker-only model", async () => { + await addCustomModel(PROVIDER, PICKER_MODEL, "DeepSeek R1 via picker"); + + const providers = await getActiveProvidersWithSyncedModel(PICKER_MODEL); + assert.ok( + providers.includes(PROVIDER), + "bare inference must see picker-added customModels, not only syncedAvailableModels" + ); +}); + +test("#12597 same-id custom overlay does not drop the synced row", async () => { + await addCustomModel(PROVIDER, SYNCED_MODEL, "Operator name for gpt-4.1"); + + const catalog = await getActiveSyncedCatalog(PROVIDER); + const matches = catalog.models.filter((model) => model.id === SYNCED_MODEL); + assert.equal(matches.length, 1); + assert.equal(matches[0]?.name, "Operator name for gpt-4.1"); +}); + +test("#12597 sparse custom overlay does not wipe synced capability fields", async () => { + const db = core.getDbInstance(); + await replaceSyncedAvailableModelsForConnection(PROVIDER, CONNECTION_ID, [ + { + id: SYNCED_MODEL, + name: SYNCED_MODEL, + source: "imported", + supportsThinking: true, + inputTokenLimit: 128000, + supportedThinkingEfforts: ["low", "high"], + }, + ]); + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('customModels', ?, ?)" + ).run(PROVIDER, JSON.stringify([{ id: SYNCED_MODEL, name: "Operator name" }])); + + const catalog = await getActiveSyncedCatalog(PROVIDER); + const match = catalog.models.find((model) => model.id === SYNCED_MODEL); + assert.equal(match?.name, "Operator name"); + assert.equal(match?.supportsThinking, true); + assert.equal(match?.inputTokenLimit, 128000); + assert.deepEqual(match?.supportedThinkingEfforts, ["low", "high"]); +}); + +test("#12597 without a custom row the picker id is still absent (lock the old contract)", async () => { + const catalog = await getActiveSyncedCatalog(PROVIDER); + assert.equal(catalogContainsModel(catalog, PICKER_MODEL), false); + const { excludedProviders } = await reconcileProvidersWithActiveSyncedCatalog( + [PROVIDER], + PICKER_MODEL + ); + assert.deepEqual(excludedProviders, [PROVIDER]); + assert.deepEqual(await getActiveProvidersWithSyncedModel(PICKER_MODEL), []); +});