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
14 changes: 13 additions & 1 deletion src/app/api/v1/models/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
80 changes: 80 additions & 0 deletions tests/unit/models-catalog-combo-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> };
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<string, unknown>).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<string, unknown>).effort_tiers, undefined);
});

test("single-target combo preserves its direct model metadata", async () => {
await providersDb.createProviderConnection({
provider: "codex",
Expand Down
Loading