diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index 0bb3588906..651694281f 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -7,9 +7,30 @@ import { resolveModelAliasFromMap, getModelInfoCore, } from "@omniroute/open-sse/services/model.ts"; +import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; export { parseModel }; +/** + * Reserved provider prefixes — built-in provider ids + aliases. User-defined + * compatible-node prefixes must not be allowed to shadow these, otherwise a + * node with prefix="cf" would hijack cloudflare-ai requests (and similar for + * every built-in provider). Ported from upstream 9router 047fdc89. + * + * Built lazily so the registry is only walked once per process. + */ +let _reservedProviderPrefixes: Set | null = null; +function getReservedProviderPrefixes(): Set { + if (_reservedProviderPrefixes) return _reservedProviderPrefixes; + const reserved = new Set(); + for (const entry of Object.values(REGISTRY)) { + if (entry?.id) reserved.add(entry.id); + if (entry?.alias) reserved.add(entry.alias); + } + _reservedProviderPrefixes = reserved; + return reserved; +} + /** * Build a combined model alias map that merges both alias stores: * 1. DB-namespace aliases (key_value WHERE namespace='modelAliases') — set via @@ -98,45 +119,58 @@ export async function getModelInfo(modelStr) { // Ensure prefixToCheck is always a concise identifier, not a full model string const prefixToCheck = parsed.providerAlias || parsed.provider; - // Check OpenAI Compatible nodes - // Match by node.prefix (user-defined alias) OR node.id (internal UUID id stored by - // combo steps), so that combo targets using the internal node id still resolve - // correctly (#2778). - const openaiNodes = await getProviderNodes({ type: "openai-compatible" }); - const matchedOpenAI = openaiNodes.find( - (node) => node.prefix === prefixToCheck || node.id === prefixToCheck - ); - if (matchedOpenAI) { - const { apiFormat, targetFormat } = await lookupCustomModelMeta( - matchedOpenAI.id as string, - parsed.model as string - ); - return { - provider: matchedOpenAI.id, - model: parsed.model, - extendedContext, - ...(apiFormat && { apiFormat }), - ...(targetFormat && { targetFormat }), - }; - } + // Compatible-node prefixes are user-defined. They must not be allowed to + // shadow built-in provider ids/aliases (e.g. `cf` → cloudflare-ai). When + // prefixToCheck matches a built-in registry id/alias, skip the compatible- + // node prefix lookup so the request still routes to the built-in provider. + // Internal UUID-prefixed node ids (e.g. "openai-compatible-responses-...") + // are never in the reserved set, so the #2778 combo path still works. + // Ported from upstream 9router 047fdc89. + const reserved = getReservedProviderPrefixes(); + const isReservedPrefix = + typeof prefixToCheck === "string" && reserved.has(prefixToCheck); - // Check Anthropic Compatible nodes - const anthropicNodes = await getProviderNodes({ type: "anthropic-compatible" }); - const matchedAnthropic = anthropicNodes.find( - (node) => node.prefix === prefixToCheck || node.id === prefixToCheck - ); - if (matchedAnthropic) { - const { apiFormat, targetFormat } = await lookupCustomModelMeta( - matchedAnthropic.id as string, - parsed.model as string + if (!isReservedPrefix) { + // Check OpenAI Compatible nodes + // Match by node.prefix (user-defined alias) OR node.id (internal UUID id stored by + // combo steps), so that combo targets using the internal node id still resolve + // correctly (#2778). + const openaiNodes = await getProviderNodes({ type: "openai-compatible" }); + const matchedOpenAI = openaiNodes.find( + (node) => node.prefix === prefixToCheck || node.id === prefixToCheck ); - return { - provider: matchedAnthropic.id, - model: parsed.model, - extendedContext, - ...(apiFormat && { apiFormat }), - ...(targetFormat && { targetFormat }), - }; + if (matchedOpenAI) { + const { apiFormat, targetFormat } = await lookupCustomModelMeta( + matchedOpenAI.id as string, + parsed.model as string + ); + return { + provider: matchedOpenAI.id, + model: parsed.model, + extendedContext, + ...(apiFormat && { apiFormat }), + ...(targetFormat && { targetFormat }), + }; + } + + // Check Anthropic Compatible nodes + const anthropicNodes = await getProviderNodes({ type: "anthropic-compatible" }); + const matchedAnthropic = anthropicNodes.find( + (node) => node.prefix === prefixToCheck || node.id === prefixToCheck + ); + if (matchedAnthropic) { + const { apiFormat, targetFormat } = await lookupCustomModelMeta( + matchedAnthropic.id as string, + parsed.model as string + ); + return { + provider: matchedAnthropic.id, + model: parsed.model, + extendedContext, + ...(apiFormat && { apiFormat }), + ...(targetFormat && { targetFormat }), + }; + } } // stripModelPrefix: if enabled, strip provider prefix and re-resolve diff --git a/tests/unit/image-compat-node-alias-shadow.test.ts b/tests/unit/image-compat-node-alias-shadow.test.ts new file mode 100644 index 0000000000..06d4d1db08 --- /dev/null +++ b/tests/unit/image-compat-node-alias-shadow.test.ts @@ -0,0 +1,80 @@ +/** + * Regression test — ported from decolua/9router commit 047fdc89: + * "fix(image): prevent compatible nodes from shadowing provider aliases" + * + * A user-defined openai-compatible provider node with `prefix: "cf"` must NOT + * shadow the built-in `cloudflare-ai` provider (alias `cf`). Cloudflare image + * routes like `cf/@cf/black-forest-labs/...` must keep resolving to the + * built-in cloudflare-ai provider, regardless of any compatible-node prefix + * collision. + * + * Inverse case: a non-reserved compatible-node prefix (e.g. `oct`) must still + * resolve to the compatible node. The fix is a reserved-prefix guard — it must + * not break user-defined prefixes that don't collide with built-in aliases. + */ +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"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-image-compat-shadow-") +); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { getModelInfo } = await import("../../src/sse/services/model.ts"); + +test.before(async () => { + await providersDb.createProviderNode({ + id: "openai-compatible-cf-collision", + type: "openai-compatible", + name: "Compatible CF Collision", + prefix: "cf", + baseUrl: "https://compatible.test/v1", + chatPath: "/v1/chat/completions", + modelsPath: "/v1/models", + }); + await providersDb.createProviderNode({ + id: "openai-compatible-oct-passthrough", + type: "openai-compatible", + name: "Compatible OCT", + prefix: "oct", + baseUrl: "https://compatible-oct.test/v1", + chatPath: "/v1/chat/completions", + modelsPath: "/v1/models", + }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("compatible node with prefix=cf must NOT shadow the built-in cloudflare-ai alias", async () => { + const info = (await getModelInfo("cf/@cf/black-forest-labs/flux-2-klein-9b")) as { + provider?: string; + model?: string; + }; + assert.equal( + info.provider, + "cloudflare-ai", + "cf/ must keep routing to the built-in cloudflare-ai provider (not the compatible node)" + ); + assert.equal(info.model, "@cf/black-forest-labs/flux-2-klein-9b"); +}); + +test("non-reserved compatible-node prefix (oct) still routes to the compatible node", async () => { + const info = (await getModelInfo("oct/gpt-image-1")) as { + provider?: string; + model?: string; + }; + assert.equal( + info.provider, + "openai-compatible-oct-passthrough", + "user-defined prefixes that don't collide with built-in aliases must still resolve to the compatible node" + ); + assert.equal(info.model, "gpt-image-1"); +});