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;
|
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 { provider, model } = modelInfo;
|
||||||
const requestStartTime = Date.now();
|
const requestStartTime = Date.now();
|
||||||
// Stable per-session color so all lines of one CLI conversation share a tag
|
// 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 || {};
|
if (credentials) credentials.rawHeaders = clientRawRequest?.headers || {};
|
||||||
|
|
||||||
// Auto-strip media blocks the model can't read (vision/audio/pdf) before translation.
|
// 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) {
|
if (!passthrough) {
|
||||||
const caps = getCapabilitiesForModel(provider, model);
|
const caps = { ...getCapabilitiesForModel(provider, model), ...(capsOverride || {}) };
|
||||||
if (stripUnsupportedModalities(body, sourceFormat, caps)) {
|
if (stripUnsupportedModalities(body, sourceFormat, caps)) {
|
||||||
log?.debug?.("MODALITY", `stripped unsupported media for ${provider}/${model}`);
|
log?.debug?.("MODALITY", `stripped unsupported media for ${provider}/${model}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -317,6 +317,11 @@ export const PATTERN_CAPABILITIES = [
|
|||||||
{ pattern: "*ling-*", caps: { reasoning: true, contextWindow: 128000 } },
|
{ 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,
|
* Resolve capabilities for a model using the 4-step fallback chain,
|
||||||
* merged over DEFAULT_CAPABILITIES so the result is always complete.
|
* 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 };
|
return { ...DEFAULT_CAPABILITIES };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,19 +157,23 @@ export async function getCommandCodeUsage(apiKey, proxyOptions = null) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Monthly credits consumed this billing period (from summary when present,
|
// The credits API reports remaining balances (monthly/purchased/free),
|
||||||
// else the credits object's monthlyCredits as a fallback).
|
// 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 =
|
const monthlyUsed =
|
||||||
typeof summary?.totalCredits === "number"
|
typeof summary?.totalCredits === "number"
|
||||||
? summary.totalCredits
|
? summary.totalCredits
|
||||||
: typeof credits?.credits?.monthlyCredits === "number"
|
: typeof summary?.totalCost === "number"
|
||||||
? credits.credits.monthlyCredits
|
? summary.totalCost
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const monthlyTotal =
|
const creditsObj = credits?.credits || {};
|
||||||
typeof credits?.credits?.monthlyCredits === "number"
|
const remaining =
|
||||||
? credits.credits.monthlyCredits
|
Math.max(0, Number(creditsObj.monthlyCredits) || 0) +
|
||||||
: 0;
|
Math.max(0, Number(creditsObj.purchasedCredits) || 0) +
|
||||||
|
Math.max(0, Number(creditsObj.freeCredits) || 0);
|
||||||
|
const monthlyTotal = monthlyUsed + remaining;
|
||||||
|
|
||||||
if (monthlyTotal > 0 || monthlyUsed > 0) {
|
if (monthlyTotal > 0 || monthlyUsed > 0) {
|
||||||
quotas["Monthly credits"] = makeQuota({
|
quotas["Monthly credits"] = makeQuota({
|
||||||
|
|||||||
40
package.json
40
package.json
@@ -22,44 +22,44 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@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",
|
"@node-saml/node-saml": "^5.1.0",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.11.3",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"confbox": "^0.2.4",
|
"confbox": "^0.2.4",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.7",
|
||||||
"jose": "^6.1.3",
|
"jose": "^6.2.9",
|
||||||
"marked": "^18.0.1",
|
"marked": "^18.0.10",
|
||||||
"material-symbols": "^0.44.6",
|
"material-symbols": "^0.44.12",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"next": "^16.1.6",
|
"next": "^16.3.1",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.4.0",
|
||||||
"node-machine-id": "^1.1.12",
|
"node-machine-id": "^1.1.12",
|
||||||
"open": "^11.0.0",
|
"open": "^11.0.1",
|
||||||
"ora": "^9.1.0",
|
"ora": "^9.4.1",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-is": "^16.13.1",
|
"react-is": "^16.13.1",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.10.1",
|
||||||
"selfsigned": "^5.5.0",
|
"selfsigned": "^5.5.0",
|
||||||
"socks-proxy-agent": "^8.0.5",
|
"socks-proxy-agent": "^8.0.5",
|
||||||
"sql.js": "^1.14.1",
|
"sql.js": "^1.14.2",
|
||||||
"undici": "^7.19.2",
|
"undici": "^7.29.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.2",
|
||||||
"zustand": "^5.0.10"
|
"zustand": "^5.0.15"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"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",
|
"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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9.39.5",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.26",
|
||||||
"tailwindcss": "^4"
|
"tailwindcss": "^4.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export default function CombosPage() {
|
|||||||
const [editingCombo, setEditingCombo] = useState(null);
|
const [editingCombo, setEditingCombo] = useState(null);
|
||||||
const [activeProviders, setActiveProviders] = useState([]);
|
const [activeProviders, setActiveProviders] = useState([]);
|
||||||
const [comboStrategies, setComboStrategies] = useState({});
|
const [comboStrategies, setComboStrategies] = useState({});
|
||||||
|
const [globalComboStrategy, setGlobalComboStrategy] = useState("fallback");
|
||||||
const [capacityAdapter, setCapacityAdapter] = useState(EMPTY_CAPACITY_ADAPTER);
|
const [capacityAdapter, setCapacityAdapter] = useState(EMPTY_CAPACITY_ADAPTER);
|
||||||
const { getCaps } = useModelCaps();
|
const { getCaps } = useModelCaps();
|
||||||
const [confirmState, setConfirmState] = useState(null);
|
const [confirmState, setConfirmState] = useState(null);
|
||||||
@@ -77,6 +78,7 @@ export default function CombosPage() {
|
|||||||
setActiveProviders(providersData.connections || []);
|
setActiveProviders(providersData.connections || []);
|
||||||
}
|
}
|
||||||
setComboStrategies(settingsData.comboStrategies || {});
|
setComboStrategies(settingsData.comboStrategies || {});
|
||||||
|
setGlobalComboStrategy(settingsData.comboStrategy || "fallback");
|
||||||
const rawAdapter = settingsData.capacityAdapter || {};
|
const rawAdapter = settingsData.capacityAdapter || {};
|
||||||
const normalized = {};
|
const normalized = {};
|
||||||
for (const cap of CAPACITY_ADAPTER_CAPS) {
|
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
|
// Merge a per-combo strategy patch into settings.comboStrategies.
|
||||||
// patch (strategy back to default "fallback") drops the entry entirely.
|
// 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) => {
|
const handleSetComboStrategy = async (comboName, patch) => {
|
||||||
try {
|
try {
|
||||||
const updated = { ...comboStrategies };
|
const updated = { ...comboStrategies };
|
||||||
const next = { ...(updated[comboName] || {}), ...patch };
|
const next = { ...(updated[comboName] || {}), ...patch };
|
||||||
// Prune to keep settings clean: default fallback with no extras = no entry.
|
// Prune to keep settings clean: default fallback with no extras = no entry,
|
||||||
if (!next.fallbackStrategy || next.fallbackStrategy === "fallback") {
|
// 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];
|
delete updated[comboName];
|
||||||
} else {
|
} else {
|
||||||
updated[comboName] = next;
|
updated[comboName] = next;
|
||||||
@@ -239,6 +246,7 @@ export default function CombosPage() {
|
|||||||
onEdit={() => setEditingCombo(combo)}
|
onEdit={() => setEditingCombo(combo)}
|
||||||
onDelete={() => handleDelete(combo.id)}
|
onDelete={() => handleDelete(combo.id)}
|
||||||
strategy={comboStrategies[combo.name] || {}}
|
strategy={comboStrategies[combo.name] || {}}
|
||||||
|
globalStrategy={globalComboStrategy}
|
||||||
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
|
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -294,9 +302,11 @@ const STRATEGY_OPTIONS = [
|
|||||||
{ value: "fusion", label: "Fusion — panel + judge" },
|
{ 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 [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 judge = strategy.judgeModel || "";
|
||||||
const isFusion = current === "fusion";
|
const isFusion = current === "fusion";
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ export default function ComboDetailPage() {
|
|||||||
setName(c.name);
|
setName(c.name);
|
||||||
setProviders(c.models || []);
|
setProviders(c.models || []);
|
||||||
const s = settingsRes.ok ? await settingsRes.json() : {};
|
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() : [];
|
const allLogs = logsRes.ok ? await logsRes.json() : [];
|
||||||
setLogs(allLogs.filter((l) => typeof l === "string" && l.includes(c.name)).slice(0, 50));
|
setLogs(allLogs.filter((l) => typeof l === "string" && l.includes(c.name)).slice(0, 50));
|
||||||
} catch { /* noop */ }
|
} catch { /* noop */ }
|
||||||
@@ -154,8 +156,14 @@ export default function ComboDetailPage() {
|
|||||||
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
|
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
|
||||||
const s = settingsRes.ok ? await settingsRes.json() : {};
|
const s = settingsRes.ok ? await settingsRes.json() : {};
|
||||||
const updated = { ...(s.comboStrategies || {}) };
|
const updated = { ...(s.comboStrategies || {}) };
|
||||||
if (enabled) updated[combo.name] = { fallbackStrategy: "round-robin" };
|
if (enabled) {
|
||||||
else delete updated[combo.name];
|
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", {
|
await fetch("/api/settings", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
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 = () => {
|
const handleOAuthSuccess = () => {
|
||||||
fetchConnections();
|
fetchConnections();
|
||||||
setShowOAuthModal(false);
|
setShowOAuthModal(false);
|
||||||
@@ -960,6 +982,9 @@ export default function ProviderDetailPage() {
|
|||||||
|
|
||||||
const selectedConnections = connections.filter((conn) => selectedConnectionIds.includes(conn.id));
|
const selectedConnections = connections.filter((conn) => selectedConnectionIds.includes(conn.id));
|
||||||
const allSelected = connections.length > 0 && selectedConnectionIds.length === connections.length;
|
const allSelected = connections.length > 0 && selectedConnectionIds.length === connections.length;
|
||||||
|
const allSelectedDisabled =
|
||||||
|
selectedConnections.length > 0 &&
|
||||||
|
selectedConnections.every((c) => (c.isActive ?? true) === false);
|
||||||
|
|
||||||
const toggleSelectConnection = (connectionId) => {
|
const toggleSelectConnection = (connectionId) => {
|
||||||
setSelectedConnectionIds((prev) => (
|
setSelectedConnectionIds((prev) => (
|
||||||
@@ -1676,6 +1701,16 @@ export default function ProviderDetailPage() {
|
|||||||
Delete Selected ({selectedConnectionIds.length})
|
Delete Selected ({selectedConnectionIds.length})
|
||||||
</Button>
|
</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 && (
|
{selectedConnectionIds.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ const ENDPOINT_COLUMNS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const PROVIDER_COLUMNS = [
|
const PROVIDER_COLUMNS = [
|
||||||
{ field: "provider", label: "Provider" },
|
{ field: "provider", label: "Provider / API Key" },
|
||||||
{ field: "requests", label: "Requests", align: "right" },
|
{ field: "requests", label: "Requests", align: "right" },
|
||||||
{ field: "lastUsed", label: "Last Used", 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
|
// Resolve a provider key (built-in id, alias, or custom node id like
|
||||||
// "openai-compatible-chat-096baf9a-...") to a human-friendly name.
|
// "openai-compatible-chat-096baf9a-...") to a human-friendly name.
|
||||||
// Prefers the connected-providers state (nodeName > connection name)
|
// Prefers the canonical provider name from the registry so e.g. "commandcode"
|
||||||
// and falls back to the module-level registry lookup.
|
// 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) => {
|
const getProviderLabel = useCallback((providerId) => {
|
||||||
if (!providerId) return "Unknown Provider";
|
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);
|
const match = providers.find((p) => p.provider === providerId || p.id === providerId);
|
||||||
if (match) return match.nodeName || match.name || providerId;
|
if (match) return match.nodeName || match.name || providerId;
|
||||||
return resolveProviderLabel(providerId);
|
return resolveProviderLabel(providerId);
|
||||||
@@ -591,12 +594,55 @@ export default function UsageStats({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "provider": {
|
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 {
|
return {
|
||||||
columns: PROVIDER_COLUMNS,
|
columns: PROVIDER_COLUMNS,
|
||||||
groupedData: groupDataByKey(
|
groupedData,
|
||||||
sortData(stats.byProvider, {}, sortBy, sortOrder),
|
|
||||||
"provider",
|
|
||||||
),
|
|
||||||
storageKey: "usage-stats:expanded-providers",
|
storageKey: "usage-stats:expanded-providers",
|
||||||
emptyMessage: "No usage recorded yet.",
|
emptyMessage: "No usage recorded yet.",
|
||||||
renderSummaryCells: (group) => (
|
renderSummaryCells: (group) => (
|
||||||
@@ -612,7 +658,7 @@ export default function UsageStats({
|
|||||||
renderDetailCells: (item) => (
|
renderDetailCells: (item) => (
|
||||||
<>
|
<>
|
||||||
<td className="px-6 py-3 font-medium">
|
<td className="px-6 py-3 font-medium">
|
||||||
{getProviderLabel(item.provider || item.key)}
|
{item.keyName}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-3 text-right">{fmt(item.requests)}</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">
|
<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)
|
if (!stats && !loading)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
extractApiKey,
|
extractApiKey,
|
||||||
isValidApiKey,
|
isValidApiKey,
|
||||||
} from "../services/auth.js";
|
} 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 { getModelInfo, getComboModels } from "../services/model.js";
|
||||||
import { handleChatCore } from "open-sse/handlers/chatCore.js";
|
import { handleChatCore } from "open-sse/handlers/chatCore.js";
|
||||||
import { DEFAULT_HEADROOM_URL } from "@/lib/headroom/detect";
|
import { DEFAULT_HEADROOM_URL } from "@/lib/headroom/detect";
|
||||||
@@ -259,6 +260,13 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
// Use shared chatCore
|
// Use shared chatCore
|
||||||
const chatSettings = await getSettings();
|
const chatSettings = await getSettings();
|
||||||
const providerThinking = (chatSettings.providerThinking || {})[provider] || null;
|
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({
|
const result = await handleChatCore({
|
||||||
body: { ...body, model: `${provider}/${model}` },
|
body: { ...body, model: `${provider}/${model}` },
|
||||||
modelInfo: { provider, model },
|
modelInfo: { provider, model },
|
||||||
@@ -284,6 +292,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
pxpipeTransform: chatSettings.pxpipeEnabled ? await getPxpipeTransform() : null,
|
pxpipeTransform: chatSettings.pxpipeEnabled ? await getPxpipeTransform() : null,
|
||||||
onPxpipeEvent: appendPxpipeEvent,
|
onPxpipeEvent: appendPxpipeEvent,
|
||||||
providerThinking,
|
providerThinking,
|
||||||
|
capsOverride,
|
||||||
// Detect source format by endpoint + body
|
// Detect source format by endpoint + body
|
||||||
sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null,
|
sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null,
|
||||||
onCredentialsRefreshed: async (newCreds) => {
|
onCredentialsRefreshed: async (newCreds) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -113,9 +113,11 @@ describe("getUsageForProvider(commandcode)", () => {
|
|||||||
total: 6,
|
total: 6,
|
||||||
resetAt: new Date(1786379982640).toISOString(),
|
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({
|
expect(usage.quotas["Monthly credits"]).toMatchObject({
|
||||||
used: 0.1,
|
used: 0.1,
|
||||||
total: 9.9,
|
total: 10,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,8 +196,8 @@ describe("parseQuotaData(commandcode)", () => {
|
|||||||
},
|
},
|
||||||
"Monthly credits": {
|
"Monthly credits": {
|
||||||
used: 0.1,
|
used: 0.1,
|
||||||
total: 9.9,
|
total: 10,
|
||||||
remainingPercentage: 98.99,
|
remainingPercentage: 99,
|
||||||
resetAt: null,
|
resetAt: null,
|
||||||
unit: "$",
|
unit: "$",
|
||||||
},
|
},
|
||||||
@@ -205,14 +207,11 @@ describe("parseQuotaData(commandcode)", () => {
|
|||||||
name: "5-hour window",
|
name: "5-hour window",
|
||||||
used: 0.05,
|
used: 0.05,
|
||||||
total: 3,
|
total: 3,
|
||||||
remainingPercentage: 98.33,
|
|
||||||
unit: "$",
|
|
||||||
});
|
});
|
||||||
expect(rows[1]).toMatchObject({
|
expect(rows[1]).toMatchObject({
|
||||||
name: "Monthly credits",
|
name: "Monthly credits",
|
||||||
used: 0.1,
|
used: 0.1,
|
||||||
total: 9.9,
|
total: 10,
|
||||||
remainingPercentage: 98.99,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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