fix(usage): address code review findings - SQL injection, stream usage recording, DB singleton

This commit is contained in:
Gemini Nexus Bot committed 2026-04-22 14:34:39 -03:00
1 parent 1258b32cb5
commit 9b37bdb2d9
11 files changed
+538 -100

No files matched your search

+7
View File
@@ -51,6 +51,7 @@
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"fastify": "^5.8.4", "fastify": "^5.8.4",
"find-process": "^2.0.0", "find-process": "^2.0.0",
"gpt-tokenizer": "^3.4.0",
"i18next": "^25.6.3", "i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"keytar": "^7.9.0", "keytar": "^7.9.0",
@@ -18531,6 +18532,12 @@
"url": "https://github.com/sindresorhus/got?sponsor=1" "url": "https://github.com/sindresorhus/got?sponsor=1"
} }
}, },
"node_modules/gpt-tokenizer": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-3.4.0.tgz",
"integrity": "sha512-wxFLnhIXTDjYebd9A9pGl3e31ZpSypbpIJSOswbgop5jLte/AsZVDvjlbEuVFlsqZixVKqbcoNmRlFDf6pz/UQ==",
"license": "MIT"
},
"node_modules/graceful-fs": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+1
View File
@@ -136,6 +136,7 @@
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"fastify": "^5.8.4", "fastify": "^5.8.4",
"find-process": "^2.0.0", "find-process": "^2.0.0",
"gpt-tokenizer": "^3.4.0",
"i18next": "^25.6.3", "i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"keytar": "^7.9.0", "keytar": "^7.9.0",
+2 -1
View File
@@ -59,7 +59,7 @@ function sleepSync(ms: number): void {
* Ensures that the cloud database file and schema exist. * Ensures that the cloud database file and schema exist.
* @param dbPath {string} The path to the database file. * @param dbPath {string} The path to the database file.
*/ */
function ensureDatabaseInitialized(dbPath: string): void { export function ensureDatabaseInitialized(dbPath: string): void {
const dir = path.dirname(dbPath); const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
@@ -68,6 +68,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
let db: Database.Database | null = null; let db: Database.Database | null = null;
try { try {
db = new Database(dbPath); db = new Database(dbPath);
db.pragma('journal_mode = WAL');
configureDatabase(db, { busyTimeoutMs: SQLITE_BUSY_TIMEOUT_MS }); configureDatabase(db, { busyTimeoutMs: SQLITE_BUSY_TIMEOUT_MS });
// Create accounts table // Create accounts table
+72 -45
View File
@@ -1,5 +1,9 @@
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { getCloudDb } from './cloudHandler'; import Database from 'better-sqlite3';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { openDrizzleConnection } from './dbConnection';
import { getCloudAccountsDbPath } from '../../utils/paths';
import { ensureDatabaseInitialized } from './cloudHandler';
import { tokenUsage } from './schema'; import { tokenUsage } from './schema';
import { import {
HourlyUsageSchema, HourlyUsageSchema,
@@ -19,24 +23,47 @@ export interface RecordUsageParams {
requestType?: string; requestType?: string;
} }
function buildDateFilter(start?: number, end?: number): string { let usageDbConnection: { raw: Database.Database; orm: BetterSQLite3Database<typeof import('./schema')> } | null = null;
const conditions: string[] = [];
if (start !== undefined) { function getUsageDb() {
conditions.push(`timestamp >= ${start}`); if (!usageDbConnection) {
const dbPath = getCloudAccountsDbPath();
ensureDatabaseInitialized(dbPath);
usageDbConnection = openDrizzleConnection(
dbPath,
{ readonly: false, fileMustExist: false },
{ busyTimeoutMs: 3000 },
);
} }
if (end !== undefined) { return usageDbConnection;
conditions.push(`timestamp <= ${end}`);
}
return conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '';
} }
function buildAccountFilter(accountId?: string): string { function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
return accountId ? `AND account_id = '${accountId}'` : ''; const conditions: string[] = [];
const params: number[] = [];
if (start !== undefined) {
conditions.push(`timestamp >= ?`);
params.push(start);
}
if (end !== undefined) {
conditions.push(`timestamp <= ?`);
params.push(end);
}
return {
sql: conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '',
params,
};
}
function buildAccountFilter(accountId?: string): { sql: string; params: unknown[] } {
return accountId
? { sql: 'AND account_id = ?', params: [accountId] }
: { sql: '', params: [] };
} }
export class TokenUsageRepo { export class TokenUsageRepo {
static recordUsage(params: RecordUsageParams): void { static recordUsage(params: RecordUsageParams): boolean {
const { raw, orm } = getCloudDb(); const { orm } = getUsageDb();
try { try {
orm orm
.insert(tokenUsage) .insert(tokenUsage)
@@ -50,10 +77,10 @@ export class TokenUsageRepo {
requestType: params.requestType ?? null, requestType: params.requestType ?? null,
}) })
.run(); .run();
return true;
} catch (error) { } catch (error) {
logger.error('Failed to record token usage', error); logger.error('Failed to record token usage', error);
} finally { return false;
raw.close();
} }
} }
@@ -68,8 +95,10 @@ export class TokenUsageRepo {
totalTokens: number; totalTokens: number;
requests: number; requests: number;
}> { }> {
const { raw } = getCloudDb(); const { raw } = getUsageDb();
try { try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = ` const sql = `
SELECT SELECT
strftime('%Y-%m-%d %H:00', timestamp / 1000, 'unixepoch', 'localtime') as bucket, strftime('%Y-%m-%d %H:00', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
@@ -79,18 +108,16 @@ export class TokenUsageRepo {
COUNT(*) as requests COUNT(*) as requests
FROM token_usage FROM token_usage
WHERE 1=1 WHERE 1=1
${buildAccountFilter(accountId)} ${accountFilter.sql}
${buildDateFilter(start, end)} ${dateFilter.sql}
GROUP BY bucket GROUP BY bucket
ORDER BY bucket ORDER BY bucket
`; `;
const rows = raw.prepare(sql).all() as unknown[]; const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => HourlyUsageSchema.parse(row)); return rows.map((row) => HourlyUsageSchema.parse(row));
} catch (error) { } catch (error) {
logger.error('Failed to get hourly usage', error); logger.error('Failed to get hourly usage', error);
return []; return [];
} finally {
raw.close();
} }
} }
@@ -105,8 +132,10 @@ export class TokenUsageRepo {
totalTokens: number; totalTokens: number;
requests: number; requests: number;
}> { }> {
const { raw } = getCloudDb(); const { raw } = getUsageDb();
try { try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = ` const sql = `
SELECT SELECT
strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch', 'localtime') as bucket, strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
@@ -116,18 +145,16 @@ export class TokenUsageRepo {
COUNT(*) as requests COUNT(*) as requests
FROM token_usage FROM token_usage
WHERE 1=1 WHERE 1=1
${buildAccountFilter(accountId)} ${accountFilter.sql}
${buildDateFilter(start, end)} ${dateFilter.sql}
GROUP BY bucket GROUP BY bucket
ORDER BY bucket ORDER BY bucket
`; `;
const rows = raw.prepare(sql).all() as unknown[]; const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => DailyUsageSchema.parse(row)); return rows.map((row) => DailyUsageSchema.parse(row));
} catch (error) { } catch (error) {
logger.error('Failed to get daily usage', error); logger.error('Failed to get daily usage', error);
return []; return [];
} finally {
raw.close();
} }
} }
@@ -142,8 +169,10 @@ export class TokenUsageRepo {
totalTokens: number; totalTokens: number;
requests: number; requests: number;
}> { }> {
const { raw } = getCloudDb(); const { raw } = getUsageDb();
try { try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = ` const sql = `
SELECT SELECT
strftime('%Y-%W', timestamp / 1000, 'unixepoch', 'localtime') as bucket, strftime('%Y-%W', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
@@ -153,18 +182,16 @@ export class TokenUsageRepo {
COUNT(*) as requests COUNT(*) as requests
FROM token_usage FROM token_usage
WHERE 1=1 WHERE 1=1
${buildAccountFilter(accountId)} ${accountFilter.sql}
${buildDateFilter(start, end)} ${dateFilter.sql}
GROUP BY bucket GROUP BY bucket
ORDER BY bucket ORDER BY bucket
`; `;
const rows = raw.prepare(sql).all() as unknown[]; const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => WeeklyUsageSchema.parse(row)); return rows.map((row) => WeeklyUsageSchema.parse(row));
} catch (error) { } catch (error) {
logger.error('Failed to get weekly usage', error); logger.error('Failed to get weekly usage', error);
return []; return [];
} finally {
raw.close();
} }
} }
@@ -179,8 +206,10 @@ export class TokenUsageRepo {
totalTokens: number; totalTokens: number;
requests: number; requests: number;
}> { }> {
const { raw } = getCloudDb(); const { raw } = getUsageDb();
try { try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = ` const sql = `
SELECT SELECT
strftime('%Y-%m', timestamp / 1000, 'unixepoch', 'localtime') as bucket, strftime('%Y-%m', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
@@ -190,18 +219,16 @@ export class TokenUsageRepo {
COUNT(*) as requests COUNT(*) as requests
FROM token_usage FROM token_usage
WHERE 1=1 WHERE 1=1
${buildAccountFilter(accountId)} ${accountFilter.sql}
${buildDateFilter(start, end)} ${dateFilter.sql}
GROUP BY bucket GROUP BY bucket
ORDER BY bucket ORDER BY bucket
`; `;
const rows = raw.prepare(sql).all() as unknown[]; const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => MonthlyUsageSchema.parse(row)); return rows.map((row) => MonthlyUsageSchema.parse(row));
} catch (error) { } catch (error) {
logger.error('Failed to get monthly usage', error); logger.error('Failed to get monthly usage', error);
return []; return [];
} finally {
raw.close();
} }
} }
@@ -216,8 +243,10 @@ export class TokenUsageRepo {
totalTokens: number; totalTokens: number;
requests: number; requests: number;
}> { }> {
const { raw } = getCloudDb(); const { raw } = getUsageDb();
try { try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = ` const sql = `
SELECT SELECT
model, model,
@@ -227,18 +256,16 @@ export class TokenUsageRepo {
COUNT(*) as requests COUNT(*) as requests
FROM token_usage FROM token_usage
WHERE 1=1 WHERE 1=1
${buildAccountFilter(accountId)} ${accountFilter.sql}
${buildDateFilter(start, end)} ${dateFilter.sql}
GROUP BY model GROUP BY model
ORDER BY totalTokens DESC ORDER BY totalTokens DESC
`; `;
const rows = raw.prepare(sql).all() as unknown[]; const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => ModelUsageSchema.parse(row)); return rows.map((row) => ModelUsageSchema.parse(row));
} catch (error) { } catch (error) {
logger.error('Failed to get model usage', error); logger.error('Failed to get model usage', error);
return []; return [];
} finally {
raw.close();
} }
} }
} }
+18 -9
View File
@@ -497,9 +497,9 @@ i18n
usage: { usage: {
title: 'Token Usage', title: 'Token Usage',
description: 'Monitor your API token consumption over time.', description: 'Monitor your API token consumption over time.',
range24h: '24 Hours', range24h: 'Last 24 Hours',
range7d: '7 Days', range7d: 'Last 7 Days',
range30d: '30 Days', range30d: 'Last 30 Days',
totalTokens: 'Total Tokens', totalTokens: 'Total Tokens',
promptTokens: 'Prompt Tokens', promptTokens: 'Prompt Tokens',
completionTokens: 'Completion Tokens', completionTokens: 'Completion Tokens',
@@ -507,6 +507,9 @@ i18n
dailyUsage: 'Daily Usage', dailyUsage: 'Daily Usage',
byModel: 'Usage by Model', byModel: 'Usage by Model',
hourlyUsage: 'Hourly Usage', hourlyUsage: 'Hourly Usage',
loadFailed: 'Failed to load usage data.',
retry: 'Retry',
allAccounts: 'All Accounts',
}, },
}, },
}, },
@@ -987,9 +990,9 @@ i18n
usage: { usage: {
title: 'Token 用量', title: 'Token 用量',
description: '监控您的 API Token 消耗情况。', description: '监控您的 API Token 消耗情况。',
range24h: '24 小时', range24h: '最近 24 小时',
range7d: '7 天', range7d: '最近 7 天',
range30d: '30 天', range30d: '最近 30 天',
totalTokens: '总 Token 数', totalTokens: '总 Token 数',
promptTokens: 'Prompt Token 数', promptTokens: 'Prompt Token 数',
completionTokens: 'Completion Token 数', completionTokens: 'Completion Token 数',
@@ -997,6 +1000,9 @@ i18n
dailyUsage: '每日用量', dailyUsage: '每日用量',
byModel: '按模型统计', byModel: '按模型统计',
hourlyUsage: '每小时用量', hourlyUsage: '每小时用量',
loadFailed: '加载用量数据失败。',
retry: '重试',
allAccounts: '所有账号',
}, },
}, },
}, },
@@ -1483,9 +1489,9 @@ i18n
usage: { usage: {
title: 'Использование токенов', title: 'Использование токенов',
description: 'Мониторинг потребления API токенов.', description: 'Мониторинг потребления API токенов.',
range24h: '24 часа', range24h: 'Последние 24 часа',
range7d: '7 дней', range7d: 'Последние 7 дней',
range30d: '30 дней', range30d: 'Последние 30 дней',
totalTokens: 'Всего токенов', totalTokens: 'Всего токенов',
promptTokens: 'Prompt токены', promptTokens: 'Prompt токены',
completionTokens: 'Completion токены', completionTokens: 'Completion токены',
@@ -1493,6 +1499,9 @@ i18n
dailyUsage: 'Ежедневное использование', dailyUsage: 'Ежедневное использование',
byModel: 'По моделям', byModel: 'По моделям',
hourlyUsage: 'Почасовое использование', hourlyUsage: 'Почасовое использование',
loadFailed: 'Не удалось загрузить данные об использовании.',
retry: 'Повторить',
allAccounts: 'Все аккаунты',
}, },
}, },
}, },
+6 -3
View File
@@ -476,9 +476,9 @@ const vi = {
usage: { usage: {
title: 'Token Usage', title: 'Token Usage',
description: 'Theo dõi mức tiêu thụ API token của bạn.', description: 'Theo dõi mức tiêu thụ API token của bạn.',
range24h: '24 giờ', range24h: '24 giờ qua',
range7d: '7 ngày', range7d: '7 ngày qua',
range30d: '30 ngày', range30d: '30 ngày qua',
totalTokens: 'Tổng Token', totalTokens: 'Tổng Token',
promptTokens: 'Prompt Token', promptTokens: 'Prompt Token',
completionTokens: 'Completion Token', completionTokens: 'Completion Token',
@@ -486,6 +486,9 @@ const vi = {
dailyUsage: 'Sử dụng hàng ngày', dailyUsage: 'Sử dụng hàng ngày',
byModel: 'Theo Model', byModel: 'Theo Model',
hourlyUsage: 'Sử dụng theo giờ', hourlyUsage: 'Sử dụng theo giờ',
loadFailed: 'Không thể tải dữ liệu sử dụng.',
retry: 'Thử lại',
allAccounts: 'Tất cả tài khoản',
}, },
}; };
+75 -16
View File
@@ -19,12 +19,15 @@ import {
Legend, Legend,
} from 'recharts'; } from 'recharts';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4']; const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4'];
function UsagePage() { function UsagePage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [range, setRange] = useState<'24h' | '7d' | '30d'>('7d'); const [range, setRange] = useState<'24h' | '7d' | '30d'>('7d');
const [selectedAccountId, setSelectedAccountId] = useState<string | undefined>(undefined);
const { data: accounts } = useCloudAccounts();
function getTimeRange(r: '24h' | '7d' | '30d') { function getTimeRange(r: '24h' | '7d' | '30d') {
const now = Date.now(); const now = Date.now();
@@ -37,31 +40,50 @@ function UsagePage() {
return { start: now - 30 * 24 * 60 * 60 * 1000, end: now }; return { start: now - 30 * 24 * 60 * 60 * 1000, end: now };
} }
const { data: dailyData, isLoading: dailyLoading } = useQuery({ const {
queryKey: ['usage', 'day', range], data: dailyData,
isLoading: dailyLoading,
error: dailyError,
refetch: refetchDaily,
} = useQuery({
queryKey: ['usage', 'day', range, selectedAccountId],
queryFn: () => { queryFn: () => {
const { start, end } = getTimeRange(range); const { start, end } = getTimeRange(range);
return ipc.client.usage.getUsageByDay({ start, end }); return ipc.client.usage.getUsageByDay({ accountId: selectedAccountId, start, end });
}, },
refetchInterval: 30000,
}); });
const { data: modelData, isLoading: modelLoading } = useQuery({ const {
queryKey: ['usage', 'model', range], data: modelData,
isLoading: modelLoading,
error: modelError,
refetch: refetchModel,
} = useQuery({
queryKey: ['usage', 'model', range, selectedAccountId],
queryFn: () => { queryFn: () => {
const { start, end } = getTimeRange(range); const { start, end } = getTimeRange(range);
return ipc.client.usage.getUsageByModel({ start, end }); return ipc.client.usage.getUsageByModel({ accountId: selectedAccountId, start, end });
}, },
refetchInterval: 30000,
}); });
const { data: hourlyData, isLoading: hourlyLoading } = useQuery({ const {
queryKey: ['usage', 'hour', range], data: hourlyData,
isLoading: hourlyLoading,
error: hourlyError,
refetch: refetchHourly,
} = useQuery({
queryKey: ['usage', 'hour', range, selectedAccountId],
queryFn: () => { queryFn: () => {
const { start, end } = getTimeRange(range); const { start, end } = getTimeRange(range);
return ipc.client.usage.getUsageByHour({ start, end }); return ipc.client.usage.getUsageByHour({ accountId: selectedAccountId, start, end });
}, },
refetchInterval: 30000,
}); });
const isLoading = dailyLoading || modelLoading || hourlyLoading; const isLoading = dailyLoading || modelLoading || hourlyLoading;
const anyError = dailyError || modelError || hourlyError;
const totals = useMemo(() => { const totals = useMemo(() => {
const source = dailyData || []; const source = dailyData || [];
@@ -95,6 +117,8 @@ function UsagePage() {
const hourlyChartData = useMemo(() => { const hourlyChartData = useMemo(() => {
return (hourlyData || []).map((d) => ({ return (hourlyData || []).map((d) => ({
name: d.bucket, name: d.bucket,
Prompt: d.promptTokens,
Completion: d.completionTokens,
Total: d.totalTokens, Total: d.totalTokens,
})); }));
}, [hourlyData]); }, [hourlyData]);
@@ -107,6 +131,24 @@ function UsagePage() {
); );
} }
if (anyError) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4">
<p className="text-destructive">{t('usage.loadFailed')}</p>
<button
onClick={() => {
refetchDaily();
refetchModel();
refetchHourly();
}}
className="text-primary underline"
>
{t('usage.retry')}
</button>
</div>
);
}
return ( return (
<div className="container mx-auto max-w-6xl space-y-6 p-6"> <div className="container mx-auto max-w-6xl space-y-6 p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -114,13 +156,27 @@ function UsagePage() {
<h2 className="text-3xl font-bold tracking-tight">{t('usage.title')}</h2> <h2 className="text-3xl font-bold tracking-tight">{t('usage.title')}</h2>
<p className="text-muted-foreground mt-1">{t('usage.description')}</p> <p className="text-muted-foreground mt-1">{t('usage.description')}</p>
</div> </div>
<Tabs value={range} onValueChange={(v) => setRange(v as '24h' | '7d' | '30d')}> <div className="flex items-center gap-4">
<TabsList> <select
<TabsTrigger value="24h">{t('usage.range24h')}</TabsTrigger> value={selectedAccountId || ''}
<TabsTrigger value="7d">{t('usage.range7d')}</TabsTrigger> onChange={(e) => setSelectedAccountId(e.target.value || undefined)}
<TabsTrigger value="30d">{t('usage.range30d')}</TabsTrigger> className="border rounded px-2 py-1"
</TabsList> >
</Tabs> <option value="">{t('usage.allAccounts')}</option>
{accounts?.map((acc) => (
<option key={acc.id} value={acc.id}>
{acc.email}
</option>
))}
</select>
<Tabs value={range} onValueChange={(v) => setRange(v as '24h' | '7d' | '30d')}>
<TabsList>
<TabsTrigger value="24h">{t('usage.range24h')}</TabsTrigger>
<TabsTrigger value="7d">{t('usage.range7d')}</TabsTrigger>
<TabsTrigger value="30d">{t('usage.range30d')}</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div> </div>
{/* Summary Cards */} {/* Summary Cards */}
@@ -228,6 +284,9 @@ function UsagePage() {
<XAxis dataKey="name" /> <XAxis dataKey="name" />
<YAxis /> <YAxis />
<Tooltip /> <Tooltip />
<Legend />
<Bar dataKey="Prompt" fill="#3b82f6" />
<Bar dataKey="Completion" fill="#10b981" />
<Bar dataKey="Total" fill="#8b5cf6" /> <Bar dataKey="Total" fill="#8b5cf6" />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
+76 -25
View File
@@ -35,6 +35,7 @@ import { getMaxOutputTokens, getThinkingBudget } from '../../../lib/geminiNexus/
import { resolveRequestUserAgent } from './request-user-agent'; import { resolveRequestUserAgent } from './request-user-agent';
import { UpstreamRequestError } from './clients/upstream-error'; import { UpstreamRequestError } from './clients/upstream-error';
import { trafficLogger } from '../../../utils/traffic-logger'; import { trafficLogger } from '../../../utils/traffic-logger';
import { encode } from 'gpt-tokenizer';
@Injectable() @Injectable()
export class ProxyService { export class ProxyService {
@@ -46,16 +47,16 @@ export class ProxyService {
@Inject(TokenUsageService) private readonly tokenUsageService: TokenUsageService, @Inject(TokenUsageService) private readonly tokenUsageService: TokenUsageService,
) {} ) {}
private recordUsage( private async recordUsage(
accountId: string, accountId: string,
model: string, model: string,
usage: { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number } | undefined | null, usage: { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number } | undefined | null,
requestType: string, requestType: string,
): void { ): Promise<void> {
if (!usage) { if (!usage) {
return; return;
} }
this.tokenUsageService.recordUsage({ await this.tokenUsageService.recordUsage({
accountId, accountId,
model, model,
promptTokens: usage.promptTokenCount ?? 0, promptTokens: usage.promptTokenCount ?? 0,
@@ -66,6 +67,14 @@ export class ProxyService {
}); });
} }
private estimateTokens(text: string): number {
try {
return encode(text).length;
} catch {
return Math.ceil(text.length / 4);
}
}
// --- Anthropic Handlers --- // --- Anthropic Handlers ---
async handleAnthropicMessages( async handleAnthropicMessages(
@@ -131,7 +140,7 @@ export class ProxyService {
token.token.upstream_proxy_url, token.token.upstream_proxy_url,
extraHeaders, extraHeaders,
); );
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'anthropic'); await this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'anthropic');
return this.toAnthropicChatResponse(transformResponse(response)); return this.toAnthropicChatResponse(transformResponse(response));
} }
} catch (error) { } catch (error) {
@@ -163,7 +172,7 @@ export class ProxyService {
token.token.upstream_proxy_url, token.token.upstream_proxy_url,
extraHeaders, extraHeaders,
); );
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'anthropic'); await this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'anthropic');
return this.toAnthropicChatResponse(transformResponse(response)); return this.toAnthropicChatResponse(transformResponse(response));
} }
} catch (fallbackErr) { } catch (fallbackErr) {
@@ -202,7 +211,7 @@ export class ProxyService {
token.token.upstream_proxy_url, token.token.upstream_proxy_url,
extraHeaders, extraHeaders,
); );
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'anthropic'); await this.recordUsage(token.id, 'gemini-3-flash', response.usageMetadata, 'anthropic');
const transformed = this.toAnthropicChatResponse(transformResponse(response)); const transformed = this.toAnthropicChatResponse(transformResponse(response));
return { return {
...transformed, ...transformed,
@@ -232,6 +241,16 @@ export class ProxyService {
_model: string, _model: string,
accountId: string, accountId: string,
): Observable<string> { ): Observable<string> {
function extractUsageFromMetadata(meta: unknown): { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number } | undefined {
if (!meta || typeof meta !== 'object') return undefined;
const m = meta as Record<string, unknown>;
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<string>((subscriber) => { return new Observable<string>((subscriber) => {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
@@ -298,12 +317,10 @@ export class ProxyService {
const finishChunks = state.emitFinish(lastFinishReason, lastUsageMetadata); const finishChunks = state.emitFinish(lastFinishReason, lastUsageMetadata);
finishChunks.forEach((c) => subscriber.next(c)); finishChunks.forEach((c) => subscriber.next(c));
this.recordUsage( const usage = extractUsageFromMetadata(lastUsageMetadata);
accountId, if (usage) {
_model, void this.recordUsage(accountId, _model, usage, 'anthropic');
lastUsageMetadata as { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number }, }
'anthropic',
);
subscriber.complete(); subscriber.complete();
}); });
@@ -371,7 +388,7 @@ export class ProxyService {
extraHeaders, extraHeaders,
); );
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'gemini'); await this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'gemini');
return this.normalizeGeminiGenerateResponse(response); return this.normalizeGeminiGenerateResponse(response);
} catch (err) { } catch (err) {
if (err instanceof Error && this.isProjectContextError(err.message)) { if (err instanceof Error && this.isProjectContextError(err.message)) {
@@ -394,7 +411,7 @@ export class ProxyService {
token.token.upstream_proxy_url, token.token.upstream_proxy_url,
extraHeaders, extraHeaders,
); );
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'gemini'); await this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'gemini');
return this.normalizeGeminiGenerateResponse(response); return this.normalizeGeminiGenerateResponse(response);
} catch (fallbackErr) { } catch (fallbackErr) {
lastError = fallbackErr; lastError = fallbackErr;
@@ -468,7 +485,7 @@ export class ProxyService {
token.token.upstream_proxy_url, token.token.upstream_proxy_url,
extraHeaders, extraHeaders,
); );
return this.passthroughSseStream(stream); return this.passthroughSseStream(stream, token.id, effectiveTargetModel);
} catch (err) { } catch (err) {
if (err instanceof Error && this.isProjectContextError(err.message)) { if (err instanceof Error && this.isProjectContextError(err.message)) {
this.logger.warn( this.logger.warn(
@@ -490,7 +507,7 @@ export class ProxyService {
token.token.upstream_proxy_url, token.token.upstream_proxy_url,
extraHeaders, extraHeaders,
); );
return this.passthroughSseStream(stream); return this.passthroughSseStream(stream, token.id, effectiveTargetModel);
} catch (fallbackErr) { } catch (fallbackErr) {
lastError = fallbackErr; lastError = fallbackErr;
} }
@@ -511,14 +528,36 @@ export class ProxyService {
throw lastError || new Error('Gemini stream request failed after retries'); throw lastError || new Error('Gemini stream request failed after retries');
} }
private passthroughSseStream(upstreamStream: NodeJS.ReadableStream): Observable<string> { private passthroughSseStream(
upstreamStream: NodeJS.ReadableStream,
accountId: string,
model: string,
): Observable<string> {
return new Observable<string>((subscriber) => { return new Observable<string>((subscriber) => {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = '';
let receivedData = false; let receivedData = false;
let lastUsageMetadata: Record<string, unknown> | undefined;
upstreamStream.on('data', (chunk: Buffer) => { upstreamStream.on('data', (chunk: Buffer) => {
receivedData = true; receivedData = true;
subscriber.next(decoder.decode(chunk, { stream: true })); buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
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;
}
} catch { /* ignore */ }
}
}); });
upstreamStream.on('end', () => { upstreamStream.on('end', () => {
@@ -526,6 +565,9 @@ export class ProxyService {
subscriber.error(new Error('Empty response stream')); subscriber.error(new Error('Empty response stream'));
return; return;
} }
if (lastUsageMetadata) {
void this.recordUsage(accountId, model, lastUsageMetadata as any, 'gemini');
}
subscriber.complete(); subscriber.complete();
}); });
@@ -787,7 +829,7 @@ export class ProxyService {
token.token.upstream_proxy_url, token.token.upstream_proxy_url,
extraHeaders, extraHeaders,
); );
return this.processStreamResponse(stream, request.model, requestId); return this.processStreamResponse(stream, request.model, requestId, token.id, effectiveTargetModel);
} catch (streamError) { } catch (streamError) {
this.logger.warn( this.logger.warn(
`Stream path failed for model=${request.model}; falling back to non-stream generation: ${ `Stream path failed for model=${request.model}; falling back to non-stream generation: ${
@@ -803,7 +845,7 @@ export class ProxyService {
extraHeaders, extraHeaders,
); );
trafficLogger.logUpstreamResponse(requestId || 'unknown', '/v1/chat/completions', 200, response, 0); trafficLogger.logUpstreamResponse(requestId || 'unknown', '/v1/chat/completions', 200, response, 0);
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai'); await this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai');
this.logger.log( this.logger.log(
`Upstream response snippet after stream fallback: ${JSON.stringify(response).substring(0, 500)}`, `Upstream response snippet after stream fallback: ${JSON.stringify(response).substring(0, 500)}`,
); );
@@ -824,7 +866,7 @@ export class ProxyService {
this.logger.log( this.logger.log(
`Upstream response snippet (non-stream): ${JSON.stringify(response).substring(0, 500)}`, `Upstream response snippet (non-stream): ${JSON.stringify(response).substring(0, 500)}`,
); );
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai'); await this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai');
// Transform Gemini response to OpenAI format // Transform Gemini response to OpenAI format
const claudeResponse = transformResponse(response); const claudeResponse = transformResponse(response);
this.logger.log( this.logger.log(
@@ -851,7 +893,7 @@ export class ProxyService {
token.token.upstream_proxy_url, token.token.upstream_proxy_url,
extraHeaders, extraHeaders,
); );
return this.processStreamResponse(stream, request.model, requestId); return this.processStreamResponse(stream, request.model, requestId, token.id, effectiveTargetModel);
} }
trafficLogger.logUpstream(requestId || 'unknown', '/v1/chat/completions', fallbackBody, extraHeaders, { model: effectiveTargetModel }); trafficLogger.logUpstream(requestId || 'unknown', '/v1/chat/completions', fallbackBody, extraHeaders, { model: effectiveTargetModel });
@@ -862,7 +904,7 @@ export class ProxyService {
extraHeaders, extraHeaders,
); );
trafficLogger.logUpstreamResponse(requestId || 'unknown', '/v1/chat/completions', 200, response, 0); trafficLogger.logUpstreamResponse(requestId || 'unknown', '/v1/chat/completions', 200, response, 0);
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai'); await this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai');
const claudeResponse = transformResponse(response); const claudeResponse = transformResponse(response);
return this.convertClaudeToOpenAIResponse(claudeResponse, request.model); return this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
} catch (fallbackErr) { } catch (fallbackErr) {
@@ -1001,7 +1043,9 @@ export class ProxyService {
private processStreamResponse( private processStreamResponse(
upstreamStream: NodeJS.ReadableStream, upstreamStream: NodeJS.ReadableStream,
model: string, model: string,
requestId?: string, requestId: string | undefined,
accountId: string,
effectiveTargetModel: string,
): Observable<string> { ): Observable<string> {
return new Observable<string>((subscriber) => { return new Observable<string>((subscriber) => {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
@@ -1010,6 +1054,7 @@ export class ProxyService {
let hasSentRoleChunk = false; let hasSentRoleChunk = false;
let hasSentDone = false; let hasSentDone = false;
let toolCallIndex = 0; let toolCallIndex = 0;
let lastUsageMetadata: Record<string, unknown> | undefined;
const streamId = `chatcmpl-${uuidv4()}`; const streamId = `chatcmpl-${uuidv4()}`;
const created = Math.floor(Date.now() / 1000); const created = Math.floor(Date.now() / 1000);
@@ -1055,6 +1100,9 @@ export class ProxyService {
trafficLogger.logUpstreamResponse(requestId || 'unknown', '/v1/chat/completions', 200, json, 0, { rawLine: trimmed }); trafficLogger.logUpstreamResponse(requestId || 'unknown', '/v1/chat/completions', 200, json, 0, { rawLine: trimmed });
// v1internal API wraps response in {"response": {"candidates": [...]}} // v1internal API wraps response in {"response": {"candidates": [...]}}
const responsePayload = json.response ?? json; const responsePayload = json.response ?? json;
if (responsePayload.usageMetadata) {
lastUsageMetadata = responsePayload.usageMetadata;
}
const candidate = responsePayload.candidates?.[0]; const candidate = responsePayload.candidates?.[0];
const parts = candidate?.content?.parts || []; const parts = candidate?.content?.parts || [];
@@ -1178,7 +1226,6 @@ export class ProxyService {
pushChunk(finishChunk); pushChunk(finishChunk);
subscriber.next('data: [DONE]\n\n'); subscriber.next('data: [DONE]\n\n');
hasSentDone = true; hasSentDone = true;
subscriber.complete();
} }
} catch { } catch {
// ignore parse errors on individual SSE lines // ignore parse errors on individual SSE lines
@@ -1206,6 +1253,10 @@ export class ProxyService {
subscriber.next('data: [DONE]\n\n'); subscriber.next('data: [DONE]\n\n');
hasSentDone = true; hasSentDone = true;
} }
if (lastUsageMetadata) {
// TODO: integrate local tokenizer fallback for missing usageMetadata
void this.recordUsage(accountId, effectiveTargetModel, lastUsageMetadata as any, 'openai');
}
subscriber.complete(); subscriber.complete();
}); });
@@ -3,7 +3,7 @@ import { TokenUsageRepo, RecordUsageParams } from '../../../ipc/database/usageHa
@Injectable() @Injectable()
export class TokenUsageService { export class TokenUsageService {
recordUsage(params: RecordUsageParams): void { async recordUsage(params: RecordUsageParams): Promise<void> {
TokenUsageRepo.recordUsage(params); TokenUsageRepo.recordUsage(params);
} }
+193
View File
@@ -0,0 +1,193 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TokenUsageRepo } from '../../ipc/database/usageHandler';
const mockRun = vi.fn();
const mockAll = vi.fn();
const mockPrepare = vi.fn(() => ({ all: mockAll, run: mockRun }));
const mockClose = vi.fn();
const mockInsert = vi.fn(() => ({
values: vi.fn(() => ({ run: mockRun })),
}));
vi.mock('../../ipc/database/dbConnection', () => ({
openDrizzleConnection: vi.fn(() => ({
raw: {
prepare: mockPrepare,
close: mockClose,
},
orm: {
insert: mockInsert,
},
})),
}));
vi.mock('../../ipc/database/cloudHandler', () => ({
ensureDatabaseInitialized: vi.fn(),
}));
vi.mock('../../utils/paths', () => ({
getCloudAccountsDbPath: vi.fn(() => '/mock/db/path.sqlite'),
}));
vi.mock('../../utils/logger', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
},
}));
describe('TokenUsageRepo', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('recordUsage', () => {
it('should insert usage record into database', () => {
const params = {
accountId: 'acc-1',
model: 'gemini-pro',
promptTokens: 10,
completionTokens: 20,
totalTokens: 30,
timestamp: Date.now(),
requestType: 'chat',
};
TokenUsageRepo.recordUsage(params);
expect(mockInsert).toHaveBeenCalled();
expect(mockRun).toHaveBeenCalled();
});
it('should handle null requestType', () => {
const params = {
accountId: 'acc-1',
model: 'gemini-pro',
promptTokens: 10,
completionTokens: 20,
totalTokens: 30,
timestamp: Date.now(),
};
TokenUsageRepo.recordUsage(params);
expect(mockInsert).toHaveBeenCalled();
expect(mockRun).toHaveBeenCalled();
});
});
describe('getUsageByHour', () => {
it('should return hourly aggregated data', () => {
mockAll.mockReturnValue([
{
bucket: '2024-01-01 10:00',
promptTokens: 100,
completionTokens: 200,
totalTokens: 300,
requests: 5,
},
]);
const result = TokenUsageRepo.getUsageByHour('acc-1', 1704110400000, 1704114000000);
expect(mockPrepare).toHaveBeenCalled();
expect(result).toHaveLength(1);
expect(result[0].bucket).toBe('2024-01-01 10:00');
expect(result[0].totalTokens).toBe(300);
});
it('should return empty array on error', () => {
mockPrepare.mockImplementationOnce(() => {
throw new Error('DB error');
});
const result = TokenUsageRepo.getUsageByHour();
expect(result).toEqual([]);
});
});
describe('getUsageByDay', () => {
it('should return daily aggregated data', () => {
mockAll.mockReturnValue([
{
bucket: '2024-01-01',
promptTokens: 1000,
completionTokens: 2000,
totalTokens: 3000,
requests: 50,
},
]);
const result = TokenUsageRepo.getUsageByDay();
expect(mockPrepare).toHaveBeenCalled();
expect(result).toHaveLength(1);
expect(result[0].bucket).toBe('2024-01-01');
});
});
describe('getUsageByWeek', () => {
it('should return weekly aggregated data', () => {
mockAll.mockReturnValue([
{
bucket: '2024-01',
promptTokens: 5000,
completionTokens: 10000,
totalTokens: 15000,
requests: 200,
},
]);
const result = TokenUsageRepo.getUsageByWeek();
expect(result).toHaveLength(1);
});
});
describe('getUsageByMonth', () => {
it('should return monthly aggregated data', () => {
mockAll.mockReturnValue([
{
bucket: '2024-01',
promptTokens: 20000,
completionTokens: 40000,
totalTokens: 60000,
requests: 1000,
},
]);
const result = TokenUsageRepo.getUsageByMonth();
expect(result).toHaveLength(1);
});
});
describe('getUsageByModel', () => {
it('should return model aggregated data', () => {
mockAll.mockReturnValue([
{
model: 'gemini-pro',
promptTokens: 10000,
completionTokens: 20000,
totalTokens: 30000,
requests: 500,
},
{
model: 'claude-sonnet',
promptTokens: 5000,
completionTokens: 10000,
totalTokens: 15000,
requests: 250,
},
]);
const result = TokenUsageRepo.getUsageByModel();
expect(result).toHaveLength(2);
expect(result[0].model).toBe('gemini-pro');
});
});
});
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TokenUsageRepo } from '../../ipc/database/usageHandler';
import { usageRouter } from '../../ipc/usage/router';
describe('usageRouter', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should expose all usage endpoints', () => {
expect(usageRouter).toHaveProperty('recordUsage');
expect(usageRouter).toHaveProperty('getUsageByHour');
expect(usageRouter).toHaveProperty('getUsageByDay');
expect(usageRouter).toHaveProperty('getUsageByWeek');
expect(usageRouter).toHaveProperty('getUsageByMonth');
expect(usageRouter).toHaveProperty('getUsageByModel');
});
it('recordUsage should delegate to TokenUsageRepo.recordUsage', async () => {
const spy = vi.spyOn(TokenUsageRepo, 'recordUsage').mockImplementation(() => true);
const input = {
accountId: 'acc-1',
model: 'gemini-pro',
promptTokens: 10,
completionTokens: 20,
totalTokens: 30,
timestamp: Date.now(),
requestType: 'chat',
};
const handler = (usageRouter.recordUsage as any)['~orpc'].handler;
await handler({ input });
expect(spy).toHaveBeenCalledWith(input);
spy.mockRestore();
});
it('getUsageByHour should delegate to TokenUsageRepo.getUsageByHour', async () => {
const spy = vi.spyOn(TokenUsageRepo, 'getUsageByHour').mockReturnValue([]);
const input = { accountId: 'acc-1', start: 1000, end: 2000 };
const handler = (usageRouter.getUsageByHour as any)['~orpc'].handler;
await handler({ input });
expect(spy).toHaveBeenCalledWith('acc-1', 1000, 2000);
spy.mockRestore();
});
it('getUsageByDay should delegate to TokenUsageRepo.getUsageByDay', async () => {
const spy = vi.spyOn(TokenUsageRepo, 'getUsageByDay').mockReturnValue([]);
const input = { start: 1000, end: 2000 };
const handler = (usageRouter.getUsageByDay as any)['~orpc'].handler;
await handler({ input });
expect(spy).toHaveBeenCalledWith(undefined, 1000, 2000);
spy.mockRestore();
});
it('getUsageByWeek should delegate to TokenUsageRepo.getUsageByWeek', async () => {
const spy = vi.spyOn(TokenUsageRepo, 'getUsageByWeek').mockReturnValue([]);
const input = {};
const handler = (usageRouter.getUsageByWeek as any)['~orpc'].handler;
await handler({ input });
expect(spy).toHaveBeenCalledWith(undefined, undefined, undefined);
spy.mockRestore();
});
it('getUsageByMonth should delegate to TokenUsageRepo.getUsageByMonth', async () => {
const spy = vi.spyOn(TokenUsageRepo, 'getUsageByMonth').mockReturnValue([]);
const input = { accountId: 'acc-2' };
const handler = (usageRouter.getUsageByMonth as any)['~orpc'].handler;
await handler({ input });
expect(spy).toHaveBeenCalledWith('acc-2', undefined, undefined);
spy.mockRestore();
});
it('getUsageByModel should delegate to TokenUsageRepo.getUsageByModel', async () => {
const spy = vi.spyOn(TokenUsageRepo, 'getUsageByModel').mockReturnValue([]);
const input = { accountId: 'acc-1', start: 1, end: 2 };
const handler = (usageRouter.getUsageByModel as any)['~orpc'].handler;
await handler({ input });
expect(spy).toHaveBeenCalledWith('acc-1', 1, 2);
spy.mockRestore();
});
});