7 Commits

13 changed files with 292 additions and 89 deletions

View File

@@ -57,7 +57,7 @@ export function stripContinuityFields(body) {
return body;
}
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking }) {
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking, capsOverride }) {
const { provider, model } = modelInfo;
const requestStartTime = Date.now();
// Stable per-session color so all lines of one CLI conversation share a tag
@@ -149,8 +149,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
if (credentials) credentials.rawHeaders = clientRawRequest?.headers || {};
// Auto-strip media blocks the model can't read (vision/audio/pdf) before translation.
// capsOverride lets the app layer assert per-model capabilities (e.g. user-registered
// models) on top of the static tables.
if (!passthrough) {
const caps = getCapabilitiesForModel(provider, model);
const caps = { ...getCapabilitiesForModel(provider, model), ...(capsOverride || {}) };
if (stripUnsupportedModalities(body, sourceFormat, caps)) {
log?.debug?.("MODALITY", `stripped unsupported media for ${provider}/${model}`);
}

View File

@@ -317,6 +317,11 @@ export const PATTERN_CAPABILITIES = [
{ pattern: "*ling-*", caps: { reasoning: true, contextWindow: 128000 } },
];
// OpenRouter-style gateways validate modalities upstream — a text-only model
// sent an image gets a clear upstream error instead of silent corruption. So for
// unknown models on these providers, trust vision instead of stripping images.
const TRUST_UPSTREAM_VISION = new Set(["openrouter"]);
/**
* Resolve capabilities for a model using the 4-step fallback chain,
* merged over DEFAULT_CAPABILITIES so the result is always complete.
@@ -349,6 +354,9 @@ export function getCapabilitiesForModel(provider, model) {
}
}
// 4. Floor
// 4. Floor (upstream-validated gateways keep vision on for unknown models)
if (provider && TRUST_UPSTREAM_VISION.has(provider)) {
return { ...DEFAULT_CAPABILITIES, vision: true };
}
return { ...DEFAULT_CAPABILITIES };
}

View File

@@ -157,19 +157,23 @@ export async function getCommandCodeUsage(apiKey, proxyOptions = null) {
});
}
// Monthly credits consumed this billing period (from summary when present,
// else the credits object's monthlyCredits as a fallback).
// The credits API reports remaining balances (monthly/purchased/free),
// not a total. The official CLI renders the monthly line as
// `used = summary.totalCost`, `total = totalCost + remaining` — i.e.
// the plan ceiling is the sum of what was consumed and what is left.
const monthlyUsed =
typeof summary?.totalCredits === "number"
? summary.totalCredits
: typeof credits?.credits?.monthlyCredits === "number"
? credits.credits.monthlyCredits
: typeof summary?.totalCost === "number"
? summary.totalCost
: 0;
const monthlyTotal =
typeof credits?.credits?.monthlyCredits === "number"
? credits.credits.monthlyCredits
: 0;
const creditsObj = credits?.credits || {};
const remaining =
Math.max(0, Number(creditsObj.monthlyCredits) || 0) +
Math.max(0, Number(creditsObj.purchasedCredits) || 0) +
Math.max(0, Number(creditsObj.freeCredits) || 0);
const monthlyTotal = monthlyUsed + remaining;
if (monthlyTotal > 0 || monthlyUsed > 0) {
quotas["Monthly credits"] = makeQuota({

View File

@@ -22,44 +22,44 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.7.0",
"@next/third-parties": "^16.2.9",
"@next/third-parties": "^16.3.1",
"@node-saml/node-saml": "^5.1.0",
"@xyflow/react": "^12.10.1",
"@xyflow/react": "^12.11.3",
"bcryptjs": "^3.0.3",
"chalk": "^5.6.2",
"confbox": "^0.2.4",
"express": "^5.2.1",
"http-proxy-middleware": "^3.0.5",
"jose": "^6.1.3",
"marked": "^18.0.1",
"material-symbols": "^0.44.6",
"http-proxy-middleware": "^3.0.7",
"jose": "^6.2.9",
"marked": "^18.0.10",
"material-symbols": "^0.44.12",
"monaco-editor": "^0.55.1",
"next": "^16.1.6",
"node-forge": "^1.3.3",
"next": "^16.3.1",
"node-forge": "^1.4.0",
"node-machine-id": "^1.1.12",
"open": "^11.0.0",
"ora": "^9.1.0",
"open": "^11.0.1",
"ora": "^9.4.1",
"prop-types": "^15.8.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-is": "^16.13.1",
"recharts": "^3.7.0",
"recharts": "^3.10.1",
"selfsigned": "^5.5.0",
"socks-proxy-agent": "^8.0.5",
"sql.js": "^1.14.1",
"undici": "^7.19.2",
"uuid": "^13.0.0",
"zustand": "^5.0.10"
"sql.js": "^1.14.2",
"undici": "^7.29.0",
"uuid": "^13.0.2",
"zustand": "^5.0.15"
},
"optionalDependencies": {
"better-sqlite3": "^12.6.2"
"better-sqlite3": "^12.11.1"
},
"comment_better_sqlite3": "kept in optionalDependencies so npm install doesn't fail on systems without build tools — sql.js is used as fallback at runtime",
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"eslint": "^9",
"@tailwindcss/postcss": "^4.3.3",
"eslint": "^9.39.5",
"eslint-config-next": "16.1.6",
"postcss": "^8.5.6",
"tailwindcss": "^4"
"postcss": "^8.5.26",
"tailwindcss": "^4.3.3"
}
}

View File

@@ -51,6 +51,7 @@ export default function CombosPage() {
const [editingCombo, setEditingCombo] = useState(null);
const [activeProviders, setActiveProviders] = useState([]);
const [comboStrategies, setComboStrategies] = useState({});
const [globalComboStrategy, setGlobalComboStrategy] = useState("fallback");
const [capacityAdapter, setCapacityAdapter] = useState(EMPTY_CAPACITY_ADAPTER);
const { getCaps } = useModelCaps();
const [confirmState, setConfirmState] = useState(null);
@@ -77,6 +78,7 @@ export default function CombosPage() {
setActiveProviders(providersData.connections || []);
}
setComboStrategies(settingsData.comboStrategies || {});
setGlobalComboStrategy(settingsData.comboStrategy || "fallback");
const rawAdapter = settingsData.capacityAdapter || {};
const normalized = {};
for (const cap of CAPACITY_ADAPTER_CAPS) {
@@ -159,14 +161,19 @@ export default function CombosPage() {
});
};
// Merge a per-combo strategy patch into settings.comboStrategies. Passing an empty
// patch (strategy back to default "fallback") drops the entry entirely.
// Merge a per-combo strategy patch into settings.comboStrategies.
// A "fallback" entry is only pruned when the global strategy is also fallback;
// otherwise it's kept so the combo explicitly overrides global round-robin/fusion.
const handleSetComboStrategy = async (comboName, patch) => {
try {
const updated = { ...comboStrategies };
const next = { ...(updated[comboName] || {}), ...patch };
// Prune to keep settings clean: default fallback with no extras = no entry.
if (!next.fallbackStrategy || next.fallbackStrategy === "fallback") {
// Prune to keep settings clean: default fallback with no extras = no entry,
// but only when it matches the global default (otherwise it's an override).
if (
(!next.fallbackStrategy || next.fallbackStrategy === "fallback") &&
(globalComboStrategy === "fallback" || !globalComboStrategy)
) {
delete updated[comboName];
} else {
updated[comboName] = next;
@@ -239,6 +246,7 @@ export default function CombosPage() {
onEdit={() => setEditingCombo(combo)}
onDelete={() => handleDelete(combo.id)}
strategy={comboStrategies[combo.name] || {}}
globalStrategy={globalComboStrategy}
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
/>
))}
@@ -294,9 +302,11 @@ const STRATEGY_OPTIONS = [
{ value: "fusion", label: "Fusion — panel + judge" },
];
function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) {
function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, globalStrategy = "fallback", onSetStrategy }) {
const [showJudgeSelect, setShowJudgeSelect] = useState(false);
const current = strategy.fallbackStrategy || "fallback";
// Show the effective strategy: per-combo override first, then the global
// default (combos without an entry fall through to settings.comboStrategy).
const current = strategy.fallbackStrategy || globalStrategy || "fallback";
const judge = strategy.judgeModel || "";
const isFusion = current === "fusion";

View File

@@ -84,7 +84,9 @@ export default function ComboDetailPage() {
setName(c.name);
setProviders(c.models || []);
const s = settingsRes.ok ? await settingsRes.json() : {};
setRoundRobin(s.comboStrategies?.[c.name]?.fallbackStrategy === "round-robin");
// Effective strategy: per-combo override first, then the global default.
const eff = s.comboStrategies?.[c.name]?.fallbackStrategy || s.comboStrategy || "fallback";
setRoundRobin(eff === "round-robin");
const allLogs = logsRes.ok ? await logsRes.json() : [];
setLogs(allLogs.filter((l) => typeof l === "string" && l.includes(c.name)).slice(0, 50));
} catch { /* noop */ }
@@ -154,8 +156,14 @@ export default function ComboDetailPage() {
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
const s = settingsRes.ok ? await settingsRes.json() : {};
const updated = { ...(s.comboStrategies || {}) };
if (enabled) updated[combo.name] = { fallbackStrategy: "round-robin" };
else delete updated[combo.name];
if (enabled) {
updated[combo.name] = { fallbackStrategy: "round-robin" };
} else {
// Explicit fallback override so the combo stays ordered even when the
// global default is round-robin (a bare delete would inherit it).
if ((s.comboStrategy || "fallback") === "fallback") delete updated[combo.name];
else updated[combo.name] = { fallbackStrategy: "fallback" };
}
await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },

View File

@@ -863,6 +863,28 @@ export default function ProviderDetailPage() {
});
};
// Enable/disable all selected connections in parallel, keeping the selection.
const handleBulkToggleActive = async (newActive) => {
const idsToUpdate = [...selectedConnectionIds];
if (idsToUpdate.length === 0) return;
const settled = await Promise.allSettled(
idsToUpdate.map((id) =>
fetch(`/api/providers/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive: newActive }),
}).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
}),
),
);
const failed = settled.filter((r) => r.status === "rejected").length;
setConnections((prev) =>
prev.map((c) => (idsToUpdate.includes(c.id) ? { ...c, isActive: newActive } : c)),
);
if (failed > 0) alert(`Updated ${idsToUpdate.length - failed} connection(s), ${failed} failed.`);
};
const handleOAuthSuccess = () => {
fetchConnections();
setShowOAuthModal(false);
@@ -960,6 +982,9 @@ export default function ProviderDetailPage() {
const selectedConnections = connections.filter((conn) => selectedConnectionIds.includes(conn.id));
const allSelected = connections.length > 0 && selectedConnectionIds.length === connections.length;
const allSelectedDisabled =
selectedConnections.length > 0 &&
selectedConnections.every((c) => (c.isActive ?? true) === false);
const toggleSelectConnection = (connectionId) => {
setSelectedConnectionIds((prev) => (
@@ -1676,6 +1701,16 @@ export default function ProviderDetailPage() {
Delete Selected ({selectedConnectionIds.length})
</Button>
)}
{selectedConnectionIds.length > 0 && (
<Button
size="sm"
variant="secondary"
icon={allSelectedDisabled ? "toggle_on" : "toggle_off"}
onClick={() => handleBulkToggleActive(!allSelectedDisabled)}
>
{allSelectedDisabled ? "Enable" : "Disable"} Selected ({selectedConnectionIds.length})
</Button>
)}
{selectedConnectionIds.length > 0 && (
<Button
size="sm"

View File

@@ -280,7 +280,7 @@ const ENDPOINT_COLUMNS = [
];
const PROVIDER_COLUMNS = [
{ field: "provider", label: "Provider" },
{ field: "provider", label: "Provider / API Key" },
{ field: "requests", label: "Requests", align: "right" },
{ field: "lastUsed", label: "Last Used", align: "right" },
];
@@ -321,10 +321,13 @@ export default function UsageStats({
// Resolve a provider key (built-in id, alias, or custom node id like
// "openai-compatible-chat-096baf9a-...") to a human-friendly name.
// Prefers the connected-providers state (nodeName > connection name)
// and falls back to the module-level registry lookup.
// Prefers the canonical provider name from the registry so e.g. "commandcode"
// shows "Command Code" instead of a connection/account name; falls back to
// the connected-providers state (nodeName > connection name) for custom nodes.
const getProviderLabel = useCallback((providerId) => {
if (!providerId) return "Unknown Provider";
const known = getProviderByAlias(providerId) || AI_PROVIDERS[providerId] || FREE_PROVIDERS[providerId];
if (known?.name) return known.name;
const match = providers.find((p) => p.provider === providerId || p.id === providerId);
if (match) return match.nodeName || match.name || providerId;
return resolveProviderLabel(providerId);
@@ -591,12 +594,55 @@ export default function UsageStats({
};
}
case "provider": {
// Detail rows = the API keys used per provider (grouped from byApiKey),
// so expanding a provider shows its keys instead of repeating the provider.
// byApiKey[].provider is the display name (node name for custom nodes);
// map display names back to provider ids via the connected-providers state.
const displayToId = {};
for (const p of providers) {
if (p.nodeName) displayToId[p.nodeName] = p.provider;
if (p.name && !displayToId[p.name]) displayToId[p.name] = p.provider;
}
const keyGroupsByProvider = {};
for (const ak of Object.values(sortData(stats.byApiKey, {}, sortBy, sortOrder))) {
const pid = displayToId[ak.provider] || ak.provider || "unknown";
const kname = ak.keyName || ak.apiKeyMasked || "Unknown Key";
const bucket = keyGroupsByProvider[pid] || (keyGroupsByProvider[pid] = {});
const g = bucket[kname] || (bucket[kname] = {
requests: 0,
promptTokens: 0,
completionTokens: 0,
cachedTokens: 0,
totalTokens: 0,
cost: 0,
inputCost: 0,
cachedCost: 0,
outputCost: 0,
lastUsed: null,
keyName: kname,
});
g.requests += ak.requests || 0;
g.promptTokens += ak.promptTokens || 0;
g.completionTokens += ak.completionTokens || 0;
g.cachedTokens += ak.cachedTokens || 0;
g.totalTokens += ak.totalTokens || 0;
g.cost += ak.cost || 0;
g.inputCost += ak.inputCost || 0;
g.cachedCost += ak.cachedCost || 0;
g.outputCost += ak.outputCost || 0;
if (ak.lastUsed && (!g.lastUsed || new Date(ak.lastUsed) > new Date(g.lastUsed)))
g.lastUsed = ak.lastUsed;
}
const groupedData = groupDataByKey(
sortData(stats.byProvider, {}, sortBy, sortOrder),
"provider",
).map((group) => ({
...group,
items: Object.values(keyGroupsByProvider[group.groupKey] || {}),
}));
return {
columns: PROVIDER_COLUMNS,
groupedData: groupDataByKey(
sortData(stats.byProvider, {}, sortBy, sortOrder),
"provider",
),
groupedData,
storageKey: "usage-stats:expanded-providers",
emptyMessage: "No usage recorded yet.",
renderSummaryCells: (group) => (
@@ -612,7 +658,7 @@ export default function UsageStats({
renderDetailCells: (item) => (
<>
<td className="px-6 py-3 font-medium">
{getProviderLabel(item.provider || item.key)}
{item.keyName}
</td>
<td className="px-6 py-3 text-right">{fmt(item.requests)}</td>
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
@@ -664,7 +710,7 @@ export default function UsageStats({
};
}
}
}, [stats, tableView, sortBy, sortOrder, getProviderLabel]);
}, [stats, tableView, sortBy, sortOrder, getProviderLabel, providers]);
if (!stats && !loading)
return (

View File

@@ -7,7 +7,8 @@ import {
extractApiKey,
isValidApiKey,
} from "../services/auth.js";
import { getSettings } from "@/lib/localDb";
import { getSettings, getCustomModels } from "@/lib/localDb";
import { capabilitiesFromServiceKind } from "open-sse/providers/capabilities.js";
import { getModelInfo, getComboModels } from "../services/model.js";
import { handleChatCore } from "open-sse/handlers/chatCore.js";
import { DEFAULT_HEADROOM_URL } from "@/lib/headroom/detect";
@@ -259,6 +260,13 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
// Use shared chatCore
const chatSettings = await getSettings();
const providerThinking = (chatSettings.providerThinking || {})[provider] || null;
// User-registered models (Provider page → "Add Model") trust upstream: keep
// images/media in the request instead of stripping by static-capability guess.
let capsOverride = null;
try {
const cm = (await getCustomModels()).find((m) => m.providerAlias === provider && m.id === model);
if (cm) capsOverride = { ...capabilitiesFromServiceKind(cm.type), vision: true };
} catch { /* fail-open to static caps */ }
const result = await handleChatCore({
body: { ...body, model: `${provider}/${model}` },
modelInfo: { provider, model },
@@ -284,6 +292,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
pxpipeTransform: chatSettings.pxpipeEnabled ? await getPxpipeTransform() : null,
onPxpipeEvent: appendPxpipeEvent,
providerThinking,
capsOverride,
// Detect source format by endpoint + body
sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null,
onCredentialsRefreshed: async (newCreds) => {

View File

@@ -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,

View File

@@ -113,9 +113,11 @@ describe("getUsageForProvider(commandcode)", () => {
total: 6,
resetAt: new Date(1786379982640).toISOString(),
});
// monthlyCredits/purchasedCredits/freeCredits are remaining balances;
// total = consumed (summary.totalCredits) + remaining, matching the CLI.
expect(usage.quotas["Monthly credits"]).toMatchObject({
used: 0.1,
total: 9.9,
total: 10,
});
});
@@ -194,8 +196,8 @@ describe("parseQuotaData(commandcode)", () => {
},
"Monthly credits": {
used: 0.1,
total: 9.9,
remainingPercentage: 98.99,
total: 10,
remainingPercentage: 99,
resetAt: null,
unit: "$",
},
@@ -205,14 +207,11 @@ describe("parseQuotaData(commandcode)", () => {
name: "5-hour window",
used: 0.05,
total: 3,
remainingPercentage: 98.33,
unit: "$",
});
expect(rows[1]).toMatchObject({
name: "Monthly credits",
used: 0.1,
total: 9.9,
remainingPercentage: 98.99,
total: 10,
});
});
});

View File

@@ -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;

View File

@@ -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() }));