mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
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:
1
changelog.d/fixes/12934-custom-models-live-catalog.md
Normal file
1
changelog.d/fixes/12934-custom-models-live-catalog.md
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
136
tests/unit/custom-models-live-catalog-12597.test.ts
Normal file
136
tests/unit/custom-models-live-catalog-12597.test.ts
Normal 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), []);
|
||||
});
|
||||
Reference in New Issue
Block a user