fix(combo): survive a malformed customModels row when building auto/* pools

prepareVirtualAutoComboInputs() reads getCustomModels(providerId) straight into
`for (const m of customModels) if (m.id ...)`. That blob is operator-writable and
is returned as raw parsed JSON, so a null / non-object row threw "Cannot read
properties of null (reading 'id')" and EVERY auto/* combo failed to materialize
("[catalog] Could not materialize built-in auto model auto/<id>"), silently
degrading the whole zero-setup routing surface to its minimal catalog entries.

Filter the rows to objects first, the same way the /v1/models custom-model pass
already does. Regression test in combo-auto-pool-visible-only.test.ts fails with
the original TypeError before the guard.
This commit is contained in:
diegosouzapw
2026-09-10 19:11:36 -03:00
parent ed44f4ae12
commit b3d3d9524c
2 changed files with 49 additions and 1 deletions

View File

@@ -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/<id>").
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<string>();
for (const models of Object.values(syncedByConnection)) {
for (const m of models) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id);

View File

@@ -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/<id>").
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"
);
});