diff --git a/.prettierignore b/.prettierignore index a46a213..5ebf2fa 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,3 +10,4 @@ openspec/ .agent/ .claude/ .codex +pnpm-lock.yaml \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 03019bd..accbd93 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -50,4 +50,11 @@ export default defineConfig([ 'prefer-const': 'off', }, }, + { + // Node maintenance scripts are CommonJS; require() is the native API there. + files: ['scripts/**/*.js'], + rules: { + '@typescript-eslint/no-require-imports': 'off', + }, + }, ]); diff --git a/src/components/CloudAccountList.tsx b/src/components/CloudAccountList.tsx index 2e12653..5d211d7 100644 --- a/src/components/CloudAccountList.tsx +++ b/src/components/CloudAccountList.tsx @@ -417,7 +417,11 @@ export function CloudAccountList() { const setSelectionState = (id: string, selected: boolean) => setSelectedIds((prev) => { const next = new Set(prev); - selected ? next.add(id) : next.delete(id); + if (selected) { + next.add(id); + } else { + next.delete(id); + } return next; }); const toggleSelectAllAccounts = () => diff --git a/src/components/usage/CostReportCard.tsx b/src/components/usage/CostReportCard.tsx index 4e6a143..7ef3faf 100644 --- a/src/components/usage/CostReportCard.tsx +++ b/src/components/usage/CostReportCard.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { calculateCost } from '@/constants/pricing'; export interface ModelCostEntry { model: string; diff --git a/src/components/usage/ModelPricingDialog.tsx b/src/components/usage/ModelPricingDialog.tsx index dab5945..e9d2343 100644 --- a/src/components/usage/ModelPricingDialog.tsx +++ b/src/components/usage/ModelPricingDialog.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { Dialog, DialogContent, @@ -10,7 +10,6 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; -import { Label } from '@/components/ui/label'; import { Loader2, RefreshCw, RotateCcw } from 'lucide-react'; import { MODEL_PRICING } from '@/constants/pricing'; @@ -35,8 +34,16 @@ export const ModelPricingDialog: React.FC = ({ Record >({}); const [isFetching, setIsFetching] = useState(false); + const [pricingSource, setPricingSource] = useState<{ + open: boolean; + customPricing: typeof customPricing; + }>({ open, customPricing }); - useEffect(() => { + // Re-seed the editable copy when the dialog opens or the source pricing changes. + // Adjusting state during render replaces the previous synchronous setState inside + // an effect (https://react.dev/learn/you-might-not-need-an-effect). + if (pricingSource.open !== open || pricingSource.customPricing !== customPricing) { + setPricingSource({ open, customPricing }); if (open) { // Initialize local state with custom pricing or empty if default const initial: Record = {}; @@ -49,7 +56,7 @@ export const ModelPricingDialog: React.FC = ({ }); setLocalPricing(initial); } - }, [open, customPricing]); + } const handlePriceChange = (model: string, type: 'input' | 'output', value: string) => { const numValue = parseFloat(value); diff --git a/src/components/usage/MonitorTab.tsx b/src/components/usage/MonitorTab.tsx index 2d89c75..00ac8a6 100644 --- a/src/components/usage/MonitorTab.tsx +++ b/src/components/usage/MonitorTab.tsx @@ -58,9 +58,8 @@ export const MonitorTab: React.FC = () => { tokensPrompt: log.metadata?.tokensPrompt as number | undefined, tokensCompletion: log.metadata?.tokensCompletion as number | undefined, tokensTotal: - (((log.metadata?.tokensPrompt as number) || 0) + - ((log.metadata?.tokensCompletion as number) || 0)) || - undefined, + ((log.metadata?.tokensPrompt as number) || 0) + + ((log.metadata?.tokensCompletion as number) || 0) || undefined, })) : []; const stats = statsData?.success ? statsData.data : undefined; diff --git a/src/components/usage/TrafficLogTable.tsx b/src/components/usage/TrafficLogTable.tsx index 41f0f94..20fda99 100644 --- a/src/components/usage/TrafficLogTable.tsx +++ b/src/components/usage/TrafficLogTable.tsx @@ -26,7 +26,16 @@ interface TrafficLogTableProps { isLoading: boolean; } -type SortKey = 'timestamp' | 'direction' | 'endpoint' | 'model' | 'method' | 'status' | 'tokens' | 'cost' | 'duration'; +type SortKey = + | 'timestamp' + | 'direction' + | 'endpoint' + | 'model' + | 'method' + | 'status' + | 'tokens' + | 'cost' + | 'duration'; type SortDir = 'asc' | 'desc'; function SortIcon({ @@ -79,13 +88,10 @@ function getBodyPreview(body: unknown): string | null { // Request body: extract first user message if (Array.isArray(obj.messages)) { - const userMsg = obj.messages.find( - (m: Record) => m.role === 'user', - ); + const userMsg = obj.messages.find((m: Record) => m.role === 'user'); if (userMsg) { - const content = typeof userMsg.content === 'string' - ? userMsg.content - : JSON.stringify(userMsg.content); + const content = + typeof userMsg.content === 'string' ? userMsg.content : JSON.stringify(userMsg.content); return content.length > 120 ? content.slice(0, 120) + '…' : content; } } @@ -95,9 +101,7 @@ function getBodyPreview(body: unknown): string | null { const firstChoice = obj.choices[0] as Record | undefined; const message = firstChoice?.message as Record | undefined; if (message?.content && typeof message.content === 'string') { - return message.content.length > 120 - ? message.content.slice(0, 120) + '…' - : message.content; + return message.content.length > 120 ? message.content.slice(0, 120) + '…' : message.content; } } @@ -254,18 +258,16 @@ export const TrafficLogTable: React.FC = ({ logs, isLoadin toggleExpand(logKey)} > {/* Expand chevron */} {isExpanded ? ( - + ) : ( - + )} @@ -300,11 +302,9 @@ export const TrafficLogTable: React.FC = ({ logs, isLoadin )} - {log.tokensTotal ? ( - `${formatTokens(log.tokensPrompt)} / ${formatTokens(log.tokensCompletion)}` - ) : ( - '—' - )} + {log.tokensTotal + ? `${formatTokens(log.tokensPrompt)} / ${formatTokens(log.tokensCompletion)}` + : '—'} {log.durationMs != null ? `${log.durationMs}ms` : '—'} @@ -323,7 +323,7 @@ export const TrafficLogTable: React.FC = ({ logs, isLoadin
{/* Preview line */} {preview && ( -
+
"{preview}"
)} @@ -339,18 +339,32 @@ export const TrafficLogTable: React.FC = ({ logs, isLoadin )} {log.tokensPrompt != null && ( - + Input: {log.tokensPrompt.toLocaleString()} tokens )} {log.tokensCompletion != null && ( - + Output: {log.tokensCompletion.toLocaleString()} tokens )} {log.tokensTotal != null && log.model && ( - - Cost: ${calculateCost(log.model, log.tokensPrompt || 0, log.tokensCompletion || 0).toFixed(6)} + + Cost: $ + {calculateCost( + log.model, + log.tokensPrompt || 0, + log.tokensCompletion || 0, + ).toFixed(6)} )} {log.durationMs != null && ( @@ -363,11 +377,15 @@ export const TrafficLogTable: React.FC = ({ logs, isLoadin {/* Body content */}
- - {log.direction === 'inbound' ? 'Request Body' : log.direction === 'outbound' ? 'Response Body' : 'Payload'} + + {log.direction === 'inbound' + ? 'Request Body' + : log.direction === 'outbound' + ? 'Response Body' + : 'Payload'}
-
+                                
                                   {formatBody(log.body)}
                                 
diff --git a/src/ipc/proxy-advanced/handler.ts b/src/ipc/proxy-advanced/handler.ts index 2dbf331..bff424e 100644 --- a/src/ipc/proxy-advanced/handler.ts +++ b/src/ipc/proxy-advanced/handler.ts @@ -1,9 +1,6 @@ import { logger } from '../../utils/logger'; import { getServiceOrThrow, getServiceOptional } from './service-registry'; -import type { - IdeConfigResult, - SupportedIde, -} from '../../server/modules/proxy/proxy-ide-config.service'; +import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service'; import { TrafficLogsRepo } from '../database/proxyMetricsHandler'; import { CloudAccountRepo } from '../database/cloudHandler'; import { ConfigManager } from '../config/manager'; @@ -257,9 +254,7 @@ export async function getRecentRequests(): Promise<{ } } -export async function replayRequest( - requestId: string, -): Promise<{ +export async function replayRequest(requestId: string): Promise<{ success: boolean; data?: { original: unknown; newResponse: unknown }; error?: string; diff --git a/src/routes/usage.tsx b/src/routes/usage.tsx index 016641b..d372146 100644 --- a/src/routes/usage.tsx +++ b/src/routes/usage.tsx @@ -406,7 +406,7 @@ function UsagePage() {
- +
Analytics @@ -415,299 +415,301 @@ function UsagePage() {
{hasQuotaWarning && ( - - - Quota Warning - - One or more models have exceeded 80% of their allocated usage limit. - - - )} + + + Quota Warning + + One or more models have exceeded 80% of their allocated usage limit. + + + )} - {/* ─── Stat cards ─── */} - -
- -
+ {/* ─── Stat cards ─── */} + +
- Input Tokens - - - - - -

Tokens sent to the model (prompt, context, files).

-
-
-
- } - value={formatNumber(totals.promptTokens)} - icon={MessageSquare} + label="Total Tokens" + value={formatNumber(totals.totalTokens)} + icon={Zap} isLoading={dailyLoading} - accent="blue" + accent="amber" trend={ - prevDailyData - ? calcTrend(totals.promptTokens, prevTotals.promptTokens) - : undefined + prevDailyData ? calcTrend(totals.totalTokens, prevTotals.totalTokens) : undefined } - sparkline={sparklinePrompt} + sparkline={sparklineTotal} /> -
-
- - Output Tokens - - - - - -

Tokens generated by the model in response.

-
-
-
- } - value={formatNumber(totals.completionTokens)} - icon={Activity} - isLoading={dailyLoading} - accent="green" - trend={ - prevDailyData - ? calcTrend(totals.completionTokens, prevTotals.completionTokens) - : undefined - } - sparkline={sparklineCompletion} - /> -
- -
- +
+ - Estimated Cost + Input Tokens -

Approximate cost based on configured pricing.

+

Tokens sent to the model (prompt, context, files).

- + } + value={formatNumber(totals.promptTokens)} + icon={MessageSquare} + isLoading={dailyLoading} + accent="blue" + trend={ + prevDailyData + ? calcTrend(totals.promptTokens, prevTotals.promptTokens) + : undefined + } + sparkline={sparklinePrompt} + /> +
+
+ + Output Tokens + + + + + +

Tokens generated by the model in response.

+
+
+
+ } + value={formatNumber(totals.completionTokens)} + icon={Activity} + isLoading={dailyLoading} + accent="green" + trend={ + prevDailyData + ? calcTrend(totals.completionTokens, prevTotals.completionTokens) + : undefined + } + sparkline={sparklineCompletion} + /> + + +
+ +
+ Estimated Cost + + + + + +

Approximate cost based on configured pricing.

+
+
+
+ +
+ } + value={`$${totalCost.toFixed(2)}`} + icon={DollarSign} + isLoading={modelLoading} + accent="purple" + /> + + +
+ + {/* ─── Prompt/Completion ratio bar + top models ─── */} + {!dailyLoading && totals.totalTokens > 0 && ( +
+
+
+ + Input / Output + + + {formatNumber(totals.totalTokens)} total + +
+
+
+
+
+
+
+
+ + Input + + {promptRatio}% + +
+
+ + Output + + {completionRatio}% + +
- } - value={`$${totalCost.toFixed(2)}`} - icon={DollarSign} + + Avg/req:{' '} + + {formatNumber(avgReqSize)} + + +
+
+ + +
+ )} + + {/* ─── Charts — Area + Model ranking ─── */} +
+
+ (range === '24h' ? refetchHourly() : refetchDaily())} + emptyTitle="No usage data" + emptyDescription="Data will appear here once proxy requests are recorded." + errorMessage="Failed to load usage data" + retryLabel="Retry" + > +
+ (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`), + }} + tooltip={LineTooltip} + legends={[ + { + anchor: 'bottom', + direction: 'row', + justify: false, + translateX: 0, + translateY: 36, + itemsSpacing: 16, + itemWidth: 80, + itemHeight: 16, + itemDirection: 'left-to-right', + symbolSize: 8, + symbolShape: 'circle', + }, + ]} + animate={true} + motionConfig="gentle" + /> +
+
+
+ +
+ refetchModel()} + emptyTitle="No model data" + emptyDescription="Model distribution will appear after proxy usage." + errorMessage="Failed to load model data" + retryLabel="Retry" + > +
+ {topModelsData.map((m) => ( + + ))} +
+
+
+
+ + {/* ─── Cost Report ─── */} + {!dailyLoading && modelData && modelData.length > 0 && ( +
+ acc + r.inputCost, 0)} + totalOutputCost={costReport.reduce((acc, r) => acc + r.outputCost, 0)} + totalCost={totalCost} isLoading={modelLoading} - accent="purple" />
-
- + )} - {/* ─── Prompt/Completion ratio bar + top models ─── */} - {!dailyLoading && totals.totalTokens > 0 && ( -
-
-
- - Input / Output - - - {formatNumber(totals.totalTokens)} total - -
-
-
-
-
-
-
-
- - Input - {promptRatio}% -
-
- - Output - - {completionRatio}% - -
-
- - Avg/req:{' '} - - {formatNumber(avgReqSize)} - - -
-
- - -
- )} - - {/* ─── Charts — Area + Model ranking ─── */} -
-
- (range === '24h' ? refetchHourly() : refetchDaily())} - emptyTitle="No usage data" - emptyDescription="Data will appear here once proxy requests are recorded." - errorMessage="Failed to load usage data" - retryLabel="Retry" - > -
- (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`), - }} - tooltip={LineTooltip} - legends={[ - { - anchor: 'bottom', - direction: 'row', - justify: false, - translateX: 0, - translateY: 36, - itemsSpacing: 16, - itemWidth: 80, - itemHeight: 16, - itemDirection: 'left-to-right', - symbolSize: 8, - symbolShape: 'circle', - }, - ]} - animate={true} - motionConfig="gentle" - /> -
-
-
- -
- refetchModel()} - emptyTitle="No model data" - emptyDescription="Model distribution will appear after proxy usage." - errorMessage="Failed to load model data" - retryLabel="Retry" - > -
- {topModelsData.map((m) => ( - - ))} -
-
-
-
- - {/* ─── Cost Report ─── */} - {!dailyLoading && modelData && modelData.length > 0 && ( -
- acc + r.inputCost, 0)} - totalOutputCost={costReport.reduce((acc, r) => acc + r.outputCost, 0)} - totalCost={totalCost} - isLoading={modelLoading} - /> -
- )} - - {/* ─── Modals ─── */} + {/* ─── Modals ─── */} { const path = await import('path'); const agentDir = path.join(process.cwd(), 'temp_test_agent'); - return { - IS_DEV_ENVIRONMENT: false, + return { + IS_DEV_ENVIRONMENT: false, getAgentDir: vi.fn(() => agentDir), getAccountsFilePath: vi.fn(() => path.join(agentDir, 'accounts.json')), getBackupsDir: vi.fn(() => path.join(agentDir, 'backups')), diff --git a/src/tests/unit/cloudHandler-sync.test.ts b/src/tests/unit/cloudHandler-sync.test.ts index b9ca05e..0485979 100644 --- a/src/tests/unit/cloudHandler-sync.test.ts +++ b/src/tests/unit/cloudHandler-sync.test.ts @@ -57,7 +57,7 @@ vi.mock('../../ipc/database/dbConnection', () => ({ }), })); -vi.mock('../../utils/paths', () => ({ +vi.mock('../../utils/paths', () => ({ IS_DEV_ENVIRONMENT: false, getGeminiNexusDbPaths: () => ['mock-db'], getCloudAccountsDbPath: () => 'mock-cloud-db', diff --git a/src/tests/unit/database-backup-keys.test.ts b/src/tests/unit/database-backup-keys.test.ts index 07561b4..ec7f76e 100644 --- a/src/tests/unit/database-backup-keys.test.ts +++ b/src/tests/unit/database-backup-keys.test.ts @@ -21,7 +21,7 @@ vi.mock('../../ipc/database/dbConnection', () => ({ }), })); -vi.mock('../../utils/paths', () => ({ +vi.mock('../../utils/paths', () => ({ IS_DEV_ENVIRONMENT: false, getGeminiNexusDbPaths: () => ['mock-db'], })); diff --git a/src/tests/unit/database.test.ts b/src/tests/unit/database.test.ts index 26db91f..13e911c 100644 --- a/src/tests/unit/database.test.ts +++ b/src/tests/unit/database.test.ts @@ -47,7 +47,7 @@ vi.mock('../../utils/logger', () => ({ }, })); -vi.mock('../../utils/paths', () => ({ +vi.mock('../../utils/paths', () => ({ IS_DEV_ENVIRONMENT: false, getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/test.vscdb']), })); diff --git a/src/tests/unit/ipc/cloud-repo.test.ts b/src/tests/unit/ipc/cloud-repo.test.ts index ce003f9..64bece0 100644 --- a/src/tests/unit/ipc/cloud-repo.test.ts +++ b/src/tests/unit/ipc/cloud-repo.test.ts @@ -64,7 +64,7 @@ vi.mock('../../../ipc/database/dbConnection', () => ({ configureDatabase: vi.fn(), })); -vi.mock('../../../utils/paths', () => ({ +vi.mock('../../../utils/paths', () => ({ IS_DEV_ENVIRONMENT: false, getCloudAccountsDbPath: vi.fn(() => '/mock/db/path.sqlite'), })); diff --git a/src/tests/unit/ipc/database-handler.test.ts b/src/tests/unit/ipc/database-handler.test.ts index 6e4614a..0a86e93 100644 --- a/src/tests/unit/ipc/database-handler.test.ts +++ b/src/tests/unit/ipc/database-handler.test.ts @@ -48,7 +48,7 @@ vi.mock('../../../utils/logger', () => ({ }, })); -vi.mock('../../../utils/paths', () => ({ +vi.mock('../../../utils/paths', () => ({ IS_DEV_ENVIRONMENT: false, getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/test.vscdb']), })); diff --git a/src/tests/unit/ipc/device-handler.test.ts b/src/tests/unit/ipc/device-handler.test.ts index 50ea2c0..61cc820 100644 --- a/src/tests/unit/ipc/device-handler.test.ts +++ b/src/tests/unit/ipc/device-handler.test.ts @@ -52,7 +52,7 @@ vi.mock('../../../utils/logger', () => ({ }, })); -vi.mock('../../../utils/paths', () => ({ +vi.mock('../../../utils/paths', () => ({ IS_DEV_ENVIRONMENT: false, getGeminiNexusStoragePaths: vi.fn().mockReturnValue(['/tmp/storage.json']), getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/state.vscdb']), diff --git a/src/tests/unit/ipc/proxy-advanced-handler.test.ts b/src/tests/unit/ipc/proxy-advanced-handler.test.ts index 110cb68..d3c3504 100644 --- a/src/tests/unit/ipc/proxy-advanced-handler.test.ts +++ b/src/tests/unit/ipc/proxy-advanced-handler.test.ts @@ -460,5 +460,5 @@ describe('Proxy Advanced Handler', () => { expect(result.data?.proxySetting).toBe('OPENAI_BASE_URL'); vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = null; }); - }); + }); }); diff --git a/src/tests/unit/ipc/proxy-metrics-repo.test.ts b/src/tests/unit/ipc/proxy-metrics-repo.test.ts index 0b8a0b2..861d679 100644 --- a/src/tests/unit/ipc/proxy-metrics-repo.test.ts +++ b/src/tests/unit/ipc/proxy-metrics-repo.test.ts @@ -24,7 +24,7 @@ vi.mock('../../../ipc/database/cloudHandler', () => ({ ensureDatabaseInitialized: vi.fn(), })); -vi.mock('../../../utils/paths', () => ({ +vi.mock('../../../utils/paths', () => ({ IS_DEV_ENVIRONMENT: false, getCloudAccountsDbPath: vi.fn(() => '/mock/db/path.sqlite'), })); diff --git a/src/tests/unit/logger.test.ts b/src/tests/unit/logger.test.ts index 92a70a6..8794f5d 100644 --- a/src/tests/unit/logger.test.ts +++ b/src/tests/unit/logger.test.ts @@ -4,8 +4,8 @@ import path from 'path'; vi.mock('../../utils/paths', async () => { const path = await import('path'); - return { - IS_DEV_ENVIRONMENT: false, + return { + IS_DEV_ENVIRONMENT: false, getAgentDir: vi.fn(() => path.join(process.cwd(), 'temp_test_logs')), }; }); diff --git a/src/tests/unit/setup.ts b/src/tests/unit/setup.ts index 36c7121..9df4c66 100644 --- a/src/tests/unit/setup.ts +++ b/src/tests/unit/setup.ts @@ -104,8 +104,8 @@ vi.mock('electron', () => { session: { webRequest: { onBeforeSendHeaders: vi.fn(), - } - } + }, + }, }, on: vi.fn(), })), diff --git a/src/tests/unit/usage-handler.test.ts b/src/tests/unit/usage-handler.test.ts index e6c0fbc..fdf5886 100644 --- a/src/tests/unit/usage-handler.test.ts +++ b/src/tests/unit/usage-handler.test.ts @@ -25,7 +25,7 @@ vi.mock('../../ipc/database/cloudHandler', () => ({ ensureDatabaseInitialized: vi.fn(), })); -vi.mock('../../utils/paths', () => ({ +vi.mock('../../utils/paths', () => ({ IS_DEV_ENVIRONMENT: false, getCloudAccountsDbPath: vi.fn(() => '/mock/db/path.sqlite'), }));