From 78aaecb0d1ddf03e478fa30fc2c87853afc8ffe7 Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Tue, 30 Jun 2026 03:14:29 +0200 Subject: [PATCH] chore(duplication): share combo builder model options (#5477) Share combo builder model options; add regression test locking custom-model source classification (manual->custom, api-sync->imported). Co-authored-by: diegosouzapw --- src/lib/combos/builderOptions.ts | 268 +++++++----------- .../combo-builder-model-source-5477.test.ts | 45 +++ 2 files changed, 149 insertions(+), 164 deletions(-) create mode 100644 tests/unit/combo-builder-model-source-5477.test.ts diff --git a/src/lib/combos/builderOptions.ts b/src/lib/combos/builderOptions.ts index b343f17c90..b1dee3c1e9 100644 --- a/src/lib/combos/builderOptions.ts +++ b/src/lib/combos/builderOptions.ts @@ -315,6 +315,98 @@ function addModelOption( ); } +function buildModelOptions( + providerId: string, + builtInModels: RegistryModel[], + syncedModels: SyncedModelLike[], + customModels: CustomModelLike[] +): Map { + const modelMap = new Map(); + const fallbackModels = getCompatibleFallbackModels(providerId, builtInModels); + + for (const model of syncedModels) { + const resolved = getResolvedModelCapabilities({ + provider: providerId, + model: toStringOrNull(model.id), + }); + addModelOption(modelMap, providerId, { + id: toStringOrNull(model.id), + name: toStringOrNull(model.name), + source: "imported", + supportedEndpoints: toStringArray(model.supportedEndpoints), + contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow, + outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens, + supportsThinking: + typeof model.supportsThinking === "boolean" + ? model.supportsThinking + : (resolved.supportsThinking ?? undefined), + }); + } + + for (const model of builtInModels) { + const resolved = getResolvedModelCapabilities({ + provider: providerId, + model: toStringOrNull(model.id), + }); + addModelOption(modelMap, providerId, { + id: toStringOrNull(model.id), + name: toStringOrNull(model.name), + source: "system", + contextLength: toNumberOrNull(model.contextLength) ?? resolved.contextWindow, + outputTokenLimit: resolved.maxOutputTokens, + supportsThinking: resolved.supportsThinking ?? undefined, + }); + } + + for (const model of customModels) { + if (model.isHidden === true) continue; + const source = ["api-sync", "auto-sync", "imported"].includes( + toStringOrNull(model.source)?.toLowerCase() || "" + ) + ? "imported" + : ("custom" as BuilderModelSource); + const resolved = getResolvedModelCapabilities({ + provider: providerId, + model: toStringOrNull(model.id), + }); + addModelOption(modelMap, providerId, { + id: toStringOrNull(model.id), + name: toStringOrNull(model.name), + source, + supportedEndpoints: toStringArray(model.supportedEndpoints), + apiFormat: toStringOrNull(model.apiFormat), + contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow, + outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens, + supportsThinking: + typeof model.supportsThinking === "boolean" + ? model.supportsThinking + : (resolved.supportsThinking ?? undefined), + }); + } + + if (Array.isArray(fallbackModels)) { + for (const model of fallbackModels) { + const resolved = getResolvedModelCapabilities({ + provider: providerId, + model: toStringOrNull(model.id), + }); + addModelOption(modelMap, providerId, { + id: toStringOrNull(model.id), + name: toStringOrNull(model.name), + source: "fallback", + contextLength: + typeof (model as { contextLength?: number }).contextLength === "number" + ? (model as { contextLength?: number }).contextLength || null + : resolved.contextWindow, + outputTokenLimit: resolved.maxOutputTokens, + supportsThinking: resolved.supportsThinking ?? undefined, + }); + } + } + + return modelMap; +} + function compareConnections( left: ComboBuilderConnectionOption, right: ComboBuilderConnectionOption @@ -398,7 +490,6 @@ export async function getComboBuilderOptions(): Promise(); const builtInModels = getModelsByProviderId(providerId); const syncedModels = normalizeSyncedModels( (syncedModelsMap as Record)[providerId] @@ -406,92 +497,17 @@ export async function getComboBuilderOptions(): Promise)[providerId] ); - const fallbackModels = getCompatibleFallbackModels(providerId, builtInModels); const acceptsArbitraryModel = Boolean((AI_PROVIDERS[providerId] as JsonRecord | undefined)?.passthroughModels) || isOpenAICompatibleProvider(providerId) || isAnthropicCompatibleProvider(providerId) || isClaudeCodeCompatibleProvider(providerId); - - for (const model of syncedModels) { - const resolved = getResolvedModelCapabilities({ - provider: providerId, - model: toStringOrNull(model.id), - }); - addModelOption(modelMap, providerId, { - id: toStringOrNull(model.id), - name: toStringOrNull(model.name), - source: "imported", - supportedEndpoints: toStringArray(model.supportedEndpoints), - contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow, - outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens, - supportsThinking: - typeof model.supportsThinking === "boolean" - ? model.supportsThinking - : (resolved.supportsThinking ?? undefined), - }); - } - - for (const model of builtInModels as RegistryModel[]) { - const resolved = getResolvedModelCapabilities({ - provider: providerId, - model: toStringOrNull(model.id), - }); - addModelOption(modelMap, providerId, { - id: toStringOrNull(model.id), - name: toStringOrNull(model.name), - source: "system", - contextLength: toNumberOrNull(model.contextLength) ?? resolved.contextWindow, - outputTokenLimit: resolved.maxOutputTokens, - supportsThinking: resolved.supportsThinking ?? undefined, - }); - } - - for (const model of customModels) { - if (model.isHidden === true) continue; - const source = ["api-sync", "auto-sync", "imported"].includes( - toStringOrNull(model.source)?.toLowerCase() || "" - ) - ? "imported" - : ("custom" as BuilderModelSource); - const resolved = getResolvedModelCapabilities({ - provider: providerId, - model: toStringOrNull(model.id), - }); - addModelOption(modelMap, providerId, { - id: toStringOrNull(model.id), - name: toStringOrNull(model.name), - source, - supportedEndpoints: toStringArray(model.supportedEndpoints), - apiFormat: toStringOrNull(model.apiFormat), - contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow, - outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens, - supportsThinking: - typeof model.supportsThinking === "boolean" - ? model.supportsThinking - : (resolved.supportsThinking ?? undefined), - }); - } - - if (Array.isArray(fallbackModels)) { - for (const model of fallbackModels) { - const resolved = getResolvedModelCapabilities({ - provider: providerId, - model: toStringOrNull(model.id), - }); - addModelOption(modelMap, providerId, { - id: toStringOrNull(model.id), - name: toStringOrNull(model.name), - source: "fallback", - contextLength: - typeof (model as { contextLength?: number }).contextLength === "number" - ? (model as { contextLength?: number }).contextLength || null - : resolved.contextWindow, - outputTokenLimit: resolved.maxOutputTokens, - supportsThinking: resolved.supportsThinking ?? undefined, - }); - } - } + const modelMap = buildModelOptions( + providerId, + builtInModels as RegistryModel[], + syncedModels, + customModels + ); const normalizedConnections = providerConnections .map((connection) => buildConnectionOption(connection)) @@ -536,7 +552,6 @@ export async function getComboBuilderOptions(): Promise(); const builtInModels = getModelsByProviderId(providerId); const syncedModels = normalizeSyncedModels( (syncedModelsMap as Record)[providerId] @@ -544,92 +559,17 @@ export async function getComboBuilderOptions(): Promise)[providerId] ); - const fallbackModels = getCompatibleFallbackModels(providerId, builtInModels); const acceptsArbitraryModel = Boolean((AI_PROVIDERS[providerId] as JsonRecord | undefined)?.passthroughModels) || isOpenAICompatibleProvider(providerId) || isAnthropicCompatibleProvider(providerId) || isClaudeCodeCompatibleProvider(providerId); - - for (const model of syncedModels) { - const resolved = getResolvedModelCapabilities({ - provider: providerId, - model: toStringOrNull(model.id), - }); - addModelOption(modelMap, providerId, { - id: toStringOrNull(model.id), - name: toStringOrNull(model.name), - source: "imported", - supportedEndpoints: toStringArray(model.supportedEndpoints), - contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow, - outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens, - supportsThinking: - typeof model.supportsThinking === "boolean" - ? model.supportsThinking - : (resolved.supportsThinking ?? undefined), - }); - } - - for (const model of builtInModels as RegistryModel[]) { - const resolved = getResolvedModelCapabilities({ - provider: providerId, - model: toStringOrNull(model.id), - }); - addModelOption(modelMap, providerId, { - id: toStringOrNull(model.id), - name: toStringOrNull(model.name), - source: "system", - contextLength: toNumberOrNull(model.contextLength) ?? resolved.contextWindow, - outputTokenLimit: resolved.maxOutputTokens, - supportsThinking: resolved.supportsThinking ?? undefined, - }); - } - - for (const model of customModels) { - if (model.isHidden === true) continue; - const source = ["api-sync", "auto-sync", "imported"].includes( - toStringOrNull(model.source)?.toLowerCase() || "" - ) - ? "imported" - : ("custom" as BuilderModelSource); - const resolved = getResolvedModelCapabilities({ - provider: providerId, - model: toStringOrNull(model.id), - }); - addModelOption(modelMap, providerId, { - id: toStringOrNull(model.id), - name: toStringOrNull(model.name), - source, - supportedEndpoints: toStringArray(model.supportedEndpoints), - apiFormat: toStringOrNull(model.apiFormat), - contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow, - outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens, - supportsThinking: - typeof model.supportsThinking === "boolean" - ? model.supportsThinking - : (resolved.supportsThinking ?? undefined), - }); - } - - if (Array.isArray(fallbackModels)) { - for (const model of fallbackModels) { - const resolved = getResolvedModelCapabilities({ - provider: providerId, - model: toStringOrNull(model.id), - }); - addModelOption(modelMap, providerId, { - id: toStringOrNull(model.id), - name: toStringOrNull(model.name), - source: "fallback", - contextLength: - typeof (model as { contextLength?: number }).contextLength === "number" - ? (model as { contextLength?: number }).contextLength || null - : resolved.contextWindow, - outputTokenLimit: resolved.maxOutputTokens, - supportsThinking: resolved.supportsThinking ?? undefined, - }); - } - } + const modelMap = buildModelOptions( + providerId, + builtInModels as RegistryModel[], + syncedModels, + customModels + ); // #2901: no-auth providers must route under their alias (e.g. "oc"), not // their id — "opencode/" misroutes to the opencode-zen api-key tier diff --git a/tests/unit/combo-builder-model-source-5477.test.ts b/tests/unit/combo-builder-model-source-5477.test.ts new file mode 100644 index 0000000000..230e4fe48d --- /dev/null +++ b/tests/unit/combo-builder-model-source-5477.test.ts @@ -0,0 +1,45 @@ +/** + * #5477 — the combo builder's per-model option construction was extracted into a + * shared `buildModelOptions` helper (one source of truth for the synced / + * built-in / custom / fallback branches). This test drives the real builder and + * locks the custom-model **source classification** branch: a custom model whose + * stored `source` is one of api-sync/auto-sync/imported must surface as + * `imported`, everything else as `custom`. A silent divergence in that mapping is + * exactly what the extraction could introduce. + */ +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"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-source-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const { getComboBuilderOptions } = await import("../../src/lib/combos/builderOptions.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#5477 buildModelOptions classifies custom-model source (manual -> custom, api-sync -> imported)", async () => { + // Attach to a no-auth provider ("opencode") — it surfaces in the builder + // without a configured connection, so the custom-model branch is exercised. + await modelsDb.addCustomModel("opencode", "zzz-manual-5477", "Manual 5477", "manual"); + await modelsDb.addCustomModel("opencode", "zzz-apisync-5477", "ApiSync 5477", "api-sync"); + + const payload = await getComboBuilderOptions(); + + const allModels = payload.providers.flatMap((p) => p.models); + const manual = allModels.find((m) => m.id === "zzz-manual-5477"); + const apiSync = allModels.find((m) => m.id === "zzz-apisync-5477"); + + assert.ok(manual, "manual custom model must appear in the combo builder output"); + assert.ok(apiSync, "api-sync custom model must appear in the combo builder output"); + + assert.equal(manual.source, "custom", "source=manual must classify as 'custom'"); + assert.equal(apiSync.source, "imported", "source=api-sync must classify as 'imported'"); +});