diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 2d543f8..fd95a37 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -8,7 +8,7 @@ import { createRequestLogger } from "../utils/requestLogger.js"; import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js"; import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js"; import { handleBypassRequest } from "../utils/bypassHandler.js"; -import { saveRequestUsage } from "@/lib/usageDb.js"; +import { saveRequestUsage, trackPendingRequest } from "@/lib/usageDb.js"; /** * Extract usage from non-streaming response body @@ -122,6 +122,9 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred const providerUrl = buildProviderUrl(provider, model, stream); const providerHeaders = buildProviderHeaders(provider, credentials, stream, translatedBody); + // Track pending request + trackPendingRequest(model, provider, connectionId, true); + // 2. Log converted request to provider reqLogger.logConvertedRequest(providerUrl, providerHeaders, translatedBody); @@ -155,6 +158,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred signal: streamController.signal }); } catch (error) { + trackPendingRequest(model, provider, connectionId, false); if (error.name === "AbortError") { streamController.handleError(error); return createErrorResult(499, "Request aborted"); @@ -248,6 +252,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Check provider response - return error info for fallback handling if (!providerResponse.ok) { + trackPendingRequest(model, provider, connectionId, false); const { statusCode, message } = await parseUpstreamError(providerResponse); const errMsg = formatProviderError(new Error(message), provider, model, statusCode); console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`); @@ -260,6 +265,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Non-streaming response if (!stream) { + trackPendingRequest(model, provider, connectionId, false); const responseBody = await providerResponse.json(); // Notify success - caller can clear error status if needed diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index f052bc7..678a5e4 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -1,6 +1,6 @@ import { translateResponse, initState } from "../translator/index.js"; import { FORMATS } from "../translator/formats.js"; -import { saveRequestUsage } from "@/lib/usageDb.js"; +import { saveRequestUsage, trackPendingRequest } from "@/lib/usageDb.js"; // Get HH:MM:SS timestamp function getTimeString() { @@ -220,6 +220,7 @@ export function createSSEStream(options = {}) { }, flush(controller) { + trackPendingRequest(model, provider, connectionId, false); try { const remaining = decoder.decode(); if (remaining) buffer += remaining; diff --git a/src/lib/usageDb.js b/src/lib/usageDb.js index 62fd899..b3289b4 100644 --- a/src/lib/usageDb.js +++ b/src/lib/usageDb.js @@ -49,6 +49,35 @@ const defaultData = { // Singleton instance let dbInstance = null; +// Track in-flight requests in memory +const pendingRequests = { + byModel: {}, + byAccount: {} +}; + +/** + * Track a pending request + * @param {string} model + * @param {string} provider + * @param {string} connectionId + * @param {boolean} started - true if started, false if finished + */ +export function trackPendingRequest(model, provider, connectionId, started) { + const modelKey = provider ? `${model} (${provider})` : model; + + // Track by model + if (!pendingRequests.byModel[modelKey]) pendingRequests.byModel[modelKey] = 0; + pendingRequests.byModel[modelKey] = Math.max(0, pendingRequests.byModel[modelKey] + (started ? 1 : -1)); + + // Track by account + if (connectionId) { + const accountKey = connectionId; // We use connectionId as key here + 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)); + } +} + /** * Get usage database instance (singleton) */ @@ -170,9 +199,31 @@ export async function getUsageStats() { byProvider: {}, byModel: {}, byAccount: {}, - last10Minutes: [] + last10Minutes: [], + pending: pendingRequests, + activeRequests: [] }; + // Build active requests list from pending counts + for (const [connectionId, models] of Object.entries(pendingRequests.byAccount)) { + for (const [modelKey, count] of Object.entries(models)) { + if (count > 0) { + const accountName = connectionMap[connectionId] || `Account ${connectionId.slice(0, 8)}...`; + // modelKey is "model (provider)" + const match = modelKey.match(/^(.*) \((.*)\)$/); + const modelName = match ? match[1] : modelKey; + const providerName = match ? match[2] : "unknown"; + + stats.activeRequests.push({ + model: modelName, + provider: providerName, + account: accountName, + count + }); + } + } + } + // Initialize 10-minute buckets using stable minute boundaries const now = new Date(); // Floor to the start of the current minute @@ -232,12 +283,16 @@ export async function getUsageStats() { promptTokens: 0, completionTokens: 0, rawModel: entry.model, - provider: entry.provider + provider: entry.provider, + lastUsed: entry.timestamp }; } stats.byModel[modelKey].requests++; stats.byModel[modelKey].promptTokens += promptTokens; stats.byModel[modelKey].completionTokens += completionTokens; + if (new Date(entry.timestamp) > new Date(stats.byModel[modelKey].lastUsed)) { + stats.byModel[modelKey].lastUsed = entry.timestamp; + } // By Account (model + oauth account) // Use connectionId if available, otherwise fallback to provider name @@ -253,12 +308,16 @@ export async function getUsageStats() { rawModel: entry.model, provider: entry.provider, connectionId: entry.connectionId, - accountName: accountName + accountName: accountName, + lastUsed: entry.timestamp }; } stats.byAccount[accountKey].requests++; stats.byAccount[accountKey].promptTokens += promptTokens; stats.byAccount[accountKey].completionTokens += completionTokens; + if (new Date(entry.timestamp) > new Date(stats.byAccount[accountKey].lastUsed)) { + stats.byAccount[accountKey].lastUsed = entry.timestamp; + } } } diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index 4a692d1..76c2686 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -36,7 +36,7 @@ export default function UsageStats() { const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); - const [autoRefresh, setAutoRefresh] = useState(false); + const [autoRefresh, setAutoRefresh] = useState(true); const toggleSort = (field) => { const params = new URLSearchParams(searchParams.toString()); @@ -49,12 +49,13 @@ export default function UsageStats() { router.replace(`?${params.toString()}`, { scroll: false }); }; - const sortData = (dataMap) => { + const sortData = (dataMap, pendingMap = {}) => { return Object.entries(dataMap || {}) .map(([key, data]) => ({ ...data, key, totalTokens: (data.promptTokens || 0) + (data.completionTokens || 0), + pending: pendingMap[key] || 0, })) .sort((a, b) => { let valA = a[sortBy]; @@ -71,13 +72,27 @@ export default function UsageStats() { }; const sortedModels = useMemo( - () => sortData(stats?.byModel), - [stats?.byModel, sortBy, sortOrder] - ); - const sortedAccounts = useMemo( - () => sortData(stats?.byAccount), - [stats?.byAccount, sortBy, sortOrder] + () => sortData(stats?.byModel, stats?.pending?.byModel), + [stats?.byModel, stats?.pending?.byModel, sortBy, sortOrder] ); + const sortedAccounts = useMemo(() => { + // For accounts, pendingMap is by connectionId, but dataMap is by accountKey + // We need to map connectionId pending counts to accountKeys + const accountPendingMap = {}; + if (stats?.pending?.byAccount) { + Object.entries(stats.byAccount || {}).forEach(([accountKey, data]) => { + const connPending = stats.pending.byAccount[data.connectionId]; + if (connPending) { + // Get modelKey (rawModel (provider)) + const modelKey = data.provider + ? `${data.rawModel} (${data.provider})` + : data.rawModel; + accountPendingMap[accountKey] = connPending[modelKey] || 0; + } + }); + } + return sortData(stats?.byAccount, accountPendingMap); + }, [stats?.byAccount, stats?.pending?.byAccount, sortBy, sortOrder]); useEffect(() => { fetchStats(); @@ -118,6 +133,20 @@ export default function UsageStats() { // Format number with commas const fmt = (n) => new Intl.NumberFormat().format(n || 0); + // 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(); + }; + return (