Compare commits
7 Commits
6770f6ba0b
...
1d56e2dbc5
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d56e2dbc5 | |||
| 55f10c11e5 | |||
| eedad6c5ea | |||
| 99752a397c | |||
| bb8d67ba9c | |||
| 144dda2ac2 | |||
| bc9719fac7 |
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
40
package.json
40
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user