diff --git a/src/lib/combos/builderOptions.ts b/src/lib/combos/builderOptions.ts index 5b2ac572ac..8d73047e99 100644 --- a/src/lib/combos/builderOptions.ts +++ b/src/lib/combos/builderOptions.ts @@ -20,6 +20,7 @@ import { isOpenAICompatibleProvider, } from "@/shared/constants/providers"; import type { RegistryModel } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { appendSyncedEffortVariants } from "@omniroute/open-sse/utils/syncedEffortVariants"; type JsonRecord = Record; @@ -48,6 +49,7 @@ type SyncedModelLike = { outputTokenLimit?: number; description?: string; supportsThinking?: boolean; + supportedThinkingEfforts?: string[]; }; type ProviderConnectionLike = { @@ -382,6 +384,64 @@ function buildModelOptions( }); } + // #8072: expose reasoning-effort variants (e.g. model-high, model-medium) + // in the Combo Builder picker, matching the catalog/Playground behaviour. + // Convert synced models with supportedThinkingEfforts into catalog-shaped + // entries, run the shared appendSyncedEffortVariants utility, and add any + // new variant ids that aren't already in the model map. + const catalogShaped = syncedModels + .filter( + (m): m is SyncedModelLike & { id: string; supportedThinkingEfforts: string[] } => + typeof m.id === "string" && + Array.isArray(m.supportedThinkingEfforts) && + m.supportedThinkingEfforts.length > 0 + ) + .map((m) => ({ + id: `${providerId}/${m.id}`, + owned_by: providerId, + root: m.id, + name: m.name || m.id, + capabilities: { effort_tiers: m.supportedThinkingEfforts }, + })); + if (catalogShaped.length > 0) { + // Track each tier variant's true base model id (the synced model's own raw, + // unsuffixed id) directly while iterating tiers here. We can't re-derive the + // base id from `variant.root` after calling appendSyncedEffortVariants: that + // utility sets a variant's `root` to `${baseRoot}-${tier}` (still tier-suffixed, + // see open-sse/utils/syncedEffortVariants.ts), not the base model id, so a + // lookup keyed on it never matches an entry in modelMap and variants silently + // fail to inherit contextLength/outputTokenLimit/supportedEndpoints/supportsThinking. + const baseRawIdByVariantId = new Map(); + for (const shaped of catalogShaped) { + for (const tier of shaped.capabilities.effort_tiers) { + if (typeof tier === "string" && tier.length > 0) { + baseRawIdByVariantId.set(`${shaped.id}-${tier}`, shaped.root); + } + } + } + + const withVariants = appendSyncedEffortVariants(catalogShaped); + for (const variant of withVariants) { + if (typeof variant.id !== "string") continue; + // Strip the provider prefix to get the raw model id for the builder + const rawId = variant.id.startsWith(`${providerId}/`) + ? variant.id.slice(providerId.length + 1) + : variant.id; + if (modelMap.has(rawId)) continue; + const baseId = baseRawIdByVariantId.get(variant.id) ?? rawId; + const base = modelMap.get(baseId); + addModelOption(modelMap, providerId, { + id: rawId, + name: base ? `${base.name} (${rawId.slice(baseId.length + 1)})` : rawId, + source: "imported", + supportedEndpoints: base?.supportedEndpoints, + contextLength: base?.contextLength ?? null, + outputTokenLimit: base?.outputTokenLimit ?? null, + supportsThinking: base?.supportsThinking, + }); + } + } + for (const model of builtInModels) { const resolved = getResolvedModelCapabilities({ provider: providerId, @@ -575,9 +635,8 @@ export async function getComboBuilderOptions(): Promise connection.isActive diff --git a/tests/unit/combo-builder-effort-variants-8072.test.ts b/tests/unit/combo-builder-effort-variants-8072.test.ts new file mode 100644 index 0000000000..166a00a176 --- /dev/null +++ b/tests/unit/combo-builder-effort-variants-8072.test.ts @@ -0,0 +1,99 @@ +/** + * #8072 — the Combo Builder model picker did not expose the `-` + * reasoning-effort variants that the catalog/Playground already surface for + * synced models declaring `supportedThinkingEfforts` (#7694). PR #8165 wired + * `buildModelOptions()` (src/lib/combos/builderOptions.ts) to run the shared + * `appendSyncedEffortVariants` utility and add the resulting variant ids. + * + * Regression guard for a baseId-derivation bug found in that wiring: + * `appendSyncedEffortVariants` sets a variant's own `root` field to + * `${baseRoot}-${tier}` (still tier-suffixed — see + * open-sse/utils/syncedEffortVariants.ts), not the true base model id. Deriving + * `baseId` from `variant.root` therefore never matches an entry in `modelMap`, + * so every effort variant silently fell back to bare defaults instead of + * inheriting contextLength/outputTokenLimit/supportedEndpoints/supportsThinking + * from its base model. This test seeds a synced model with + * `supportedThinkingEfforts` via `replaceSyncedAvailableModelsForConnection`, + * calls `getComboBuilderOptions()`, and asserts both that the variant ids + * appear and that they inherit the base entry's metadata. + */ +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-effort-8072-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.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("#8072 buildModelOptions: synced - effort variants appear in the Combo Builder picker and inherit the base model's metadata", async () => { + const connection = await providersDb.createProviderConnection({ + provider: "huggingface", + authType: "apikey", + name: "huggingface-8072-effort", + apiKey: "huggingface-key-8072", + isActive: true, + testStatus: "active", + }); + + const BASE_MODEL_ID = "some-org/reasoning-model-8072"; + + await modelsDb.replaceSyncedAvailableModelsForConnection("huggingface", connection.id, [ + { + id: BASE_MODEL_ID, + name: "Reasoning Model 8072", + supportedThinkingEfforts: ["low", "medium", "high"], + supportedEndpoints: ["chat"], + inputTokenLimit: 131072, + outputTokenLimit: 8192, + supportsThinking: true, + }, + ]); + + const payload = await getComboBuilderOptions(); + const provider = payload.providers.find((p) => p.providerId === "huggingface"); + assert.ok(provider, "huggingface provider must appear in the combo builder output"); + + const base = provider!.models.find((m) => m.id === BASE_MODEL_ID); + assert.ok(base, "base synced model must appear in the provider's models list"); + + for (const tier of ["low", "medium", "high"]) { + const variantId = `${BASE_MODEL_ID}-${tier}`; + const variant = provider!.models.find((m) => m.id === variantId); + assert.ok(variant, `expected a "${variantId}" effort-variant entry in the model picker`); + + // The bug: baseId was derived from `variant.root` (tier-suffixed), which never + // matched the base entry in modelMap, so these fields silently fell back to + // null/undefined instead of being inherited from the base model. + assert.equal( + variant!.contextLength, + base!.contextLength, + `${variantId} must inherit contextLength from the base model` + ); + assert.equal( + variant!.outputTokenLimit, + base!.outputTokenLimit, + `${variantId} must inherit outputTokenLimit from the base model` + ); + assert.deepEqual( + variant!.supportedEndpoints, + base!.supportedEndpoints, + `${variantId} must inherit supportedEndpoints from the base model` + ); + assert.equal( + variant!.supportsThinking, + base!.supportsThinking, + `${variantId} must inherit supportsThinking from the base model` + ); + } +});