From 144dda2ac2a34c7ce979a0b0d43cd5cd0da3418b Mon Sep 17 00:00:00 2001 From: luulam Date: Sat, 22 Aug 2026 14:33:53 +0700 Subject: [PATCH] fix(routing): fall through to compatible node when built-in alias has no credentials --- src/sse/services/model.js | 84 ++++++++++++++++++---------- tests/unit/model-routing.test.js | 61 +++++++++++++++++++- tests/unit/xai-video-handler.test.js | 1 + 3 files changed, 114 insertions(+), 32 deletions(-) diff --git a/src/sse/services/model.js b/src/sse/services/model.js index 80207903..99248847 100644 --- a/src/sse/services/model.js +++ b/src/sse/services/model.js @@ -3,6 +3,7 @@ import { getModelAliases, getComboByName, getProviderNodes, + getProviderConnections, } from "@/lib/localDb"; import { parseModel as parseModelCore, @@ -44,6 +45,52 @@ export async function resolveModelAlias(alias) { return resolveModelAliasFromMap(alias, aliases); } +/** + * Match a user-defined compatible node (openai/anthropic/custom-embedding) for a + * model string. + * + * Built-in provider ids/aliases (e.g. `cf`, `cloudflare-ai`, `tr`) are reserved: + * a compatible node must not shadow them. But when the built-in provider has no + * active credentials, a matching compatible node is the user's explicit intent + * (e.g. prefix `tr` -> a tokenrouter.com gateway), so fall through to it instead + * of failing with "No active credentials for provider". + */ +async function matchCompatibleNode(providerAlias, model, builtinProviderId) { + const reserved = RESERVED_PROVIDER_PREFIXES.has(providerAlias); + if (reserved) { + const builtinConns = await getProviderConnections({ + provider: builtinProviderId, + isActive: true, + }); + // Built-in route wins as long as it has credentials. + if (builtinConns.length > 0) return null; + } + + const openaiNodes = await getProviderNodes({ type: "openai-compatible" }); + const matchedOpenAI = openaiNodes.find( + (node) => node.prefix === providerAlias, + ); + if (matchedOpenAI) return { provider: matchedOpenAI.id, model }; + + const anthropicNodes = await getProviderNodes({ + type: "anthropic-compatible", + }); + const matchedAnthropic = anthropicNodes.find( + (node) => node.prefix === providerAlias, + ); + if (matchedAnthropic) return { provider: matchedAnthropic.id, model }; + + const embeddingNodes = await getProviderNodes({ + type: "custom-embedding", + }); + const matchedEmbedding = embeddingNodes.find( + (node) => node.prefix === providerAlias, + ); + if (matchedEmbedding) return { provider: matchedEmbedding.id, model }; + + return null; +} + /** * Get full model info (parse or resolve) */ @@ -51,37 +98,12 @@ 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 }; - } - - 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 matched = await matchCompatibleNode( + parsed.providerAlias, + parsed.model, + parsed.provider, + ); + if (matched) return matched; return { provider: parsed.provider, model: parsed.model, diff --git a/tests/unit/model-routing.test.js b/tests/unit/model-routing.test.js index 827e1319..06643670 100644 --- a/tests/unit/model-routing.test.js +++ b/tests/unit/model-routing.test.js @@ -11,10 +11,12 @@ async function setupDb() { vi.resetModules(); const { createProviderNode } = await import("@/models/index.js"); + const { createProviderConnection } = await import("@/models/index.js"); const { getModelInfo } = await import("@/sse/services/model.js"); return { createProviderNode, + createProviderConnection, getModelInfo, cleanup() { fs.rmSync(tempDir, { recursive: true, force: true }); @@ -38,10 +40,18 @@ describe("model routing", () => { else process.env.DATA_DIR = originalDataDir; }); - it("keeps built-in provider aliases ahead of compatible node prefixes", async () => { + it("keeps built-in provider aliases ahead of compatible node prefixes when built-in has credentials", async () => { const ctx = await setupDb(); cleanup = ctx.cleanup; + await ctx.createProviderConnection({ + provider: "cloudflare-ai", + authType: "apikey", + name: "CF Key", + apiKey: "cf-test-key", + isActive: true, + }); + await ctx.createProviderNode({ id: "openai-compatible-chat-test", type: "openai-compatible", @@ -58,6 +68,55 @@ describe("model routing", () => { }); }); + it("routes reserved alias prefix to compatible node when built-in has no credentials", async () => { + const ctx = await setupDb(); + cleanup = ctx.cleanup; + + await ctx.createProviderNode({ + id: "openai-compatible-chat-test", + type: "openai-compatible", + name: "Compatible TR Collision", + prefix: "tr", + apiType: "chat", + baseUrl: "https://api.tokenrouter.com/v1", + }); + + // No tokenrouter connection → custom node wins instead of 404. + await expect(ctx.getModelInfo("tr/qwen/qwen3.8-max-free")) + .resolves.toEqual({ + provider: "openai-compatible-chat-test", + model: "qwen/qwen3.8-max-free", + }); + }); + + it("keeps built-in route when both built-in credentials and reserved-prefix compatible node exist", async () => { + const ctx = await setupDb(); + cleanup = ctx.cleanup; + + await ctx.createProviderConnection({ + provider: "tokenrouter", + authType: "apikey", + name: "TR Key", + apiKey: "tr-test-key", + isActive: true, + }); + + await ctx.createProviderNode({ + id: "openai-compatible-chat-test", + type: "openai-compatible", + name: "Compatible TR Collision", + prefix: "tr", + apiType: "chat", + baseUrl: "https://api.tokenrouter.com/v1", + }); + + await expect(ctx.getModelInfo("tr/qwen/qwen3.8-max-free")) + .resolves.toEqual({ + provider: "tokenrouter", + model: "qwen/qwen3.8-max-free", + }); + }); + it("still routes non-reserved compatible node prefixes", async () => { const ctx = await setupDb(); cleanup = ctx.cleanup; diff --git a/tests/unit/xai-video-handler.test.js b/tests/unit/xai-video-handler.test.js index 563ee08c..6cadab95 100644 --- a/tests/unit/xai-video-handler.test.js +++ b/tests/unit/xai-video-handler.test.js @@ -32,6 +32,7 @@ vi.mock("@/lib/localDb", () => ({ getComboByName: vi.fn(async () => null), getModelAliases: vi.fn(async () => ({})), getProviderNodes: vi.fn(async () => []), + getProviderConnections: vi.fn(async () => []), })); vi.mock("@/sse/utils/logger.js", () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }));