Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions public/js/monitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -108,10 +117,10 @@ function renderMonitorCards(data) {
</div>
<div class="monitor-card tokens">
<div class="monitor-card-title">Tokens</div>
<div class="monitor-card-value">${formatCompactNumber(totals.totalTokens)}</div>
<div class="monitor-card-value">${formatTokenNumber(totals.totalTokens)}</div>
<div class="monitor-card-sub">
<span>输入 ${formatCompactNumber(totals.inputTokens)}</span>
<span>输出 ${formatCompactNumber(totals.outputTokens)}</span>
<span>输入 ${formatTokenNumber(totals.inputTokens)}</span>
<span>输出 ${formatTokenNumber(totals.outputTokens)}</span>
</div>
</div>
<div class="monitor-card tpm">
Expand Down Expand Up @@ -165,7 +174,7 @@ function renderModelUsageChart(data) {
<div class="model-legend-item">
<span class="model-color" style="background:${colors[index % colors.length]}"></span>
<span class="model-name" title="${escapeHtml(item.model)}">${escapeHtml(item.model)}</span>
<span class="model-tokens">${formatCompactNumber(item.totalTokens)}</span>
<span class="model-tokens">${formatTokenNumber(item.totalTokens)}</span>
<span class="model-percent">${formatNumber(percent, 1)}%</span>
</div>
`;
Expand All @@ -175,7 +184,7 @@ function renderModelUsageChart(data) {
<div class="donut-wrap">
<div class="donut-chart" style="background: conic-gradient(${gradientStops});">
<div class="donut-hole">
<span>${formatCompactNumber(totalTokens)}</span>
<span>${formatTokenNumber(totalTokens)}</span>
<small>Tokens</small>
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/server/handlers/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/server/handlers/common/retry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/server/handlers/gemini.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 9 additions & 7 deletions src/server/handlers/geminicli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 格式)
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -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 ')
);

// 缓存签名(假流式响应)
Expand Down Expand Up @@ -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 返回的签名,否则使用缓存的签名
Expand Down
3 changes: 2 additions & 1 deletion src/server/handlers/openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
});
Expand Down
38 changes: 25 additions & 13 deletions src/utils/usageStats.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ??
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Loading