From 1ae4e311b7c18b0d826495f5d48c4cb222ed11c8 Mon Sep 17 00:00:00 2001 From: Blade096 <46746496+Blade096@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:44:08 +0700 Subject: [PATCH] feat: add GLM Coding (China) provider and Usage by API Keys statistics Co-authored-by: Cursor --- open-sse/handlers/chatCore.js | 15 +- open-sse/utils/stream.js | 18 +- open-sse/utils/usageTracking.js | 4 +- src/lib/usageDb.js | 86 ++- src/shared/components/UsageStats.js | 904 ++++++++++++++++++++++------ src/sse/handlers/chat.js | 7 +- 6 files changed, 835 insertions(+), 199 deletions(-) diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 914a822..f8161a6 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -344,8 +344,9 @@ function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { * @param {function} options.onRequestSuccess - Callback when request succeeds (to clear error status) * @param {function} options.onDisconnect - Callback when client disconnects * @param {string} options.connectionId - Connection ID for usage tracking + * @param {string} options.apiKey - API key for usage tracking */ -export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent }) { +export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey }) { const { provider, model } = modelInfo; const requestStartTime = Date.now(); @@ -587,7 +588,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred model: model || "unknown", tokens: usage, timestamp: new Date().toISOString(), - connectionId: connectionId || undefined + connectionId: connectionId || undefined, + apiKey: apiKey || undefined }).catch(err => { console.error("Failed to save usage stats:", err.message); }); @@ -704,7 +706,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred model: model || "unknown", tokens: usage, timestamp: new Date().toISOString(), - connectionId: connectionId || undefined + connectionId: connectionId || undefined, + apiKey: apiKey || undefined }).catch(err => { console.error("Failed to save streaming usage stats:", err.message); }); @@ -719,13 +722,13 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred if (needsCodexTranslation) { log?.debug?.("STREAM", `Codex translation mode: openai-responses → openai`); - transformStream = createSSETransformStreamWithLogger('openai-responses', 'openai', provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete); + transformStream = createSSETransformStreamWithLogger('openai-responses', 'openai', provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey); } else if (needsTranslation(targetFormat, sourceFormat)) { log?.debug?.("STREAM", `Translation mode: ${targetFormat} → ${sourceFormat}`); - transformStream = createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete); + transformStream = createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey); } else { log?.debug?.("STREAM", `Standard passthrough mode`); - transformStream = createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete); + transformStream = createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey); } const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController); diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 1e7938a..ed2a278 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -29,6 +29,7 @@ const STREAM_MODE = { * @param {string} options.connectionId - Connection ID for usage tracking * @param {object} options.body - Request body (for input token estimation) * @param {function} options.onStreamComplete - Callback when stream completes (content, usage) + * @param {string} options.apiKey - API key for usage tracking */ export function createSSEStream(options = {}) { const { @@ -41,7 +42,8 @@ export function createSSEStream(options = {}) { model = null, connectionId = null, body = null, - onStreamComplete = null + onStreamComplete = null, + apiKey = null } = options; let buffer = ""; @@ -246,7 +248,7 @@ export function createSSEStream(options = {}) { } if (hasValidUsage(usage)) { - logUsage(provider, usage, model, connectionId); + logUsage(provider, usage, model, connectionId, apiKey); } else { appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { }); } @@ -308,7 +310,7 @@ export function createSSEStream(options = {}) { } if (hasValidUsage(state?.usage)) { - logUsage(state.provider || targetFormat, state.usage, model, connectionId); + logUsage(state.provider || targetFormat, state.usage, model, connectionId, apiKey); } else { appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { }); } @@ -326,7 +328,7 @@ export function createSSEStream(options = {}) { }); } -export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null) { +export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null) { return createSSEStream({ mode: STREAM_MODE.TRANSLATE, targetFormat, @@ -337,11 +339,12 @@ export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, p model, connectionId, body, - onStreamComplete + onStreamComplete, + apiKey }); } -export function createPassthroughStreamWithLogger(provider = null, reqLogger = null, model = null, connectionId = null, body = null, onStreamComplete = null) { +export function createPassthroughStreamWithLogger(provider = null, reqLogger = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null) { return createSSEStream({ mode: STREAM_MODE.PASSTHROUGH, provider, @@ -349,6 +352,7 @@ export function createPassthroughStreamWithLogger(provider = null, reqLogger = n model, connectionId, body, - onStreamComplete + onStreamComplete, + apiKey }); } diff --git a/open-sse/utils/usageTracking.js b/open-sse/utils/usageTracking.js index 36054b3..20a71d5 100644 --- a/open-sse/utils/usageTracking.js +++ b/open-sse/utils/usageTracking.js @@ -279,7 +279,7 @@ export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI /** * Log usage with cache info (green color) */ -export function logUsage(provider, usage, model = null, connectionId = null) { +export function logUsage(provider, usage, model = null, connectionId = null, apiKey = null) { if (!usage || typeof usage !== "object") return; const p = provider?.toUpperCase() || "UNKNOWN"; @@ -318,6 +318,6 @@ export function logUsage(provider, usage, model = null, connectionId = null) { cache_creation_input_tokens: cacheCreation || 0, reasoning_tokens: reasoning || 0 }; - saveRequestUsage({ model, provider, connectionId, tokens }).catch(() => { }); + saveRequestUsage({ model, provider, connectionId, tokens, apiKey: apiKey || undefined }).catch(() => { }); appendRequestLog({ model, provider, connectionId, tokens, status: "200 OK" }).catch(() => { }); } diff --git a/src/lib/usageDb.js b/src/lib/usageDb.js index 99546ce..ecdb054 100644 --- a/src/lib/usageDb.js +++ b/src/lib/usageDb.js @@ -139,7 +139,7 @@ export async function getUsageDb() { /** * Save request usage - * @param {object} entry - Usage entry { provider, model, tokens: { prompt_tokens, completion_tokens, ... }, connectionId? } + * @param {object} entry - Usage entry { provider, model, tokens: { prompt_tokens, completion_tokens, ... }, connectionId?, apiKey? } */ export async function saveRequestUsage(entry) { if (isCloud) return; // Skip saving in Workers @@ -349,8 +349,8 @@ export async function getUsageStats() { const db = await getUsageDb(); const history = db.data.history || []; - // Import localDb to get provider connection names - const { getProviderConnections } = await import("@/lib/localDb.js"); + // Import localDb to get provider connection names and API keys + const { getProviderConnections, getApiKeys } = await import("@/lib/localDb.js"); // Fetch all provider connections to get account names let allConnections = []; @@ -367,14 +367,33 @@ export async function getUsageStats() { connectionMap[conn.id] = conn.name || conn.email || conn.id; } + // Fetch all API keys to get key names + let allApiKeys = []; + try { + allApiKeys = await getApiKeys(); + } catch (error) { + console.warn("Could not fetch API keys for usage stats:", error.message); + } + + // Create a map from API key to key info + const apiKeyMap = {}; + for (const key of allApiKeys) { + apiKeyMap[key.key] = { + name: key.name, + id: key.id, + createdAt: key.createdAt + }; + } + const stats = { totalRequests: history.length, totalPromptTokens: 0, totalCompletionTokens: 0, - totalCost: 0, // NEW + totalCost: 0, byProvider: {}, byModel: {}, byAccount: {}, + byApiKey: {}, last10Minutes: [], pending: pendingRequests, activeRequests: [] @@ -507,6 +526,65 @@ export async function getUsageStats() { stats.byAccount[accountKey].lastUsed = entry.timestamp; } } + + // Handle requests with API key + if (entry.apiKey && typeof entry.apiKey === "string") { + const keyInfo = apiKeyMap[entry.apiKey]; + const keyName = keyInfo?.name || entry.apiKey.slice(0, 8) + "..."; + // Use full API key to avoid collisions (keys with same prefix) + const apiKeyKey = entry.apiKey; + // Group by API Key + Model + Provider combination to track different models used with the same key + const apiKeyModelKey = `${apiKeyKey}|${entry.model}|${entry.provider || 'unknown'}`; + + if (!stats.byApiKey[apiKeyModelKey]) { + stats.byApiKey[apiKeyModelKey] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cost: 0, + rawModel: entry.model, + provider: entry.provider, + apiKey: entry.apiKey, + keyName: keyName, + apiKeyKey: apiKeyKey, + lastUsed: entry.timestamp + }; + } + const apiKeyEntry = stats.byApiKey[apiKeyModelKey]; + apiKeyEntry.requests++; + apiKeyEntry.promptTokens += promptTokens; + apiKeyEntry.completionTokens += completionTokens; + apiKeyEntry.cost += entryCost; + if (new Date(entry.timestamp) > new Date(apiKeyEntry.lastUsed)) { + apiKeyEntry.lastUsed = entry.timestamp; + } + } else { + const apiKeyKey = "local-no-key"; + const keyName = "Local (No API Key)"; + + if (!stats.byApiKey[apiKeyKey]) { + stats.byApiKey[apiKeyKey] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cost: 0, + rawModel: entry.model, + provider: entry.provider, + apiKey: null, + keyName: keyName, + apiKeyKey: apiKeyKey, + lastUsed: entry.timestamp + }; + } + const apiKeyEntry = stats.byApiKey[apiKeyKey]; + apiKeyEntry.requests++; + apiKeyEntry.promptTokens += promptTokens; + apiKeyEntry.completionTokens += completionTokens; + apiKeyEntry.cost += entryCost; + if (new Date(entry.timestamp) > new Date(apiKeyEntry.lastUsed)) { + apiKeyEntry.lastUsed = entry.timestamp; + } + } } return stats; diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index 550d8ca..563e1ec 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo, useCallback } from "react"; +import { useState, useEffect, useMemo, useCallback, Fragment } from "react"; import PropTypes from "prop-types"; import { useSearchParams, useRouter } from "next/navigation"; import Card from "./Card"; @@ -43,8 +43,12 @@ export default function UsageStats() { const router = useRouter(); const searchParams = useSearchParams(); - const sortBy = searchParams.get("sortBy") || "rawModel"; - const sortOrder = searchParams.get("sortOrder") || "asc"; + const modelSortBy = searchParams.get("modelSortBy") || "rawModel"; + const modelSortOrder = searchParams.get("modelSortOrder") || "asc"; + const accountSortBy = searchParams.get("accountSortBy") || "rawModel"; + const accountSortOrder = searchParams.get("accountSortOrder") || "asc"; + const apiKeySortBy = searchParams.get("apiKeySortBy") || "keyName"; + const apiKeySortOrder = searchParams.get("apiKeySortOrder") || "asc"; const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); @@ -52,26 +56,38 @@ export default function UsageStats() { const [viewMode, setViewMode] = useState("tokens"); // 'tokens' or 'costs' const [refreshInterval, setRefreshInterval] = useState(5000); // Start with 5s const [prevTotalRequests, setPrevTotalRequests] = useState(0); + const [expandedModels, setExpandedModels] = useState(new Set()); + const [expandedAccounts, setExpandedAccounts] = useState(new Set()); + const [expandedApiKeys, setExpandedApiKeys] = useState(new Set()); - const toggleSort = (field) => { + const toggleSort = (tableType, field) => { + const sortKeyMap = { + model: { by: "modelSortBy", order: "modelSortOrder" }, + account: { by: "accountSortBy", order: "accountSortOrder" }, + apiKey: { by: "apiKeySortBy", order: "apiKeySortOrder" } + }; + const sortKeys = sortKeyMap[tableType]; const params = new URLSearchParams(searchParams.toString()); - if (sortBy === field) { - params.set("sortOrder", sortOrder === "asc" ? "desc" : "asc"); + + const currentBy = params.get(sortKeys.by); + const currentOrder = params.get(sortKeys.order); + + if (currentBy === field) { + params.set(sortKeys.order, currentOrder === "asc" ? "desc" : "asc"); } else { - params.set("sortBy", field); - params.set("sortOrder", "asc"); + params.set(sortKeys.by, field); + params.set(sortKeys.order, "asc"); } router.replace(`?${params.toString()}`, { scroll: false }); }; - const sortData = useCallback((dataMap, pendingMap = {}) => { + const sortData = useCallback((dataMap, pendingMap = {}, sortBy, sortOrder) => { return Object.entries(dataMap || {}) .map(([key, data]) => { const totalTokens = (data.promptTokens || 0) + (data.completionTokens || 0); const totalCost = data.cost || 0; - // Calculate cost breakdown (estimated based on token ratio) const inputCost = totalTokens > 0 ? (data.promptTokens || 0) * (totalCost / totalTokens) @@ -95,7 +111,6 @@ export default function UsageStats() { let valA = a[sortBy]; let valB = b[sortBy]; - // Handle case-insensitive sorting for strings if (typeof valA === "string") valA = valA.toLowerCase(); if (typeof valB === "string") valB = valB.toLowerCase(); @@ -103,21 +118,100 @@ export default function UsageStats() { if (valA > valB) return sortOrder === "asc" ? 1 : -1; return 0; }); - }, [sortBy, sortOrder]); + }, []); + + /** + * Extract grouping key from data item based on field type + * @param {object} item - Data item with usage stats + * @param {string} keyField - Field to use for grouping ('rawModel', 'accountName', 'keyName') + * @returns {string} - Grouping key value + */ + const getGroupKey = useCallback((item, keyField) => { + switch (keyField) { + case 'rawModel': + return item.rawModel || 'Unknown Model'; + case 'accountName': + return item.accountName || `Account ${item.connectionId?.slice(0, 8)}...` || 'Unknown Account'; + case 'keyName': + return item.keyName || 'Unknown Key'; + default: + return item[keyField] || 'Unknown'; + } + }, []); + + /** + * Group flat data array by key field and calculate aggregated values + * @param {Array} data - Flat array of data items (output from sortData) + * @param {string} keyField - Field to use for grouping ('rawModel', 'accountName', 'keyName') + * @returns {Array} - Array of groups with summary and items + */ + const groupDataByKey = useCallback((data, keyField) => { + if (!Array.isArray(data)) return []; + + const groups = {}; + + data.forEach((item) => { + const groupKey = getGroupKey(item, keyField); + + if (!groups[groupKey]) { + groups[groupKey] = { + groupKey, + summary: { + requests: 0, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cost: 0, + inputCost: 0, + outputCost: 0, + lastUsed: null, + pending: 0 + }, + items: [] + }; + } + + const group = groups[groupKey]; + + // Aggregate values: sum for most, max for lastUsed + group.summary.requests += item.requests || 0; + group.summary.promptTokens += item.promptTokens || 0; + group.summary.completionTokens += item.completionTokens || 0; + group.summary.totalTokens += item.totalTokens || 0; + group.summary.cost += item.cost || 0; + group.summary.inputCost += item.inputCost || 0; + group.summary.outputCost += item.outputCost || 0; + group.summary.pending += item.pending || 0; + + // Take max for lastUsed (most recent timestamp) + if (item.lastUsed) { + if (!group.summary.lastUsed || new Date(item.lastUsed) > new Date(group.summary.lastUsed)) { + group.summary.lastUsed = item.lastUsed; + } + } + + // Add item to group + group.items.push(item); + }); + + return Object.values(groups); + }, [getGroupKey]); const sortedModels = useMemo( - () => sortData(stats?.byModel, stats?.pending?.byModel), - [stats?.byModel, stats?.pending?.byModel, sortData] + () => sortData(stats?.byModel, stats?.pending?.byModel, modelSortBy, modelSortOrder), + [stats?.byModel, stats?.pending?.byModel, modelSortBy, modelSortOrder, sortData] + ); + + const groupedModels = useMemo( + () => groupDataByKey(sortedModels, 'rawModel'), + [sortedModels, groupDataByKey] ); const sortedAccounts = useMemo(() => { - // For accounts, pendingMap is by connectionId, but dataMap is by accountKey - // We need to map connectionId pending counts to accountKeys const accountPendingMap = {}; if (stats?.pending?.byAccount) { Object.entries(stats.byAccount || {}).forEach(([accountKey, data]) => { const connPending = stats.pending.byAccount[data.connectionId]; if (connPending) { - // Get modelKey (rawModel (provider)) const modelKey = data.provider ? `${data.rawModel} (${data.provider})` : data.rawModel; @@ -125,8 +219,20 @@ export default function UsageStats() { } }); } - return sortData(stats?.byAccount, accountPendingMap); - }, [stats?.byAccount, stats?.pending?.byAccount, sortData]); + return sortData(stats?.byAccount, accountPendingMap, accountSortBy, accountSortOrder); + }, [stats?.byAccount, stats?.pending?.byAccount, accountSortBy, accountSortOrder, sortData]); + + const groupedAccounts = useMemo( + () => groupDataByKey(sortedAccounts, 'accountName'), + [sortedAccounts, groupDataByKey] + ); + + const sortedApiKeys = useMemo(() => sortData(stats?.byApiKey, {}, apiKeySortBy, apiKeySortOrder), [stats?.byApiKey, apiKeySortBy, apiKeySortOrder, sortData]); + + const groupedApiKeys = useMemo( + () => groupDataByKey(sortedApiKeys, 'keyName'), + [sortedApiKeys, groupDataByKey] + ); const fetchStats = useCallback(async (showLoading = true) => { if (showLoading) setLoading(true); @@ -158,6 +264,99 @@ export default function UsageStats() { fetchStats(); }, [fetchStats]); + useEffect(() => { + try { + const saved = localStorage.getItem('usage-stats:expanded-models'); + if (saved) { + setExpandedModels(new Set(JSON.parse(saved))); + } + } catch (error) { + console.error("Failed to load expanded models from localStorage:", error); + } + }, []); + + useEffect(() => { + try { + localStorage.setItem('usage-stats:expanded-models', JSON.stringify([...expandedModels])); + } catch (error) { + console.error("Failed to save expanded models to localStorage:", error); + } + }, [expandedModels]); + + useEffect(() => { + try { + const saved = localStorage.getItem('usage-stats:expanded-accounts'); + if (saved) { + setExpandedAccounts(new Set(JSON.parse(saved))); + } + } catch (error) { + console.error("Failed to load expanded accounts from localStorage:", error); + } + }, []); + + useEffect(() => { + try { + localStorage.setItem('usage-stats:expanded-accounts', JSON.stringify([...expandedAccounts])); + } catch (error) { + console.error("Failed to save expanded accounts to localStorage:", error); + } + }, [expandedAccounts]); + + useEffect(() => { + try { + const saved = localStorage.getItem('usage-stats:expanded-apikeys'); + if (saved) { + setExpandedApiKeys(new Set(JSON.parse(saved))); + } + } catch (error) { + console.error("Failed to load expanded API keys from localStorage:", error); + } + }, []); + + useEffect(() => { + try { + localStorage.setItem('usage-stats:expanded-apikeys', JSON.stringify([...expandedApiKeys])); + } catch (error) { + console.error("Failed to save expanded API keys to localStorage:", error); + } + }, [expandedApiKeys]); + + const toggleModelGroup = useCallback((groupKey) => { + setExpandedModels(prev => { + const next = new Set(prev); + if (next.has(groupKey)) { + next.delete(groupKey); + } else { + next.add(groupKey); + } + return next; + }); + }, []); + + const toggleAccountGroup = useCallback((groupKey) => { + setExpandedAccounts(prev => { + const next = new Set(prev); + if (next.has(groupKey)) { + next.delete(groupKey); + } else { + next.add(groupKey); + } + return next; + }); + }, []); + + const toggleApiKeyGroup = useCallback((groupKey) => { + setExpandedApiKeys(prev => { + const next = new Set(prev); + if (next.has(groupKey)) { + next.delete(groupKey); + } else { + next.add(groupKey); + } + return next; + }); + }, []); + useEffect(() => { let intervalId; let isPageVisible = true; @@ -371,81 +570,81 @@ export default function UsageStats() { toggleSort("rawModel")} + onClick={() => toggleSort("model", "rawModel")} > Model{" "} toggleSort("provider")} + onClick={() => toggleSort("model", "provider")} > Provider{" "} toggleSort("requests")} + onClick={() => toggleSort("model", "requests")} > Requests{" "} toggleSort("lastUsed")} + onClick={() => toggleSort("model", "lastUsed")} > Last Used{" "} {viewMode === "tokens" ? ( <> toggleSort("promptTokens")} + onClick={() => toggleSort("model", "promptTokens")} > Input Tokens{" "} toggleSort("completionTokens")} + onClick={() => toggleSort("model", "completionTokens")} > Output Tokens{" "} toggleSort("totalTokens")} + onClick={() => toggleSort("model", "totalTokens")} > Total Tokens{" "} @@ -453,35 +652,35 @@ export default function UsageStats() { <> toggleSort("promptTokens")} + onClick={() => toggleSort("model", "promptTokens")} > Input Cost{" "} toggleSort("completionTokens")} + onClick={() => toggleSort("model", "completionTokens")} > Output Cost{" "} toggleSort("cost")} + onClick={() => toggleSort("model", "cost")} > Total Cost{" "} @@ -489,55 +688,108 @@ export default function UsageStats() { - {sortedModels.map((data) => ( - - 0 ? "text-primary" : "" - }`} + {groupedModels.map((group) => ( + + toggleModelGroup(group.groupKey)} > - {data.rawModel} - - - 0 ? "primary" : "neutral"} - size="sm" + +
+ + chevron_right + + 0 ? "text-primary" : ""}`}> + {group.groupKey} + +
+ + — + {fmt(group.summary.requests)} + + {fmtTime(group.summary.lastUsed)} + + {viewMode === "tokens" ? ( + <> + + {fmt(group.summary.promptTokens)} + + + {fmt(group.summary.completionTokens)} + + + {fmt(group.summary.totalTokens)} + + + ) : ( + <> + + {fmtCost(group.summary.inputCost)} + + + {fmtCost(group.summary.outputCost)} + + + {fmtCost(group.summary.totalCost)} + + + )} + + {expandedModels.has(group.groupKey) && group.items.map((item) => ( + - {data.provider} -
- - {fmt(data.requests)} - - {fmtTime(data.lastUsed)} - - {viewMode === "tokens" ? ( - <> - - {fmt(data.promptTokens)} + 0 ? "text-primary" : "" + }`} + > + {item.rawModel} - - {fmt(data.completionTokens)} + + 0 ? "primary" : "neutral"} + size="sm" + > + {item.provider} + - - {fmt(data.totalTokens)} + {fmt(item.requests)} + + {fmtTime(item.lastUsed)} - - ) : ( - <> - - {fmtCost(data.inputCost)} - - - {fmtCost(data.outputCost)} - - - {fmtCost(data.totalCost)} - - - )} - + {viewMode === "tokens" ? ( + <> + + {fmt(item.promptTokens)} + + + {fmt(item.completionTokens)} + + + {fmt(item.totalTokens)} + + + ) : ( + <> + + {fmtCost(item.inputCost)} + + + {fmtCost(item.outputCost)} + + + {fmtCost(item.totalCost)} + + + )} + + ))} +
))} - {sortedModels.length === 0 && ( + {groupedModels.length === 0 && ( toggleSort("rawModel")} + onClick={() => toggleSort("account", "rawModel")} > Model{" "} toggleSort("provider")} + onClick={() => toggleSort("account", "provider")} > Provider{" "} toggleSort("accountName")} + onClick={() => toggleSort("account", "accountName")} > Account{" "} toggleSort("requests")} + onClick={() => toggleSort("account", "requests")} > Requests{" "} toggleSort("lastUsed")} + onClick={() => toggleSort("account", "lastUsed")} > Last Used{" "} {viewMode === "tokens" ? ( <> toggleSort("promptTokens")} + onClick={() => toggleSort("account", "promptTokens")} > Input Tokens{" "} toggleSort("completionTokens")} + onClick={() => toggleSort("account", "completionTokens")} > Output Tokens{" "} toggleSort("totalTokens")} + onClick={() => toggleSort("account", "totalTokens")} > Total Tokens{" "} @@ -656,35 +908,35 @@ export default function UsageStats() { <> toggleSort("promptTokens")} + onClick={() => toggleSort("account", "promptTokens")} > Input Cost{" "} toggleSort("completionTokens")} + onClick={() => toggleSort("account", "completionTokens")} > Output Cost{" "} toggleSort("cost")} + onClick={() => toggleSort("account", "cost")} > Total Cost{" "} @@ -692,65 +944,111 @@ export default function UsageStats() { - {sortedAccounts.map((data) => ( - - 0 ? "text-primary" : "" - }`} + {groupedAccounts.map((group) => ( + + toggleAccountGroup(group.groupKey)} > - {data.rawModel} - - - 0 ? "primary" : "neutral"} - size="sm" + +
+ + chevron_right + + 0 ? "text-primary" : ""}`}> + {group.groupKey} + +
+ + — + — + {fmt(group.summary.requests)} + + {fmtTime(group.summary.lastUsed)} + + {viewMode === "tokens" ? ( + <> + — + — + + {fmt(group.summary.totalTokens)} + + + ) : ( + <> + — + — + + {fmtCost(group.summary.totalCost)} + + + )} + + {expandedAccounts.has(group.groupKey) && group.items.map((item) => ( + - {data.provider} -
- - - 0 ? "text-primary" : "" - }`} - > - {data.accountName || - `Account ${data.connectionId?.slice(0, 8)}...`} - - - {fmt(data.requests)} - - {fmtTime(data.lastUsed)} - - {viewMode === "tokens" ? ( - <> - - {fmt(data.promptTokens)} + + 0 ? "text-primary" : "" + }`} + > + {item.accountName || + `Account ${item.connectionId?.slice(0, 8)}...`} + - - {fmt(data.completionTokens)} + 0 ? "text-primary" : "" + }`} + > + {item.rawModel} - - {fmt(data.totalTokens)} + + 0 ? "primary" : "neutral"} + size="sm" + > + {item.provider} + - - ) : ( - <> - - {fmtCost(data.inputCost)} + {fmt(item.requests)} + + {fmtTime(item.lastUsed)} - - {fmtCost(data.outputCost)} - - - {fmtCost(data.totalCost)} - - - )} - + {viewMode === "tokens" ? ( + <> + + {fmt(item.promptTokens)} + + + {fmt(item.completionTokens)} + + + {fmt(item.totalTokens)} + + + ) : ( + <> + + {fmtCost(item.inputCost)} + + + {fmtCost(item.outputCost)} + + + {fmtCost(item.totalCost)} + + + )} + + ))} +
))} - {sortedAccounts.length === 0 && ( + {groupedAccounts.length === 0 && ( + + +
+

Usage by API Key

+
+
+ + + + + + + + + {viewMode === "tokens" ? ( + <> + + + + + ) : ( + <> + + + + + )} + + + + {groupedApiKeys.map((group) => ( + + toggleApiKeyGroup(group.groupKey)} + > + + + + + + {viewMode === "tokens" ? ( + <> + + + + + ) : ( + <> + + + + + )} + + {expandedApiKeys.has(group.groupKey) && group.items.map((item) => ( + + + + + + + {viewMode === "tokens" ? ( + <> + + + + + ) : ( + <> + + + + + )} + + ))} + + ))} + {groupedApiKeys.length === 0 && ( + + + + )} + +
toggleSort("apiKey", "keyName")} + > + API Key Name{" "} + + toggleSort("apiKey", "rawModel")} + > + Model{" "} + + toggleSort("apiKey", "provider")} + > + Provider{" "} + + toggleSort("apiKey", "requests")} + > + Requests{" "} + + toggleSort("apiKey", "lastUsed")} + > + Last Used{" "} + + toggleSort("apiKey", "promptTokens")} + > + Input Tokens{" "} + + toggleSort("apiKey", "completionTokens")} + > + Output Tokens{" "} + + toggleSort("apiKey", "totalTokens")} + > + Total Tokens{" "} + + toggleSort("apiKey", "promptTokens")} + > + Input Cost{" "} + + toggleSort("apiKey", "completionTokens")} + > + Output Cost{" "} + + toggleSort("apiKey", "cost")} + > + Total Cost{" "} + +
+
+ + chevron_right + + + {group.groupKey} + +
+
{fmt(group.summary.requests)} + {fmtTime(group.summary.lastUsed)} + + {fmt(group.summary.promptTokens)} + + {fmt(group.summary.completionTokens)} + + {fmt(group.summary.totalTokens)} + + {fmtCost(group.summary.inputCost)} + + {fmtCost(group.summary.outputCost)} + + {fmtCost(group.summary.totalCost)} +
+ {item.keyName} + + {item.rawModel} + + + {item.provider} + + {fmt(item.requests)} + {fmtTime(item.lastUsed)} + + {fmt(item.promptTokens)} + + {fmt(item.completionTokens)} + + {fmt(item.totalTokens)} + + {fmtCost(item.inputCost)} + + {fmtCost(item.outputCost)} + + {fmtCost(item.totalCost)} +
+ No API key usage recorded yet. Make requests using API keys to see data here. +
+
+
); } diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index e38e7e4..18b537d 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -83,19 +83,19 @@ export async function handleChat(request, clientRawRequest = null) { return handleComboChat({ body, models: comboModels, - handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request), + handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey), log }); } // Single model request - return handleSingleModelChat(body, modelStr, clientRawRequest, request); + return handleSingleModelChat(body, modelStr, clientRawRequest, request, apiKey); } /** * Handle single model chat request */ -async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null) { +async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null, apiKey = null) { const modelInfo = await getModelInfo(modelStr); if (!modelInfo.provider) { log.warn("CHAT", "Invalid model format", { model: modelStr }); @@ -153,6 +153,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re clientRawRequest, connectionId: credentials.connectionId, userAgent, + apiKey, onCredentialsRefreshed: async (newCreds) => { await updateProviderCredentials(credentials.connectionId, { accessToken: newCreds.accessToken,