From 54e71daf633a08bc96a5e8b7bd8a559fcb3c4fd2 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:27:29 -0300 Subject: [PATCH] fix(combo): type and sanitize custom auto models --- open-sse/services/combo/autoStrategy.ts | 22 +++++++++++- .../combo-auto-candidate-expansion.test.ts | 34 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/open-sse/services/combo/autoStrategy.ts b/open-sse/services/combo/autoStrategy.ts index 4db43b580dcf..04c199df160e 100644 --- a/open-sse/services/combo/autoStrategy.ts +++ b/open-sse/services/combo/autoStrategy.ts @@ -433,6 +433,23 @@ export function scoreAutoTargets( * explicitly-resolved targets are returned unchanged (the combo still runs). * Exported for unit testing. Mutates and returns `eligibleTargets`. */ +type AutoCustomModel = { id: string; supportedEndpoints?: readonly string[] }; + +function isValidAutoCustomModel(model: unknown): model is AutoCustomModel { + if (!model || typeof model !== "object" || Array.isArray(model)) return false; + const candidate = model as { id?: unknown; supportedEndpoints?: unknown }; + if (typeof candidate.id !== "string" || candidate.id.length === 0) return false; + if (candidate.supportedEndpoints === undefined) return true; + return ( + Array.isArray(candidate.supportedEndpoints) && + candidate.supportedEndpoints.every((endpoint) => typeof endpoint === "string") + ); +} + +function sanitizeAutoCustomModels(rawModels: unknown): AutoCustomModel[] { + return Array.isArray(rawModels) ? rawModels.filter(isValidAutoCustomModel) : []; +} + export async function expandAutoComboCandidatePool( eligibleTargets: ResolvedComboTarget[], combo: { autoConfig?: unknown; config?: unknown } | null | undefined @@ -500,13 +517,16 @@ export async function expandAutoComboCandidatePool( // synced a subset (e.g. OpenRouter with importFreeModelsOnly). // #11088 (option 1): the synced store now persists non-chat models too — // chat combo pools must keep filtering them out at read time. - const [syncedModelsRaw, customModels] = await Promise.all([ + const [syncedModelsRaw, rawCustomModels] = await Promise.all([ getSyncedAvailableModels(providerId), getCustomModels(providerId), ]); const syncedModels = filterChatSelectableModels(providerId, syncedModelsRaw); // Custom rows include speech / transcription / image models imported from a // media provider's local catalog or added by hand; they are not chat targets. + // The key_value blob is operator-writable, so discard malformed rows before + // applying the endpoint policy. + const customModels = sanitizeAutoCustomModels(rawCustomModels); const chatCustomModels = filterChatSelectableModels(providerId, customModels); const hiddenModels = hiddenModelsMap.get(providerId); const userVisibleIds = new Set(); diff --git a/tests/unit/combo-auto-candidate-expansion.test.ts b/tests/unit/combo-auto-candidate-expansion.test.ts index 74af1adc214f..cf8718fb418c 100644 --- a/tests/unit/combo-auto-candidate-expansion.test.ts +++ b/tests/unit/combo-auto-candidate-expansion.test.ts @@ -64,6 +64,40 @@ test("expandAutoComboCandidatePool adds every model of an active provider when n } }); +test("expandAutoComboCandidatePool ignores malformed custom rows while keeping chat models", async () => { + await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "OpenAI", + apiKey: "sk-test-openai", + }); + + // customModels is persisted as operator-writable JSON. A malformed row must not + // abort the whole expansion or cause the static catalogue to replace the + // explicitly visible custom chat model. + core + .getDbInstance() + .prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)") + .run( + "customModels", + "openai", + JSON.stringify([ + null, + "not-an-object", + { name: "Missing Id" }, + { id: "gpt-custom", supportedEndpoints: ["chat"] }, + ]) + ); + + const expanded = await combo.expandAutoComboCandidatePool([], { config: {} }); + + assert.deepEqual( + expanded.map((target) => target.modelStr), + ["openai/gpt-custom"], + "only the well-formed visible custom chat model should be expanded" + ); +}); + test("expandAutoComboCandidatePool excludes retired Qwen rows with synced models", async () => { const db = core.getDbInstance(); db.exec(`