mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
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
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Usage by API Endpoint Table */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="p-4 border-b border-border bg-bg-subtle/50">
|
||||
<h3 className="font-semibold">Usage by API Endpoint</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-bg-subtle/30 text-text-muted uppercase text-xs">
|
||||
<tr>
|
||||
<th
|
||||
className="px-6 py-3 cursor-pointer hover:bg-bg-subtle/50"
|
||||
onClick={() => toggleSort("endpoint", "endpoint")}
|
||||
>
|
||||
Endpoint{" "}
|
||||
<SortIcon
|
||||
field="endpoint"
|
||||
currentSort={endpointSortBy}
|
||||
currentOrder={endpointSortOrder}
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 cursor-pointer hover:bg-bg-subtle/50"
|
||||
onClick={() => toggleSort("endpoint", "rawModel")}
|
||||
>
|
||||
Model{" "}
|
||||
<SortIcon
|
||||
field="rawModel"
|
||||
currentSort={endpointSortBy}
|
||||
currentOrder={endpointSortOrder}
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 cursor-pointer hover:bg-bg-subtle/50"
|
||||
onClick={() => toggleSort("endpoint", "provider")}
|
||||
>
|
||||
Provider{" "}
|
||||
<SortIcon
|
||||
field="provider"
|
||||
currentSort={endpointSortBy}
|
||||
currentOrder={endpointSortOrder}
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||
onClick={() => toggleSort("endpoint", "requests")}
|
||||
>
|
||||
Requests{" "}
|
||||
<SortIcon
|
||||
field="requests"
|
||||
currentSort={endpointSortBy}
|
||||
currentOrder={endpointSortOrder}
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||
onClick={() => toggleSort("endpoint", "lastUsed")}
|
||||
>
|
||||
Last Used{" "}
|
||||
<SortIcon
|
||||
field="lastUsed"
|
||||
currentSort={endpointSortBy}
|
||||
currentOrder={endpointSortOrder}
|
||||
/>
|
||||
</th>
|
||||
{viewMode === "tokens" ? (
|
||||
<>
|
||||
<th
|
||||
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||
onClick={() => toggleSort("endpoint", "promptTokens")}
|
||||
>
|
||||
Input Tokens{" "}
|
||||
<SortIcon
|
||||
field="promptTokens"
|
||||
currentSort={endpointSortBy}
|
||||
currentOrder={endpointSortOrder}
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||
onClick={() => toggleSort("endpoint", "completionTokens")}
|
||||
>
|
||||
Output Tokens{" "}
|
||||
<SortIcon
|
||||
field="completionTokens"
|
||||
currentSort={endpointSortBy}
|
||||
currentOrder={endpointSortOrder}
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||
onClick={() => toggleSort("endpoint", "totalTokens")}
|
||||
>
|
||||
Total Tokens{" "}
|
||||
<SortIcon
|
||||
field="totalTokens"
|
||||
currentSort={endpointSortBy}
|
||||
currentOrder={endpointSortOrder}
|
||||
/>
|
||||
</th>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<th
|
||||
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||
onClick={() => toggleSort("endpoint", "promptTokens")}
|
||||
>
|
||||
Input Cost{" "}
|
||||
<SortIcon
|
||||
field="promptTokens"
|
||||
currentSort={endpointSortBy}
|
||||
currentOrder={endpointSortOrder}
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||
onClick={() => toggleSort("endpoint", "completionTokens")}
|
||||
>
|
||||
Output Cost{" "}
|
||||
<SortIcon
|
||||
field="completionTokens"
|
||||
currentSort={endpointSortBy}
|
||||
currentOrder={endpointSortOrder}
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||
onClick={() => toggleSort("endpoint", "cost")}
|
||||
>
|
||||
Total Cost{" "}
|
||||
<SortIcon
|
||||
field="cost"
|
||||
currentSort={endpointSortBy}
|
||||
currentOrder={endpointSortOrder}
|
||||
/>
|
||||
</th>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{groupedEndpoints.map((group) => (
|
||||
<Fragment key={group.groupKey}>
|
||||
<tr
|
||||
key={`summary-${group.groupKey}`}
|
||||
className="group-summary cursor-pointer hover:bg-bg-subtle/50 transition-colors"
|
||||
onClick={() => toggleEndpointGroup(group.groupKey)}
|
||||
>
|
||||
<td className="px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`material-symbols-outlined text-[18px] text-text-muted transition-transform ${expandedEndpoints.has(group.groupKey) ? 'rotate-90' : ''}`}>
|
||||
chevron_right
|
||||
</span>
|
||||
<span className="font-medium font-mono text-sm">
|
||||
{group.groupKey}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-text-muted">—</td>
|
||||
<td className="px-6 py-3 text-text-muted">—</td>
|
||||
<td className="px-6 py-3 text-right">{fmt(group.summary.requests)}</td>
|
||||
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
|
||||
{fmtTime(group.summary.lastUsed)}
|
||||
</td>
|
||||
{viewMode === "tokens" ? (
|
||||
<>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{fmt(group.summary.promptTokens)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{fmt(group.summary.completionTokens)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right font-medium">
|
||||
{fmt(group.summary.totalTokens)}
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{fmtCost(group.summary.inputCost)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{fmtCost(group.summary.outputCost)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right font-medium text-warning">
|
||||
{fmtCost(group.summary.totalCost)}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
{expandedEndpoints.has(group.groupKey) && group.items.map((item) => (
|
||||
<tr
|
||||
key={`detail-${item.key}`}
|
||||
className="group-detail hover:bg-bg-subtle/20 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-3 font-medium font-mono text-sm">
|
||||
{item.endpoint}
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
{item.rawModel}
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<Badge variant="neutral" size="sm">
|
||||
{item.provider}
|
||||
</Badge>
|
||||
</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">
|
||||
{fmtTime(item.lastUsed)}
|
||||
</td>
|
||||
{viewMode === "tokens" ? (
|
||||
<>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{fmt(item.promptTokens)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{fmt(item.completionTokens)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right font-medium">
|
||||
{fmt(item.totalTokens)}
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{fmtCost(item.inputCost)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{fmtCost(item.outputCost)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right font-medium text-warning">
|
||||
{fmtCost(item.totalCost)}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
{groupedEndpoints.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={9}
|
||||
className="px-6 py-8 text-center text-text-muted"
|
||||
>
|
||||
No endpoint usage recorded yet. Make requests to see data here.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user