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/16493-combo-prefixed-name-resolve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(combos):** resolve client-namespaced combo names (`<namespace>/<combo>`) to the combo when the namespace is not a known provider; explicit `provider/model` pins still reach the provider ([#16493](https://github.com/diegosouzapw/OmniRoute/pull/16493)) — thanks @Jordannst
9 changes: 9 additions & 0 deletions open-sse/services/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ for (const [aliasOrId, models] of Object.entries(PROVIDER_MODELS)) {
}
}
const KNOWN_MODEL_IDS = new Set(MODEL_TO_PROVIDERS.keys());

/**
* True when `modelId` is a model id some static provider catalog exposes verbatim
* (including slash-bearing ids such as `meta-llama/...`). Used by the combo resolver
* so a catalogued model id is never re-read as `<client-namespace>/<combo>`.
*/
export function isKnownCatalogModelId(modelId: string | null | undefined): boolean {
return typeof modelId === "string" && KNOWN_MODEL_IDS.has(modelId);
}
// Bare Codex CLI defaults must always route to the `codex` provider (chatgpt.com
// OAuth) even when other providers that also catalog the model id (e.g.
// `agentrouter`, `openai`) are active. The Codex cookie quota on the user's
Expand Down
63 changes: 63 additions & 0 deletions src/sse/services/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ import {
getModelInfoCore,
splitSyncedEffortSuffix,
stripContextWindowSuffix,
resolveProviderAlias,
isKnownCatalogModelId,
} from "@omniroute/open-sse/services/model.ts";
import { getLearnedReasoningEffortForModel } from "@omniroute/open-sse/services/learnedReasoningEffortCaps.ts";
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
import { ANTIGRAVITY_MODEL_ALIASES } from "@omniroute/open-sse/config/antigravityModelAliases.ts";
import { getRegisteredProviderEffortBaseModelId } from "@omniroute/open-sse/utils/registeredEffortVariants.ts";
import { getReservedProviderPrefixes } from "@/shared/constants/reservedProviderPrefixes";
import { getProviderByAlias } from "@/shared/constants/providers";
import {
assertMicrosoftDesignerWebProviderAvailable,
isMicrosoftDesignerWebProviderRetiredError,
Expand Down Expand Up @@ -708,6 +711,66 @@ export async function getCombo(modelStr) {
return combo;
}

return await getComboByClientNamespacedName(modelStr);
}

/**
* True when `segment` names a provider the router would dispatch to: a built-in
* registry id/alias (incl. retired tombstones), a provider-catalog id/alias, a manual
* alias (`xiaomi`, `llamacpp`, …) or a custom compatible-node prefix/internal id.
* Compared as typed and lowercased, so `OpenAI/…` is still treated as a provider pin.
*/
async function isKnownProviderSegment(segment: string): Promise<boolean> {
const candidates = Array.from(new Set([segment, segment.toLowerCase()]));
const reserved = getReservedProviderPrefixes();
for (const candidate of candidates) {
if (reserved.has(candidate)) return true;
if (getProviderByAlias(candidate)) return true;
if (resolveProviderAlias(candidate) !== candidate) return true;
}
const nodes = await getCachedProviderNodes();
return nodes.some(
(node) =>
!!node && candidates.some((candidate) => node.prefix === candidate || node.id === candidate)
);
}

/**
* Last-resort combo lookup for client-namespaced names (`<namespace>/<comboName>`):
* clients such as OpenCode register an OmniRoute combo under their own provider key
* and send `<thatKey>/<comboName>`. The bare name (after the last `/`) is tried only
* when NO namespace segment is a known provider and the full string is not a
* catalogued model id — an explicit `provider/model` pin always reaches the provider,
* even when a combo shares the model's bare name (#6940 per-model fallback combos).
* `combo/<name>` or a model-combo mapping remain the way to target a combo under a
* provider-named namespace.
*/
async function getComboByClientNamespacedName(modelStr: unknown) {
if (typeof modelStr !== "string") return null;
const lastSlash = modelStr.lastIndexOf("/");
if (lastSlash <= 0) return null;
const baseName = modelStr.slice(lastSlash + 1).trim();
if (!baseName) return null;
if (isKnownCatalogModelId(modelStr)) return null;

const namespaceSegments = modelStr
.slice(0, lastSlash)
.split("/")
.map((segment) => segment.trim())
.filter(Boolean);
if (namespaceSegments.length === 0) return null;
for (const segment of namespaceSegments) {
if (await isKnownProviderSegment(segment)) return null;
}

let combo = await getComboByName(baseName);
if (!combo || !Array.isArray(combo.models) || combo.models.length === 0) {
combo = await getComboByNameInsensitive(baseName);
}
if (combo && Array.isArray(combo.models) && combo.models.length > 0) {
console.info(`[MODEL] "${modelStr}" → combo "${combo.name}" (client namespace stripped)`);
return combo;
}
return null;
}

Expand Down
155 changes: 155 additions & 0 deletions tests/unit/combo-prefixed-name-resolve.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
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";

// Client-namespaced combo names (`<client-namespace>/<combo>`).
//
// Some clients (OpenCode, multi-proxy configs) register an OmniRoute combo under their
// own provider key and then send `<that-key>/<comboName>`. Before this fix the combo
// resolver only matched the full string, so the combo was missed and the request fell
// through to provider inference with the namespace still attached.
//
// Owner decision: the namespace is stripped ONLY when it is not a known provider
// (registered id/alias, manual alias, or a custom compatible-node prefix/id). An explicit
// `provider/model` pin keeps going straight to that provider even when a combo with the
// same bare name exists — combos named like a bare model id are the documented per-model
// fallback mechanism (#6940 / #8530) and must not hijack explicit provider pins.

const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-prefixed-name-"));
process.env.DATA_DIR = TEST_DATA_DIR;

const core = await import("../../src/lib/db/core.ts");
const combosDb = await import("../../src/lib/db/combos.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const sseModelService = await import("../../src/sse/services/model.ts");

type ComboLike = { name: string; models: unknown[] };

async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}

async function seedCombo(name: string, model = "llama-3.1-8b-instant") {
return combosDb.createCombo({ name, models: [{ provider: "groq", model }] });
}

test.beforeEach(async () => {
await resetStorage();
});

test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});

test("client namespace that is not a provider resolves to the combo by its bare name", async () => {
await seedCombo("lordx.1");

const resolved = (await sseModelService.getCombo("my-client/lordx.1")) as ComboLike | null;
assert.ok(resolved, "expected my-client/lordx.1 to resolve to the lordx.1 combo");
assert.equal(resolved.name, "lordx.1");

const viaForModel = (await sseModelService.getComboForModel(
"my-client/lordx.1"
)) as ComboLike | null;
assert.equal(viaForModel?.name, "lordx.1");
});

test("multi-segment client namespace resolves using the segment after the last slash", async () => {
await seedCombo("my-combo");

const resolved = (await sseModelService.getCombo("my-proxy/team-a/my-combo")) as ComboLike | null;
assert.equal(resolved?.name, "my-combo");
});

test("bare-name fallback is case-insensitive like the unprefixed path (#4446)", async () => {
await seedCombo("MASTER-LIGHT");

const resolved = (await sseModelService.getCombo(
"opencode-omniroute/master-light"
)) as ComboLike | null;
assert.equal(resolved?.name, "MASTER-LIGHT");
});

test("exact full-name match wins over the bare-name fallback", async () => {
await seedCombo("x/y", "llama-3.3-70b-versatile");
await seedCombo("y");

const resolved = (await sseModelService.getCombo("x/y")) as ComboLike | null;
assert.equal(resolved?.name, "x/y");
});

test("known provider id keeps an explicit provider/model pin even with a homonymous combo", async () => {
await seedCombo("gpt-5.5");

assert.equal(await sseModelService.getCombo("openai/gpt-5.5"), null);
assert.equal(await sseModelService.getComboForModel("openai/gpt-5.5"), null);
// The bare name still resolves to the combo (documented #6940 mechanism).
assert.equal(((await sseModelService.getCombo("gpt-5.5")) as ComboLike | null)?.name, "gpt-5.5");
});

test("known provider id (openrouter) is never stripped to reach a combo", async () => {
await seedCombo("lordx.1");

assert.equal(await sseModelService.getCombo("openrouter/lordx.1"), null);
});

test("known provider aliases (registry alias and manual alias) keep the provider pin", async () => {
await seedCombo("gpt-5.5");
await seedCombo("mimo-v2-flash");

// `cx` is the registry alias of codex; `xiaomi` is a manual alias → xiaomi-mimo.
assert.equal(await sseModelService.getCombo("cx/gpt-5.5"), null);
assert.equal(await sseModelService.getCombo("xiaomi/mimo-v2-flash"), null);
});

test("known provider prefix is matched case-insensitively for the guard", async () => {
await seedCombo("gpt-5.5");

assert.equal(await sseModelService.getCombo("OpenAI/gpt-5.5"), null);
});

test("custom compatible-node prefix and node id keep routing to the node", async () => {
const node = (await providersDb.createProviderNode({
type: "openai-compatible",
name: "My Gateway",
prefix: "mygw",
baseUrl: "https://gateway.example.com/v1",
})) as { id: string };
await seedCombo("some-model");

assert.equal(await sseModelService.getCombo("mygw/some-model"), null);
assert.equal(await sseModelService.getCombo(`${node.id}/some-model`), null);
});

test("catalogued slash-bearing model id is not re-read as <namespace>/<combo>", async () => {
// `z-ai/glm-5.2` is a verbatim catalog id (its `z-ai` segment is not a provider id).
await seedCombo("glm-5.2");

assert.equal(await sseModelService.getCombo("z-ai/glm-5.2"), null);
});

test("provider pin nested under a client namespace is not stripped", async () => {
await seedCombo("gpt-5.5");

assert.equal(await sseModelService.getCombo("my-client/openai/gpt-5.5"), null);
});

test("no matching combo under a client namespace returns null", async () => {
await seedCombo("lordx.1");

assert.equal(await sseModelService.getCombo("my-client/nonexistent"), null);
assert.equal(await sseModelService.getCombo("my-client/"), null);
});

test("combo/<name> prefix behaviour is unchanged", async () => {
await seedCombo("lordx.1");

const resolved = (await sseModelService.getCombo("combo/lordx.1")) as ComboLike | null;
assert.equal(resolved?.name, "lordx.1");
assert.equal(await sseModelService.getCombo("combo/missing"), null);
});
Loading