Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion open-sse/services/combo/autoStrategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string>();
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/combo-auto-candidate-expansion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
Expand Down
Loading