diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index 558f4b5694..c9b68d4453 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -685,10 +685,21 @@ export async function prepareVirtualAutoComboInputs( // back to the static catalog only when the user has none. This keeps catalog-only // models (e.g. openrouter/auto) out of every auto/* pool when the operator only // synced a subset (e.g. OpenRouter with importFreeModelsOnly). - const [syncedByConnection, customModels] = await Promise.all([ + const [syncedByConnection, rawCustomModels] = await Promise.all([ getSyncedAvailableModelsByConnection(providerId), getCustomModels(providerId), ]); + // The `customModels` key_value blob is operator-writable and is stored as raw + // parsed JSON, so a row can be `null` or a non-object. The catalog builder + // already filters those out (catalog.ts, "Add custom models"); without the same + // filter here every read below null-derefs and the whole auto/* pool fails to + // materialize ("Could not materialize built-in auto model auto/"). + const customModels: Array<{ id?: string }> = ( + Array.isArray(rawCustomModels) ? rawCustomModels : [] + ).filter( + (model: unknown): model is { id?: string } => + !!model && typeof model === "object" && !Array.isArray(model) + ); const userVisibleIds = new Set(); for (const models of Object.values(syncedByConnection)) { for (const m of models) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id); diff --git a/tests/unit/combo-auto-pool-visible-only.test.ts b/tests/unit/combo-auto-pool-visible-only.test.ts index 002140163e..dd70289798 100644 --- a/tests/unit/combo-auto-pool-visible-only.test.ts +++ b/tests/unit/combo-auto-pool-visible-only.test.ts @@ -173,3 +173,40 @@ test("virtual auto-combo pool filters EVERY provider with partial sync, not just "kilocode pool must contain exactly the two synced models" ); }); + +test("virtual auto-combo pool survives a malformed customModels row", async () => { + // The `customModels` key_value blob is operator-writable and is read back as raw + // parsed JSON, so a row can be null / a non-object / carry no id. Before the guard + // in prepareVirtualAutoComboInputs those rows threw + // "Cannot read properties of null (reading 'id')" and EVERY auto/* combo failed to + // materialize ("[catalog] Could not materialize built-in auto model auto/"). + const conn = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "OpenAI", + apiKey: "sk-test-openai", + }); + const connectionId = (conn as { id?: string }).id; + await modelsDb.replaceSyncedAvailableModelsForConnection("openai", connectionId, [ + { id: "gpt-4o-mini", name: "GPT-4o mini", source: "imported" as const }, + ]); + 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: "operator-custom" }]) + ); + + const prepared = await virtualFactory.prepareVirtualAutoComboInputs(); + const openaiCandidates = prepared.regularCandidates.filter((c) => c.provider === "openai"); + assert.ok( + openaiCandidates.some((c) => c.model === "gpt-4o-mini"), + "the synced model must still reach the pool despite the malformed custom rows" + ); + assert.ok( + openaiCandidates.some((c) => c.model === "operator-custom"), + "the one well-formed custom row must still reach the pool" + ); +});