diff --git a/src/sse/services/model.js b/src/sse/services/model.js index d426e4f0..ba4cc6c2 100644 --- a/src/sse/services/model.js +++ b/src/sse/services/model.js @@ -1,6 +1,7 @@ // 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 = { @@ -8,6 +9,13 @@ 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]) { @@ -31,23 +39,26 @@ export async function getModelInfo(modelStr) { const parsed = parseModel(modelStr); if (!parsed.isAlias) { - // Always check provider-node prefix matching using original input first - 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. 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 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, diff --git a/tests/unit/model-routing.test.js b/tests/unit/model-routing.test.js new file mode 100644 index 00000000..827e1319 --- /dev/null +++ b/tests/unit/model-routing.test.js @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const originalDataDir = process.env.DATA_DIR; + +async function setupDb() { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-model-routing-")); + process.env.DATA_DIR = tempDir; + vi.resetModules(); + + const { createProviderNode } = await import("@/models/index.js"); + const { getModelInfo } = await import("@/sse/services/model.js"); + + return { + createProviderNode, + getModelInfo, + cleanup() { + fs.rmSync(tempDir, { recursive: true, force: true }); + }, + }; +} + +describe("model routing", () => { + let cleanup = () => {}; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + cleanup(); + cleanup = () => {}; + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; + }); + + it("keeps built-in provider aliases ahead of compatible node prefixes", async () => { + const ctx = await setupDb(); + cleanup = ctx.cleanup; + + await ctx.createProviderNode({ + id: "openai-compatible-chat-test", + type: "openai-compatible", + name: "Compatible CF Collision", + prefix: "cf", + apiType: "chat", + baseUrl: "https://compatible.test/v1", + }); + + await expect(ctx.getModelInfo("cf/@cf/black-forest-labs/flux-2-klein-9b")) + .resolves.toEqual({ + provider: "cloudflare-ai", + model: "@cf/black-forest-labs/flux-2-klein-9b", + }); + }); + + it("still routes non-reserved compatible node prefixes", async () => { + const ctx = await setupDb(); + cleanup = ctx.cleanup; + + await ctx.createProviderNode({ + id: "openai-compatible-chat-test", + type: "openai-compatible", + name: "Compatible OCT", + prefix: "oct", + apiType: "chat", + baseUrl: "https://compatible.test/v1", + }); + + await expect(ctx.getModelInfo("oct/gpt-image-1")) + .resolves.toEqual({ + provider: "openai-compatible-chat-test", + model: "gpt-image-1", + }); + }); +});