fix(routing): fall through to compatible node when built-in alias has no credentials
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
|||||||
getModelAliases,
|
getModelAliases,
|
||||||
getComboByName,
|
getComboByName,
|
||||||
getProviderNodes,
|
getProviderNodes,
|
||||||
|
getProviderConnections,
|
||||||
} from "@/lib/localDb";
|
} from "@/lib/localDb";
|
||||||
import {
|
import {
|
||||||
parseModel as parseModelCore,
|
parseModel as parseModelCore,
|
||||||
@@ -44,6 +45,52 @@ export async function resolveModelAlias(alias) {
|
|||||||
return resolveModelAliasFromMap(alias, aliases);
|
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)
|
* Get full model info (parse or resolve)
|
||||||
*/
|
*/
|
||||||
@@ -51,37 +98,12 @@ export async function getModelInfo(modelStr) {
|
|||||||
const parsed = parseModel(modelStr);
|
const parsed = parseModel(modelStr);
|
||||||
|
|
||||||
if (!parsed.isAlias) {
|
if (!parsed.isAlias) {
|
||||||
// Provider-node prefixes are user-defined. They must not override built-in
|
const matched = await matchCompatibleNode(
|
||||||
// provider ids/aliases such as `cf`, `cloudflare-ai`, `openai`, or `hf`.
|
parsed.providerAlias,
|
||||||
if (!RESERVED_PROVIDER_PREFIXES.has(parsed.providerAlias)) {
|
parsed.model,
|
||||||
const openaiNodes = await getProviderNodes({ type: "openai-compatible" });
|
parsed.provider,
|
||||||
const matchedOpenAI = openaiNodes.find(
|
);
|
||||||
(node) => node.prefix === parsed.providerAlias,
|
if (matched) return matched;
|
||||||
);
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
provider: parsed.provider,
|
provider: parsed.provider,
|
||||||
model: parsed.model,
|
model: parsed.model,
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ async function setupDb() {
|
|||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|
||||||
const { createProviderNode } = await import("@/models/index.js");
|
const { createProviderNode } = await import("@/models/index.js");
|
||||||
|
const { createProviderConnection } = await import("@/models/index.js");
|
||||||
const { getModelInfo } = await import("@/sse/services/model.js");
|
const { getModelInfo } = await import("@/sse/services/model.js");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createProviderNode,
|
createProviderNode,
|
||||||
|
createProviderConnection,
|
||||||
getModelInfo,
|
getModelInfo,
|
||||||
cleanup() {
|
cleanup() {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
@@ -38,10 +40,18 @@ describe("model routing", () => {
|
|||||||
else process.env.DATA_DIR = originalDataDir;
|
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();
|
const ctx = await setupDb();
|
||||||
cleanup = ctx.cleanup;
|
cleanup = ctx.cleanup;
|
||||||
|
|
||||||
|
await ctx.createProviderConnection({
|
||||||
|
provider: "cloudflare-ai",
|
||||||
|
authType: "apikey",
|
||||||
|
name: "CF Key",
|
||||||
|
apiKey: "cf-test-key",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
await ctx.createProviderNode({
|
await ctx.createProviderNode({
|
||||||
id: "openai-compatible-chat-test",
|
id: "openai-compatible-chat-test",
|
||||||
type: "openai-compatible",
|
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 () => {
|
it("still routes non-reserved compatible node prefixes", async () => {
|
||||||
const ctx = await setupDb();
|
const ctx = await setupDb();
|
||||||
cleanup = ctx.cleanup;
|
cleanup = ctx.cleanup;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ vi.mock("@/lib/localDb", () => ({
|
|||||||
getComboByName: vi.fn(async () => null),
|
getComboByName: vi.fn(async () => null),
|
||||||
getModelAliases: vi.fn(async () => ({})),
|
getModelAliases: vi.fn(async () => ({})),
|
||||||
getProviderNodes: 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() }));
|
vi.mock("@/sse/utils/logger.js", () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user