fix(catalog): union picker customModels into the dispatch-time live catalog (#12597) (#12934)

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.
This commit is contained in:
Bob.Hou
2026-09-07 08:00:58 -04:00
committed by GitHub
parent 1b97f42ba3
commit aa35d460dc
4 changed files with 199 additions and 4 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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<SyncedAvailableModel[]> {
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<string, SyncedAvailableModel>();
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<SyncedAvailableModel>;
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<Active
const models = enrichCursorCatalog(
storedProviderId,
collectModelsForConnections(modelsByConnection, activeConnectionIds)
await unionCustomModels(
storedProviderId,
collectModelsForConnections(modelsByConnection, activeConnectionIds)
)
);
if (models.length > 0) {
return {
@@ -164,7 +206,7 @@ export async function getActiveSyncedCatalog(providerId: string): Promise<Active
authoritative: false,
models: enrichCursorCatalog(
storedProviderId,
await getSyncedAvailableModels(storedProviderId)
await unionCustomModels(storedProviderId, await getSyncedAvailableModels(storedProviderId))
),
};
} catch {
@@ -204,7 +246,10 @@ export async function getAllActiveSyncedModels(): Promise<Record<string, SyncedA
const models = enrichCursorCatalog(
providerId,
collectModelsForConnections(modelsByConnection, connectionIds)
await unionCustomModels(
providerId,
collectModelsForConnections(modelsByConnection, connectionIds)
)
);
if (models.length > 0) {

View File

@@ -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), []);
});