From 157440bc3ba8503fb839047af49fe9c68ff80152 Mon Sep 17 00:00:00 2001 From: Erick Kinnee Date: Tue, 6 Oct 2026 10:18:26 -0500 Subject: [PATCH] fix(api): resolve builder combo metadata through custom node prefixes --- src/app/api/v1/models/catalog.ts | 14 +++- .../models-catalog-combo-metadata.test.ts | 80 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 28b1c56d2bd9..e649d6345efb 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -563,7 +563,19 @@ async function buildUnifiedModelsResponseCore( getProviderPrefixesFromMaps(aliasMaps, providerId, rawProvider); const getComboTargetModelId = (target: ComboCatalogTarget) => { - const resolved = getComboTargetModelIdFromMaps(aliasMaps, target); + // Builder steps retain the node ID for connection selection while `modelStr` + // uses its public prefix. Normalize only a prefix that resolves to that same node; + // another provider's prefix must not change this target's identity. + const rawProvider = (target.providerId ?? target.provider)?.trim(); + const prefix = rawProvider ? providerIdToPrefix[rawProvider] : undefined; + const modelStr = target.modelStr?.trim(); + const normalizedTarget = + prefix && + providerNodeIdByPrefix[prefix] === rawProvider && + modelStr?.startsWith(`${prefix}/`) + ? { ...target, modelStr: `${rawProvider}${modelStr.slice(prefix.length)}` } + : target; + const resolved = getComboTargetModelIdFromMaps(aliasMaps, normalizedTarget); if (!resolved) return null; const nodeId = providerNodeIdByPrefix[resolved.providerId]; return nodeId ? { ...resolved, providerId: nodeId } : resolved; diff --git a/tests/unit/models-catalog-combo-metadata.test.ts b/tests/unit/models-catalog-combo-metadata.test.ts index 15b419270b9f..236c0e682904 100644 --- a/tests/unit/models-catalog-combo-metadata.test.ts +++ b/tests/unit/models-catalog-combo-metadata.test.ts @@ -16,12 +16,92 @@ const contextOverrides = await import("../../src/lib/db/modelContextOverrides.ts const capabilityOverrides = await import("../../src/lib/db/modelCapabilityOverrides.ts"); const overrideRoute = await import("../../src/app/api/model-capability-overrides/route.ts"); const catalog = await import("../../src/app/api/v1/models/catalog.ts"); +const { getComboBuilderOptions } = await import("../../src/lib/combos/builderOptions.ts"); +const { buildGlobalModelList, buildManualComboModelStep } = + await import("../../src/lib/combos/builderDraft.ts"); test.after(() => { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); +test("builder-saved provider-node combos preserve metadata with a public model prefix", async () => { + const nodeId = "openai-compatible-chat-builder-metadata"; + const prefix = "builder-metadata"; + const modelId = "gpt-5.6-luna"; + await providersDb.createProviderNode({ + id: nodeId, + type: "openai-compatible", + prefix, + name: "Builder Metadata", + apiType: "chat", + baseUrl: "https://example.com/v1", + }); + const connection = await providersDb.createProviderConnection({ + provider: nodeId, + authType: "api_key", + name: "builder-metadata-connection", + apiKey: "sk-test", + isActive: true, + testStatus: "active", + }); + await modelsDb.replaceSyncedAvailableModelsForConnection(nodeId, connection.id, [ + { id: modelId, name: "Builder Model" }, + ]); + capabilityOverrides.setModelCapabilityOverride( + `${nodeId}/${modelId}`, + "reasoning_efforts", + "low,high" + ); + + const options = await getComboBuilderOptions(); + const globalStep = buildGlobalModelList(options.providers).find( + (entry) => entry.providerId === nodeId && entry.modelId === modelId + )?.step; + const manualStep = buildManualComboModelStep({ + value: `${prefix}/${modelId}`, + providers: options.providers, + }); + assert.ok(globalStep); + assert.ok(manualStep); + for (const step of [globalStep, manualStep]) { + assert.equal(step.providerId, nodeId, "connection identity stays keyed to the node"); + assert.equal(step.model, `${prefix}/${modelId}`); + } + + for (const [suffix, step] of [ + ["global", globalStep], + ["manual", manualStep], + ["legacy", { kind: "model", providerId: nodeId, model: `${nodeId}/${modelId}` }], + ["foreign", { kind: "model", providerId: nodeId, model: `other-node/${modelId}` }], + ] as const) { + await combosDb.createCombo({ + name: `builder-metadata-${suffix}-combo`, + strategy: "priority", + models: [step], + }); + } + const response = await catalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as { data: Array> }; + const direct = body.data.find((item) => item.id === `${prefix}/${modelId}`); + assert.equal(response.status, 200); + assert.ok(direct); + assert.ok(Number(direct.max_output_tokens) > 0); + for (const suffix of ["global", "manual", "legacy"]) { + const combo = body.data.find((item) => item.id === `builder-metadata-${suffix}-combo`); + assert.ok(combo); + for (const field of ["context_length", "max_output_tokens", "input_modalities"]) { + assert.deepEqual(combo[field], direct[field], `${suffix}: ${field}`); + } + assert.deepEqual((combo.capabilities as Record).effort_tiers, ["low", "high"]); + } + const foreign = body.data.find((item) => item.id === "builder-metadata-foreign-combo"); + assert.ok(foreign); + assert.equal((foreign.capabilities as Record).effort_tiers, undefined); +}); + test("single-target combo preserves its direct model metadata", async () => { await providersDb.createProviderConnection({ provider: "codex",