mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat: implement usage tracking for AI requests
Adds local token usage tracking for all AI providers. Usage data is captured during stream processing and stored in a local database. Includes a new Usage tab in the Providers dashboard to visualize historical token consumption. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
decolua
co-authored by
Claude Sonnet 4.5
parent
5645d0a0fb
commit
9c3d6f4ad8
@@ -8,6 +8,46 @@ import { createRequestLogger } from "../utils/requestLogger.js";
|
|||||||
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
||||||
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
|
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
|
||||||
import { handleBypassRequest } from "../utils/bypassHandler.js";
|
import { handleBypassRequest } from "../utils/bypassHandler.js";
|
||||||
|
import { saveRequestUsage } from "@/lib/usageDb.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract usage from non-streaming response body
|
||||||
|
* Handles different provider response formats
|
||||||
|
*/
|
||||||
|
function extractUsageFromResponse(responseBody, provider) {
|
||||||
|
if (!responseBody) return null;
|
||||||
|
|
||||||
|
// OpenAI format
|
||||||
|
if (responseBody.usage) {
|
||||||
|
return {
|
||||||
|
prompt_tokens: responseBody.usage.prompt_tokens || 0,
|
||||||
|
completion_tokens: responseBody.usage.completion_tokens || 0,
|
||||||
|
cached_tokens: responseBody.usage.prompt_tokens_details?.cached_tokens,
|
||||||
|
reasoning_tokens: responseBody.usage.completion_tokens_details?.reasoning_tokens
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude format
|
||||||
|
if (responseBody.usage?.input_tokens !== undefined || responseBody.usage?.output_tokens !== undefined) {
|
||||||
|
return {
|
||||||
|
prompt_tokens: responseBody.usage.input_tokens || 0,
|
||||||
|
completion_tokens: responseBody.usage.output_tokens || 0,
|
||||||
|
cache_read_input_tokens: responseBody.usage.cache_read_input_tokens,
|
||||||
|
cache_creation_input_tokens: responseBody.usage.cache_creation_input_tokens
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini format
|
||||||
|
if (responseBody.usageMetadata) {
|
||||||
|
return {
|
||||||
|
prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0,
|
||||||
|
completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0,
|
||||||
|
reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core chat handler - shared between SSE and Worker
|
* Core chat handler - shared between SSE and Worker
|
||||||
@@ -20,8 +60,9 @@ import { handleBypassRequest } from "../utils/bypassHandler.js";
|
|||||||
* @param {function} options.onCredentialsRefreshed - Callback when credentials are refreshed
|
* @param {function} options.onCredentialsRefreshed - Callback when credentials are refreshed
|
||||||
* @param {function} options.onRequestSuccess - Callback when request succeeds (to clear error status)
|
* @param {function} options.onRequestSuccess - Callback when request succeeds (to clear error status)
|
||||||
* @param {function} options.onDisconnect - Callback when client disconnects
|
* @param {function} options.onDisconnect - Callback when client disconnects
|
||||||
|
* @param {string} options.connectionId - Connection ID for usage tracking
|
||||||
*/
|
*/
|
||||||
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest }) {
|
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId }) {
|
||||||
const { provider, model } = modelInfo;
|
const { provider, model } = modelInfo;
|
||||||
|
|
||||||
const sourceFormat = detectFormat(body);
|
const sourceFormat = detectFormat(body);
|
||||||
@@ -220,12 +261,29 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
// Non-streaming response
|
// Non-streaming response
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
const responseBody = await providerResponse.json();
|
const responseBody = await providerResponse.json();
|
||||||
|
|
||||||
// Notify success - caller can clear error status if needed
|
// Notify success - caller can clear error status if needed
|
||||||
if (onRequestSuccess) {
|
if (onRequestSuccess) {
|
||||||
await onRequestSuccess();
|
await onRequestSuccess();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log usage for non-streaming responses
|
||||||
|
const usage = extractUsageFromResponse(responseBody, provider);
|
||||||
|
if (usage) {
|
||||||
|
const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider.toUpperCase()} | in=${usage.prompt_tokens || 0} | out=${usage.completion_tokens || 0}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`;
|
||||||
|
console.log(`${COLORS.green}${msg}${COLORS.reset}`);
|
||||||
|
|
||||||
|
saveRequestUsage({
|
||||||
|
provider: provider || "unknown",
|
||||||
|
model: model || "unknown",
|
||||||
|
tokens: usage,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
connectionId: connectionId || undefined
|
||||||
|
}).catch(err => {
|
||||||
|
console.error("Failed to save usage stats:", err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
response: new Response(JSON.stringify(responseBody), {
|
response: new Response(JSON.stringify(responseBody), {
|
||||||
@@ -254,9 +312,9 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
// Create transform stream with logger for streaming response
|
// Create transform stream with logger for streaming response
|
||||||
let transformStream;
|
let transformStream;
|
||||||
if (needsTranslation(targetFormat, sourceFormat)) {
|
if (needsTranslation(targetFormat, sourceFormat)) {
|
||||||
transformStream = createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap);
|
transformStream = createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId);
|
||||||
} else {
|
} else {
|
||||||
transformStream = createPassthroughStreamWithLogger(provider, reqLogger);
|
transformStream = createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pipe response through transform with disconnect detection
|
// Pipe response through transform with disconnect detection
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ import { createResponsesApiTransformStream } from "../transformer/responsesTrans
|
|||||||
* @param {function} options.onCredentialsRefreshed - Callback when credentials are refreshed
|
* @param {function} options.onCredentialsRefreshed - Callback when credentials are refreshed
|
||||||
* @param {function} options.onRequestSuccess - Callback when request succeeds
|
* @param {function} options.onRequestSuccess - Callback when request succeeds
|
||||||
* @param {function} options.onDisconnect - Callback when client disconnects
|
* @param {function} options.onDisconnect - Callback when client disconnects
|
||||||
|
* @param {string} options.connectionId - Connection ID for usage tracking
|
||||||
* @returns {Promise<{success: boolean, response?: Response, status?: number, error?: string}>}
|
* @returns {Promise<{success: boolean, response?: Response, status?: number, error?: string}>}
|
||||||
*/
|
*/
|
||||||
export async function handleResponsesCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect }) {
|
export async function handleResponsesCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, connectionId }) {
|
||||||
// Convert Responses API format to Chat Completions format
|
// Convert Responses API format to Chat Completions format
|
||||||
const convertedBody = convertResponsesApiFormat(body);
|
const convertedBody = convertResponsesApiFormat(body);
|
||||||
|
|
||||||
@@ -34,7 +35,8 @@ export async function handleResponsesCore({ body, modelInfo, credentials, log, o
|
|||||||
log,
|
log,
|
||||||
onCredentialsRefreshed,
|
onCredentialsRefreshed,
|
||||||
onRequestSuccess,
|
onRequestSuccess,
|
||||||
onDisconnect
|
onDisconnect,
|
||||||
|
connectionId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success || !result.response) {
|
if (!result.success || !result.response) {
|
||||||
|
|||||||
+52
-32
@@ -1,5 +1,6 @@
|
|||||||
import { translateResponse, initState } from "../translator/index.js";
|
import { translateResponse, initState } from "../translator/index.js";
|
||||||
import { FORMATS } from "../translator/formats.js";
|
import { FORMATS } from "../translator/formats.js";
|
||||||
|
import { saveRequestUsage } from "@/lib/usageDb.js";
|
||||||
|
|
||||||
// Get HH:MM:SS timestamp
|
// Get HH:MM:SS timestamp
|
||||||
function getTimeString() {
|
function getTimeString() {
|
||||||
@@ -48,27 +49,39 @@ export const COLORS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Log usage with cache info (green color)
|
// Log usage with cache info (green color)
|
||||||
function logUsage(provider, usage) {
|
function logUsage(provider, usage, model = null, connectionId = null) {
|
||||||
if (!usage) return;
|
if (!usage) return;
|
||||||
|
|
||||||
const p = provider?.toUpperCase() || "UNKNOWN";
|
const p = provider?.toUpperCase() || "UNKNOWN";
|
||||||
const inTokens = usage.prompt_tokens || 0;
|
const inTokens = usage.prompt_tokens || 0;
|
||||||
const outTokens = usage.completion_tokens || 0;
|
const outTokens = usage.completion_tokens || 0;
|
||||||
|
|
||||||
let msg = `[${getTimeString()}] 📊 [USAGE] ${p} | in=${inTokens} | out=${outTokens}`;
|
let msg = `[${getTimeString()}] 📊 [USAGE] ${p} | in=${inTokens} | out=${outTokens}`;
|
||||||
|
if (connectionId) msg += ` | account=${connectionId.slice(0, 8)}...`;
|
||||||
|
|
||||||
if (usage.cache_creation_input_tokens) msg += ` | cache_write=${usage.cache_creation_input_tokens}`;
|
if (usage.cache_creation_input_tokens) msg += ` | cache_write=${usage.cache_creation_input_tokens}`;
|
||||||
if (usage.cache_read_input_tokens) msg += ` | cache_read=${usage.cache_read_input_tokens}`;
|
if (usage.cache_read_input_tokens) msg += ` | cache_read=${usage.cache_read_input_tokens}`;
|
||||||
if (usage.cached_tokens) msg += ` | cached=${usage.cached_tokens}`;
|
if (usage.cached_tokens) msg += ` | cached=${usage.cached_tokens}`;
|
||||||
if (usage.reasoning_tokens) msg += ` | reasoning=${usage.reasoning_tokens}`;
|
if (usage.reasoning_tokens) msg += ` | reasoning=${usage.reasoning_tokens}`;
|
||||||
|
|
||||||
console.log(`${COLORS.green}${msg}${COLORS.reset}`);
|
console.log(`${COLORS.green}${msg}${COLORS.reset}`);
|
||||||
|
|
||||||
|
// Save to DB
|
||||||
|
saveRequestUsage({
|
||||||
|
provider: provider || "unknown",
|
||||||
|
model: model || "unknown",
|
||||||
|
tokens: usage,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
connectionId: connectionId || undefined
|
||||||
|
}).catch(err => {
|
||||||
|
console.error("Failed to save usage stats:", err.message);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse SSE data line
|
// Parse SSE data line
|
||||||
function parseSSELine(line) {
|
function parseSSELine(line) {
|
||||||
if (!line || !line.startsWith("data:")) return null;
|
if (!line || !line.startsWith("data:")) return null;
|
||||||
|
|
||||||
const data = line.slice(5).trim();
|
const data = line.slice(5).trim();
|
||||||
if (data === "[DONE]") return { done: true };
|
if (data === "[DONE]") return { done: true };
|
||||||
|
|
||||||
@@ -91,17 +104,17 @@ function parseSSELine(line) {
|
|||||||
*/
|
*/
|
||||||
export function formatSSE(data, sourceFormat) {
|
export function formatSSE(data, sourceFormat) {
|
||||||
if (data.done) return "data: [DONE]\n\n";
|
if (data.done) return "data: [DONE]\n\n";
|
||||||
|
|
||||||
// OpenAI Responses API format: has event field
|
// OpenAI Responses API format: has event field
|
||||||
if (data.event && data.data) {
|
if (data.event && data.data) {
|
||||||
return `event: ${data.event}\ndata: ${JSON.stringify(data.data)}\n\n`;
|
return `event: ${data.event}\ndata: ${JSON.stringify(data.data)}\n\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claude format: include event prefix
|
// Claude format: include event prefix
|
||||||
if (sourceFormat === FORMATS.CLAUDE && data.type) {
|
if (sourceFormat === FORMATS.CLAUDE && data.type) {
|
||||||
return `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`;
|
return `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `data: ${JSON.stringify(data)}\n\n`;
|
return `data: ${JSON.stringify(data)}\n\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,22 +134,26 @@ const STREAM_MODE = {
|
|||||||
* @param {string} options.sourceFormat - Client format (for translate mode)
|
* @param {string} options.sourceFormat - Client format (for translate mode)
|
||||||
* @param {string} options.provider - Provider name
|
* @param {string} options.provider - Provider name
|
||||||
* @param {object} options.reqLogger - Request logger instance
|
* @param {object} options.reqLogger - Request logger instance
|
||||||
|
* @param {string} options.model - Model name
|
||||||
|
* @param {string} options.connectionId - Connection ID for usage tracking
|
||||||
*/
|
*/
|
||||||
export function createSSEStream(options = {}) {
|
export function createSSEStream(options = {}) {
|
||||||
const {
|
const {
|
||||||
mode = STREAM_MODE.TRANSLATE,
|
mode = STREAM_MODE.TRANSLATE,
|
||||||
targetFormat,
|
targetFormat,
|
||||||
sourceFormat,
|
sourceFormat,
|
||||||
provider = null,
|
provider = null,
|
||||||
reqLogger = null,
|
reqLogger = null,
|
||||||
toolNameMap = null
|
toolNameMap = null,
|
||||||
|
model = null,
|
||||||
|
connectionId = null
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
let usage = null;
|
let usage = null;
|
||||||
|
|
||||||
// State for translate mode
|
// State for translate mode
|
||||||
const state = mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider, toolNameMap } : null;
|
const state = mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider, toolNameMap } : null;
|
||||||
|
|
||||||
@@ -151,7 +168,7 @@ export function createSSEStream(options = {}) {
|
|||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
|
|
||||||
// Passthrough mode: normalize and forward
|
// Passthrough mode: normalize and forward
|
||||||
if (mode === STREAM_MODE.PASSTHROUGH) {
|
if (mode === STREAM_MODE.PASSTHROUGH) {
|
||||||
if (trimmed.startsWith("data:") && trimmed.slice(5).trim() !== "[DONE]") {
|
if (trimmed.startsWith("data:") && trimmed.slice(5).trim() !== "[DONE]") {
|
||||||
@@ -216,7 +233,7 @@ export function createSSEStream(options = {}) {
|
|||||||
reqLogger?.appendConvertedChunk?.(output);
|
reqLogger?.appendConvertedChunk?.(output);
|
||||||
controller.enqueue(encoder.encode(output));
|
controller.enqueue(encoder.encode(output));
|
||||||
}
|
}
|
||||||
if (usage) logUsage(provider, usage);
|
if (usage) logUsage(provider, usage, model, connectionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +267,7 @@ export function createSSEStream(options = {}) {
|
|||||||
reqLogger?.appendConvertedChunk?.(doneOutput);
|
reqLogger?.appendConvertedChunk?.(doneOutput);
|
||||||
controller.enqueue(encoder.encode(doneOutput));
|
controller.enqueue(encoder.encode(doneOutput));
|
||||||
|
|
||||||
if (state?.usage) logUsage(state.provider || targetFormat, state.usage);
|
if (state?.usage) logUsage(state.provider || targetFormat, state.usage, model, connectionId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error in flush:", error);
|
console.log("Error in flush:", error);
|
||||||
}
|
}
|
||||||
@@ -259,22 +276,25 @@ export function createSSEStream(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convenience functions for backward compatibility
|
// Convenience functions for backward compatibility
|
||||||
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null) {
|
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null) {
|
||||||
return createSSEStream({
|
return createSSEStream({
|
||||||
mode: STREAM_MODE.TRANSLATE,
|
mode: STREAM_MODE.TRANSLATE,
|
||||||
targetFormat,
|
targetFormat,
|
||||||
sourceFormat,
|
sourceFormat,
|
||||||
provider,
|
provider,
|
||||||
reqLogger,
|
reqLogger,
|
||||||
toolNameMap
|
toolNameMap,
|
||||||
|
model,
|
||||||
|
connectionId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPassthroughStreamWithLogger(provider = null, reqLogger = null) {
|
export function createPassthroughStreamWithLogger(provider = null, reqLogger = null, model = null, connectionId = null) {
|
||||||
return createSSEStream({
|
return createSSEStream({
|
||||||
mode: STREAM_MODE.PASSTHROUGH,
|
mode: STREAM_MODE.PASSTHROUGH,
|
||||||
provider,
|
provider,
|
||||||
reqLogger
|
reqLogger,
|
||||||
|
model,
|
||||||
|
connectionId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+6775
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"description": "9Router web dashboard",
|
"description": "9Router web dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev --port 3091",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Card, CardSkeleton, Badge } from "@/shared/components";
|
import { Card, CardSkeleton, Badge, UsageStats } from "@/shared/components";
|
||||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
||||||
|
|
||||||
export default function ProvidersPage() {
|
export default function ProvidersPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState("connections");
|
||||||
const [connections, setConnections] = useState([]);
|
const [connections, setConnections] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -31,33 +31,33 @@ export default function ProvidersPage() {
|
|||||||
const providerConnections = connections.filter(
|
const providerConnections = connections.filter(
|
||||||
c => c.provider === providerId && c.authType === authType
|
c => c.provider === providerId && c.authType === authType
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper: check if connection is effectively active (cooldown expired)
|
// Helper: check if connection is effectively active (cooldown expired)
|
||||||
const getEffectiveStatus = (conn) => {
|
const getEffectiveStatus = (conn) => {
|
||||||
const isCooldown = conn.rateLimitedUntil && new Date(conn.rateLimitedUntil).getTime() > Date.now();
|
const isCooldown = conn.rateLimitedUntil && new Date(conn.rateLimitedUntil).getTime() > Date.now();
|
||||||
return (conn.testStatus === "unavailable" && !isCooldown) ? "active" : conn.testStatus;
|
return (conn.testStatus === "unavailable" && !isCooldown) ? "active" : conn.testStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
const connected = providerConnections.filter(c => {
|
const connected = providerConnections.filter(c => {
|
||||||
const status = getEffectiveStatus(c);
|
const status = getEffectiveStatus(c);
|
||||||
return status === "active" || status === "success";
|
return status === "active" || status === "success";
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
const errorConns = providerConnections.filter(c => {
|
const errorConns = providerConnections.filter(c => {
|
||||||
const status = getEffectiveStatus(c);
|
const status = getEffectiveStatus(c);
|
||||||
return status === "error" || status === "expired" || status === "unavailable";
|
return status === "error" || status === "expired" || status === "unavailable";
|
||||||
});
|
});
|
||||||
|
|
||||||
const error = errorConns.length;
|
const error = errorConns.length;
|
||||||
const total = providerConnections.length;
|
const total = providerConnections.length;
|
||||||
|
|
||||||
// Get latest error info
|
// Get latest error info
|
||||||
const latestError = errorConns.sort((a, b) =>
|
const latestError = errorConns.sort((a, b) =>
|
||||||
new Date(b.lastErrorAt || 0) - new Date(a.lastErrorAt || 0)
|
new Date(b.lastErrorAt || 0) - new Date(a.lastErrorAt || 0)
|
||||||
)[0];
|
)[0];
|
||||||
const errorCode = latestError ? getErrorCode(latestError.lastError) : null;
|
const errorCode = latestError ? getErrorCode(latestError.lastError) : null;
|
||||||
const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null;
|
const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null;
|
||||||
|
|
||||||
return { connected, error, total, errorCode, errorTime };
|
return { connected, error, total, errorCode, errorTime };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,36 +71,66 @@ export default function ProvidersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-6">
|
||||||
{/* OAuth Providers */}
|
{/* Tabs */}
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex border-b border-border">
|
||||||
<h2 className="text-xl font-semibold">OAuth Providers</h2>
|
<button
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
onClick={() => setActiveTab("connections")}
|
||||||
{Object.entries(OAUTH_PROVIDERS).map(([key, info]) => (
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
<ProviderCard
|
activeTab === "connections"
|
||||||
key={key}
|
? "border-primary text-primary"
|
||||||
providerId={key}
|
: "border-transparent text-text-muted hover:text-text-primary"
|
||||||
provider={info}
|
}`}
|
||||||
stats={getProviderStats(key, "oauth")}
|
>
|
||||||
/>
|
Connections
|
||||||
))}
|
</button>
|
||||||
</div>
|
<button
|
||||||
|
onClick={() => setActiveTab("usage")}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === "usage"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-text-muted hover:text-text-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Usage
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Key Providers */}
|
{activeTab === "usage" ? (
|
||||||
<div className="flex flex-col gap-4">
|
<UsageStats />
|
||||||
<h2 className="text-xl font-semibold">API Key Providers</h2>
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<>
|
||||||
{Object.entries(APIKEY_PROVIDERS).map(([key, info]) => (
|
{/* OAuth Providers */}
|
||||||
<ApiKeyProviderCard
|
<div className="flex flex-col gap-4">
|
||||||
key={key}
|
<h2 className="text-xl font-semibold">OAuth Providers</h2>
|
||||||
providerId={key}
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
provider={info}
|
{Object.entries(OAUTH_PROVIDERS).map(([key, info]) => (
|
||||||
stats={getProviderStats(key, "apikey")}
|
<ProviderCard
|
||||||
/>
|
key={key}
|
||||||
))}
|
providerId={key}
|
||||||
</div>
|
provider={info}
|
||||||
</div>
|
stats={getProviderStats(key, "oauth")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Key Providers */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-xl font-semibold">API Key Providers</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{Object.entries(APIKEY_PROVIDERS).map(([key, info]) => (
|
||||||
|
<ApiKeyProviderCard
|
||||||
|
key={key}
|
||||||
|
providerId={key}
|
||||||
|
provider={info}
|
||||||
|
stats={getProviderStats(key, "apikey")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getUsageStats } from "@/lib/usageDb";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const stats = await getUsageStats();
|
||||||
|
return NextResponse.json(stats);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching usage stats:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch usage stats" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { Low } from "lowdb";
|
||||||
|
import { JSONFile } from "lowdb/node";
|
||||||
|
import path from "path";
|
||||||
|
import os from "os";
|
||||||
|
import fs from "fs";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
// Get app name from root package.json config
|
||||||
|
function getAppName() {
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
// Look for root package.json (monorepo root)
|
||||||
|
const rootPkgPath = path.resolve(__dirname, "../../../package.json");
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(rootPkgPath, "utf-8"));
|
||||||
|
return pkg.config?.appName || "9router";
|
||||||
|
} catch {
|
||||||
|
return "9router";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user data directory based on platform
|
||||||
|
function getUserDataDir() {
|
||||||
|
const platform = process.platform;
|
||||||
|
const homeDir = os.homedir();
|
||||||
|
const appName = getAppName();
|
||||||
|
|
||||||
|
if (platform === "win32") {
|
||||||
|
return path.join(process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), appName);
|
||||||
|
} else {
|
||||||
|
// macOS & Linux: ~/.{appName}
|
||||||
|
return path.join(homeDir, `.${appName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data file path - stored in user home directory
|
||||||
|
const DATA_DIR = getUserDataDir();
|
||||||
|
const DB_FILE = path.join(DATA_DIR, "usage.json");
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
if (!fs.existsSync(DATA_DIR)) {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default data structure
|
||||||
|
const defaultData = {
|
||||||
|
history: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
let dbInstance = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get usage database instance (singleton)
|
||||||
|
*/
|
||||||
|
export async function getUsageDb() {
|
||||||
|
if (!dbInstance) {
|
||||||
|
const adapter = new JSONFile(DB_FILE);
|
||||||
|
dbInstance = new Low(adapter, defaultData);
|
||||||
|
|
||||||
|
// Try to read DB with error recovery for corrupt JSON
|
||||||
|
try {
|
||||||
|
await dbInstance.read();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
console.warn('[DB] Corrupt Usage JSON detected, resetting to defaults...');
|
||||||
|
dbInstance.data = defaultData;
|
||||||
|
await dbInstance.write();
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize with default data if empty
|
||||||
|
if (!dbInstance.data) {
|
||||||
|
dbInstance.data = defaultData;
|
||||||
|
await dbInstance.write();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dbInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save request usage
|
||||||
|
* @param {object} entry - Usage entry { provider, model, tokens: { prompt_tokens, completion_tokens, ... }, connectionId? }
|
||||||
|
*/
|
||||||
|
export async function saveRequestUsage(entry) {
|
||||||
|
try {
|
||||||
|
const db = await getUsageDb();
|
||||||
|
|
||||||
|
// Add timestamp if not present
|
||||||
|
if (!entry.timestamp) {
|
||||||
|
entry.timestamp = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure history array exists
|
||||||
|
if (!Array.isArray(db.data.history)) {
|
||||||
|
db.data.history = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save usage stats:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get usage history
|
||||||
|
* @param {object} filter - Filter criteria
|
||||||
|
*/
|
||||||
|
export async function getUsageHistory(filter = {}) {
|
||||||
|
const db = await getUsageDb();
|
||||||
|
let history = db.data.history || [];
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if (filter.provider) {
|
||||||
|
history = history.filter(h => h.provider === filter.provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.model) {
|
||||||
|
history = history.filter(h => h.model === filter.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.startDate) {
|
||||||
|
const start = new Date(filter.startDate).getTime();
|
||||||
|
history = history.filter(h => new Date(h.timestamp).getTime() >= start);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.endDate) {
|
||||||
|
const end = new Date(filter.endDate).getTime();
|
||||||
|
history = history.filter(h => new Date(h.timestamp).getTime() <= end);
|
||||||
|
}
|
||||||
|
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get aggregated usage stats
|
||||||
|
*/
|
||||||
|
export async function getUsageStats() {
|
||||||
|
const db = await getUsageDb();
|
||||||
|
const history = db.data.history || [];
|
||||||
|
|
||||||
|
// Import localDb to get provider connection names
|
||||||
|
const { getProviderConnections } = await import("@/lib/localDb.js");
|
||||||
|
|
||||||
|
// Fetch all provider connections to get account names
|
||||||
|
let allConnections = [];
|
||||||
|
try {
|
||||||
|
allConnections = await getProviderConnections();
|
||||||
|
} catch (error) {
|
||||||
|
// If localDb is not available (e.g., in some environments), continue without account names
|
||||||
|
console.warn("Could not fetch provider connections for usage stats:", error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map from connectionId to account name
|
||||||
|
const connectionMap = {};
|
||||||
|
for (const conn of allConnections) {
|
||||||
|
connectionMap[conn.id] = conn.name || conn.email || conn.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
totalRequests: history.length,
|
||||||
|
totalPromptTokens: 0,
|
||||||
|
totalCompletionTokens: 0,
|
||||||
|
byProvider: {},
|
||||||
|
byModel: {},
|
||||||
|
byAccount: {},
|
||||||
|
last10Minutes: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize 10-minute buckets using stable minute boundaries
|
||||||
|
const now = new Date();
|
||||||
|
// Floor to the start of the current minute
|
||||||
|
const currentMinuteStart = new Date(Math.floor(now.getTime() / 60000) * 60000);
|
||||||
|
const tenMinutesAgo = new Date(currentMinuteStart.getTime() - 9 * 60 * 1000);
|
||||||
|
|
||||||
|
// Create buckets keyed by minute timestamp for stable lookups
|
||||||
|
const bucketMap = {};
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const bucketTime = new Date(currentMinuteStart.getTime() - (9 - i) * 60 * 1000);
|
||||||
|
const bucketKey = bucketTime.getTime();
|
||||||
|
bucketMap[bucketKey] = {
|
||||||
|
requests: 0,
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 0
|
||||||
|
};
|
||||||
|
stats.last10Minutes.push(bucketMap[bucketKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of history) {
|
||||||
|
const promptTokens = entry.tokens?.prompt_tokens || 0;
|
||||||
|
const completionTokens = entry.tokens?.completion_tokens || 0;
|
||||||
|
const entryTime = new Date(entry.timestamp);
|
||||||
|
|
||||||
|
stats.totalPromptTokens += promptTokens;
|
||||||
|
stats.totalCompletionTokens += completionTokens;
|
||||||
|
|
||||||
|
// Last 10 minutes aggregation - floor entry time to its minute
|
||||||
|
if (entryTime >= tenMinutesAgo && entryTime <= now) {
|
||||||
|
const entryMinuteStart = Math.floor(entryTime.getTime() / 60000) * 60000;
|
||||||
|
if (bucketMap[entryMinuteStart]) {
|
||||||
|
bucketMap[entryMinuteStart].requests++;
|
||||||
|
bucketMap[entryMinuteStart].promptTokens += promptTokens;
|
||||||
|
bucketMap[entryMinuteStart].completionTokens += completionTokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// By Provider
|
||||||
|
if (!stats.byProvider[entry.provider]) {
|
||||||
|
stats.byProvider[entry.provider] = {
|
||||||
|
requests: 0,
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
stats.byProvider[entry.provider].requests++;
|
||||||
|
stats.byProvider[entry.provider].promptTokens += promptTokens;
|
||||||
|
stats.byProvider[entry.provider].completionTokens += completionTokens;
|
||||||
|
|
||||||
|
// By Model
|
||||||
|
// Format: "modelName (provider)" if provider is known
|
||||||
|
const modelKey = entry.provider ? `${entry.model} (${entry.provider})` : entry.model;
|
||||||
|
|
||||||
|
if (!stats.byModel[modelKey]) {
|
||||||
|
stats.byModel[modelKey] = {
|
||||||
|
requests: 0,
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 0,
|
||||||
|
rawModel: entry.model,
|
||||||
|
provider: entry.provider
|
||||||
|
};
|
||||||
|
}
|
||||||
|
stats.byModel[modelKey].requests++;
|
||||||
|
stats.byModel[modelKey].promptTokens += promptTokens;
|
||||||
|
stats.byModel[modelKey].completionTokens += completionTokens;
|
||||||
|
|
||||||
|
// By Account (model + oauth account)
|
||||||
|
// Use connectionId if available, otherwise fallback to provider name
|
||||||
|
if (entry.connectionId) {
|
||||||
|
const accountName = connectionMap[entry.connectionId] || `Account ${entry.connectionId.slice(0, 8)}...`;
|
||||||
|
const accountKey = `${entry.model} (${entry.provider} - ${accountName})`;
|
||||||
|
|
||||||
|
if (!stats.byAccount[accountKey]) {
|
||||||
|
stats.byAccount[accountKey] = {
|
||||||
|
requests: 0,
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 0,
|
||||||
|
rawModel: entry.model,
|
||||||
|
provider: entry.provider,
|
||||||
|
connectionId: entry.connectionId,
|
||||||
|
accountName: accountName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
stats.byAccount[accountKey].requests++;
|
||||||
|
stats.byAccount[accountKey].promptTokens += promptTokens;
|
||||||
|
stats.byAccount[accountKey].completionTokens += completionTokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Card from "./Card";
|
||||||
|
import Badge from "./Badge";
|
||||||
|
import { CardSkeleton } from "./Loading";
|
||||||
|
|
||||||
|
function MiniBarGraph({ data, colorClass = "bg-primary" }) {
|
||||||
|
const max = Math.max(...data, 1);
|
||||||
|
return (
|
||||||
|
<div className="flex items-end gap-1 h-8 w-24">
|
||||||
|
{data.slice(-9).map((val, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`flex-1 rounded-t-sm transition-all duration-500 ${colorClass}`}
|
||||||
|
style={{ height: `${Math.max((val / max) * 100, 5)}%` }}
|
||||||
|
title={val}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsageStats() {
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let interval;
|
||||||
|
if (autoRefresh) {
|
||||||
|
interval = setInterval(() => {
|
||||||
|
fetchStats(false); // fetch without loading skeleton
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [autoRefresh]);
|
||||||
|
|
||||||
|
const fetchStats = async (showLoading = true) => {
|
||||||
|
if (showLoading) setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/usage/history");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setStats(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch usage stats:", error);
|
||||||
|
} finally {
|
||||||
|
if (showLoading) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <CardSkeleton />;
|
||||||
|
|
||||||
|
if (!stats) return <div className="text-text-muted">Failed to load usage statistics.</div>;
|
||||||
|
|
||||||
|
// Format number with commas
|
||||||
|
const fmt = (n) => new Intl.NumberFormat().format(n || 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Header with Auto Refresh Toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-semibold">Usage Overview</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm font-medium text-text-muted flex items-center gap-2 cursor-pointer">
|
||||||
|
<span>Auto Refresh (5s)</span>
|
||||||
|
<div
|
||||||
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${autoRefresh ? 'bg-primary' : 'bg-bg-subtle border border-border'}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${autoRefresh ? 'translate-x-5' : 'translate-x-1'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card className="p-4 flex flex-col gap-1">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-text-muted text-sm uppercase font-semibold">Total Requests</span>
|
||||||
|
<span className="text-2xl font-bold">{fmt(stats.totalRequests)}</span>
|
||||||
|
</div>
|
||||||
|
<MiniBarGraph
|
||||||
|
data={(stats.last10Minutes || []).map(m => m.requests)}
|
||||||
|
colorClass="bg-text-muted/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 flex flex-col gap-1">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-text-muted text-sm uppercase font-semibold">Total Input Tokens</span>
|
||||||
|
<span className="text-2xl font-bold text-primary">{fmt(stats.totalPromptTokens)}</span>
|
||||||
|
</div>
|
||||||
|
<MiniBarGraph
|
||||||
|
data={(stats.last10Minutes || []).map(m => m.promptTokens)}
|
||||||
|
colorClass="bg-primary/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 flex flex-col gap-1">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-text-muted text-sm uppercase font-semibold">Total Output Tokens</span>
|
||||||
|
<span className="text-2xl font-bold text-success">{fmt(stats.totalCompletionTokens)}</span>
|
||||||
|
</div>
|
||||||
|
<MiniBarGraph
|
||||||
|
data={(stats.last10Minutes || []).map(m => m.completionTokens)}
|
||||||
|
colorClass="bg-success/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage by Model Table */}
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-border bg-bg-subtle/50">
|
||||||
|
<h3 className="font-semibold">Usage by Model</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">Model</th>
|
||||||
|
<th className="px-6 py-3">Provider</th>
|
||||||
|
<th className="px-6 py-3 text-right">Requests</th>
|
||||||
|
<th className="px-6 py-3 text-right">Input Tokens</th>
|
||||||
|
<th className="px-6 py-3 text-right">Output Tokens</th>
|
||||||
|
<th className="px-6 py-3 text-right">Total Tokens</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{Object.entries(stats.byModel || {}).map(([key, data]) => (
|
||||||
|
<tr key={key} className="hover:bg-bg-subtle/20">
|
||||||
|
<td className="px-6 py-3 font-medium">{data.rawModel}</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<Badge variant="neutral" size="sm">{data.provider}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right">{fmt(data.requests)}</td>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted">{fmt(data.promptTokens)}</td>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted">{fmt(data.completionTokens)}</td>
|
||||||
|
<td className="px-6 py-3 text-right font-medium">{fmt(data.promptTokens + data.completionTokens)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{Object.keys(stats.byModel || {}).length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-8 text-center text-text-muted">
|
||||||
|
No usage recorded yet. Make some requests to see data here.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Usage by Account Table */}
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-border bg-bg-subtle/50">
|
||||||
|
<h3 className="font-semibold">Usage by Account</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">Model</th>
|
||||||
|
<th className="px-6 py-3">Provider</th>
|
||||||
|
<th className="px-6 py-3">Account</th>
|
||||||
|
<th className="px-6 py-3 text-right">Requests</th>
|
||||||
|
<th className="px-6 py-3 text-right">Input Tokens</th>
|
||||||
|
<th className="px-6 py-3 text-right">Output Tokens</th>
|
||||||
|
<th className="px-6 py-3 text-right">Total Tokens</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{Object.entries(stats.byAccount || {}).map(([key, data]) => (
|
||||||
|
<tr key={key} className="hover:bg-bg-subtle/20">
|
||||||
|
<td className="px-6 py-3 font-medium">{data.rawModel}</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<Badge variant="neutral" size="sm">{data.provider}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<span className="font-medium">{data.accountName || `Account ${data.connectionId?.slice(0, 8)}...`}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right">{fmt(data.requests)}</td>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted">{fmt(data.promptTokens)}</td>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted">{fmt(data.completionTokens)}</td>
|
||||||
|
<td className="px-6 py-3 text-right font-medium">{fmt(data.promptTokens + data.completionTokens)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{Object.keys(stats.byAccount || {}).length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-6 py-8 text-center text-text-muted">
|
||||||
|
No account-specific usage recorded yet. Make requests using OAuth accounts to see data here.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export { default as Header } from "./Header";
|
|||||||
export { default as Footer } from "./Footer";
|
export { default as Footer } from "./Footer";
|
||||||
export { default as OAuthModal } from "./OAuthModal";
|
export { default as OAuthModal } from "./OAuthModal";
|
||||||
export { default as ModelSelectModal } from "./ModelSelectModal";
|
export { default as ModelSelectModal } from "./ModelSelectModal";
|
||||||
|
export { default as UsageStats } from "./UsageStats";
|
||||||
|
|
||||||
// Layouts
|
// Layouts
|
||||||
export * from "./layouts";
|
export * from "./layouts";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Re-export from open-sse (single source of truth)
|
// Import directly from file to avoid pulling in server-side dependencies via index.js
|
||||||
export {
|
export {
|
||||||
PROVIDER_MODELS,
|
PROVIDER_MODELS,
|
||||||
getProviderModels,
|
getProviderModels,
|
||||||
getDefaultModel,
|
getDefaultModel,
|
||||||
@@ -8,10 +8,10 @@ export {
|
|||||||
getModelTargetFormat,
|
getModelTargetFormat,
|
||||||
PROVIDER_ID_TO_ALIAS,
|
PROVIDER_ID_TO_ALIAS,
|
||||||
getModelsByProviderId
|
getModelsByProviderId
|
||||||
} from "open-sse";
|
} from "open-sse/config/providerModels.js";
|
||||||
|
|
||||||
import { AI_PROVIDERS } from "./providers.js";
|
import { AI_PROVIDERS } from "./providers.js";
|
||||||
import { PROVIDER_MODELS as MODELS } from "open-sse";
|
import { PROVIDER_MODELS as MODELS } from "open-sse/config/providerModels.js";
|
||||||
|
|
||||||
// Providers that accept any model (passthrough)
|
// Providers that accept any model (passthrough)
|
||||||
const PASSTHROUGH_PROVIDERS = new Set(
|
const PASSTHROUGH_PROVIDERS = new Set(
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null) {
|
|||||||
credentials: refreshedCredentials,
|
credentials: refreshedCredentials,
|
||||||
log,
|
log,
|
||||||
clientRawRequest,
|
clientRawRequest,
|
||||||
|
connectionId: credentials.connectionId,
|
||||||
onCredentialsRefreshed: async (newCreds) => {
|
onCredentialsRefreshed: async (newCreds) => {
|
||||||
await updateProviderCredentials(credentials.connectionId, {
|
await updateProviderCredentials(credentials.connectionId, {
|
||||||
accessToken: newCreds.accessToken,
|
accessToken: newCreds.accessToken,
|
||||||
|
|||||||
Reference in New Issue
Block a user