diff --git a/src/sse/services/model.js b/src/sse/services/model.js index ba4cc6c24a..a5a077e991 100644 --- a/src/sse/services/model.js +++ b/src/sse/services/model.js @@ -1,7 +1,6 @@ // Re-export from open-sse with localDb integration import { getModelAliases, getComboByName, getProviderNodes } from "@/lib/localDb"; import { parseModel as parseModelCore, resolveModelAliasFromMap, getModelInfoCore } from "open-sse/services/model.js"; -import REGISTRY from "open-sse/providers/registry/index.js"; // Local provider alias overrides (HMR-friendly, applied on top of open-sse map) const LOCAL_PROVIDER_ALIASES = { @@ -9,13 +8,6 @@ const LOCAL_PROVIDER_ALIASES = { "xiaomi-tokenplan": "xiaomi-tokenplan", }; -const RESERVED_PROVIDER_PREFIXES = new Set(Object.keys(LOCAL_PROVIDER_ALIASES)); -for (const entry of REGISTRY) { - RESERVED_PROVIDER_PREFIXES.add(entry.id); - if (entry.alias) RESERVED_PROVIDER_PREFIXES.add(entry.alias); - for (const alias of entry.aliases || []) RESERVED_PROVIDER_PREFIXES.add(alias); -} - export function parseModel(modelStr) { const parsed = parseModelCore(modelStr); if (parsed?.providerAlias && LOCAL_PROVIDER_ALIASES[parsed.providerAlias]) { @@ -39,27 +31,31 @@ export async function getModelInfo(modelStr) { const parsed = parseModel(modelStr); if (!parsed.isAlias) { - // Provider-node prefixes are user-defined. They must not override built-in - // provider ids/aliases such as `cf`, `cloudflare-ai`, `openai`, or `hf`. - if (!RESERVED_PROVIDER_PREFIXES.has(parsed.providerAlias)) { - const openaiNodes = await getProviderNodes({ type: "openai-compatible" }); - const matchedOpenAI = openaiNodes.find((node) => node.prefix === parsed.providerAlias); - if (matchedOpenAI) { - return { provider: matchedOpenAI.id, model: parsed.model }; - } + // Provider-node prefixes are user-defined. Check custom nodes first — if the + // user explicitly created a node with a given prefix, route to it even when + // the prefix collides with a built-in provider id/alias (e.g. a custom + // "tokenrouter" node with prefix "tr"). The user's credentials are stored + // under the node ID; routing to the built-in provider instead would fail + // with "No credentials for provider". When no custom node matches, fall + // through to the built-in provider resolution below. + const openaiNodes = await getProviderNodes({ type: "openai-compatible" }); + const matchedOpenAI = openaiNodes.find((node) => node.prefix === parsed.providerAlias); + if (matchedOpenAI) { + return { provider: matchedOpenAI.id, model: parsed.model }; + } - const anthropicNodes = await getProviderNodes({ type: "anthropic-compatible" }); - const matchedAnthropic = anthropicNodes.find((node) => node.prefix === parsed.providerAlias); - if (matchedAnthropic) { - return { provider: matchedAnthropic.id, model: parsed.model }; - } + const anthropicNodes = await getProviderNodes({ type: "anthropic-compatible" }); + const matchedAnthropic = anthropicNodes.find((node) => node.prefix === parsed.providerAlias); + if (matchedAnthropic) { + return { provider: matchedAnthropic.id, model: parsed.model }; + } - const embeddingNodes = await getProviderNodes({ type: "custom-embedding" }); - const matchedEmbedding = embeddingNodes.find((node) => node.prefix === parsed.providerAlias); - if (matchedEmbedding) { - return { provider: matchedEmbedding.id, model: parsed.model }; - } + const embeddingNodes = await getProviderNodes({ type: "custom-embedding" }); + const matchedEmbedding = embeddingNodes.find((node) => node.prefix === parsed.providerAlias); + if (matchedEmbedding) { + return { provider: matchedEmbedding.id, model: parsed.model }; } + return { provider: parsed.provider, model: parsed.model diff --git a/tests/unit/model-routing.test.js b/tests/unit/model-routing.test.js index 827e1319d9..9674906136 100644 --- a/tests/unit/model-routing.test.js +++ b/tests/unit/model-routing.test.js @@ -38,10 +38,13 @@ describe("model routing", () => { else process.env.DATA_DIR = originalDataDir; }); - it("keeps built-in provider aliases ahead of compatible node prefixes", async () => { + it("routes to custom node when its prefix collides with a built-in provider alias", async () => { const ctx = await setupDb(); cleanup = ctx.cleanup; + // User explicitly creates a custom node with prefix "cf" (cloudflare-ai alias). + // Credentials are stored under the node ID, so routing must go to the node, + // not the built-in provider (which would have no credentials). await ctx.createProviderNode({ id: "openai-compatible-chat-test", type: "openai-compatible", @@ -53,11 +56,36 @@ describe("model routing", () => { await expect(ctx.getModelInfo("cf/@cf/black-forest-labs/flux-2-klein-9b")) .resolves.toEqual({ - provider: "cloudflare-ai", + provider: "openai-compatible-chat-test", model: "@cf/black-forest-labs/flux-2-klein-9b", }); }); + it("routes to custom node when prefix matches built-in tokenrouter alias (tr)", async () => { + const ctx = await setupDb(); + cleanup = ctx.cleanup; + + // Reproduces: user creates a custom openai-compatible node named "tokenrouter" + // with prefix "tr". The built-in provider "tokenrouter" also has alias "tr". + // Previously, the reserved-prefix guard skipped the custom node lookup and + // routed to the built-in provider, which had no credentials → + // "No active credentials for provider: tokenrouter". + await ctx.createProviderNode({ + id: "openai-compatible-chat-tr-node", + type: "openai-compatible", + name: "tokenrouter", + prefix: "tr", + apiType: "chat", + baseUrl: "https://api.tokenrouter.com/v1", + }); + + await expect(ctx.getModelInfo("tr/qwen/qwen3.8-max-free")) + .resolves.toEqual({ + provider: "openai-compatible-chat-tr-node", + model: "qwen/qwen3.8-max-free", + }); + }); + it("still routes non-reserved compatible node prefixes", async () => { const ctx = await setupDb(); cleanup = ctx.cleanup;