`;
@@ -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;