Skip to content
Merged
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
1 change: 1 addition & 0 deletions changelog.d/fixes/15668-custom-node-combo-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(api):** Preserve token limits, modalities, and reasoning tiers for custom-provider combos saved with public model prefixes and internal node IDs ([#15668](https://github.com/diegosouzapw/OmniRoute/pull/15668)) — thanks @ekinnee
2 changes: 1 addition & 1 deletion src/app/api/v1/models/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@ async function buildUnifiedModelsResponseCore(
getProviderPrefixesFromMaps(aliasMaps, providerId, rawProvider);

const getComboTargetModelId = (target: ComboCatalogTarget) => {
const resolved = getComboTargetModelIdFromMaps(aliasMaps, target);
const resolved = getComboTargetModelIdFromMaps(aliasMaps, target, providerNodeIdByPrefix);
if (!resolved) return null;
const nodeId = providerNodeIdByPrefix[resolved.providerId];
return nodeId ? { ...resolved, providerId: nodeId } : resolved;
Expand Down
22 changes: 20 additions & 2 deletions src/app/api/v1/models/catalogProviderMaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,18 @@ export function getProviderPrefixes(
);
}

function normalizeProviderNodeModelPrefix(
modelStr: string,
providerId: string,
providerNodeIdByPrefix: Record<string, string>
): string {
const slashIndex = modelStr.indexOf("/");
if (slashIndex > 0 && providerNodeIdByPrefix[modelStr.slice(0, slashIndex)] === providerId) {
return `${providerId}${modelStr.slice(slashIndex)}`;
}
return modelStr;
}

/**
* Strip a provider/alias prefix off a combo target's `modelStr` and resolve its
* canonical providerId, so downstream registry/spec/synced-capability lookups
Expand All @@ -168,17 +180,23 @@ export function getProviderPrefixes(
*/
export function getComboTargetModelId(
maps: AliasMaps,
target: ProviderPrefixedTarget
target: ProviderPrefixedTarget,
providerNodeIdByPrefix: Record<string, string> = {}
): { providerId: string; modelId: string } | null {
const rawProvider =
typeof target.providerId === "string"
? target.providerId.trim()
: typeof target.provider === "string"
? target.provider.trim()
: "";
const modelStr = typeof target.modelStr === "string" ? target.modelStr.trim() : "";
let modelStr = typeof target.modelStr === "string" ? target.modelStr.trim() : "";
if (!rawProvider || rawProvider === "unknown" || !modelStr) return null;

// Builder steps retain the node ID for connection selection but use its public
// model prefix. Normalize only a prefix whose selected node matches that ID.
// Keep the model qualified so the existing resolver strips exactly one prefix.
modelStr = normalizeProviderNodeModelPrefix(modelStr, rawProvider, providerNodeIdByPrefix);

const providerId = resolveCanonicalProviderId(maps.aliasToProviderId, rawProvider);
if (!providerId || providerId === "unknown") return null;

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