From 425b0009b815191dc2b153514312cbc12b1ecb57 Mon Sep 17 00:00:00 2001 From: Gemini Nexus Bot Date: Wed, 22 Apr 2026 15:31:54 -0300 Subject: [PATCH] fix(usage): apply code review fixes - SSE passthrough type, per-chart errors, Select component --- src/ipc/database/schema.ts | 1 + src/ipc/database/usageHandler.ts | 2 + src/localization/i18n.ts | 3 + src/localization/vi.ts | 1 + src/routes/usage.tsx | 332 +++++++++++------- src/server/modules/proxy/proxy.service.ts | 103 ++++-- .../modules/usage/token-usage.service.ts | 17 +- src/types/db.ts | 1 + 8 files changed, 303 insertions(+), 157 deletions(-) diff --git a/src/ipc/database/schema.ts b/src/ipc/database/schema.ts index b80b38b..0e5d6f6 100644 --- a/src/ipc/database/schema.ts +++ b/src/ipc/database/schema.ts @@ -37,4 +37,5 @@ export const tokenUsage = sqliteTable('token_usage', { totalTokens: integer('total_tokens').notNull().default(0), timestamp: integer('timestamp').notNull(), requestType: text('request_type'), + isEstimated: integer('is_estimated'), }); diff --git a/src/ipc/database/usageHandler.ts b/src/ipc/database/usageHandler.ts index 6c90060..f9b43b9 100644 --- a/src/ipc/database/usageHandler.ts +++ b/src/ipc/database/usageHandler.ts @@ -21,6 +21,7 @@ export interface RecordUsageParams { totalTokens: number; timestamp: number; requestType?: string; + isEstimated?: boolean; } let usageDbConnection: { raw: Database.Database; orm: BetterSQLite3Database } | null = null; @@ -75,6 +76,7 @@ export class TokenUsageRepo { totalTokens: params.totalTokens, timestamp: params.timestamp, requestType: params.requestType ?? null, + isEstimated: params.isEstimated ? 1 : null, }) .run(); return true; diff --git a/src/localization/i18n.ts b/src/localization/i18n.ts index 3076ee3..7250997 100644 --- a/src/localization/i18n.ts +++ b/src/localization/i18n.ts @@ -510,6 +510,7 @@ i18n loadFailed: 'Failed to load usage data.', retry: 'Retry', allAccounts: 'All Accounts', + noData: 'No data available', }, }, }, @@ -1003,6 +1004,7 @@ i18n loadFailed: '加载用量数据失败。', retry: '重试', allAccounts: '所有账号', + noData: '暂无数据', }, }, }, @@ -1502,6 +1504,7 @@ i18n loadFailed: 'Не удалось загрузить данные об использовании.', retry: 'Повторить', allAccounts: 'Все аккаунты', + noData: 'Нет данных', }, }, }, diff --git a/src/localization/vi.ts b/src/localization/vi.ts index 269fea4..db3987c 100644 --- a/src/localization/vi.ts +++ b/src/localization/vi.ts @@ -489,6 +489,7 @@ const vi = { loadFailed: 'Không thể tải dữ liệu sử dụng.', retry: 'Thử lại', allAccounts: 'Tất cả tài khoản', + noData: 'Không có dữ liệu', }, }; diff --git a/src/routes/usage.tsx b/src/routes/usage.tsx index 65e8be5..71c4b50 100644 --- a/src/routes/usage.tsx +++ b/src/routes/usage.tsx @@ -2,6 +2,13 @@ import { createFileRoute } from '@tanstack/react-router'; import { useQuery } from '@tanstack/react-query'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { useTranslation } from 'react-i18next'; import { Loader2, TrendingUp, TrendingDown, Activity, Layers } from 'lucide-react'; import { ipc } from '@/ipc/manager'; @@ -27,7 +34,7 @@ function UsagePage() { const { t } = useTranslation(); const [range, setRange] = useState<'24h' | '7d' | '30d'>('7d'); const [selectedAccountId, setSelectedAccountId] = useState(undefined); - const { data: accounts } = useCloudAccounts(); + const { data: accounts, isLoading: accountsLoading } = useCloudAccounts(); function getTimeRange(r: '24h' | '7d' | '30d') { const now = Date.now(); @@ -52,6 +59,8 @@ function UsagePage() { return ipc.client.usage.getUsageByDay({ accountId: selectedAccountId, start, end }); }, refetchInterval: 30000, + refetchIntervalInBackground: false, + staleTime: 60_000, }); const { @@ -66,6 +75,8 @@ function UsagePage() { return ipc.client.usage.getUsageByModel({ accountId: selectedAccountId, start, end }); }, refetchInterval: 30000, + refetchIntervalInBackground: false, + staleTime: 60_000, }); const { @@ -80,10 +91,11 @@ function UsagePage() { return ipc.client.usage.getUsageByHour({ accountId: selectedAccountId, start, end }); }, refetchInterval: 30000, + refetchIntervalInBackground: false, + staleTime: 60_000, }); - const isLoading = dailyLoading || modelLoading || hourlyLoading; - const anyError = dailyError || modelError || hourlyError; + const isAllLoading = dailyLoading && modelLoading && hourlyLoading; const totals = useMemo(() => { const source = dailyData || []; @@ -123,7 +135,7 @@ function UsagePage() { })); }, [hourlyData]); - if (isLoading) { + if (isAllLoading) { return (
@@ -131,23 +143,65 @@ function UsagePage() { ); } - if (anyError) { - return ( -
-

{t('usage.loadFailed')}

+ const dailyPlaceholder = dailyError ? ( + + +

{t('usage.loadFailed')}

-
- ); - } + + + ) : !dailyData ? ( + + +

{t('usage.noData')}

+
+
+ ) : null; + + const modelPlaceholder = modelError ? ( + + +

{t('usage.loadFailed')}

+ +
+
+ ) : !modelData ? ( + + +

{t('usage.noData')}

+
+
+ ) : null; + + const hourlyPlaceholder = hourlyError ? ( + + +

{t('usage.loadFailed')}

+ +
+
+ ) : !hourlyData ? ( + + +

{t('usage.noData')}

+
+
+ ) : null; return (
@@ -157,18 +211,30 @@ function UsagePage() {

{t('usage.description')}

- + + {accountsLoading ? ( + + + {t('common.loading')} + + ) : ( + + )} + + + {t('usage.allAccounts')} + {accounts?.map((acc) => ( + + {acc.email} + + ))} + + setRange(v as '24h' | '7d' | '30d')}> {t('usage.range24h')} @@ -180,118 +246,126 @@ function UsagePage() {
{/* Summary Cards */} -
- - - {t('usage.totalTokens')} - - - -
{totals.totalTokens.toLocaleString()}
-
-
+ {dailyPlaceholder || ( +
+ + + {t('usage.totalTokens')} + + + +
{totals.totalTokens.toLocaleString()}
+
+
- - - {t('usage.promptTokens')} - - - -
{totals.promptTokens.toLocaleString()}
-
-
+ + + {t('usage.promptTokens')} + + + +
{totals.promptTokens.toLocaleString()}
+
+
- - - {t('usage.completionTokens')} - - - -
{totals.completionTokens.toLocaleString()}
-
-
+ + + {t('usage.completionTokens')} + + + +
{totals.completionTokens.toLocaleString()}
+
+
- - - {t('usage.totalRequests')} - - - -
{totals.requests.toLocaleString()}
-
-
-
+ + + {t('usage.totalRequests')} + + + +
{totals.requests.toLocaleString()}
+
+
+
+ )} {/* Charts */}
- - - {t('usage.dailyUsage')} - - - - - - - - - - - - - - - + {dailyPlaceholder || ( + + + {t('usage.dailyUsage')} + + + + + + + + + + + + + + + + )} - - - {t('usage.byModel')} - - - - - - {pieData.map((_, index) => ( - - ))} - - - - - - - + {modelPlaceholder || ( + + + {t('usage.byModel')} + + + + + + {pieData.map((_, index) => ( + + ))} + + + + + + + + )}
{range === '24h' && ( - - - {t('usage.hourlyUsage')} - - - - - - - - - - - - - - - - + hourlyPlaceholder || ( + + + {t('usage.hourlyUsage')} + + + + + + + + + + + + + + + + + ) )}
); diff --git a/src/server/modules/proxy/proxy.service.ts b/src/server/modules/proxy/proxy.service.ts index 0a48fdc..e08bff7 100644 --- a/src/server/modules/proxy/proxy.service.ts +++ b/src/server/modules/proxy/proxy.service.ts @@ -52,6 +52,7 @@ export class ProxyService { model: string, usage: { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number } | undefined | null, requestType: string, + isEstimated?: boolean, ): Promise { if (!usage) { return; @@ -64,6 +65,7 @@ export class ProxyService { totalTokens: usage.totalTokenCount ?? 0, timestamp: Date.now(), requestType, + isEstimated, }); } @@ -75,6 +77,18 @@ export class ProxyService { } } + private extractUsageFromMetadata( + meta: unknown, + ): { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number } | undefined { + if (!meta || typeof meta !== 'object') return undefined; + const m = meta as Record; + return { + promptTokenCount: typeof m.promptTokenCount === 'number' ? m.promptTokenCount : undefined, + candidatesTokenCount: typeof m.candidatesTokenCount === 'number' ? m.candidatesTokenCount : undefined, + totalTokenCount: typeof m.totalTokenCount === 'number' ? m.totalTokenCount : undefined, + }; + } + // --- Anthropic Handlers --- async handleAnthropicMessages( @@ -140,7 +154,7 @@ export class ProxyService { token.token.upstream_proxy_url, extraHeaders, ); - await this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'anthropic'); + this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'anthropic'); return this.toAnthropicChatResponse(transformResponse(response)); } } catch (error) { @@ -172,7 +186,7 @@ export class ProxyService { token.token.upstream_proxy_url, extraHeaders, ); - await this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'anthropic'); + this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'anthropic'); return this.toAnthropicChatResponse(transformResponse(response)); } } catch (fallbackErr) { @@ -211,7 +225,7 @@ export class ProxyService { token.token.upstream_proxy_url, extraHeaders, ); - await this.recordUsage(token.id, 'gemini-3-flash', response.usageMetadata, 'anthropic'); + this.recordUsage(token.id, 'gemini-3-flash', response.usageMetadata, 'anthropic'); const transformed = this.toAnthropicChatResponse(transformResponse(response)); return { ...transformed, @@ -241,16 +255,6 @@ export class ProxyService { _model: string, accountId: string, ): Observable { - function extractUsageFromMetadata(meta: unknown): { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number } | undefined { - if (!meta || typeof meta !== 'object') return undefined; - const m = meta as Record; - return { - promptTokenCount: typeof m.promptTokenCount === 'number' ? m.promptTokenCount : undefined, - candidatesTokenCount: typeof m.candidatesTokenCount === 'number' ? m.candidatesTokenCount : undefined, - totalTokenCount: typeof m.totalTokenCount === 'number' ? m.totalTokenCount : undefined, - }; - } - return new Observable((subscriber) => { const decoder = new TextDecoder(); let buffer = ''; @@ -260,6 +264,7 @@ export class ProxyService { let lastFinishReason: string | undefined; let lastUsageMetadata: Record | undefined; + let accumulatedText = ''; let receivedData = false; @@ -296,6 +301,9 @@ export class ProxyService { if (this.isGeminiPart(part)) { const chunks = processor.process(part); chunks.forEach((c) => subscriber.next(c)); + if (part.text) { + accumulatedText += part.text; + } } // Reset error state on successful parse @@ -317,9 +325,20 @@ export class ProxyService { const finishChunks = state.emitFinish(lastFinishReason, lastUsageMetadata); finishChunks.forEach((c) => subscriber.next(c)); - const usage = extractUsageFromMetadata(lastUsageMetadata); + const usage = this.extractUsageFromMetadata(lastUsageMetadata); if (usage) { - void this.recordUsage(accountId, _model, usage, 'anthropic'); + this.recordUsage(accountId, _model, usage, 'anthropic').catch((err) => + this.logger.error('Usage recording failed', err), + ); + } else if (accumulatedText) { + const estimated = this.estimateTokens(accumulatedText); + this.recordUsage(accountId, _model, { + promptTokenCount: 0, + candidatesTokenCount: estimated, + totalTokenCount: estimated, + }, 'anthropic', true).catch((err) => + this.logger.error('Usage recording failed', err), + ); } subscriber.complete(); }); @@ -388,7 +407,7 @@ export class ProxyService { extraHeaders, ); - await this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'gemini'); + this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'gemini'); return this.normalizeGeminiGenerateResponse(response); } catch (err) { if (err instanceof Error && this.isProjectContextError(err.message)) { @@ -411,7 +430,7 @@ export class ProxyService { token.token.upstream_proxy_url, extraHeaders, ); - await this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'gemini'); + this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'gemini'); return this.normalizeGeminiGenerateResponse(response); } catch (fallbackErr) { lastError = fallbackErr; @@ -538,6 +557,7 @@ export class ProxyService { let buffer = ''; let receivedData = false; let lastUsageMetadata: Record | undefined; + let accumulatedText = ''; upstreamStream.on('data', (chunk: Buffer) => { receivedData = true; @@ -549,15 +569,20 @@ export class ProxyService { const trimmed = line.trim(); if (!trimmed.startsWith('data: ')) continue; const dataStr = trimmed.slice(6); - subscriber.next(`data: ${dataStr}\n\n`); if (dataStr === '[DONE]') continue; try { const parsed = JSON.parse(dataStr); if (parsed?.usageMetadata) { lastUsageMetadata = parsed.usageMetadata; } + const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text; + if (typeof text === 'string') { + accumulatedText += text; + } } catch { /* ignore */ } } + + subscriber.next(chunk.toString()); }); upstreamStream.on('end', () => { @@ -565,8 +590,20 @@ export class ProxyService { subscriber.error(new Error('Empty response stream')); return; } - if (lastUsageMetadata) { - void this.recordUsage(accountId, model, lastUsageMetadata as any, 'gemini'); + const usage = this.extractUsageFromMetadata(lastUsageMetadata); + if (usage) { + this.recordUsage(accountId, model, usage, 'gemini').catch((err) => + this.logger.error('Usage recording failed', err), + ); + } else if (accumulatedText) { + const estimated = this.estimateTokens(accumulatedText); + this.recordUsage(accountId, model, { + promptTokenCount: 0, + candidatesTokenCount: estimated, + totalTokenCount: estimated, + }, 'gemini', true).catch((err) => + this.logger.error('Usage recording failed', err), + ); } subscriber.complete(); }); @@ -845,7 +882,7 @@ export class ProxyService { extraHeaders, ); trafficLogger.logUpstreamResponse(requestId || 'unknown', '/v1/chat/completions', 200, response, 0); - await this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai'); + this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai'); this.logger.log( `Upstream response snippet after stream fallback: ${JSON.stringify(response).substring(0, 500)}`, ); @@ -866,7 +903,7 @@ export class ProxyService { this.logger.log( `Upstream response snippet (non-stream): ${JSON.stringify(response).substring(0, 500)}`, ); - await this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai'); + this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai'); // Transform Gemini response to OpenAI format const claudeResponse = transformResponse(response); this.logger.log( @@ -904,7 +941,7 @@ export class ProxyService { extraHeaders, ); trafficLogger.logUpstreamResponse(requestId || 'unknown', '/v1/chat/completions', 200, response, 0); - await this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai'); + this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai'); const claudeResponse = transformResponse(response); return this.convertClaudeToOpenAIResponse(claudeResponse, request.model); } catch (fallbackErr) { @@ -1055,6 +1092,7 @@ export class ProxyService { let hasSentDone = false; let toolCallIndex = 0; let lastUsageMetadata: Record | undefined; + let accumulatedText = ''; const streamId = `chatcmpl-${uuidv4()}`; const created = Math.floor(Date.now() / 1000); @@ -1108,6 +1146,7 @@ export class ProxyService { for (const part of parts) { if (part.thought && part.text) { + accumulatedText += part.text; // Reasoning/thinking parts: emit via reasoning_content delta field // so clients that support it can display thinking separately. const reasoningChunk = { @@ -1183,6 +1222,7 @@ export class ProxyService { } if (part.text) { + accumulatedText += part.text; pushRoleChunk(); const contentChunk = { id: streamId, @@ -1253,9 +1293,20 @@ export class ProxyService { subscriber.next('data: [DONE]\n\n'); hasSentDone = true; } - if (lastUsageMetadata) { - // TODO: integrate local tokenizer fallback for missing usageMetadata - void this.recordUsage(accountId, effectiveTargetModel, lastUsageMetadata as any, 'openai'); + const usage = this.extractUsageFromMetadata(lastUsageMetadata); + if (usage) { + this.recordUsage(accountId, effectiveTargetModel, usage, 'openai').catch((err) => + this.logger.error('Usage recording failed', err), + ); + } else if (accumulatedText) { + const estimated = this.estimateTokens(accumulatedText); + this.recordUsage(accountId, effectiveTargetModel, { + promptTokenCount: 0, + candidatesTokenCount: estimated, + totalTokenCount: estimated, + }, 'openai', true).catch((err) => + this.logger.error('Usage recording failed', err), + ); } subscriber.complete(); }); diff --git a/src/server/modules/usage/token-usage.service.ts b/src/server/modules/usage/token-usage.service.ts index 27bb112..34ba49e 100644 --- a/src/server/modules/usage/token-usage.service.ts +++ b/src/server/modules/usage/token-usage.service.ts @@ -1,10 +1,23 @@ import { Injectable } from '@nestjs/common'; +import { z } from 'zod'; import { TokenUsageRepo, RecordUsageParams } from '../../../ipc/database/usageHandler'; +const RecordUsageInputSchema = z.object({ + accountId: z.string().min(1), + model: z.string().min(1), + promptTokens: z.number().min(0), + completionTokens: z.number().min(0), + totalTokens: z.number().min(0), + timestamp: z.number(), + requestType: z.enum(['openai', 'anthropic', 'gemini']).optional(), + isEstimated: z.boolean().optional(), +}); + @Injectable() export class TokenUsageService { - async recordUsage(params: RecordUsageParams): Promise { - TokenUsageRepo.recordUsage(params); + recordUsage(params: RecordUsageParams): boolean { + RecordUsageInputSchema.parse(params); + return TokenUsageRepo.recordUsage(params); } getUsageByHour(accountId?: string, start?: number, end?: number) { diff --git a/src/types/db.ts b/src/types/db.ts index 6073d77..fc1f0da 100644 --- a/src/types/db.ts +++ b/src/types/db.ts @@ -80,6 +80,7 @@ export const TokenUsageRowSchema = z.object({ total_tokens: z.number(), timestamp: z.number(), request_type: z.string().nullable(), + is_estimated: z.number().nullable(), }); export type TokenUsageRow = z.infer;