diff --git a/public/js/monitor.js b/public/js/monitor.js index 7c4e6d26..152bced6 100644 --- a/public/js/monitor.js +++ b/public/js/monitor.js @@ -21,6 +21,15 @@ function formatCompactNumber(value) { return formatNumber(number); } +function formatTokenNumber(value) { + const number = Number(value) || 0; + const absNumber = Math.abs(number); + if (absNumber >= 1000000000) return `${formatNumber(number / 1000000000, 2)}B`; + if (absNumber >= 1000000) return `${formatNumber(number / 1000000, 2)}M`; + if (absNumber >= 1000) return `${formatNumber(number / 1000, 2)}K`; + return formatNumber(number); +} + function initMonitorPage() { const savedDays = Number(localStorage.getItem('monitorRangeDays')) || 7; monitorState.rangeDays = [7, 14, 30].includes(savedDays) ? savedDays : 7; @@ -108,10 +117,10 @@ function renderMonitorCards(data) {
Tokens
-
${formatCompactNumber(totals.totalTokens)}
+
${formatTokenNumber(totals.totalTokens)}
- 输入 ${formatCompactNumber(totals.inputTokens)} - 输出 ${formatCompactNumber(totals.outputTokens)} + 输入 ${formatTokenNumber(totals.inputTokens)} + 输出 ${formatTokenNumber(totals.outputTokens)}
@@ -165,7 +174,7 @@ function renderModelUsageChart(data) {
${escapeHtml(item.model)} - ${formatCompactNumber(item.totalTokens)} + ${formatTokenNumber(item.totalTokens)} ${formatNumber(percent, 1)}%
`; @@ -175,7 +184,7 @@ function renderModelUsageChart(data) {
- ${formatCompactNumber(totalTokens)} + ${formatTokenNumber(totalTokens)} Tokens
diff --git a/src/server/handlers/claude.js b/src/server/handlers/claude.js index 5dc17223..bf9e6f59 100644 --- a/src/server/handlers/claude.js +++ b/src/server/handlers/claude.js @@ -11,7 +11,7 @@ import logger from '../../utils/logger.js'; import config from '../../config/config.js'; import tokenManager from '../../auth/token_manager.js'; import quotaManager from '../../auth/quota_manager.js'; -import { setUsageMetrics } from '../../utils/usageStats.js'; +import { recordUsageAttemptFailure, setUsageMetrics } from '../../utils/usageStats.js'; import { createClaudeResponse } from '../formatters/claude.js'; import { validateIncomingChatRequest } from '../validators/chat.js'; import { getSafeRetries } from './common/retry.js'; @@ -97,6 +97,7 @@ export const handleClaudeRequest = async (req, res, isStream) => { const createRetryOptions = (prefix) => ({ loggerPrefix: prefix, onAttempt: () => tokenManager.recordRequest(token, model), + onAttemptFailure: () => recordUsageAttemptFailure(req, { model: model }), getTokenId: () => tokenId, modelId: model, refreshQuota, diff --git a/src/server/handlers/common/retry.js b/src/server/handlers/common/retry.js index 05e9ab35..367d5fb3 100644 --- a/src/server/handlers/common/retry.js +++ b/src/server/handlers/common/retry.js @@ -287,6 +287,10 @@ export async function with429Retry(fn, maxRetries, options = {}, legacyOnAttempt } return await fn(attempt, shouldUseCredits); } catch (error) { + if (typeof retryOptions.onAttemptFailure === 'function') { + retryOptions.onAttemptFailure(error, attempt); + } + const status = getStatus(error); if (status !== 429 && status !== 503) { throw error; diff --git a/src/server/handlers/gemini.js b/src/server/handlers/gemini.js index b52f14b7..930c97fc 100644 --- a/src/server/handlers/gemini.js +++ b/src/server/handlers/gemini.js @@ -10,7 +10,7 @@ import logger from '../../utils/logger.js'; import config from '../../config/config.js'; import tokenManager from '../../auth/token_manager.js'; import quotaManager from '../../auth/quota_manager.js'; -import { setUsageMetrics } from '../../utils/usageStats.js'; +import { recordUsageAttemptFailure, setUsageMetrics } from '../../utils/usageStats.js'; import { createGeminiResponse } from '../formatters/gemini.js'; import { validateIncomingChatRequest } from '../validators/chat.js'; import { getSafeRetries } from './common/retry.js'; @@ -141,6 +141,7 @@ export const handleGeminiRequest = async (req, res, modelName, isStream) => { const createRetryOptions = (prefix) => ({ loggerPrefix: prefix, onAttempt: () => tokenManager.recordRequest(token, modelName), + onAttemptFailure: () => recordUsageAttemptFailure(req, { model: modelName }), getTokenId: () => tokenId, modelId: modelName, refreshQuota, diff --git a/src/server/handlers/geminicli.js b/src/server/handlers/geminicli.js index ef428344..d1646531 100644 --- a/src/server/handlers/geminicli.js +++ b/src/server/handlers/geminicli.js @@ -33,7 +33,7 @@ import { import { setSignature, getSignature, shouldCacheSignature, isImageModel } from '../../utils/thoughtSignatureCache.js'; import { getSafeRetries } from './common/retry.js'; import { disableTimeouts } from './common/timeouts.js'; -import { setUsageMetrics } from '../../utils/usageStats.js'; +import { recordUsageAttemptFailure, setUsageMetrics } from '../../utils/usageStats.js'; /** * 处理 Gemini CLI 格式的聊天请求(支持 OpenAI/Gemini/Claude 格式) @@ -64,6 +64,11 @@ export const handleGeminiCliRequest = async (req, res, forceFormat = null) => { const { id, created } = createResponseMeta(); const safeRetries = getSafeRetries(config.retryTimes); + const createRetryOptions = (prefix) => ({ + loggerPrefix: prefix, + onAttempt: () => recordRequest(token), + onAttemptFailure: () => recordUsageAttemptFailure(req, { model: responseModel }) + }); // 假流式模式:使用非流式 API 获取数据,然后模拟流式输出 const useFakeStreaming = features.fakeStreaming && stream; @@ -86,8 +91,7 @@ export const handleGeminiCliRequest = async (req, res, forceFormat = null) => { await with429Retry( () => generateStreamResponse(geminiRequest, token, actualModel, (data) => writer.onEvent(data)), safeRetries, - '[GeminiCLI] chat.stream ', - () => recordRequest(token) + createRetryOptions('[GeminiCLI] chat.stream ') ); writer.finalize(); @@ -114,8 +118,7 @@ export const handleGeminiCliRequest = async (req, res, forceFormat = null) => { const { content, reasoningContent, reasoningSignature, toolCalls, usage } = await with429Retry( () => generateNoStreamResponse(geminiRequest, token, actualModel), safeRetries, - '[GeminiCLI] chat.fake_stream ', - () => recordRequest(token) + createRetryOptions('[GeminiCLI] chat.fake_stream ') ); // 缓存签名(假流式响应) @@ -160,8 +163,7 @@ export const handleGeminiCliRequest = async (req, res, forceFormat = null) => { const { content, reasoningContent, reasoningSignature, toolCalls, usage } = await with429Retry( () => generateNoStreamResponse(geminiRequest, token, actualModel), safeRetries, - '[GeminiCLI] chat.no_stream ', - () => recordRequest(token) + createRetryOptions('[GeminiCLI] chat.no_stream ') ); // 处理签名:优先使用 API 返回的签名,否则使用缓存的签名 diff --git a/src/server/handlers/openai.js b/src/server/handlers/openai.js index 01abf8c5..3494f72d 100644 --- a/src/server/handlers/openai.js +++ b/src/server/handlers/openai.js @@ -10,7 +10,7 @@ import logger from '../../utils/logger.js'; import config from '../../config/config.js'; import tokenManager from '../../auth/token_manager.js'; import quotaManager from '../../auth/quota_manager.js'; -import { setUsageMetrics } from '../../utils/usageStats.js'; +import { recordUsageAttemptFailure, setUsageMetrics } from '../../utils/usageStats.js'; import { createOpenAIStreamChunk as createStreamChunk, createOpenAIChatCompletionResponse @@ -75,6 +75,7 @@ export const handleOpenAIRequest = async (req, res) => { const createRetryOptions = (prefix) => ({ loggerPrefix: prefix, onAttempt: () => tokenManager.recordRequest(token, model), + onAttemptFailure: () => recordUsageAttemptFailure(req, { model: model }), getTokenId: () => tokenId, modelId: model, refreshQuota, diff --git a/src/server/index.js b/src/server/index.js index f9d0525a..d805629c 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -112,11 +112,15 @@ app.use((req, res, next) => { req.apiUsageMetrics = { model: inferredModel, usage: {} }; const startedAt = Date.now(); res.on('finish', () => { + const failedAttempts = Number(req.apiUsageMetrics?.failedAttempts) || 0; + const isFailedResponse = res.statusCode >= 400 || res.statusCode === 0; usageStats.record({ timestamp: startedAt, statusCode: res.statusCode, model: req.apiUsageMetrics?.model || inferredModel, usage: req.apiUsageMetrics?.usage || {}, + successCount: isFailedResponse ? 0 : 1, + failedCount: isFailedResponse ? Math.max(failedAttempts, 1) : failedAttempts, path: fullPath }); }); diff --git a/src/utils/usageStats.js b/src/utils/usageStats.js index 5341cf97..528ce79f 100644 --- a/src/utils/usageStats.js +++ b/src/utils/usageStats.js @@ -28,6 +28,12 @@ function emptyTotals() { }; } +function toSafeCount(value, fallback = 0) { + const number = Number(value); + if (!Number.isFinite(number) || number < 0) return fallback; + return Math.floor(number); +} + function normalizeUsage(usage = {}) { const inputTokens = Number( usage.prompt_tokens ?? @@ -103,30 +109,27 @@ class UsageStatsStore { return API_PATH_PREFIXES.some(prefix => path.startsWith(prefix)); } - record({ timestamp = Date.now(), statusCode = 0, model = 'unknown', usage = {} } = {}) { + record({ timestamp = Date.now(), statusCode = 0, model = 'unknown', usage = {}, successCount, failedCount } = {}) { const day = startOfUtcDay(timestamp); const bucket = this.buckets.get(day) || createBucket(day); const normalizedUsage = normalizeUsage(usage); const modelName = typeof model === 'string' && model.trim() ? model.trim() : 'unknown'; const failed = statusCode >= 400 || statusCode === 0; + const success = successCount === undefined ? (failed ? 0 : 1) : toSafeCount(successCount); + const failedAttempts = failedCount === undefined ? (failed ? 1 : 0) : toSafeCount(failedCount); + const requests = success + failedAttempts; - bucket.requests += 1; - if (failed) { - bucket.failed += 1; - } else { - bucket.success += 1; - } + bucket.requests += requests; + bucket.success += success; + bucket.failed += failedAttempts; bucket.inputTokens += normalizedUsage.inputTokens; bucket.outputTokens += normalizedUsage.outputTokens; bucket.totalTokens += normalizedUsage.totalTokens; const modelStats = bucket.models[modelName] || emptyTotals(); - modelStats.requests += 1; - if (failed) { - modelStats.failed += 1; - } else { - modelStats.success += 1; - } + modelStats.requests += requests; + modelStats.success += success; + modelStats.failed += failedAttempts; modelStats.inputTokens += normalizedUsage.inputTokens; modelStats.outputTokens += normalizedUsage.outputTokens; modelStats.totalTokens += normalizedUsage.totalTokens; @@ -211,4 +214,13 @@ export function setUsageMetrics(req, { model, usage } = {}) { ...(usage !== undefined ? { usage } : {}) }; } + +export function recordUsageAttemptFailure(req, { model } = {}) { + if (!req) return; + req.apiUsageMetrics = { + ...(req.apiUsageMetrics || {}), + ...(model !== undefined ? { model } : {}), + failedAttempts: toSafeCount(req.apiUsageMetrics?.failedAttempts) + 1 + }; +} export default usageStats;