From adf57aa0c9234ad66a5e220f2f79a8ae74b5e800 Mon Sep 17 00:00:00 2001 From: decolua Date: Sat, 21 Feb 2026 14:36:06 +0700 Subject: [PATCH] Fixed Codex --- next.config.mjs | 2 + open-sse/handlers/chatCore.js | 7 +- open-sse/translator/helpers/geminiHelper.js | 9 +- .../translator/response/openai-responses.js | 12 +- package.json | 2 + .../dashboard/providers/[id]/page.js | 13 + .../usage/components/OverviewCards.js | 34 + .../usage/components/ProviderTopology.js | 249 +++ .../dashboard/usage/components/UsageChart.js | 160 ++ .../dashboard/usage/components/UsageTable.js | 247 +++ src/app/(dashboard)/dashboard/usage/page.js | 37 +- src/app/api/usage/chart/route.js | 21 + src/app/api/usage/stream/route.js | 58 + src/app/callback/page.js | 87 +- src/lib/usageDb.js | 106 +- src/shared/components/OAuthModal.js | 67 +- src/shared/components/UsageStats.js | 1933 +++-------------- src/shared/constants/providers.js | 2 +- 18 files changed, 1419 insertions(+), 1627 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/usage/components/OverviewCards.js create mode 100644 src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js create mode 100644 src/app/(dashboard)/dashboard/usage/components/UsageChart.js create mode 100644 src/app/(dashboard)/dashboard/usage/components/UsageTable.js create mode 100644 src/app/api/usage/chart/route.js create mode 100644 src/app/api/usage/stream/route.js diff --git a/next.config.mjs b/next.config.mjs index 108232f..971446e 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -16,6 +16,8 @@ const nextConfig = { path: false, }; } + // Stop watching logs directory to prevent HMR during streaming + config.watchOptions = { ...config.watchOptions, ignored: /[\\/](logs|\.next)[\\/]/ }; return config; }, async rewrites() { diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 7e3b5d1..f510933 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -906,8 +906,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred && !isDroidCLI; if (needsCodexTranslation) { - log?.debug?.("STREAM", `Codex translation mode: openai-responses → openai`); - transformStream = createSSETransformStreamWithLogger('openai-responses', 'openai', provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey); + // For claude clients, translate directly to claude format + // For openai/openai-responses clients, translate to openai (responsesHandler will re-add event: lines) + const codexTarget = sourceFormat === FORMATS.CLAUDE ? FORMATS.CLAUDE : FORMATS.OPENAI; + log?.debug?.("STREAM", `Codex translation mode: openai-responses → ${codexTarget}`); + transformStream = createSSETransformStreamWithLogger('openai-responses', codexTarget, 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, apiKey); diff --git a/open-sse/translator/helpers/geminiHelper.js b/open-sse/translator/helpers/geminiHelper.js index 8b5ef58..283d26a 100644 --- a/open-sse/translator/helpers/geminiHelper.js +++ b/open-sse/translator/helpers/geminiHelper.js @@ -100,6 +100,7 @@ export function generateProjectId() { } // Helper: Remove unsupported keywords recursively from object/array +// Also strips all vendor extension fields (x- prefixed) not supported by Gemini function removeUnsupportedKeywords(obj, keywords) { if (!obj || typeof obj !== "object") return; @@ -108,13 +109,11 @@ function removeUnsupportedKeywords(obj, keywords) { removeUnsupportedKeywords(item, keywords); } } else { - // Delete unsupported keys at current level - for (const keyword of keywords) { - if (keyword in obj) { - delete obj[keyword]; + for (const key of Object.keys(obj)) { + if (keywords.includes(key) || key.startsWith("x-")) { + delete obj[key]; } } - // Recurse into remaining values for (const value of Object.values(obj)) { if (value && typeof value === "object") { removeUnsupportedKeywords(value, keywords); diff --git a/open-sse/translator/response/openai-responses.js b/open-sse/translator/response/openai-responses.js index e0985e5..671de33 100644 --- a/open-sse/translator/response/openai-responses.js +++ b/open-sse/translator/response/openai-responses.js @@ -415,8 +415,8 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { return null; } - // Function call started - if (eventType === "response.output_item.added" && data.item?.type === "function_call") { + // Function call started (standard function_call or custom_tool_call) + if (eventType === "response.output_item.added" && (data.item?.type === "function_call" || data.item?.type === "custom_tool_call")) { const item = data.item; state.currentToolCallId = item.call_id || `call_${Date.now()}`; @@ -443,8 +443,8 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { }; } - // Function call arguments delta - if (eventType === "response.function_call_arguments.delta") { + // Function call arguments delta (standard or custom_tool_call variant) + if (eventType === "response.function_call_arguments.delta" || eventType === "response.custom_tool_call_input.delta") { const argsDelta = data.delta || ""; if (!argsDelta) return null; @@ -466,8 +466,8 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { }; } - // Function call done - if (eventType === "response.output_item.done" && data.item?.type === "function_call") { + // Function call done (standard or custom_tool_call variant) + if (eventType === "response.output_item.done" && (data.item?.type === "function_call" || data.item?.type === "custom_tool_call")) { state.toolCallIndex++; return null; } diff --git a/package.json b/package.json index 1d765cb..b61fa59 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@monaco-editor/react": "^4.7.0", + "@xyflow/react": "^12.10.1", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.6.2", "express": "^5.2.1", @@ -25,6 +26,7 @@ "ora": "^9.1.0", "react": "19.2.4", "react-dom": "19.2.4", + "recharts": "^3.7.0", "selfsigned": "^5.5.0", "socks-proxy-agent": "^8.0.5", "undici": "^7.19.2", diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index b52f4dd..1c61f52 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -18,6 +18,19 @@ export default function ProviderDetailPage() { const [loading, setLoading] = useState(true); const [providerNode, setProviderNode] = useState(null); const [showOAuthModal, setShowOAuthModal] = useState(false); + + // Auto-reopen OAuthModal if pending auth exists (survives HMR/reload) + useEffect(() => { + try { + const raw = localStorage.getItem("oauth_pending_auth"); + if (raw) { + const data = JSON.parse(raw); + if (data.provider === providerId && Date.now() - data.timestamp < 300000) { + setShowOAuthModal(true); + } + } + } catch { /* ignore */ } + }, [providerId]); const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showEditNodeModal, setShowEditNodeModal] = useState(false); diff --git a/src/app/(dashboard)/dashboard/usage/components/OverviewCards.js b/src/app/(dashboard)/dashboard/usage/components/OverviewCards.js new file mode 100644 index 0000000..6d88f6b --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/OverviewCards.js @@ -0,0 +1,34 @@ +"use client"; + +import PropTypes from "prop-types"; +import Card from "@/shared/components/Card"; + +const fmt = (n) => new Intl.NumberFormat().format(n || 0); +const fmtCost = (n) => `$${(n || 0).toFixed(2)}`; + +export default function OverviewCards({ stats }) { + return ( +
+ + Total Requests + {fmt(stats.totalRequests)} + + + Total Input Tokens + {fmt(stats.totalPromptTokens)} + + + Output Tokens + {fmt(stats.totalCompletionTokens)} + + + Total Cost + {fmtCost(stats.totalCost)} + +
+ ); +} + +OverviewCards.propTypes = { + stats: PropTypes.object.isRequired, +}; diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js b/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js new file mode 100644 index 0000000..407e2b5 --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js @@ -0,0 +1,249 @@ +"use client"; + +import { useMemo, useState } from "react"; +import PropTypes from "prop-types"; +import { + ReactFlow, + Handle, + Position, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { AI_PROVIDERS } from "@/shared/constants/providers"; + +function getProviderConfig(providerId) { + return AI_PROVIDERS[providerId] || { color: "#6b7280", name: providerId }; +} + +// Use local provider images from /public/providers/ +function getProviderImageUrl(providerId) { + return `/providers/${providerId}.png`; +} + +// Custom provider node - rectangle with image + name +function ProviderNode({ data }) { + const { label, color, imageUrl, textIcon, active } = data; + const [imgError, setImgError] = useState(false); + return ( +
+ + + + + + {/* Provider icon */} +
+ {!imgError ? ( + {label} setImgError(true)} /> + ) : ( + {textIcon} + )} +
+ + {/* Provider name */} + + {label} + + + {/* Active indicator */} + {active && ( + + + + + )} +
+ ); +} + +ProviderNode.propTypes = { + data: PropTypes.object.isRequired, +}; + +// Center 9Router node +function RouterNode({ data }) { + return ( +
+ + + + + + 9Router + 9Router + {data.activeCount > 0 && ( + + {data.activeCount} + + )} +
+ ); +} + +RouterNode.propTypes = { + data: PropTypes.object.isRequired, +}; + +const nodeTypes = { provider: ProviderNode, router: RouterNode }; + +// Place N nodes evenly along an ellipse around the router center. +function buildLayout(providers, activeSet, lastSet) { + const nodeW = 180; + const nodeH = 30; + const routerW = 120; + const routerH = 44; + const nodeGap = 24; + + const count = providers.length; + + // Compute rx so arc spacing between nodes >= nodeW + nodeGap + const minRx = ((nodeW + nodeGap) * count) / (2 * Math.PI); + const rx = Math.max(320, minRx); + const ry = Math.max(200, rx * 0.55); // ellipse ratio ~0.55 + if (count === 0) { + return { + nodes: [{ id: "router", type: "router", position: { x: 0, y: 0 }, data: { activeCount: 0 }, draggable: false }], + edges: [], + }; + } + + const nodes = []; + const edges = []; + + nodes.push({ + id: "router", + type: "router", + position: { x: -routerW / 2, y: -routerH / 2 }, + data: { activeCount: activeSet.size }, + draggable: false, + }); + + const edgeStyle = (active, last, color) => { + if (active) return { stroke: color, strokeWidth: 2, opacity: 0.8 }; + if (last) return { stroke: "#f59e0b", strokeWidth: 2, opacity: 0.7 }; + return { stroke: "var(--color-border)", strokeWidth: 1, opacity: 0.3 }; + }; + + providers.forEach((p, i) => { + const config = getProviderConfig(p.provider); + const active = activeSet.has(p.provider?.toLowerCase()); + const last = !active && lastSet.has(p.provider?.toLowerCase()); + const nodeId = `provider-${p.provider}`; + const data = { + label: config.name || p.name || p.provider, + color: config.color || "#6b7280", + imageUrl: getProviderImageUrl(p.provider), + textIcon: config.textIcon || (p.provider || "?").slice(0, 2).toUpperCase(), + active, + }; + + // Distribute evenly starting from top (−π/2), clockwise + const angle = -Math.PI / 2 + (2 * Math.PI * i) / count; + const cx = rx * Math.cos(angle); + const cy = ry * Math.sin(angle); + + // Pick router handle closest to the node direction + let sourceHandle, targetHandle; + if (Math.abs(angle + Math.PI / 2) < Math.PI / 4 || Math.abs(angle - 3 * Math.PI / 2) < Math.PI / 4) { + sourceHandle = "top"; targetHandle = "bottom"; + } else if (Math.abs(angle - Math.PI / 2) < Math.PI / 4) { + sourceHandle = "bottom"; targetHandle = "top"; + } else if (cx > 0) { + sourceHandle = "right"; targetHandle = "left"; + } else { + sourceHandle = "left"; targetHandle = "right"; + } + + nodes.push({ + id: nodeId, + type: "provider", + position: { x: cx - nodeW / 2, y: cy - nodeH / 2 }, + data, + draggable: false, + }); + + edges.push({ + id: `e-${nodeId}`, + source: "router", + sourceHandle, + target: nodeId, + targetHandle, + animated: active, + style: edgeStyle(active, last, config.color), + }); + }); + + return { nodes, edges }; +} + +export default function ProviderTopology({ providers = [], activeRequests = [], lastProvider = "" }) { + const activeSet = useMemo( + () => new Set(activeRequests.map((r) => r.provider?.toLowerCase()).filter(Boolean)), + [activeRequests] + ); + + // lastSet: providers that finished most recently (not currently active) + const lastSet = useMemo( + () => new Set(lastProvider ? [lastProvider.toLowerCase()] : []), + [lastProvider] + ); + + const { nodes: initialNodes, edges: initialEdges } = useMemo( + () => buildLayout(providers, activeSet, lastSet), + [providers, activeSet, lastSet] + ); + + return ( +
+ {providers.length === 0 ? ( +
+ No providers connected +
+ ) : ( + setTimeout(() => instance.fitView({ padding: 0.3 }), 50)} + proOptions={{ hideAttribution: true }} + panOnDrag={false} + zoomOnScroll={false} + zoomOnPinch={false} + zoomOnDoubleClick={false} + preventScrolling={false} + nodesDraggable={false} + nodesConnectable={false} + elementsSelectable={false} + /> + )} +
+ ); +} + +ProviderTopology.propTypes = { + providers: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string, + provider: PropTypes.string, + name: PropTypes.string, + })), + activeRequests: PropTypes.arrayOf(PropTypes.shape({ + provider: PropTypes.string, + model: PropTypes.string, + account: PropTypes.string, + })), + lastProvider: PropTypes.string, +}; diff --git a/src/app/(dashboard)/dashboard/usage/components/UsageChart.js b/src/app/(dashboard)/dashboard/usage/components/UsageChart.js new file mode 100644 index 0000000..7e277e8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/UsageChart.js @@ -0,0 +1,160 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import PropTypes from "prop-types"; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import Card from "@/shared/components/Card"; + +const PERIODS = [ + { value: "24h", label: "24h" }, + { value: "7d", label: "7D" }, + { value: "30d", label: "30D" }, + { value: "60d", label: "60D" }, +]; + +const fmtTokens = (n) => { + if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`; + if (n >= 1000) return `${(n / 1000).toFixed(1)}K`; + return String(n || 0); +}; + +const fmtCost = (n) => `$${(n || 0).toFixed(4)}`; + +export default function UsageChart() { + const [period, setPeriod] = useState("7d"); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [viewMode, setViewMode] = useState("tokens"); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const res = await fetch(`/api/usage/chart?period=${period}`); + if (res.ok) { + const json = await res.json(); + setData(json); + } + } catch (e) { + console.error("Failed to fetch chart data:", e); + } finally { + setLoading(false); + } + }, [period]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const hasData = data.some((d) => d.tokens > 0 || d.cost > 0); + + return ( + +
+
+ + +
+
+ {PERIODS.map((p) => ( + + ))} +
+
+ + {loading ? ( +
Loading...
+ ) : !hasData ? ( +
No data for this period
+ ) : ( + + + + + + + + + + + + + + + + + name === "tokens" ? [fmtTokens(value), "Tokens"] : [fmtCost(value), "Cost"] + } + /> + {viewMode === "tokens" ? ( + + ) : ( + + )} + + + )} +
+ ); +} + +UsageChart.propTypes = {}; diff --git a/src/app/(dashboard)/dashboard/usage/components/UsageTable.js b/src/app/(dashboard)/dashboard/usage/components/UsageTable.js new file mode 100644 index 0000000..9f3d309 --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/UsageTable.js @@ -0,0 +1,247 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo, Fragment } from "react"; +import PropTypes from "prop-types"; +import Card from "@/shared/components/Card"; +import Badge from "@/shared/components/Badge"; + +const fmt = (n) => new Intl.NumberFormat().format(n || 0); +const fmtCost = (n) => `$${(n || 0).toFixed(2)}`; + +function fmtTime(iso) { + if (!iso) return "Never"; + const diffMins = Math.floor((Date.now() - new Date(iso)) / 60000); + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`; + return new Date(iso).toLocaleDateString(); +} + +function SortIcon({ field, currentSort, currentOrder }) { + if (currentSort !== field) return ; + return {currentOrder === "asc" ? "↑" : "↓"}; +} + +SortIcon.propTypes = { + field: PropTypes.string.isRequired, + currentSort: PropTypes.string.isRequired, + currentOrder: PropTypes.string.isRequired, +}; + +/** + * Render 3 token or cost cells based on viewMode + */ +function ValueCells({ item, viewMode, isSummary = false }) { + if (viewMode === "tokens") { + return ( + <> + + {isSummary && item.promptTokens === undefined ? "—" : fmt(item.promptTokens)} + + + {isSummary && item.completionTokens === undefined ? "—" : fmt(item.completionTokens)} + + + {fmt(item.totalTokens)} + + + ); + } + return ( + <> + + {isSummary && item.inputCost === undefined ? "—" : fmtCost(item.inputCost)} + + + {isSummary && item.outputCost === undefined ? "—" : fmtCost(item.outputCost)} + + + {fmtCost(item.totalCost || item.cost)} + + + ); +} + +ValueCells.propTypes = { + item: PropTypes.object.isRequired, + viewMode: PropTypes.string.isRequired, + isSummary: PropTypes.bool, +}; + +/** + * Reusable sortable usage table with expandable group rows. + * + * @param {object} props + * @param {string} props.title - Table title + * @param {Array} props.columns - Column definitions [{field, label}] + * @param {Array} props.groupedData - Grouped data from groupDataByKey + * @param {string} props.tableType - Table type key for sort URL params + * @param {string} props.sortBy - Current sort field + * @param {string} props.sortOrder - Current sort order + * @param {function} props.onToggleSort - Sort toggle handler + * @param {string} props.viewMode - "tokens" or "costs" + * @param {string} props.storageKey - localStorage key for expanded state + * @param {function} props.renderGroupLabel - Render group summary first cell content + * @param {function} props.renderDetailCells - Render detail row custom cells (before value cells) + * @param {function} props.renderSummaryCells - Render summary row cells after group label (placeholder cols) + * @param {string} props.emptyMessage - Empty state message + */ +export default function UsageTable({ + title, + columns, + groupedData, + tableType, + sortBy, + sortOrder, + onToggleSort, + viewMode, + storageKey, + renderDetailCells, + renderSummaryCells, + emptyMessage, +}) { + const [expanded, setExpanded] = useState(new Set()); + + // Load expanded state from localStorage + useEffect(() => { + try { + const saved = localStorage.getItem(storageKey); + if (saved) setExpanded(new Set(JSON.parse(saved))); + } catch (e) { + console.error(`Failed to load ${storageKey}:`, e); + } + }, [storageKey]); + + // Save expanded state to localStorage + useEffect(() => { + try { + localStorage.setItem(storageKey, JSON.stringify([...expanded])); + } catch (e) { + console.error(`Failed to save ${storageKey}:`, e); + } + }, [expanded, storageKey]); + + const toggleGroup = useCallback((groupKey) => { + setExpanded((prev) => { + const next = new Set(prev); + next.has(groupKey) ? next.delete(groupKey) : next.add(groupKey); + return next; + }); + }, []); + + const valueColumns = useMemo(() => { + if (viewMode === "tokens") { + return [ + { field: "promptTokens", label: "Input Tokens" }, + { field: "completionTokens", label: "Output Tokens" }, + { field: "totalTokens", label: "Total Tokens" }, + ]; + } + return [ + { field: "promptTokens", label: "Input Cost" }, + { field: "completionTokens", label: "Output Cost" }, + { field: "cost", label: "Total Cost" }, + ]; + }, [viewMode]); + + const totalColSpan = columns.length + valueColumns.length; + + return ( + +
+

{title}

+
+
+ + + + {columns.map((col) => ( + + ))} + {valueColumns.map((col) => ( + + ))} + + + + {groupedData.map((group) => ( + + {/* Group summary row */} + toggleGroup(group.groupKey)} + > + + {renderSummaryCells(group)} + + + {/* Detail rows */} + {expanded.has(group.groupKey) && group.items.map((item) => ( + + {renderDetailCells(item)} + + + ))} + + ))} + {groupedData.length === 0 && ( + + + + )} + +
onToggleSort(tableType, col.field)} + > + {col.label}{" "} + + onToggleSort(tableType, col.field)} + > + {col.label}{" "} + +
+
+ + chevron_right + + 0 ? "text-primary" : ""}`}> + {group.groupKey} + +
+
+ {emptyMessage} +
+
+
+ ); +} + +UsageTable.propTypes = { + title: PropTypes.string.isRequired, + columns: PropTypes.arrayOf(PropTypes.shape({ + field: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + align: PropTypes.string, + })).isRequired, + groupedData: PropTypes.array.isRequired, + tableType: PropTypes.string.isRequired, + sortBy: PropTypes.string.isRequired, + sortOrder: PropTypes.string.isRequired, + onToggleSort: PropTypes.func.isRequired, + viewMode: PropTypes.string.isRequired, + storageKey: PropTypes.string.isRequired, + renderDetailCells: PropTypes.func.isRequired, + renderSummaryCells: PropTypes.func.isRequired, + emptyMessage: PropTypes.string.isRequired, +}; + +// Re-export utilities for use in UsageStats orchestrator +export { fmt, fmtCost, fmtTime }; diff --git a/src/app/(dashboard)/dashboard/usage/page.js b/src/app/(dashboard)/dashboard/usage/page.js index 5581af6..8b85752 100644 --- a/src/app/(dashboard)/dashboard/usage/page.js +++ b/src/app/(dashboard)/dashboard/usage/page.js @@ -18,8 +18,8 @@ function UsagePageContent() { const searchParams = useSearchParams(); const router = useRouter(); const [activeTab, setActiveTab] = useState(searchParams.get("tab") || "overview"); + const [tabLoading, setTabLoading] = useState(false); - // Sync tab with URL on mount and when URL changes useEffect(() => { const tabFromUrl = searchParams.get("tab"); if (tabFromUrl && ["overview", "logs", "limits", "details"].includes(tabFromUrl)) { @@ -27,12 +27,15 @@ function UsagePageContent() { } }, [searchParams]); - // Update URL when tab changes const handleTabChange = (value) => { + if (value === activeTab) return; + setTabLoading(true); setActiveTab(value); const params = new URLSearchParams(searchParams); params.set("tab", value); router.push(`/dashboard/usage?${params.toString()}`, { scroll: false }); + // Brief loading flash so user sees feedback + setTimeout(() => setTabLoading(false), 300); }; return ( @@ -40,7 +43,6 @@ function UsagePageContent() { - {/* Content */} - {activeTab === "overview" && ( - }> - - + {tabLoading ? ( + + ) : ( + <> + {activeTab === "overview" && ( + }> + + + )} + {activeTab === "logs" && } + {activeTab === "limits" && ( + }> + + + )} + {activeTab === "details" && } + )} - {activeTab === "logs" && } - {activeTab === "limits" && ( - }> - - - )} - {activeTab === "details" && } ); } diff --git a/src/app/api/usage/chart/route.js b/src/app/api/usage/chart/route.js new file mode 100644 index 0000000..3289ca8 --- /dev/null +++ b/src/app/api/usage/chart/route.js @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { getChartData } from "@/lib/usageDb"; + +const VALID_PERIODS = new Set(["24h", "7d", "30d", "60d"]); + +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const period = searchParams.get("period") || "7d"; + + if (!VALID_PERIODS.has(period)) { + return NextResponse.json({ error: "Invalid period" }, { status: 400 }); + } + + const data = await getChartData(period); + return NextResponse.json(data); + } catch (error) { + console.error("[API] Failed to get chart data:", error); + return NextResponse.json({ error: "Failed to fetch chart data" }, { status: 500 }); + } +} diff --git a/src/app/api/usage/stream/route.js b/src/app/api/usage/stream/route.js new file mode 100644 index 0000000..fc1a513 --- /dev/null +++ b/src/app/api/usage/stream/route.js @@ -0,0 +1,58 @@ +import { getUsageStats, statsEmitter } from "@/lib/usageDb"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const encoder = new TextEncoder(); + const state = { closed: false, keepalive: null, send: null }; + + const stream = new ReadableStream({ + async start(controller) { + state.send = async () => { + if (state.closed) return; + try { + const stats = await getUsageStats(); + if (stats.activeRequests?.length > 0) { + console.log(`[SSE] Push | active=${stats.activeRequests.length} | ${stats.activeRequests.map(r => r.provider).join(",")}`); + } + controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`)); + } catch { + // Controller closed → self-cleanup + state.closed = true; + statsEmitter.off("update", state.send); + clearInterval(state.keepalive); + } + }; + + await state.send(); + console.log(`[SSE] Client connected | listeners=${statsEmitter.listenerCount("update") + 1}`); + + statsEmitter.on("update", state.send); + + state.keepalive = setInterval(() => { + if (state.closed) { clearInterval(state.keepalive); return; } + try { + controller.enqueue(encoder.encode(": ping\n\n")); + } catch { + state.closed = true; + clearInterval(state.keepalive); + } + }, 25000); + }, + + cancel() { + state.closed = true; + statsEmitter.off("update", state.send); + clearInterval(state.keepalive); + console.log("[SSE] Client disconnected"); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} diff --git a/src/app/callback/page.js b/src/app/callback/page.js index 6638321..882ef3c 100644 --- a/src/app/callback/page.js +++ b/src/app/callback/page.js @@ -3,6 +3,43 @@ import { Suspense, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; +const OAUTH_SESSION_KEY = "oauth_pending_auth"; + +/** + * Direct exchange: callback page calls exchange API itself + * when relay to opener fails (e.g. HMR reload destroyed listeners) + */ +async function directExchange(code, state) { + try { + const raw = localStorage.getItem(OAUTH_SESSION_KEY); + if (!raw) return false; + + const session = JSON.parse(raw); + // Expired (5 min) + if (Date.now() - session.timestamp > 300000) { + localStorage.removeItem(OAUTH_SESSION_KEY); + return false; + } + + const res = await fetch(`/api/oauth/${session.provider}/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code, + redirectUri: session.redirectUri, + codeVerifier: session.codeVerifier, + state, + }), + }); + + const data = await res.json(); + localStorage.removeItem(OAUTH_SESSION_KEY); + return res.ok && data.success; + } catch { + return false; + } +} + /** * OAuth Callback Page Content */ @@ -24,7 +61,7 @@ function CallbackContent() { fullUrl: window.location.href, }; - let sent = false; + let relayed = false; // Check if this callback is from expected origin/port const expectedOrigins = [ @@ -35,8 +72,8 @@ function CallbackContent() { // Method 1: postMessage to opener (popup mode) if (window.opener) { try { - window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*"); // Allow any origin for local dev - sent = true; + window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*"); + relayed = true; } catch (e) { console.log("postMessage failed:", e); } @@ -47,7 +84,7 @@ function CallbackContent() { const channel = new BroadcastChannel("oauth_callback"); channel.postMessage(callbackData); channel.close(); - sent = true; + relayed = true; } catch (e) { console.log("BroadcastChannel failed:", e); } @@ -55,25 +92,53 @@ function CallbackContent() { // Method 3: localStorage event (fallback) try { localStorage.setItem("oauth_callback", JSON.stringify({ ...callbackData, timestamp: Date.now() })); - sent = true; + relayed = true; } catch (e) { console.log("localStorage failed:", e); } - if (sent && (code || error)) { - // Use setTimeout to avoid synchronous setState in effect + if (!(code || error)) { + setTimeout(() => setStatus("manual"), 0); + return; + } + + if (error) { setTimeout(() => { setStatus("success"); - // Auto close after 1.5 seconds setTimeout(() => { window.close(); - // If can't close (not a popup), show success message setTimeout(() => setStatus("done"), 500); }, 1500); }, 0); - } else { - setTimeout(() => setStatus("manual"), 0); + return; } + + // Try direct exchange FIRST (before relay may be lost to HMR) + // Then relay as backup for normal flow + const handleExchange = async () => { + const pending = localStorage.getItem(OAUTH_SESSION_KEY); + if (pending) { + // Direct exchange - works even if opener was destroyed by HMR + const ok = await directExchange(code, state); + if (ok) { + setStatus("success"); + setTimeout(() => { + window.close(); + setTimeout(() => setStatus("done"), 500); + }, 1500); + return; + } + } + + // Fallback: relay succeeded and OAuthModal handled it + setStatus("success"); + setTimeout(() => { + window.close(); + setTimeout(() => setStatus("done"), 500); + }, 1500); + }; + + handleExchange(); }, [searchParams]); return ( diff --git a/src/lib/usageDb.js b/src/lib/usageDb.js index b94ec36..d7c4c0e 100644 --- a/src/lib/usageDb.js +++ b/src/lib/usageDb.js @@ -1,5 +1,6 @@ import { Low } from "lowdb"; import { JSONFile } from "lowdb/node"; +import { EventEmitter } from "events"; import path from "path"; import os from "os"; import fs from "fs"; @@ -71,11 +72,18 @@ const defaultData = { // Singleton instance let dbInstance = null; -// Track in-flight requests in memory -const pendingRequests = { - byModel: {}, - byAccount: {} -}; +// Use global to share pending state across Next.js route modules +if (!global._pendingRequests) { + global._pendingRequests = { byModel: {}, byAccount: {} }; +} +const pendingRequests = global._pendingRequests; + +// Use global to share singleton across Next.js route modules +if (!global._statsEmitter) { + global._statsEmitter = new EventEmitter(); + global._statsEmitter.setMaxListeners(50); +} +export const statsEmitter = global._statsEmitter; /** * Track a pending request @@ -93,11 +101,14 @@ export function trackPendingRequest(model, provider, connectionId, started) { // Track by account if (connectionId) { - const accountKey = connectionId; // We use connectionId as key here + const accountKey = connectionId; if (!pendingRequests.byAccount[accountKey]) pendingRequests.byAccount[accountKey] = {}; if (!pendingRequests.byAccount[accountKey][modelKey]) pendingRequests.byAccount[accountKey][modelKey] = 0; pendingRequests.byAccount[accountKey][modelKey] = Math.max(0, pendingRequests.byAccount[accountKey][modelKey] + (started ? 1 : -1)); } + + console.log(`[PENDING] ${started ? "START" : "END"} | provider=${provider} | model=${model} | emitter listeners=${statsEmitter.listenerCount("update")}`); + statsEmitter.emit("update"); } /** @@ -159,12 +170,15 @@ export async function saveRequestUsage(entry) { db.data.history = []; } + const entryCost = await calculateCost(entry.provider, entry.model, entry.tokens); + entry.cost = entryCost; db.data.history.push(entry); // Optional: Limit history size if needed in future // if (db.data.history.length > 10000) db.data.history.shift(); await db.write(); + statsEmitter.emit("update"); } catch (error) { console.error("Failed to save usage stats:", error); } @@ -387,6 +401,34 @@ export async function getUsageStats() { }; } + // 20 most recent requests from history (always in sync with SSE emit) + const seen = new Set(); + const recentRequests = [...history] + .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) + .map((e) => { + const t = e.tokens || {}; + const promptTokens = t.prompt_tokens || t.input_tokens || 0; + const completionTokens = t.completion_tokens || t.output_tokens || 0; + return { + timestamp: e.timestamp, + model: e.model, + provider: e.provider || "", + promptTokens, + completionTokens, + status: e.status || "ok", + }; + }) + .filter((e) => { + if (e.promptTokens === 0 && e.completionTokens === 0) return false; + // Deduplicate: same model+provider+tokens within same minute + const minute = e.timestamp ? e.timestamp.slice(0, 16) : ""; + const key = `${e.model}|${e.provider}|${e.promptTokens}|${e.completionTokens}|${minute}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .slice(0, 20); + const stats = { totalRequests: history.length, totalPromptTokens: 0, @@ -399,7 +441,8 @@ export async function getUsageStats() { byEndpoint: {}, last10Minutes: [], pending: pendingRequests, - activeRequests: [] + activeRequests: [], + recentRequests, }; // Build active requests list from pending counts @@ -618,5 +661,54 @@ export async function getUsageStats() { return stats; } +/** + * Get time-series chart data for a given period + * @param {"24h"|"7d"|"30d"|"60d"} period + * @returns {Promise>} + */ +export async function getChartData(period = "7d") { + const db = await getUsageDb(); + const history = db.data.history || []; + const now = Date.now(); + + let bucketCount, bucketMs, labelFn; + if (period === "24h") { + bucketCount = 24; + bucketMs = 3600000; // 1 hour + labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); + } else if (period === "7d") { + bucketCount = 7; + bucketMs = 86400000; + labelFn = (ts) => new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" }); + } else if (period === "30d") { + bucketCount = 30; + bucketMs = 86400000; + labelFn = (ts) => new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" }); + } else { + bucketCount = 60; + bucketMs = 86400000; + labelFn = (ts) => new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" }); + } + + const startTime = now - bucketCount * bucketMs; + const buckets = Array.from({ length: bucketCount }, (_, i) => { + const ts = startTime + i * bucketMs; + return { label: labelFn(ts), tokens: 0, cost: 0, _ts: ts }; + }); + + for (const entry of history) { + const entryTime = new Date(entry.timestamp).getTime(); + if (entryTime < startTime || entryTime > now) continue; + const idx = Math.min(Math.floor((entryTime - startTime) / bucketMs), bucketCount - 1); + const promptTokens = entry.tokens?.prompt_tokens || 0; + const completionTokens = entry.tokens?.completion_tokens || 0; + buckets[idx].tokens += promptTokens + completionTokens; + // Use pre-stored cost if available, else 0 + buckets[idx].cost += entry.cost || 0; + } + + return buckets.map(({ label, tokens, cost }) => ({ label, tokens, cost })); +} + // Re-export request details functions from new SQLite-based module export { saveRequestDetail, getRequestDetails, getRequestDetailById } from "./requestDetailsDb.js"; diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index 43d4912..aec7153 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -5,6 +5,34 @@ import PropTypes from "prop-types"; import { Modal, Button, Input } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +const OAUTH_SESSION_KEY = "oauth_pending_auth"; + +function saveAuthSession(provider, data) { + try { + localStorage.setItem(OAUTH_SESSION_KEY, JSON.stringify({ provider, ...data, timestamp: Date.now() })); + } catch { /* storage unavailable */ } +} + +function loadAuthSession(provider) { + try { + const raw = localStorage.getItem(OAUTH_SESSION_KEY); + if (!raw) return null; + const data = JSON.parse(raw); + // Only restore if same provider and within 5 minutes + if (data.provider !== provider || Date.now() - data.timestamp > 300000) { + localStorage.removeItem(OAUTH_SESSION_KEY); + return null; + } + return data; + } catch { + return null; + } +} + +function clearAuthSession() { + try { localStorage.removeItem(OAUTH_SESSION_KEY); } catch { /* ignore */ } +} + /** * OAuth Modal Component * - Localhost: Auto callback via popup message @@ -56,9 +84,11 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, const data = await res.json(); if (!res.ok) throw new Error(data.error); + clearAuthSession(); setStep("success"); onSuccess?.(); } catch (err) { + clearAuthSession(); setError(err.message); setStep("error"); } @@ -152,6 +182,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, if (!res.ok) throw new Error(data.error); setAuthData({ ...data, redirectUri }); + saveAuthSession(provider, { codeVerifier: data.codeVerifier, redirectUri, state: data.state }); // For Codex or non-localhost: use manual input mode if (provider === "codex" || !isLocalhost) { @@ -176,13 +207,25 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, // Reset state and start OAuth when modal opens useEffect(() => { if (isOpen && provider) { + // Try restore pending auth from localStorage (survives HMR/reload) + const saved = loadAuthSession(provider); + if (saved) { + setAuthData({ codeVerifier: saved.codeVerifier, redirectUri: saved.redirectUri, state: saved.state }); + setStep("waiting"); + setCallbackUrl(""); + setError(null); + setIsDeviceCode(false); + setDeviceData(null); + setPolling(false); + return; // Don't restart OAuth — just re-listen for callback + } + setAuthData(null); setCallbackUrl(""); setError(null); setIsDeviceCode(false); setDeviceData(null); setPolling(false); - // Auto start OAuth startOAuthFlow(); } }, [isOpen, provider, startOAuthFlow]); @@ -206,6 +249,14 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, } if (code) { + // Skip if callback page already handled exchange (localStorage cleared) + const stillPending = localStorage.getItem(OAUTH_SESSION_KEY); + if (!stillPending) { + callbackProcessedRef.current = true; + setStep("success"); + onSuccess?.(); + return; + } callbackProcessedRef.current = true; await exchangeTokens(code, state); } @@ -293,10 +344,16 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, } }; + // Clear session on modal close + const handleClose = useCallback(() => { + clearAuthSession(); + onClose(); + }, [onClose]); + if (!provider || !providerInfo) return null; return ( - +
{/* Waiting Step (Localhost - popup mode) */} {step === "waiting" && !isDeviceCode && ( @@ -389,7 +446,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, -
@@ -406,7 +463,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,

Your {providerInfo.name} account has been connected.

- @@ -424,7 +481,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, - diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index 391d06a..d4a6197 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -1,1620 +1,403 @@ "use client"; -import { useState, useEffect, useMemo, useCallback, Fragment } from "react"; -import PropTypes from "prop-types"; +import { useState, useEffect, useMemo, useCallback } from "react"; import { useSearchParams, useRouter } from "next/navigation"; -import Card from "./Card"; -import Badge from "./Badge"; import { CardSkeleton } from "./Loading"; +import Badge from "./Badge"; +import Card from "./Card"; +import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards"; +import UsageTable, { fmt, fmtTime } from "@/app/(dashboard)/dashboard/usage/components/UsageTable"; +import ProviderTopology from "@/app/(dashboard)/dashboard/usage/components/ProviderTopology"; +import UsageChart from "@/app/(dashboard)/dashboard/usage/components/UsageChart"; -function SortIcon({ field, currentSort, currentOrder }) { - if (currentSort !== field) return ; - return {currentOrder === "asc" ? "↑" : "↓"}; +function timeAgo(timestamp) { + const diff = Math.floor((Date.now() - new Date(timestamp)) / 1000); + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; } -SortIcon.propTypes = { - field: PropTypes.string.isRequired, - currentSort: PropTypes.string.isRequired, - currentOrder: PropTypes.string.isRequired, -}; - -function MiniBarGraph({ data, colorClass = "bg-primary" }) { - const max = Math.max(...data, 1); +function RecentRequests({ requests = [] }) { return ( -
- {data.slice(-9).map((val, idx) => ( -
- ))} -
+ + {/* Header */} +
+ Recent Requests +
+ + {!requests.length ? ( +
No requests yet.
+ ) : ( +
+ + + + + + + + + + + {requests.map((r, i) => { + const ok = !r.status || r.status === "ok" || r.status === "success"; + return ( + + + + + + + ); + })} + +
ModelIn / OutWhen
+ + {r.model} + {fmt(r.promptTokens)}↑ + {" "} + {fmt(r.completionTokens)}↓ + {timeAgo(r.timestamp)}
+
+ )} +
); } -MiniBarGraph.propTypes = { - data: PropTypes.arrayOf(PropTypes.number).isRequired, - colorClass: PropTypes.string, -}; +function sortData(dataMap, pendingMap = {}, sortBy, sortOrder) { + return Object.entries(dataMap || {}) + .map(([key, data]) => { + const totalTokens = (data.promptTokens || 0) + (data.completionTokens || 0); + const totalCost = data.cost || 0; + const inputCost = totalTokens > 0 ? (data.promptTokens || 0) * (totalCost / totalTokens) : 0; + const outputCost = totalTokens > 0 ? (data.completionTokens || 0) * (totalCost / totalTokens) : 0; + return { ...data, key, totalTokens, totalCost, inputCost, outputCost, pending: pendingMap[key] || 0 }; + }) + .sort((a, b) => { + let valA = a[sortBy]; + let valB = b[sortBy]; + if (typeof valA === "string") valA = valA.toLowerCase(); + if (typeof valB === "string") valB = valB.toLowerCase(); + if (valA < valB) return sortOrder === "asc" ? -1 : 1; + if (valA > valB) return sortOrder === "asc" ? 1 : -1; + return 0; + }); +} + +function getGroupKey(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"; + case "endpoint": return item.endpoint || "Unknown Endpoint"; + default: return item[keyField] || "Unknown"; + } +} + +function groupDataByKey(data, keyField) { + if (!Array.isArray(data)) return []; + const groups = {}; + data.forEach((item) => { + const gk = getGroupKey(item, keyField); + if (!groups[gk]) { + groups[gk] = { + groupKey: gk, + summary: { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, cost: 0, inputCost: 0, outputCost: 0, lastUsed: null, pending: 0 }, + items: [], + }; + } + const s = groups[gk].summary; + s.requests += item.requests || 0; + s.promptTokens += item.promptTokens || 0; + s.completionTokens += item.completionTokens || 0; + s.totalTokens += item.totalTokens || 0; + s.cost += item.cost || 0; + s.inputCost += item.inputCost || 0; + s.outputCost += item.outputCost || 0; + s.pending += item.pending || 0; + if (item.lastUsed && (!s.lastUsed || new Date(item.lastUsed) > new Date(s.lastUsed))) { + s.lastUsed = item.lastUsed; + } + groups[gk].items.push(item); + }); + return Object.values(groups); +} + +const MODEL_COLUMNS = [ + { field: "rawModel", label: "Model" }, + { field: "provider", label: "Provider" }, + { field: "requests", label: "Requests", align: "right" }, + { field: "lastUsed", label: "Last Used", align: "right" }, +]; + +const ACCOUNT_COLUMNS = [ + { field: "rawModel", label: "Model" }, + { field: "provider", label: "Provider" }, + { field: "accountName", label: "Account" }, + { field: "requests", label: "Requests", align: "right" }, + { field: "lastUsed", label: "Last Used", align: "right" }, +]; + +const API_KEY_COLUMNS = [ + { field: "keyName", label: "API Key Name" }, + { field: "rawModel", label: "Model" }, + { field: "provider", label: "Provider" }, + { field: "requests", label: "Requests", align: "right" }, + { field: "lastUsed", label: "Last Used", align: "right" }, +]; + +const ENDPOINT_COLUMNS = [ + { field: "endpoint", label: "Endpoint" }, + { field: "rawModel", label: "Model" }, + { field: "provider", label: "Provider" }, + { field: "requests", label: "Requests", align: "right" }, + { field: "lastUsed", label: "Last Used", align: "right" }, +]; + +const TABLE_OPTIONS = [ + { value: "model", label: "Usage by Model" }, + { value: "account", label: "Usage by Account" }, + { value: "apiKey", label: "Usage by API Key" }, + { value: "endpoint", label: "Usage by Endpoint" }, +]; export default function UsageStats() { const router = useRouter(); const searchParams = useSearchParams(); - 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 endpointSortBy = searchParams.get("endpointSortBy") || "endpoint"; - const endpointSortOrder = searchParams.get("endpointSortOrder") || "asc"; + const sortBy = searchParams.get("sortBy") || "rawModel"; + const sortOrder = searchParams.get("sortOrder") || "asc"; const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); - const [autoRefresh, setAutoRefresh] = useState(true); - 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 [expandedEndpoints, setExpandedEndpoints] = useState(new Set()); + const [tableView, setTableView] = useState("model"); + const [providers, setProviders] = useState([]); - const toggleSort = (tableType, field) => { - const sortKeyMap = { - model: { by: "modelSortBy", order: "modelSortOrder" }, - account: { by: "accountSortBy", order: "accountSortOrder" }, - apiKey: { by: "apiKeySortBy", order: "apiKeySortOrder" }, - endpoint: { by: "endpointSortBy", order: "endpointSortOrder" } - }; - const sortKeys = sortKeyMap[tableType]; - const params = new URLSearchParams(searchParams.toString()); - - 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(sortKeys.by, field); - params.set(sortKeys.order, "asc"); - } - router.replace(`?${params.toString()}`, { scroll: false }); - }; - - 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; - - const inputCost = - totalTokens > 0 - ? (data.promptTokens || 0) * (totalCost / totalTokens) - : 0; - const outputCost = - totalTokens > 0 - ? (data.completionTokens || 0) * (totalCost / totalTokens) - : 0; - - return { - ...data, - key, - totalTokens, - totalCost, - inputCost, - outputCost, - pending: pendingMap[key] || 0, - }; + // Fetch connected providers once, deduplicate by provider type + useEffect(() => { + fetch("/api/providers") + .then((r) => r.ok ? r.json() : null) + .then((d) => { + if (!d?.connections) return; + const seen = new Set(); + const unique = d.connections.filter((c) => { + if (seen.has(c.provider)) return false; + seen.add(c.provider); + return true; + }); + setProviders(unique); }) - .sort((a, b) => { - let valA = a[sortBy]; - let valB = b[sortBy]; - - if (typeof valA === "string") valA = valA.toLowerCase(); - if (typeof valB === "string") valB = valB.toLowerCase(); - - if (valA < valB) return sortOrder === "asc" ? -1 : 1; - if (valA > valB) return sortOrder === "asc" ? 1 : -1; - return 0; - }); + .catch(() => {}); }, []); - /** - * 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'; - case 'endpoint': - return item.endpoint || 'Unknown Endpoint'; - default: - return item[keyField] || 'Unknown'; - } - }, []); + // SSE connection - no polling, event-driven + useEffect(() => { + console.log("[SSE CLIENT] connecting..."); + const es = new EventSource("/api/usage/stream"); - /** - * 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 []; + es.onopen = () => console.log("[SSE CLIENT] connected ✓"); - 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, modelSortBy, modelSortOrder), - [stats?.byModel, stats?.pending?.byModel, modelSortBy, modelSortOrder, sortData] - ); - - const groupedModels = useMemo( - () => groupDataByKey(sortedModels, 'rawModel'), - [sortedModels, groupDataByKey] - ); - const sortedAccounts = useMemo(() => { - const accountPendingMap = {}; - if (stats?.pending?.byAccount) { - Object.entries(stats.byAccount || {}).forEach(([accountKey, data]) => { - const connPending = stats.pending.byAccount[data.connectionId]; - if (connPending) { - const modelKey = data.provider - ? `${data.rawModel} (${data.provider})` - : data.rawModel; - accountPendingMap[accountKey] = connPending[modelKey] || 0; - } - }); - } - 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 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 { - const res = await fetch("/api/usage/history"); - if (res.ok) { - const data = await res.json(); + es.onmessage = (e) => { + try { + const data = JSON.parse(e.data); + console.log("[SSE CLIENT] message received | activeRequests:", data.activeRequests?.length || 0, "| providers:", data.activeRequests?.map(r => r.provider)); setStats(data); - - // Smart polling: adjust interval based on activity - const currentTotal = data.totalRequests || 0; - if (currentTotal > prevTotalRequests) { - // New requests detected - reset to fast polling - setRefreshInterval(5000); - } else { - // No change - increase interval (exponential backoff) - setRefreshInterval((prev) => Math.min(prev * 2, 60000)); // Max 60s - } - setPrevTotalRequests(currentTotal); - } - } catch (error) { - console.error("Failed to fetch usage stats:", error); - } finally { - if (showLoading) setLoading(false); - } - }, [prevTotalRequests]); - - useEffect(() => { - 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]); - - 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); - 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; - }); - }, []); - - 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; - - // Page Visibility API - pause when tab is hidden - const handleVisibilityChange = () => { - isPageVisible = !document.hidden; - if (isPageVisible && autoRefresh) { - fetchStats(false); // Fetch immediately when tab becomes visible + setLoading(false); + } catch (err) { + console.error("[SSE CLIENT] parse error:", err); } }; - document.addEventListener("visibilitychange", handleVisibilityChange); - - if (autoRefresh) { - // Clear any existing interval first - if (intervalId) clearInterval(intervalId); - - intervalId = setInterval(() => { - if (isPageVisible) { - fetchStats(false); // fetch without loading skeleton - } - }, refreshInterval); - } + es.onerror = (e) => { + console.error("[SSE CLIENT] error | readyState:", es.readyState, e); + setLoading(false); + }; return () => { - if (intervalId) clearInterval(intervalId); - document.removeEventListener("visibilitychange", handleVisibilityChange); + console.log("[SSE CLIENT] closing"); + es.close(); }; - }, [autoRefresh, refreshInterval, fetchStats]); + }, []); + + const toggleSort = useCallback((tableType, field) => { + const params = new URLSearchParams(searchParams.toString()); + if (params.get("sortBy") === field) { + params.set("sortOrder", params.get("sortOrder") === "asc" ? "desc" : "asc"); + } else { + params.set("sortBy", field); + params.set("sortOrder", "asc"); + } + router.replace(`?${params.toString()}`, { scroll: false }); + }, [searchParams, router]); + + // Compute active table data + const activeTableConfig = useMemo(() => { + if (!stats) return null; + switch (tableView) { + case "model": { + const pendingMap = stats.pending?.byModel || {}; + return { + columns: MODEL_COLUMNS, + groupedData: groupDataByKey(sortData(stats.byModel, pendingMap, sortBy, sortOrder), "rawModel"), + storageKey: "usage-stats:expanded-models", + emptyMessage: "No usage recorded yet.", + renderSummaryCells: (group) => ( + <> + — + {fmt(group.summary.requests)} + {fmtTime(group.summary.lastUsed)} + + ), + renderDetailCells: (item) => ( + <> + 0 ? "text-primary" : ""}`}>{item.rawModel} + 0 ? "primary" : "neutral"} size="sm">{item.provider} + {fmt(item.requests)} + {fmtTime(item.lastUsed)} + + ), + }; + } + case "account": { + const pendingMap = {}; + if (stats?.pending?.byAccount) { + Object.entries(stats.byAccount || {}).forEach(([accountKey, data]) => { + const connPending = stats.pending.byAccount[data.connectionId]; + if (connPending) { + const modelKey = data.provider ? `${data.rawModel} (${data.provider})` : data.rawModel; + pendingMap[accountKey] = connPending[modelKey] || 0; + } + }); + } + return { + columns: ACCOUNT_COLUMNS, + groupedData: groupDataByKey(sortData(stats.byAccount, pendingMap, sortBy, sortOrder), "accountName"), + storageKey: "usage-stats:expanded-accounts", + emptyMessage: "No account-specific usage recorded yet.", + renderSummaryCells: (group) => ( + <> + — + — + {fmt(group.summary.requests)} + {fmtTime(group.summary.lastUsed)} + + ), + renderDetailCells: (item) => ( + <> + 0 ? "text-primary" : ""}`}>{item.accountName || `Account ${item.connectionId?.slice(0, 8)}...`} + 0 ? "text-primary" : ""}`}>{item.rawModel} + 0 ? "primary" : "neutral"} size="sm">{item.provider} + {fmt(item.requests)} + {fmtTime(item.lastUsed)} + + ), + }; + } + case "apiKey": { + return { + columns: API_KEY_COLUMNS, + groupedData: groupDataByKey(sortData(stats.byApiKey, {}, sortBy, sortOrder), "keyName"), + storageKey: "usage-stats:expanded-apikeys", + emptyMessage: "No API key usage recorded yet.", + renderSummaryCells: (group) => ( + <> + — + — + {fmt(group.summary.requests)} + {fmtTime(group.summary.lastUsed)} + + ), + renderDetailCells: (item) => ( + <> + {item.keyName} + {item.rawModel} + {item.provider} + {fmt(item.requests)} + {fmtTime(item.lastUsed)} + + ), + }; + } + case "endpoint": + default: { + return { + columns: ENDPOINT_COLUMNS, + groupedData: groupDataByKey(sortData(stats.byEndpoint, {}, sortBy, sortOrder), "endpoint"), + storageKey: "usage-stats:expanded-endpoints", + emptyMessage: "No endpoint usage recorded yet.", + renderSummaryCells: (group) => ( + <> + — + — + {fmt(group.summary.requests)} + {fmtTime(group.summary.lastUsed)} + + ), + renderDetailCells: (item) => ( + <> + {item.endpoint} + {item.rawModel} + {item.provider} + {fmt(item.requests)} + {fmtTime(item.lastUsed)} + + ), + }; + } + } + }, [stats, tableView, sortBy, sortOrder]); if (loading) return ; - - if (!stats) - return ( -
Failed to load usage statistics.
- ); - - // Format number with commas - const fmt = (n) => new Intl.NumberFormat().format(n || 0); - - // Format cost with dollar sign and 2 decimals - const fmtCost = (n) => `$${(n || 0).toFixed(2)}`; - - // Time format for "Last Used" - const fmtTime = (iso) => { - if (!iso) return "Never"; - const date = new Date(iso); - const now = new Date(); - const diffMs = now - date; - const diffMins = Math.floor(diffMs / 60000); - - if (diffMins < 1) return "Just now"; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`; - return date.toLocaleDateString(); - }; + if (!stats) return
Failed to load usage statistics.
; return (
- {/* Header with Auto Refresh Toggle and View Toggle */} -
-

Usage Overview

-
- {/* View Toggle */} -
- - -
+ {/* Overview cards */} + - {/* Auto Refresh Toggle */} -
- Auto Refresh ({refreshInterval / 1000}s) - -
-
+ {/* Provider topology + Recent Requests */} +
+ +
- {/* Active Requests Summary */} - {(stats.activeRequests || []).length > 0 && ( - -
-
- - - - - Active Requests -
-
- {stats.activeRequests.map((req) => ( -
- {req.model} - | - {req.provider} - | - {req.account} - {req.count > 1 && ( - - x{req.count} - - )} -
- ))} -
-
-
- )} + {/* Token / Cost chart */} + - {/* Overview Cards */} -
- -
-
- - Total Requests - - - {fmt(stats.totalRequests)} - -
- m.requests)} - colorClass="bg-text-muted/30" - /> -
-
- -
-
- - Total Input Tokens - - - {fmt(stats.totalPromptTokens)} - -
- m.promptTokens)} - colorClass="bg-primary/50" - /> -
-
- -
-
- - Output Tokens - - - {fmt(stats.totalCompletionTokens)} - -
-
-
- - Total Cost - - - {fmtCost(stats.totalCost)} - -
-
- + {/* Table with dropdown selector */} +
+
+ +
+ {activeTableConfig && ( + + )}
- - {/* Usage by Model Table */} - -
-

Usage by Model

-
-
- - - - - - - - {viewMode === "tokens" ? ( - <> - - - - - ) : ( - <> - - - - - )} - - - - {groupedModels.map((group) => ( - - toggleModelGroup(group.groupKey)} - > - - - - - {viewMode === "tokens" ? ( - <> - - - - - ) : ( - <> - - - - - )} - - {expandedModels.has(group.groupKey) && group.items.map((item) => ( - - - - - - {viewMode === "tokens" ? ( - <> - - - - - ) : ( - <> - - - - - )} - - ))} - - ))} - {groupedModels.length === 0 && ( - - - - )} - -
toggleSort("model", "rawModel")} - > - Model{" "} - - toggleSort("model", "provider")} - > - Provider{" "} - - toggleSort("model", "requests")} - > - Requests{" "} - - toggleSort("model", "lastUsed")} - > - Last Used{" "} - - toggleSort("model", "promptTokens")} - > - Input Tokens{" "} - - toggleSort("model", "completionTokens")} - > - Output Tokens{" "} - - toggleSort("model", "totalTokens")} - > - Total Tokens{" "} - - toggleSort("model", "promptTokens")} - > - Input Cost{" "} - - toggleSort("model", "completionTokens")} - > - Output Cost{" "} - - toggleSort("model", "cost")} - > - Total Cost{" "} - -
-
- - chevron_right - - 0 ? "text-primary" : ""}`}> - {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)} -
0 ? "text-primary" : "" - }`} - > - {item.rawModel} - - 0 ? "primary" : "neutral"} - size="sm" - > - {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 usage recorded yet. Make some requests to see data here. -
-
-
- - {/* Usage by Account Table */} - -
-

Usage by Account

-
-
- - - - - - - - - {viewMode === "tokens" ? ( - <> - - - - - ) : ( - <> - - - - - )} - - - - {groupedAccounts.map((group) => ( - - toggleAccountGroup(group.groupKey)} - > - - - - - - {viewMode === "tokens" ? ( - <> - - - - - ) : ( - <> - - - - - )} - - {expandedAccounts.has(group.groupKey) && group.items.map((item) => ( - - - - - - - {viewMode === "tokens" ? ( - <> - - - - - ) : ( - <> - - - - - )} - - ))} - - ))} - {groupedAccounts.length === 0 && ( - - - - )} - -
toggleSort("account", "rawModel")} - > - Model{" "} - - toggleSort("account", "provider")} - > - Provider{" "} - - toggleSort("account", "accountName")} - > - Account{" "} - - toggleSort("account", "requests")} - > - Requests{" "} - - toggleSort("account", "lastUsed")} - > - Last Used{" "} - - toggleSort("account", "promptTokens")} - > - Input Tokens{" "} - - toggleSort("account", "completionTokens")} - > - Output Tokens{" "} - - toggleSort("account", "totalTokens")} - > - Total Tokens{" "} - - toggleSort("account", "promptTokens")} - > - Input Cost{" "} - - toggleSort("account", "completionTokens")} - > - Output Cost{" "} - - toggleSort("account", "cost")} - > - Total Cost{" "} - -
-
- - chevron_right - - 0 ? "text-primary" : ""}`}> - {group.groupKey} - -
-
{fmt(group.summary.requests)} - {fmtTime(group.summary.lastUsed)} - - {fmt(group.summary.totalTokens)} - - {fmtCost(group.summary.totalCost)} -
- 0 ? "text-primary" : "" - }`} - > - {item.accountName || - `Account ${item.connectionId?.slice(0, 8)}...`} - - 0 ? "text-primary" : "" - }`} - > - {item.rawModel} - - 0 ? "primary" : "neutral"} - size="sm" - > - {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 account-specific usage recorded yet. Make requests using - OAuth accounts to see data here. -
-
-
- - -
-

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. -
-
-
- - {/* 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. -
-
-
); } diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index fa37766..e2a2470 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -102,4 +102,4 @@ export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => { }, {}); // Providers that support usage/quota API -export const USAGE_SUPPORTED_PROVIDERS = ["antigravity", "kiro", "github", "codex", "claude"]; +export const USAGE_SUPPORTED_PROVIDERS = ["antigravity", "kiro", "github", "codex"];