From 806bd4ae14546f201dda1492f1cd004b446bec47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thi=C3=AAn=20To=C3=A1n?= Date: Fri, 20 Feb 2026 15:03:18 +0700 Subject: [PATCH] feat: add API endpoint dimension to usage statistics dashboard (#152) - Tracks endpoints like /v1/chat/completions, /v1/messages, /v1/responses - New sortable/groupable table in usage dashboard with expandable groups - Enhanced usage database aggregation by endpoint + model + provider - Added endpoint tracking to all saveRequestUsage/saveRequestDetail calls - Maintains backward compatibility with existing data structure --- open-sse/handlers/chatCore.js | 18 +- src/lib/usageDb.js | 26 +++ src/shared/components/UsageStats.js | 302 +++++++++++++++++++++++++++- 3 files changed, 339 insertions(+), 7 deletions(-) diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 8c713d5b..7e3b5d1b 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -622,7 +622,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred thinking: null, finish_reason: jsonResponse.status || "unknown" }, - status: "success" + status: "success", + endpoint: clientRawRequest?.endpoint || null }).catch(() => { }); return { @@ -656,7 +657,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred tokens: usage, timestamp: new Date().toISOString(), connectionId: connectionId || undefined, - apiKey: apiKey || undefined + apiKey: apiKey || undefined, + endpoint: clientRawRequest?.endpoint || null }).catch(() => { }); } @@ -676,7 +678,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred thinking: parsed.choices?.[0]?.message?.reasoning_content || null, finish_reason: parsed.choices?.[0]?.finish_reason || "unknown" }, - status: "success" + status: "success", + endpoint: clientRawRequest?.endpoint || null }).catch(() => { }); return { @@ -749,7 +752,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred tokens: usage, timestamp: new Date().toISOString(), connectionId: connectionId || undefined, - apiKey: apiKey || undefined + apiKey: apiKey || undefined, + endpoint: clientRawRequest?.endpoint || null }).catch(err => { console.error("Failed to save usage stats:", err.message); }); @@ -808,7 +812,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred null, finish_reason: translatedResponse?.choices?.[0]?.finish_reason || "unknown" }, - status: "success" + status: "success", + endpoint: clientRawRequest?.endpoint || null }; // Async save (don't block response) @@ -886,7 +891,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred tokens: usage, timestamp: new Date().toISOString(), connectionId: connectionId || undefined, - apiKey: apiKey || undefined + apiKey: apiKey || undefined, + endpoint: clientRawRequest?.endpoint || null }).catch(err => { console.error("Failed to save streaming usage stats:", err.message); }); diff --git a/src/lib/usageDb.js b/src/lib/usageDb.js index 12fc1752..b94ec369 100644 --- a/src/lib/usageDb.js +++ b/src/lib/usageDb.js @@ -396,6 +396,7 @@ export async function getUsageStats() { byModel: {}, byAccount: {}, byApiKey: {}, + byEndpoint: {}, last10Minutes: [], pending: pendingRequests, activeRequests: [] @@ -587,6 +588,31 @@ export async function getUsageStats() { apiKeyEntry.lastUsed = entry.timestamp; } } + + // By Endpoint (endpoint + model + provider combination) + const endpoint = entry.endpoint || "Unknown"; + const endpointModelKey = `${endpoint}|${entry.model}|${entry.provider || 'unknown'}`; + + if (!stats.byEndpoint[endpointModelKey]) { + stats.byEndpoint[endpointModelKey] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cost: 0, + endpoint: endpoint, + rawModel: entry.model, + provider: entry.provider, + lastUsed: entry.timestamp + }; + } + const endpointEntry = stats.byEndpoint[endpointModelKey]; + endpointEntry.requests++; + endpointEntry.promptTokens += promptTokens; + endpointEntry.completionTokens += completionTokens; + endpointEntry.cost += entryCost; + if (new Date(entry.timestamp) > new Date(endpointEntry.lastUsed)) { + endpointEntry.lastUsed = entry.timestamp; + } } return stats; diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index 563e1ec8..391d06a3 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -49,6 +49,8 @@ export default function UsageStats() { const accountSortOrder = searchParams.get("accountSortOrder") || "asc"; const apiKeySortBy = searchParams.get("apiKeySortBy") || "keyName"; const apiKeySortOrder = searchParams.get("apiKeySortOrder") || "asc"; + const endpointSortBy = searchParams.get("endpointSortBy") || "endpoint"; + const endpointSortOrder = searchParams.get("endpointSortOrder") || "asc"; const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); @@ -59,12 +61,14 @@ export default function UsageStats() { const [expandedModels, setExpandedModels] = useState(new Set()); const [expandedAccounts, setExpandedAccounts] = useState(new Set()); const [expandedApiKeys, setExpandedApiKeys] = useState(new Set()); + const [expandedEndpoints, setExpandedEndpoints] = useState(new Set()); const toggleSort = (tableType, field) => { const sortKeyMap = { model: { by: "modelSortBy", order: "modelSortOrder" }, account: { by: "accountSortBy", order: "accountSortOrder" }, - apiKey: { by: "apiKeySortBy", order: "apiKeySortOrder" } + apiKey: { by: "apiKeySortBy", order: "apiKeySortOrder" }, + endpoint: { by: "endpointSortBy", order: "endpointSortOrder" } }; const sortKeys = sortKeyMap[tableType]; const params = new URLSearchParams(searchParams.toString()); @@ -134,6 +138,8 @@ export default function UsageStats() { return item.accountName || `Account ${item.connectionId?.slice(0, 8)}...` || 'Unknown Account'; case 'keyName': return item.keyName || 'Unknown Key'; + case 'endpoint': + return item.endpoint || 'Unknown Endpoint'; default: return item[keyField] || 'Unknown'; } @@ -234,6 +240,16 @@ export default function UsageStats() { [sortedApiKeys, groupDataByKey] ); + const sortedEndpoints = useMemo( + () => sortData(stats?.byEndpoint, {}, endpointSortBy, endpointSortOrder), + [stats?.byEndpoint, endpointSortBy, endpointSortOrder, sortData] + ); + + const groupedEndpoints = useMemo( + () => groupDataByKey(sortedEndpoints, 'endpoint'), + [sortedEndpoints, groupDataByKey] + ); + const fetchStats = useCallback(async (showLoading = true) => { if (showLoading) setLoading(true); try { @@ -321,6 +337,25 @@ export default function UsageStats() { } }, [expandedApiKeys]); + useEffect(() => { + try { + const saved = localStorage.getItem('usage-stats:expanded-endpoints'); + if (saved) { + setExpandedEndpoints(new Set(JSON.parse(saved))); + } + } catch (error) { + console.error("Failed to load expanded endpoints from localStorage:", error); + } + }, []); + + useEffect(() => { + try { + localStorage.setItem('usage-stats:expanded-endpoints', JSON.stringify([...expandedEndpoints])); + } catch (error) { + console.error("Failed to save expanded endpoints to localStorage:", error); + } + }, [expandedEndpoints]); + const toggleModelGroup = useCallback((groupKey) => { setExpandedModels(prev => { const next = new Set(prev); @@ -357,6 +392,18 @@ export default function UsageStats() { }); }, []); + const toggleEndpointGroup = useCallback((groupKey) => { + setExpandedEndpoints(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; @@ -1315,6 +1362,259 @@ export default function UsageStats() { + + {/* Usage by API Endpoint Table */} + +
+

Usage by API Endpoint

+
+
+ + + + + + + + + {viewMode === "tokens" ? ( + <> + + + + + ) : ( + <> + + + + + )} + + + + {groupedEndpoints.map((group) => ( + + toggleEndpointGroup(group.groupKey)} + > + + + + + + {viewMode === "tokens" ? ( + <> + + + + + ) : ( + <> + + + + + )} + + {expandedEndpoints.has(group.groupKey) && group.items.map((item) => ( + + + + + + + {viewMode === "tokens" ? ( + <> + + + + + ) : ( + <> + + + + + )} + + ))} + + ))} + {groupedEndpoints.length === 0 && ( + + + + )} + +
toggleSort("endpoint", "endpoint")} + > + Endpoint{" "} + + toggleSort("endpoint", "rawModel")} + > + Model{" "} + + toggleSort("endpoint", "provider")} + > + Provider{" "} + + toggleSort("endpoint", "requests")} + > + Requests{" "} + + toggleSort("endpoint", "lastUsed")} + > + Last Used{" "} + + toggleSort("endpoint", "promptTokens")} + > + Input Tokens{" "} + + toggleSort("endpoint", "completionTokens")} + > + Output Tokens{" "} + + toggleSort("endpoint", "totalTokens")} + > + Total Tokens{" "} + + toggleSort("endpoint", "promptTokens")} + > + Input Cost{" "} + + toggleSort("endpoint", "completionTokens")} + > + Output Cost{" "} + + toggleSort("endpoint", "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.endpoint} + + {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 endpoint usage recorded yet. Make requests to see data here. +
+
+
); }