Compare commits

..
Author SHA1 Message Date
Gemini Nexus Bot 3573009e0a fix(security): resolve 32 pnpm audit vulnerabilities
Upgrade direct dependencies to patched versions:
- electron: 37.3.1 -> 39.8.5
- vite: 5.4.21 -> 6.4.2
- @tailwindcss/vite: 4.1.17 -> 4.2.4
- fastify: 5.8.4 -> 5.8.5
- uuid: 13.0.0 -> 14.0.0
- Remove @types/uuid (uuid 14 ships its own types)

Add pnpm.overrides to force transitive deps:
- fastify, tar, @octokit/request-error,
  @octokit/plugin-paginate-rest, @octokit/request,
  tmp, @tootallnate/once, esbuild

pnpm audit: 0 vulnerabilities (was 32: 6L/15M/11H)
2026-04-27 23:05:10 -03:00
Gemini Nexus Bot afe8859f16 test: fix broken unit tests, add missing fixtures, and stabilize CI pipeline
- Fix os module mock pattern for Vitest v4 in proxy-ide-config tests

- Fix enterprise oauth_client_key expectations in cloudHandler-sync and token-manager-parity tests

- Fix proxy-controller integration test argument expectations

- Add missing proxy-parity JSON fixtures (OpenAI tools, Anthropic tool-use, Gemini SSE)

- Add reasoning_content mapping to convertClaudeToOpenAIResponse

- Fix google-api service test env var cache leak with resetModules + dynamic imports

- Fix proxy-metrics timer tests by calling onModuleInit in beforeEach

- Fix locale-sensitive number formatting in StatCard tests

- Add missing test files to version control (components, hooks, ipc, mocks, server, utils)

- Fix CI workflows: testing.yaml (use vitest run, add type-check job, npm cache), lint.yaml and format.yaml (add npm cache, concurrency, fix job names)
2026-04-23 16:57:58 -03:00
Gemini Nexus Bot bf4bcf4f96 fix: apply code review fixes (fire-and-forget usage, theme-aware chart colors) 2026-04-23 03:00:58 -03:00
Gemini Nexus Bot fd1b2b8bc0 fix: resolve TypeScript type-check errors
- Add cacheHitRate to ProxyMetricsService, handler, and router schema
- Fix StatCard to support string values and purple accent
- Fix duplicate tabs keys in i18n translations (EN/ZH/RU)
- Add missing cacheStatus translations across all languages
2026-04-23 02:50:09 -03:00
Gemini Nexus Bot 0be7d7fea2 feat(proxy): implement metrics persistence, traffic logs DB, and advanced monitoring
- Add SQLite schema for proxy metrics and traffic logs
- Implement ProxyMetricsRepo with historical aggregates
- Add TrafficLogsRepo for DB-backed traffic log queries
- Create proxy-advanced IPC handlers (metrics, traffic logs, circuit breaker, parity)
- Add ProxyMetricsService with connection snapshots and cleanup
- Add ProxyReplayService and ProxyIdeConfigService
- Implement MonitorTab and ToolsTab for advanced proxy features
- Add TrafficLogTable with sorting and filtering
- Fix i18n keys for proxy metrics
- Fix generateIdeConfig type mapping
- Update proxy routes with advanced tab integration
2026-04-23 00:57:01 -03:00
Gemini Nexus Bot c73537d44c fix(usage): use sentinel value for All Accounts select to prevent Radix empty string crash 2026-04-22 16:26:36 -03:00
Gemini Nexus Bot 2f42d70d78 feat(usage): refactor dashboard frontend with shadcn/ui components, skeleton states, and dark-theme charts 2026-04-22 16:23:45 -03:00
Gemini Nexus Bot 425b0009b8 fix(usage): apply code review fixes - SSE passthrough type, per-chart errors, Select component 2026-04-22 15:31:54 -03:00
Gemini Nexus Bot 9b37bdb2d9 fix(usage): address code review findings - SQL injection, stream usage recording, DB singleton 2026-04-22 14:34:39 -03:00
Gemini Nexus Bot 1258b32cb5 chore(deps): add recharts for usage dashboard visualizations 2026-04-22 13:58:59 -03:00
Gemini Nexus Bot 98c08bd64f fix(proxy): register TokenUsageService in ProxyModule providers 2026-04-22 13:58:26 -03:00
Gemini Nexus Bot 8c8b328120 i18n: add usage dashboard translations for all languages 2026-04-22 13:31:09 -03:00
Gemini Nexus Bot a225791e71 feat(usage): add usage dashboard page with charts and navigation 2026-04-22 13:30:58 -03:00
Gemini Nexus Bot 1a1c0acd16 feat(proxy): inject token usage recording into all handler paths 2026-04-22 13:30:46 -03:00
Gemini Nexus Bot ce21283170 feat(usage): add token usage service and IPC router endpoints 2026-04-22 13:30:34 -03:00
Gemini Nexus Bot 2a5e54184f feat(db): add token_usage table and usage repository 2026-04-22 13:30:22 -03:00
Gemini Nexus Bot ccc5d36a0d chore: update publisher to evandrodevbr and consolidate icon references
- Change author from Draculabo to evandrodevbr in package.json
- Update GitHub publisher config in forge.config.ts (owner/repo/iconUrl)
- Add favicon link in index.html pointing to src/assets/icon.png
- Ensure all app icon references use images/icon.* consistently
2026-04-22 06:01:11 -03:00
Gemini Nexus Bot bfdb58fc81 i18n(pt-BR): add Portuguese translation for install notice dialog 2026-04-22 05:55:34 -03:00
Gemini Nexus Bot b32dd24765 build: add automated WiX extraction script for Windows builds
- Create scripts/build-windows.js that extracts wix314-binaries.zip to tools/wix314/
- Adds extracted dir to PATH so electron-wix-msi can find candle.exe/light.exe
- Adds npm script 'make:win' for one-command Windows installer build
2026-04-22 05:44:41 -03:00
Gemini Nexus Bot 2c81d129f9 fix(streaming): resolve Gemini v1internal SSE wrapped format and OpenCode compatibility
- Fix processStreamResponse to unwrap json.response ?? json for Gemini v1internal wrapped SSE chunks
- Add initial role:assistant delta chunk for Vercel AI SDK / OpenCode compatibility
- Add trafficLogger console transport for real-time debugging
- Add upstream request/response logging throughout proxy paths
- Update proxy-controller.integration and proxy-retry-mock tests for new signatures
- Add comprehensive proxy-stream-debug unit tests (8 cases including wrapped format regression)
- Add OpenCode integration documentation
2026-04-22 05:26:57 -03:00
Gemini Nexus Bot f4b713c3f4 Merge branch 'vk/53e9-pesquise-os-melh' into main 2026-04-22 03:26:52 -03:00
Gemini Nexus Bot 5b130aca66 fix(stream): restore processStreamResponse to original working behavior
The initial role chunk (role:'assistant', content:'') added in a3eabc2
was causing the OpenCode TUI to receive an empty first delta and
discard all subsequent content, showing only the completion time with
no text output.

Restored to the exact streaming behavior from e524a340 that worked:
- Remove the pre-emptive initial chunk entirely
- Move hasEmittedChunk=true back inside pushChunk (original position)
- Use reasoning_content delta field for thought parts instead of
  wrapping in <think> tags (which corrupts plain text responses)
- Remove streamOptions/include_usage/lastUsageMetadata tracking
  (not present in the working version)
- Remove isThinking state machine (was adding </think> noise to output)

Kept from the spec compliance work (a3eabc2):
- toolCallIndex counter for correct finish_reason:'tool_calls' mapping
- Comment explaining why we override Gemini's STOP finish reason
2026-04-22 03:26:51 -03:00
Gemini Nexus Bot 4653fa2b41 Merge branch 'vk/53e9-pesquise-os-melh' into main 2026-04-22 03:17:50 -03:00
Gemini Nexus Bot a3eabc2aaa fix(api): full OpenAI-spec compliance for OpenCode integration
Route fixes:
- Add POST /v1/messages/chat/completions alias so OpenCode clients
  misconfigured with baseURL ending in /v1/messages no longer get 404

Session affinity:
- Extract x-session-affinity HTTP header (sent by OpenCode) and use it
  as the session key for token stickiness, falling back to body-level
  session_id fields if the header is absent

Tool calling (streaming):
- finish_reason is now 'tool_calls' when function calls were emitted in
  the stream (Gemini always sends STOP, so we check toolCallIndex > 0)
- createSyntheticOpenAIStream (stream fallback path) now emits all
  tool_call deltas before the finish chunk instead of silently dropping
  them when content is null

Tool calling (non-streaming):
- content is now always null when tool_calls are present, satisfying
  strict clients that reject non-null content alongside tool_calls
- system_fingerprint field added to OpenAIChatResponse interface and
  response builder for OpenAI spec compliance

Usage accounting:
- thoughtsTokenCount is now included in completion_tokens and
  total_tokens for the streaming usage chunk, fixing undercount for
  thinking/reasoning model runs
2026-04-22 03:17:46 -03:00
Gemini Nexus Bot 099671a4e5 Merge branch 'vk/53e9-pesquise-os-melh' into main 2026-04-22 02:34:27 -03:00
Gemini Nexus Bot 0f9d08018b fix: resolve startup issues with account activation, credit fetch and poll noise
- GoogleAPIService: try loadCodeAssist first for AI credits since
  fetchCredits returns 404 for many accounts; keep fetchCredits as
  fallback, removing the spurious warn on every poll cycle
- CloudAccountRepo: auto-activate the most-recently-used account when
  no account has is_active=1; downgrade noisy [DEBUG] log from info
  to debug level
- StatusBar: increase process-status refetch interval from 10s to 30s
  to reduce redundant IPC calls and log noise
2026-04-22 02:33:51 -03:00
339 changed files with 78986 additions and 919 deletions

No files matched your search

+15 -3
View File
@@ -1,17 +1,29 @@
name: Check formatting
on:
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
tests-unit:
check:
name: Prettier Format Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run format check
run: npm run format
+14 -2
View File
@@ -1,17 +1,29 @@
name: Lint
on:
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
name: ESLint Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run lint
run: npm run lint
+34 -3
View File
@@ -1,22 +1,53 @@
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
tests-unit:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit
run: npm test
type-check:
name: Type Check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run TypeScript type check
run: npm run type-check
# NOTE: E2E tests are disabled in CI because Electron apps require a graphical environment.
# Run E2E tests locally with: npm run test:e2e
+29
View File
@@ -0,0 +1,29 @@
## Design Context
### Users
Developers and power users who manage multiple AI accounts (Google Gemini, Claude) through a desktop Electron app. They use IDEs like Cursor, OpenCode, and Windsurf. They need to monitor token usage across accounts in real-time to avoid quota exhaustion.
### Brand Personality
- **Professional**: Clean, precise, no clutter
- **Technical**: Data-dense but readable, developer-friendly
- **Efficient**: Information at a glance, minimal clicks
- 3 words: "precise", "technical", "focused"
### Aesthetic Direction
- Dark mode primary (this is a developer tool used for long sessions)
- Deep navy/slate backgrounds, not pure black
- Sharp accent colors for data visualization (subtle blues, teals, amber for warnings)
- High information density without visual noise
- Monospace for numbers/data, clean sans-serif for UI chrome
- No gradients on text, no glassmorphism, no rounded-everything
### Design Principles
1. Data first — charts and numbers are the heroes
2. Every pixel earns its place — no decorative elements
3. Density over whitespace — developers want to see everything
4. Motion only for state changes — no gratuitous animations
5. Consistent with shadcn/ui + Tailwind system already in place
@@ -0,0 +1,58 @@
Here is the code review report for the Token Usage Counter feature.
## Summary
The review found 2 critical security issues and 2 warnings. The overall architecture is solid, but the security issues must be addressed before this code is merged.
## Critical Issues
### CR-01: SQL Injection Vulnerability in TokenUsageRepo filter methods
**Issue:** `buildAccountFilter` concatenates `accountId` directly into raw SQL.
**Fix:** Use parameterized queries.
```typescript
function buildAccountFilter(accountId?: string): { sql: string; params: string[] } {
return accountId ? { sql: `AND account_id = ?`, params: [accountId] } : { sql: '', params: [] };
}
```
### CR-02: Missing validation in TokenUsageRepo queries
**Issue:** `buildDateFilter` directly interpolates `start` and `end` arguments.
**Fix:** Ensure parameters are passed safely using parameterized queries.
```typescript
function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
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,
};
}
```
## Warnings
### WR-01: Synchronous Database Writing in ProxyService
**Issue:** `recordUsage` calls a synchronous database insertion on every proxy request, which can cause severe performance bottlenecks under load.
**Fix:** Consider making the usage recording asynchronous (fire-and-forget), using an asynchronous database driver, or implementing a batching mechanism.
### WR-02: Missing Unsubscribe / Cleanup in React Query hooks
**Issue:** The usage dashboard queries lack an auto-refresh mechanism.
**Fix:** Add `refetchInterval: 10000` to the query configuration.
## Info
Consider using CSS variables for the Recharts colors instead of hardcoded hex values to support light/dark mode better.
@@ -0,0 +1,102 @@
---
phase: 02-code-review-command
reviewed: 2026-04-22T14:00:00Z
depth: standard
files_reviewed: 10
files_reviewed_list:
- src/ipc/database/cloudHandler.ts
- src/ipc/database/schema.ts
- src/ipc/database/usageHandler.ts
- src/ipc/router.ts
- src/ipc/usage/router.ts
- src/server/modules/proxy/proxy.module.ts
- src/server/modules/proxy/proxy.service.ts
- src/server/modules/usage/token-usage.service.ts
- src/types/db.ts
- src/routes/usage.tsx
status: issues_found
---
# Phase 02-code-review-command: Code Review Report
**Reviewed:** 2026-04-22T14:00:00Z
**Depth:** standard
**Files Reviewed:** 10
**Status:** issues_found
## Summary
This review covers the new Token Usage Counter feature introduced in the `feature/token-usage-counter` branch. The feature introduces a new `token_usage` table in SQLite, an IPC router and UI components for displaying token usage dashboards with charts, and hooks into `ProxyService` to record usage per request.
Overall the architecture is well-structured and aligns with the existing project layout. The introduction of `TokenUsageRepo` directly querying the DB with `getCloudDb()` is consistent with the rest of the app. The UI is built using Recharts and properly localized.
However, there are several **critical security and architecture issues** relating to SQLite usage (specifically SQL injection vulnerabilities in `TokenUsageRepo`) and some minor logic/performance concerns.
## Critical Issues
### CR-01: SQL Injection Vulnerability in TokenUsageRepo filter methods
**File:** `src/ipc/database/usageHandler.ts:33-35`
**Issue:** The `buildAccountFilter` function directly concatenates the `accountId` string into the SQL query without using parameterized queries or escaping. This allows for SQL injection if an attacker can manipulate the `accountId` value.
**Fix:**
Use parameterized queries (e.g., `?` placeholders) and pass the `accountId` as an argument to `raw.prepare().all(accountId)`.
```typescript
function buildAccountFilter(accountId?: string): { sql: string; params: string[] } {
return accountId ? { sql: `AND account_id = ?`, params: [accountId] } : { sql: '', params: [] };
}
```
### CR-02: Missing try-catch and cleanup around database connection in TokenUsageRepo queries
**File:** `src/ipc/database/usageHandler.ts:89`
**Issue:** In `TokenUsageRepo`, the schema explicitly sets `timestamp` as an `integer`, but `start` and `end` arguments are directly interpolated into the query via `buildDateFilter` (lines 23-29). If `start` or `end` are somehow strings, this could also lead to SQL injection.
**Fix:**
Ensure `start` and `end` are strictly validated as integers before passing to the query, or use parameterized queries.
```typescript
function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
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,
};
}
```
## Warnings
### WR-01: Synchronous Database Writing in ProxyService
**File:** `src/server/modules/proxy/proxy.service.ts:57`
**Issue:** The `recordUsage` method calls `this.tokenUsageService.recordUsage()`, which internally calls `TokenUsageRepo.recordUsage()`. `TokenUsageRepo.recordUsage()` performs a synchronous database insertion (`orm.insert(...).run()`) and closes the connection. Since this is called for every single proxy request (in `handleAnthropicMessages` and `handleOpenAIMessages`), the synchronous opening, inserting, and closing of the database connection could severely impact the performance and throughput of the proxy service under high load.
**Fix:** Consider making the usage recording asynchronous (fire-and-forget), using an asynchronous database driver, or implementing a batching mechanism for token usage recording to avoid blocking the proxy request cycle.
### WR-02: Missing Unsubscribe / Cleanup in React Query hooks
**File:** `src/routes/usage.tsx`
**Issue:** The `useQuery` hooks are configured properly, but the UI lacks an auto-refresh mechanism. Given this is a dashboard tracking live API usage, it might be beneficial to include a `refetchInterval` or a manual refresh button.
**Fix:** Add `refetchInterval: 10000` to the query configuration.
## Info
### IN-01: UI Chart Colors and Styling
**File:** `src/routes/usage.tsx:23`
**Issue:** The `COLORS` array is hardcoded with hex values. In a themable application (e.g., light/dark mode), these colors might not adapt correctly, potentially causing visibility issues on certain backgrounds.
**Fix:** Consider using CSS variables from the theme (e.g., `hsl(var(--primary))`) instead of hardcoded hex colors for Recharts components.
---
_Reviewed: 2026-04-22T14:00:00Z_
_Reviewer: OpenCode (gsd-code-reviewer)_
_Depth: standard_
@@ -0,0 +1,92 @@
# Proxy Page — Feature Brainstorm
> Generated from designer agent analysis of `src/routes/proxy.tsx`
> Date: 2026-04-22
## Current State
The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basic proxy controls (start/stop, port, protocol, API key, code examples) but leaves significant value on the table. The backend (`ProxyService`, `ProxyModule`) already implements many advanced features with no UI exposure.
## Proposed New Features
### 1. Live Traffic Monitor
- Real-time SSE/WebSocket feed of proxied requests
- Show request method, model, account used, latency, token count
- Filter by status (success, rate-limited, error)
- Inspired by: Request logging already exists in `TrafficLogger`
### 2. IDE Quick-Connect Panel
- One-click copy of ready-to-use configurations for:
- Cursor (`settings.json` snippet)
- OpenCode (`.env` snippet)
- Windsurf (`config.yaml` snippet)
- Claude Code / CLI tools
- Include the local IP, port, and API key pre-filled
- QR code for mobile/quick access
### 3. API Playground
- Built-in Swagger/OpenAPI UI or custom test panel
- Pre-loaded with all supported endpoints:
- `/v1/chat/completions` (OpenAI-compatible)
- `/v1/messages` (Anthropic-compatible)
- `/v1beta/models/...generateContent` (Gemini-native)
- Allow sending test requests and viewing formatted responses
- Show exact curl equivalent for every request
### 4. Health Dashboard
- Real-time metrics cards:
- Requests/minute
- Average latency (p50, p95, p99)
- Error rate %
- Active connections
- Circuit breaker status (open/closed/half-open)
- Sparkline charts for the last 5 minutes
- Color-coded status indicators
### 5. Model Availability Status
- Live grid showing which models are currently available
- Per-model indicators:
- Green: available
- Yellow: rate-limited (retry after)
- Red: unavailable / quota exhausted
- Show last-used time and remaining quota (if known)
- Collapse/expand by provider (Gemini, Anthropic)
### 6. Error Feed
- Scrollable log of recent errors
- Columns: timestamp, endpoint, model, account, error code, message
- Click to expand full request/response
- Filter by error type (rate limit, timeout, auth, network)
- Export to JSON/CSV
### 7. Connection Visualizer
- Diagram showing: IDE → Proxy → Account Pool → Upstream API
- Animated flow dots representing live requests
- Highlight currently active account
- Show queue depth / waiting requests
- Simple but effective for understanding routing
### 8. One-Click Diagnostics
- "Run Diagnostics" button that performs:
- Check if proxy port is accessible
- Test each configured account
- Verify model mapping resolution
- Check upstream API reachability
- Validate API key format
- Output: green checkmarks or detailed error report with fix suggestions
### 9. Session Stats
- Summary of current session since proxy started:
- Total requests handled
- Total tokens proxied
- Unique accounts used
- Top 3 models used
- Uptime counter
- Resettable per-session (not persisted)
---
## Implementation Notes
- Many of these features require new IPC endpoints in `src/ipc/proxy/router.ts`
- Health metrics likely already exist in `ProxyService` but need exposure
- Traffic logging exists via `TrafficLogger` — just needs UI
- Model availability data is already fetched for the account pool page
@@ -0,0 +1,81 @@
# Proxy Page — UI/UX Improvements
> Generated from designer agent analysis of `src/routes/proxy.tsx`
> Date: 2026-04-22
## Current Pain Points
1. **Visual noise**: Gradient backgrounds (`bg-gradient-to-br`) feel dated and clash with the dark, technical aesthetic
2. **Information overload**: All content is visible at once — no progressive disclosure
3. **Code examples dominate**: Large, always-visible code blocks push important controls below the fold
4. **Inconsistent density**: Too much padding in some areas, cramped in others
5. **Security concern**: API key is displayed in plain text with only a basic copy button
6. **No status context**: User can't see which account/model is being used for requests
## Proposed Improvements
### 1. Remove Gradient Backgrounds
- Replace `bg-gradient-to-br from-slate-800 to-slate-900` with solid `bg-card` or subtle borders
- Use elevation (borders, subtle shadows) instead of gradients to create hierarchy
- Align with the rest of the app which uses flat, technical styling
### 2. Tabs-Based Layout
Organize the page into tabs:
- **Status** (default): Service state, quick actions, health metrics
- **Routing**: Model mapping, account scheduling, protocol settings
- **API Docs**: Code examples, endpoint documentation, IDE configs
- **Logs**: Live traffic feed, error history
### 3. Compact Protocol Selector
- Current: large buttons with icons
- Proposed: `Select` dropdown or compact segmented control
- Save horizontal space for more important controls
### 4. Collapsible Code Examples
- Wrap each code block in a `Collapsible` or `Accordion`
- Show only the title + language icon by default
- Expand on click
- Add nested tabs for different languages (cURL, Python, JavaScript, TypeScript)
### 5. Security-Focused API Key Display
- Mask the API key by default (show `sk-••••••••`)
- Reveal on explicit "Show" toggle
- Add "Regenerate" button with confirmation dialog
- Show "Last used" timestamp
- Add warning if key is the default/weak key
### 6. Configurable Model Mapping Table
- Instead of a static list, show an editable table:
- Requested model | Resolved model | Provider | Status
- Allow users to add custom mappings
- Show which mappings are active/inactive
- Validate that resolved models exist in the account pool
### 7. Account Quota Context
- Show the currently selected "primary" account with its quota bar
- If auto-switching is enabled, show "Pool Mode: Active (N accounts)"
- Link to the account pool page for detailed management
### 8. Toast Copy Feedback
- When user clicks "Copy", show a `Toast` or `Sonner` notification
- Current: no feedback — user doesn't know if copy succeeded
- Message: "Copied to clipboard" or "Failed to copy"
### 9. Health Metrics Stub
- Even if backend metrics aren't wired yet, create the UI skeleton
- Show placeholder charts with "Coming soon" badge
- This sets expectations and makes the page feel more complete
### 10. Empty States
- When proxy is stopped, show a clear empty state:
- Icon: `ServerOff` from lucide
- Message: "Proxy is not running"
- Action: "Start Proxy" button prominently displayed
- When no accounts are configured, show a CTA to add accounts
---
## Design Principles
- **Density over whitespace**: This is a developer tool — show more information per pixel
- **Progressive disclosure**: Hide advanced options behind tabs/accordions
- **Immediate feedback**: Every action should have a visible result (toast, spinner, state change)
- **Security first**: API keys and sensitive data should never be displayed casually
@@ -0,0 +1,125 @@
# Proxy Page — Advanced Features Hidden in Backend
> Generated from oracle agent analysis of `src/server/modules/proxy/`
> Date: 2026-04-22
## Discovery
The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many sophisticated features that are **completely invisible** in the current UI. The proxy page only shows: port, protocol, API key, and basic start/stop.
## Hidden Backend Features
### 1. Advanced Routing & Scheduling Controls
**Backend location**: `proxy.service.ts`, `proxy-scheduler.service.ts`
**What's implemented**:
- `scheduler.maxRequestsPerAccount`: Limit requests per account before switching
- `scheduler.maxWaitTime`: Max time to wait for an available account
- `scheduler.retryAttempts`: Number of retries per request
- `scheduler.backoffStrategy`: `exponential` | `linear` | `fixed`
- `scheduler.priorityAccounts`: Array of account IDs to prioritize
**UI exposure**: None. These are hardcoded or set via config files only.
### 2. Live Traffic Inspector
**Backend location**: `traffic-logger.service.ts`
**What's implemented**:
- `TrafficLogger` captures every request/response with metadata
- Fields: timestamp, requestId, accountId, model, promptTokens, completionTokens, latency, status, error
- Log rotation and cleanup policies
- Export to JSON/CSV
**UI exposure**: None. Logs are only written to files.
### 3. Circuit Breaker Dashboard
**Backend location**: `proxy-circuit-breaker.service.ts`
**What's implemented**:
- Per-account circuit breaker state: `CLOSED`, `OPEN`, `HALF_OPEN`
- Failure threshold and recovery timeout
- When open: requests fail fast without hitting the API
- Metrics: failure rate, last failure time, consecutive successes
**UI exposure**: None. Circuit breaker state is internal only.
### 4. Parity Testing Sandbox
**Backend location**: `proxy-parity.service.ts`
**What's implemented**:
- Send identical requests to multiple models simultaneously
- Compare responses for consistency
- Detect model drift or API changes
- Configurable test suites
**UI exposure**: None. Only accessible via manual API calls or scripts.
### 5. Custom Model Mapping Table
**Backend location**: `proxy-model-mapper.service.ts`
**What's implemented**:
- Dynamic model resolution: `claude-opus-4` → `gemini-3-flash`
- User-defined mappings stored in database
- Regex-based model matching
- Fallback chain: if mapped model unavailable, try next
**UI exposure**: A static, read-only list is shown. No editing capability.
### 6. Upstream Proxy Configuration
**Backend location**: `proxy-upstream.service.ts`
**What's implemented**:
- `upstreamProxy.url`: Route all traffic through another proxy
- `upstreamProxy.auth`: Basic auth or bearer token for upstream
- `upstreamProxy.bypassList`: Domains to bypass
**UI exposure**: None. Set via environment variables only.
### 7. IDE Integration Quick-Setup
**Backend location**: `proxy-config.service.ts` — `generateIdeConfig()`
**What's implemented**:
- Generates ready-to-paste configs for:
- Cursor (`settings.json`)
- VS Code + Cline/Roo Code
- OpenCode (`.env`)
- Claude Code (`claude_desktop_config.json`)
- Generic OpenAI client
- Includes local IP detection, port, and API key
**UI exposure**: Code examples are generic curl snippets. No IDE-specific configs.
### 8. Health & Performance Metrics
**Backend location**: `proxy-metrics.service.ts`
**What's implemented**:
- Request latency histogram (p50, p95, p99)
- Requests per minute counter
- Error rate by status code
- Active connection gauge
- Token throughput (tokens/minute)
- Account utilization percentage
**UI exposure**: None. Metrics are only exposed via a `/metrics` endpoint for Prometheus.
### 9. Request Replay & Debug Tool
**Backend location**: `proxy-replay.service.ts`
**What's implemented**:
- Store recent requests (last 100 by default)
- Replay any request with one click
- Modify headers/body before replay
- Compare original vs. replayed response
- Export request as curl command
**UI exposure**: None.
### 10. Global Timeout & Max Wait Controls
**Backend location**: `proxy-config.service.ts`
**What's implemented**:
- `requestTimeout`: Global timeout for all upstream requests
- `connectTimeout`: TCP connection timeout
- `maxWaitTime`: Max time to wait for an available account
- `idleTimeout`: Close idle connections after N seconds
**UI exposure**: None. Hardcoded defaults.
---
## Recommendation
Create a new "Advanced" tab on the proxy page that exposes these features progressively:
1. **Phase 1**: Read-only views (circuit breaker status, health metrics, traffic logs)
2. **Phase 2**: Interactive controls (model mapping editor, scheduler settings)
3. **Phase 3**: Power-user tools (replay, parity testing, upstream proxy config)
This would transform the proxy page from a simple on/off switch into a comprehensive API gateway dashboard.
@@ -0,0 +1,156 @@
# Proxy Page — Code Organization Refactoring Plan
> Generated from oracle agent analysis of `src/routes/proxy.tsx`
> Date: 2026-04-22
## Current State
`src/routes/proxy.tsx` is a **724-line monolith** containing:
- Route definition
- State management (useState, useEffect, useMemo)
- Data fetching (useQuery equivalents for proxy config, local IPs)
- Derived state (code examples, URLs)
- UI rendering (service card, model mapping, code examples, local access banner)
- Event handlers (start/stop, copy, protocol change)
- Constants and helper functions
## Target Architecture
Split into **7+ files** following the project's conventions:
```
src/
├── routes/
│ └── proxy.tsx # ~50 lines — route definition + composition
├── components/
│ └── proxy/
│ ├── ProxyServiceCard.tsx
│ ├── ProxyModelMappingCard.tsx
│ ├── ProxyUsageExamplesCard.tsx
│ └── ProxyLocalAccessBanner.tsx
├── hooks/
│ ├── useProxyConfig.ts
│ ├── useLocalIps.ts
│ └── useProxyExamples.ts
├── constants/
│ └── proxy.ts # API endpoints, default ports, protocols
└── utils/
└── proxy.ts # URL builders, formatters, parsers
```
## File Breakdown
### 1. `src/hooks/useProxyConfig.ts`
**Responsibility**: Fetch and mutate proxy configuration
```typescript
export function useProxyConfig() {
// Fetches from ipc.client.proxy.getConfig()
// Provides: config, isLoading, error, updateConfig, startProxy, stopProxy
// Handles optimistic updates for start/stop toggling
}
```
### 2. `src/hooks/useLocalIps.ts`
**Responsibility**: Get local IP addresses for the access banner
```typescript
export function useLocalIps() {
// Fetches from ipc.client.system.getLocalIps()
// Returns: ips[], isLoading, error
}
```
### 3. `src/hooks/useProxyExamples.ts`
**Responsibility**: Generate code examples based on current config
```typescript
export function useProxyExamples(config: ProxyConfig, localIps: string[]) {
// Returns memoized examples for curl, Python, Node.js
// Updates when port, protocol, or API key changes
}
```
### 4. `src/components/proxy/ProxyServiceCard.tsx`
**Responsibility**: The main service control card
- Start/stop toggle
- Port input
- Protocol selector
- API key display (with mask/reveal)
- Status indicator
### 5. `src/components/proxy/ProxyModelMappingCard.tsx`
**Responsibility**: Model mapping display/editor
- Read-only list (current state)
- Future: editable table with add/remove
- Show provider badges
### 6. `src/components/proxy/ProxyUsageExamplesCard.tsx`
**Responsibility**: Code examples section
- Collapsible/accordion layout
- Tabs for different languages
- Copy-to-clipboard buttons
- Syntax highlighting (if available)
### 7. `src/components/proxy/ProxyLocalAccessBanner.tsx`
**Responsibility**: "Access your proxy locally" banner
- Show local IPs
- QR code (future)
- Copy URL buttons
### 8. `src/constants/proxy.ts`
**Responsibility**: Constants
```typescript
export const DEFAULT_PROXY_PORT = 3000;
export const PROTOCOLS = ['http', 'https'] as const;
export const EXAMPLE_MODELS = ['gpt-4o', 'claude-3-opus', 'gemini-pro'];
```
### 9. `src/utils/proxy.ts`
**Responsibility**: Pure helper functions
```typescript
export function buildProxyUrl(protocol: string, ip: string, port: number): string;
export function maskApiKey(key: string): string;
export function formatCurlExample(url: string, model: string, apiKey: string): string;
```
## Route File After Refactoring
```typescript
// src/routes/proxy.tsx — ~50 lines
import { createFileRoute } from '@tanstack/react-router';
import { useProxyConfig } from '@/hooks/useProxyConfig';
import { useLocalIps } from '@/hooks/useLocalIps';
import { ProxyServiceCard } from '@/components/proxy/ProxyServiceCard';
import { ProxyModelMappingCard } from '@/components/proxy/ProxyModelMappingCard';
import { ProxyUsageExamplesCard } from '@/components/proxy/ProxyUsageExamplesCard';
import { ProxyLocalAccessBanner } from '@/components/proxy/ProxyLocalAccessBanner';
function ProxyPage() {
const { config, isLoading, startProxy, stopProxy } = useProxyConfig();
const { ips } = useLocalIps();
if (isLoading) return <ProxySkeleton />;
return (
<div className="space-y-6 p-6">
<ProxyServiceCard config={config} onStart={startProxy} onStop={stopProxy} />
<ProxyModelMappingCard mappings={config.modelMappings} />
<ProxyUsageExamplesCard config={config} ips={ips} />
<ProxyLocalAccessBanner ips={ips} port={config.port} />
</div>
);
}
export const Route = createFileRoute('/proxy')({ component: ProxyPage });
```
## Migration Steps
1. Create new files (hooks, components, constants, utils)
2. Move code from `proxy.tsx` to appropriate files
3. Update imports in `proxy.tsx`
4. Extract and test each hook independently
5. Add Storybook stories for each component (if Storybook is used)
6. Verify no regression in functionality
## Benefits
- **Testability**: Each hook and component can be unit tested in isolation
- **Reusability**: `useLocalIps` can be used in other pages (e.g., settings)
- **Maintainability**: 50-line route file vs. 724-line monolith
- **Code review**: Smaller files = faster, more focused PRs
- **Onboarding**: New developers can understand the page in minutes
@@ -0,0 +1,132 @@
# Proxy Page — Code Quality Issues
> Generated from fixer agent analysis of `src/routes/proxy.tsx`
> Date: 2026-04-22
## Severity Legend
- 🔴 **Critical**: Bugs or crashes
- 🟡 **High**: Performance or reliability issues
- 🟢 **Medium**: Maintainability or best practice violations
## Issues Found
### 1. 🔴 State Duplication Between Proxy Config and Local State
**Location**: Lines ~120-180
**Issue**: The component maintains both `proxyConfig` (from IPC) and local `useState` copies for `port`, `protocol`, `apiKey`. These can drift out of sync if the backend config changes externally.
**Fix**: Use a single source of truth. Either:
- Derive local state from `proxyConfig` with `useEffect` sync
- Or use `useQuery` with `staleTime: 0` and no local cache
### 2. 🟡 Race Condition in Start/Stop Toggle
**Location**: Lines ~200-250
**Issue**: If user clicks start/stop rapidly, multiple IPC calls fire in parallel. The UI shows intermediate states that may not reflect the actual backend state.
**Fix**: Disable the toggle button while a transition is in progress. Add an `isTransitioning` flag.
### 3. 🟢 Clipboard Error Handling
**Location**: Lines ~300-320
**Issue**: `navigator.clipboard.writeText()` can fail (permission denied, not in secure context). The code has no try/catch.
**Fix**:
```typescript
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
toast.success('Copied!');
} catch {
toast.error('Failed to copy');
}
}
```
### 4. 🟢 Unstable Callbacks Causing Re-renders
**Location**: Throughout
**Issue**: Event handlers are defined inline in JSX, causing child components to re-render unnecessarily.
**Fix**: Wrap handlers in `useCallback`:
```typescript
const handleStart = useCallback(() => startProxy(), [startProxy]);
const handleStop = useCallback(() => stopProxy(), [stopProxy]);
```
### 5. 🟡 Memory Leak in setTimeout
**Location**: Lines ~400-420
**Issue**: A `setTimeout` is used for a "copy feedback" animation but is never cleared on unmount.
**Fix**: Use `useEffect` cleanup:
```typescript
useEffect(() => {
const timer = setTimeout(() => setCopied(false), 2000);
return () => clearTimeout(timer);
}, [copied]);
```
### 6. 🟢 Missing Loading States for Mutations
**Location**: Lines ~200-250
**Issue**: When starting/stopping the proxy, there's no loading spinner or disabled state on the button.
**Fix**: Add `isPending` state to the mutation hook and disable the button + show spinner while pending.
### 7. 🟢 Hardcoded i18n Strings
**Location**: Throughout
**Issue**: Many UI strings are hardcoded in English instead of using `useTranslation()`:
- "Proxy Service"
- "Start Proxy"
- "Stop Proxy"
- "Copy"
- "Copied!"
**Fix**: Extract all strings to `src/localization/i18n.ts` and use `t('proxy.startProxy')`, etc.
### 8. 🟢 Magic Numbers
**Location**: Lines ~450-500
**Issue**: Port validation uses hardcoded `3000`, `65535` without constants.
**Fix**:
```typescript
const MIN_PORT = 1024;
const MAX_PORT = 65535;
const DEFAULT_PORT = 3000;
```
### 9. 🟢 Unsafe Type Cast
**Location**: Lines ~550-600
**Issue**: `event.target.value` is cast to `Protocol` without validation.
**Fix**:
```typescript
const protocol = event.target.value;
if (!PROTOCOLS.includes(protocol)) return;
setProtocol(protocol as Protocol);
```
### 10. 🟡 Dangerous Parsing of Local IPs
**Location**: Lines ~650-700
**Issue**: `localIps` is parsed from a shell command output using string splitting that could break on unexpected formats.
**Fix**: Use a robust parser or, better, expose `os.networkInterfaces()` via a safe IPC endpoint.
### 11. 🟢 Unnecessary Re-renders from Inline Objects
**Location**: Lines ~350-380
**Issue**: Inline style objects and arrays are recreated on every render, breaking `React.memo` optimizations in child components.
**Fix**: Extract static styles outside the component or memoize:
```typescript
const cardStyle = useMemo(() => ({ padding: '1.5rem' }), []);
```
### 12. 🟢 No Validation on Port Input
**Location**: Lines ~150-170
**Issue**: User can type any value into the port field, including non-numbers, negatives, or out-of-range values.
**Fix**: Add input validation:
```typescript
<input
type="number"
min={1024}
max={65535}
value={port}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
if (value >= 1024 && value <= 65535) setPort(value);
}}
/>
```
---
## Testing Recommendations
- Add unit tests for `useProxyConfig` hook (mock IPC client)
- Test race conditions: rapid start/stop clicks
- Test clipboard failure path
- Test port validation edge cases (0, 80, 65536, NaN)
- Test with `react-testing-library` + `user-event`
@@ -0,0 +1,141 @@
# Proxy Page — Architecture Overview
> Generated from explorer agent mapping of proxy-related files
> Date: 2026-04-22
## File Map
### Frontend
| File | Lines | Role |
|------|-------|------|
| `src/routes/proxy.tsx` | 724 | Main proxy page (monolith) |
| `src/components/ModelVisibilitySettings.tsx` | ~200 | Model visibility toggle grid |
| `src/components/AccountProxySettings.tsx` | ~180 | Per-account proxy configuration |
| `src/hooks/useCloudAccounts.ts` | ~50 | Fetches cloud accounts for proxy routing |
### Backend (NestJS)
| File | Role |
|------|------|
| `src/server/modules/proxy/proxy.module.ts` | NestJS module definition |
| `src/server/modules/proxy/proxy.service.ts` | 1883 lines — core proxy logic, all handlers |
| `src/server/modules/proxy/proxy.controller.ts` | HTTP endpoints for proxy control |
| `src/server/modules/proxy/proxy-scheduler.service.ts` | Account rotation & scheduling |
| `src/server/modules/proxy/proxy-circuit-breaker.service.ts` | Per-account circuit breaker |
| `src/server/modules/proxy/traffic-logger.service.ts` | Request/response logging |
| `src/server/modules/proxy/proxy-metrics.service.ts` | Prometheus metrics |
| `src/server/modules/proxy/proxy-model-mapper.service.ts` | Model name resolution |
| `src/server/modules/proxy/proxy-parity.service.ts` | Parity testing |
| `src/server/modules/proxy/proxy-replay.service.ts` | Request replay |
| `src/server/modules/proxy/proxy-config.service.ts` | Config management |
| `src/server/modules/proxy/proxy-upstream.service.ts` | Upstream proxy support |
### IPC Layer
| File | Role |
|------|------|
| `src/ipc/router.ts` | Main IPC router composition |
| `src/ipc/proxy/router.ts` | Proxy-specific IPC endpoints |
| `src/ipc/manager.ts` | IPC client initialization |
### Types & Schema
| File | Role |
|------|------|
| `src/types/config.ts` | `ProxyConfig` Zod schema — reveals hidden fields |
| `src/types/proxy.ts` | Request/response types |
## Hidden ProxyConfig Fields
The `ProxyConfig` schema in `src/types/config.ts` contains many fields never shown in the UI:
```typescript
interface ProxyConfig {
// Shown in UI
enabled: boolean;
port: number;
protocol: 'http' | 'https';
apiKey: string;
// Hidden from UI
scheduler: {
maxRequestsPerAccount: number;
maxWaitTime: number;
retryAttempts: number;
backoffStrategy: 'exponential' | 'linear' | 'fixed';
priorityAccounts: string[];
};
upstreamProxy?: {
url: string;
auth?: { username: string; password: string };
bypassList: string[];
};
circuitBreaker: {
failureThreshold: number;
recoveryTimeout: number;
halfOpenMaxCalls: number;
};
logging: {
enabled: boolean;
maxEntries: number;
retentionDays: number;
};
metrics: {
enabled: boolean;
histogramBuckets: number[];
};
modelMappings: Array<{
from: string;
to: string;
provider: string;
enabled: boolean;
}>;
}
```
## Data Flow
```
┌─────────────────┐ IPC ┌──────────────────┐
│ proxy.tsx │ ◄──────────► │ proxy/router.ts │
│ (React UI) │ │ (IPC handlers) │
└─────────────────┘ └────────┬─────────┘
│
▼
┌──────────────────────┐
│ ProxyController │
│ (HTTP endpoints) │
└────────┬─────────────┘
│
▼
┌──────────────────────┐
│ ProxyService │
│ (Core orchestrator) │
└────────┬─────────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌───────────────┐ ┌──────────────┐
│ Scheduler │ │ CircuitBreaker│ │Model Mapper │
└─────────────┘ └───────────────┘ └──────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌───────────────┐ ┌──────────────┐
│TrafficLogger│ │MetricsService │ │ParityService │
└─────────────┘ └───────────────┘ └──────────────┘
```
## Key Observations
1. **Backend is significantly more capable than the UI suggests** — 12 services vs. 1 page
2. **IPC layer is thin** — most backend services are not exposed via IPC, only HTTP
3. **ProxyConfig schema is the source of truth** — it documents everything the backend can do
4. **No real-time communication** — UI polls for status; no WebSocket or SSE for live updates
5. **Separation of concerns is good on backend**, terrible on frontend (724-line monolith)
## Recommended Architecture Changes
1. Extend `src/ipc/proxy/router.ts` to expose all `ProxyConfig` fields
2. Add IPC endpoints for:
- `proxy.getTrafficLogs()`
- `proxy.getCircuitBreakerStatus()`
- `proxy.getMetrics()`
- `proxy.replayRequest()`
3. Create a WebSocket or SSE channel for live traffic updates
4. Refactor frontend into the 7-file structure proposed in `04-code-organization.md`
+276
View File
@@ -0,0 +1,276 @@
**✅ Documento Completo: Integração com Custom Endpoint do OpenCode (Provedores Custom / OpenAI-Compatible)**
OpenCode (https://opencode.ai) é um agente de IA para terminal que usa o **Vercel AI SDK** para se comunicar com provedores de LLM. Ele suporta nativamente provedores customizados via o pacote `@ai-sdk/openai-compatible`, que segue o padrão **OpenAI Chat Completions API** (`/v1/chat/completions`).
Isso significa que **sua API precisa implementar exatamente o formato OpenAI-compatible** para que o OpenCode consiga conversar com ela diretamente.
---
### 1. Como Configurar um Provedor Custom no OpenCode
Crie ou edite o arquivo de configuração (recomendado: `~/.config/opencode/opencode.json` ou `opencode.json` na raiz do projeto):
```json
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"minha-ia-custom": {
"npm": "@ai-sdk/openai-compatible",
"name": "Minha IA Custom (OpenCode)",
"options": {
"baseURL": "https://sua-api.com/v1",
"apiKey": "sk-sua-chave-aqui-ou-{env:MINHA_API_KEY}",
"headers": {
"X-Custom-Header": "valor-opcional"
}
},
"models": {
"meu-modelo-principal": {
"name": "Meu Modelo Principal",
"limit": {
"context": 128000,
"output": 8192
}
},
"meu-modelo-rapido": {
"name": "Modelo Rápido",
"limit": {
"context": 32000,
"output": 4096
}
}
}
}
},
"model": "minha-ia-custom/meu-modelo-principal"
}
```
**Campos importantes:**
- `npm`: Sempre use `"@ai-sdk/openai-compatible"` para APIs que expõem `/v1/chat/completions`.
- `baseURL`: URL base da sua API (deve terminar com `/v1`).
- `apiKey`: Pode ser valor direto ou `{env:NOME_VARIAVEL}`.
- `models`: Defina os modelos que sua API expõe. O `limit` ajuda o OpenCode a gerenciar contexto.
- `model`: Define o modelo padrão no formato `provider_id/model_id`.
Depois de salvar, reinicie o OpenCode ou use `/models` para selecionar.
**Comando rápido para adicionar chave:**
```bash
opencode /connect
# Escolha "Other" → digite o provider_id (ex: minha-ia-custom) → cole a API key
```
---
### 2. Como o OpenCode Envia Requisições
O OpenCode (via AI SDK) faz chamadas **POST** para:
```
POST {baseURL}/chat/completions
```
**Headers enviados:**
- `Authorization: Bearer <sua-api-key>`
- `Content-Type: application/json`
- Headers customizados que você definir em `options.headers`
**Payload típico (exemplo real):**
```json
{
"model": "meu-modelo-principal",
"messages": [
{
"role": "system",
"content": "Você é um assistente de código experiente..."
},
{
"role": "user",
"content": "Crie uma função em Python que..."
}
],
"stream": true,
"temperature": 0.7,
"max_tokens": 8192,
"tools": [
/* array de tools se o agente estiver usando ferramentas */
],
"tool_choice": "auto"
}
```
**Parâmetros comuns que o OpenCode envia:**
- `messages` (array completo do histórico)
- `stream: true` (quase sempre, para UX em tempo real)
- `temperature`, `max_tokens`, `top_p`
- `tools` + `tool_choice` (quando usa agentes com ferramentas/MCP)
- `response_format` (às vezes)
- `providerOptions` (opções específicas do provedor – raras no openai-compatible)
---
### 3. Como o OpenCode Recebe e Processa Respostas
#### A. Streaming (recomendado – usado na maioria das interações)
Sua API deve responder com `Content-Type: text/event-stream` e eventos SSE:
```
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1712345678,"model":"meu-modelo-principal","choices":[{"index":0,"delta":{"content":"Olá"},"finish_reason":null}]}
data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{"content":"! Como"},"finish_reason":null}]}
data: [DONE]
```
O OpenCode processa cada chunk em tempo real.
#### B. Resposta não-streaming (completa)
```json
{
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1712345678,
"model": "meu-modelo-principal",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Resposta completa aqui..."
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 150,
"completion_tokens": 320,
"total_tokens": 470
}
}
```
#### Suporte a Tool Calling (essencial para agentes)
Se sua API suportar `tools`, o OpenCode pode:
1. Enviar `tools` na requisição.
2. Receber `tool_calls` na resposta.
3. Executar a ferramenta (via MCP ou built-in).
4. Enviar o resultado de volta como mensagem `tool`.
**Exemplo de resposta com tool call:**
```json
{
"choices": [
{
"message": {
"role": "assistant",
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\"path\": \"src/app.ts\"}"
}
}
]
}
}
]
}
```
---
### 4. Como é Exibido no Chat do OpenCode (TUI)
- **Streaming**: Texto aparece caractere por caractere com efeito de digitação.
- **Markdown**: Suporte completo a código (syntax highlight), listas, tabelas, etc.
- **Tool Calls**: Mostra “Executando ferramenta: read_file...” + resultado.
- **Multi-turn**: Mantém histórico completo na conversa.
- **Erros**: Mostra mensagens de erro claras no terminal.
- **Contexto**: Respeita os limites de `context` e `output` que você definiu no config.
O OpenCode usa o TUI (Terminal User Interface) baseado em React/Ink para renderizar tudo de forma bonita.
---
### 5. Requisitos Mínimos da Sua API para Funcionar Perfeitamente
| Recurso | Obrigatório? | Recomendado? | Observação |
| ---------------------- | ------------ | ------------ | ---------------------------------- |
| `/v1/chat/completions` | Sim | - | Endpoint principal |
| Streaming (SSE) | Sim | - | Essencial para UX |
| Tool Calling | Não | Sim | Para agentes completos |
| System messages | Sim | - | - |
| Multi-turn history | Sim | - | - |
| Usage stats | Não | Sim | Mostra tokens gastos |
| Error handling | Sim | - | Retorne JSON de erro padrão OpenAI |
**Endpoint exato esperado:**
`POST https://sua-api.com/v1/chat/completions`
---
### 6. Exemplo Completo de Configuração + Teste
1. Configure o `opencode.json` como acima.
2. Rode:
```bash
opencode
```
3. Digite algo ou use `/models` para selecionar seu provedor.
4. Teste com:
```bash
curl -X POST https://sua-api.com/v1/chat/completions \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{"model":"meu-modelo-principal","messages":[{"role":"user","content":"Olá"}],"stream":true}'
```
---
### 7. Troubleshooting Comum
- **“Route not found” ou requests indo para lugar errado** → Verifique se `baseURL` termina com `/v1` e não com `/v1/chat/completions`.
- **Custom provider não aparece** → Certifique-se que o ID do provider no `/connect` bate com a chave no `opencode.json`.
- **Sem streaming / resposta lenta** → Implemente SSE corretamente.
- **Tool calls não funcionam** → Sua API precisa retornar `tool_calls` no formato OpenAI exato.
- **Erro de autenticação** → Use `apiKey` ou `headers` corretamente.
---
### Referências Oficiais
- **Docs Principais**: https://opencode.ai/docs/providers/
- **Models**: https://opencode.ai/docs/models/
- **Config**: https://opencode.ai/docs/config/
- **Schema de Config**: https://opencode.ai/config.json
- **GitHub**: https://github.com/anomalyco/opencode
- **AI SDK OpenAI-Compatible**: https://ai-sdk.dev/providers/openai-compatible-providers
---
**Resumo Final para Você (que está construindo a API):**
Sua API **precisa ser 100% compatível** com o endpoint OpenAI `/v1/chat/completions` (streaming + tool calling recomendado).
O OpenCode vai:
1. Configurar via `opencode.json` (baseURL + apiKey)
2. Enviar requisições padrão OpenAI
3. Receber streaming ou resposta completa
4. Renderizar tudo lindamente no TUI com suporte a ferramentas
Se você implementar exatamente o padrão OpenAI Chat Completions, vai funcionar perfeitamente com o OpenCode.
Quer que eu monte um **exemplo de servidor mínimo** (em Node.js/Fastify ou Python/FastAPI) que já funcione com o OpenCode? Posso criar um template completo para você.
+146
View File
@@ -0,0 +1,146 @@
# Brand Briefing — Gemini Nexus
## 1. Nome e Descrição
| Campo | Valor |
|-------|-------|
| **Nome** | Gemini Nexus |
| **Product Name** | Gemini Nexus |
| **Package Name** | gemini-nexus |
| **Versão** | 0.11.1 |
| **Descrição EN** | Professional multi-account manager for Google Gemini & Claude AI |
| **Descrição PT-BR** | Gerenciador multi-contas profissional para Google Gemini & Claude AI |
| **Autor** | evandrodevbr / Draculabo |
| **Licença** | CC-BY-NC-SA-4.0 (apenas fins educacionais) |
---
## 2. Propósito do Aplicativo
Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona como um **hub central (nexus)** para desenvolvedores que usam IDEs com IA.
### Funcionalidades Principais
- **Pool ilimitado de contas**: Adicionar múltiplas contas Google Gemini / Claude via OAuth
- **Auto-switching inteligente**: Troca automática de conta quando a cota acaba ou há rate limit
- **Monitoramento em tempo real**: Barras de progresso coloridas para visualizar uso de quota
- **Proxy API local**: Servidor proxy integrado compatível OpenAI/Anthropic (porta 8045)
- **Segurança**: Criptografia AES-256-GCM para dados sensíveis
### Público-Alvo
- Desenvolvedores que usam IDEs com integração AI (Cursor, Windsurf, VS Code, Claude Code, etc.)
- Power users que precisam alternar entre múltiplas contas de IA
- Usuários que precisam de um proxy API local confiável
---
## 3. Tom de Voz / Personalidade
- **Profissional e técnica**: Termos como "Nexus", "Proxy", "Gateway", "Quota", "Circuit Breaker"
- **Confiável e estável**: Monitoramento, health checks, logging robusto
- **Developer-first**: Suporte a múltiplas IDEs, geração de cURL/Python
- **Moderna e minimalista**: Interface limpa, componentes modulares
- **Global**: Suporte a i18n (EN, PT-BR, ZH, RU, VI)
---
## 4. Estilo Visual Atual
### Tema
- Dual theme (claro/escuro) com detecção automática do sistema
- Extremamente minimalista e quase monocromático
### Paleta de Cores (Tailwind OKLCH)
| Modo | Background | Foreground | Primary | Destructive | Destaque |
|------|-----------|------------|---------|-------------|----------|
| **Claro** | oklch(0.99 0 0) — quase branco | oklch(0 0 0) — preto | Preto | Vermelho | Azul |
| **Escuro** | oklch(0 0 0) — preto puro | oklch(1 0 0) — branco | Branco | Vermelho escuro | — |
### Cores Funcionais
- **Emerald/Green (`#10b981`)** — status ativo, quota alta, running
- **Amber/Yellow** — quota média, alertas
- **Rose/Red** — quota baixa, erro, stopped
- **Blue** — AI credits, informações premium
### Tipografia
- **Geist** (sans-serif) — fonte principal moderna e geométrica (da Vercel)
- **Geist Mono** (monospace) — dados técnicos, números de quota
- **Tomorrow** (display/futurista) — possíveis títulos estilizados
### Características de UI
- Bordas arredondadas (`0.5rem` / 8px)
- Sombras sutis
- Cards com hover states suaves
- Interface limpa com espaçamento generoso
---
## 5. Assets Visuais Existentes
| Arquivo | Descrição |
|---------|-----------|
| `docs/assets/logo.png` | Logo antiga (Antigravity) — 128x128, usada no README |
| `docs/assets/screenshot-main.png` | Screenshot da interface principal |
| `docs/assets/screenshot-proxy.png` | Screenshot da aba proxy |
| `docs/assets/screenshot-setting.png` | Screenshot de settings |
| `src/assets/icon.png` | Ícone do app (também é o logo antigo) |
| `src/assets/tray.png` | Ícone da system tray |
| `images/icon.ico` / `images/icon.icns` | Ícones de build para Windows/Mac |
| `images/favicon-*.png` | Favicons em múltiplos tamanhos |
| `images/32x32.png`, `64x64.png`, `128x128.png`, `[email protected]` | Ícones para AppImage Linux |
| `src/assets/fonts/geist/` | Fonte Geist (principal) |
| `src/assets/fonts/geist-mono/` | Fonte Geist Mono (código/dados) |
| `src/assets/fonts/tomorrow/` | Fonte Tomorrow (display, 4 pesos) |
---
## 6. Conceitos Visuais Sugeridos para a Logo
### A. Nodo / Conexão (Nexus)
- Ponto central conectado a múltiplos pontos (contas gerenciadas)
- Círculo central com 2-3 linhas finas irradiando
- Dois círculos interligados (Gemini + Claude)
### B. Infinito / Loop (Gemini + Proxy)
- Símbolo de infinito (∞) modificado como circuito/loop de dados
- Referência à dualidade "Gemini" (gêmeos)
- Curva contínua elegante com seta indicando fluxo
### C. Cubo / Hexágono (Nexus / Hub)
- Hexágono ou cubo isométrico minimalista
- Linhas finas, perspectiva suave
- Face "aberta" ou conectada
### D. Monograma "GN"
- Letras "G" e "N" abstraídas como nodo de rede
- Estilo geométrico, similar a Tomorrow ou Geist
### E. Proxy / Pipe (Fluxo de Dados)
- Duas barras paralelas com seta passando entre elas
- "Pipe" minimalista — conduz tráfego entre APIs
---
## 7. Diretrizes para a Nova Logo
| Elemento | Especificação |
|----------|---------------|
| **Estilo** | Minimalista, geométrica, precisa, técnica |
| **Cores principais** | Preto (`#000000`) e branco (`#ffffff`) |
| **Cor de acento (opcional)** | Emerald (`#10b981`) para "ativo/online" |
| **Versões necessárias** | Dark mode e light mode |
| **Tamanhos** | De 16x16 (tray) até 512x512 (macOS) |
| **Monocromática** | Deve funcionar em preto e branco puro |
| **Tipografia do wordmark** | Geométrica sans-serif, peso medium/semibold, tracking levemente expandido |
---
## 8. Conclusão
O app pede uma logo que evoque **conexão/nexo** (Nexus) com **múltiplos pontos** (contas) de forma geométrica e limpa, usando primariamente preto/branco com um toque sutil de verde (emerald) para representar o estado "ativo/online" central ao propósito do app.
A logo deve transmitir:
- **Tecnologia** e **precisão**
- **Conectividade** e **hub central**
- **Profissionalismo** e **confiança**
- **Modernidade** e **minimalismo**
+3 -4
View File
@@ -449,8 +449,7 @@ const config: ForgeConfig = {
makers: [
new MakerSquirrel({
setupIcon: 'images/icon.ico',
iconUrl:
'https://raw.githubusercontent.com/Draculabo/AntigravityManager/main/images/icon.ico',
iconUrl: 'https://raw.githubusercontent.com/evandrodevbr/GeminiNexus/main/images/icon.ico',
}),
...(process.platform === 'win32' && process.arch === 'x64'
? [
@@ -484,8 +483,8 @@ const config: ForgeConfig = {
name: '@electron-forge/publisher-github',
config: {
repository: {
owner: 'Draculabo',
name: 'AntigravityManager',
owner: 'evandrodevbr',
name: 'GeminiNexus',
},
draft: true,
prerelease: false,
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<title>Gemini Nexus</title>
<link rel="icon" type="image/png" href="/src/assets/icon.png" />
<meta http-equiv="Content-Security-Policy" content="script-src 'self';" />
<link rel="stylesheet" href="/src/styles/global.css" />
</head>
+671 -1
View File
@@ -17,6 +17,9 @@
"@nestjs/microservices": "^11.1.18",
"@nestjs/platform-express": "^11.1.18",
"@nestjs/platform-fastify": "^11.1.18",
"@nivo/bar": "^0.99.0",
"@nivo/core": "^0.99.0",
"@nivo/pie": "^0.99.0",
"@orpc/client": "^1.13.14",
"@orpc/server": "^1.13.14",
"@radix-ui/react-avatar": "^1.1.11",
@@ -51,6 +54,7 @@
"electron-squirrel-startup": "^1.0.1",
"fastify": "^5.8.4",
"find-process": "^2.0.0",
"gpt-tokenizer": "^3.4.0",
"i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0",
"keytar": "^7.9.0",
@@ -139,6 +143,7 @@
"typescript-eslint": "^8.46.3",
"unplugin-swc": "^1.5.9",
"vite": "^5.4.21",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.8"
}
},
@@ -4128,6 +4133,341 @@
}
}
},
"node_modules/@nivo/annotations": {
"version": "0.99.0",
"resolved": "https://registry.npmjs.org/@nivo/annotations/-/annotations-0.99.0.tgz",
"integrity": "sha512-jCuuXPbvpaqaz4xF7k5dv0OT2ubn5Nt0gWryuTe/8oVsC/9bzSuK8bM9vBty60m9tfO+X8vUYliuaCDwGksC2g==",
"license": "MIT",
"dependencies": {
"@nivo/colors": "0.99.0",
"@nivo/core": "0.99.0",
"@nivo/theming": "0.99.0",
"@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0",
"lodash": "^4.17.21"
},
"peerDependencies": {
"react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
}
},
"node_modules/@nivo/arcs": {
"version": "0.99.0",
"resolved": "https://registry.npmjs.org/@nivo/arcs/-/arcs-0.99.0.tgz",
"integrity": "sha512-UcvWLQPl+A3APk2Gm74N5xDfT+ATnVs2XkP73WxhYPWJk+dBzF00cndA5g/dptOwdFBvvo62VgcCsNiwUsjKTw==",
"license": "MIT",
"dependencies": {
"@nivo/colors": "0.99.0",
"@nivo/core": "0.99.0",
"@nivo/text": "0.99.0",
"@nivo/theming": "0.99.0",
"@react-spring/core": "9.4.5 || ^9.7.2 || ^10.0",
"@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0",
"@types/d3-shape": "^3.1.6",
"d3-shape": "^3.2.0"
},
"peerDependencies": {
"react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
}
},
"node_modules/@nivo/axes": {
"version": "0.99.0",
"resolved": "https://registry.npmjs.org/@nivo/axes/-/axes-0.99.0.tgz",
"integrity": "sha512-3KschnmEL0acRoa7INSSOSEFwJLm54aZwSev7/r8XxXlkgRBriu6ReZy/FG0wfN+ljZ4GMvx+XyIIf6kxzvrZg==",
"license": "MIT",
"dependencies": {
"@nivo/core": "0.99.0",
"@nivo/scales": "0.99.0",
"@nivo/text": "0.99.0",
"@nivo/theming": "0.99.0",
"@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0",
"@types/d3-format": "^1.4.1",
"@types/d3-time-format": "^2.3.1",
"d3-format": "^1.4.4",
"d3-time-format": "^3.0.0"
},
"peerDependencies": {
"react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
}
},
"node_modules/@nivo/axes/node_modules/d3-array": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
"integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
"license": "BSD-3-Clause",
"dependencies": {
"internmap": "^1.0.0"
}
},
"node_modules/@nivo/axes/node_modules/d3-format": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz",
"integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==",
"license": "BSD-3-Clause"
},
"node_modules/@nivo/axes/node_modules/d3-time": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz",
"integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "2"
}
},
"node_modules/@nivo/axes/node_modules/d3-time-format": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz",
"integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-time": "1 - 2"
}
},
"node_modules/@nivo/axes/node_modules/internmap": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
"integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
"license": "ISC"
},
"node_modules/@nivo/bar": {
"version": "0.99.0",
"resolved": "https://registry.npmjs.org/@nivo/bar/-/bar-0.99.0.tgz",
"integrity": "sha512-9yfMn7H6UF/TqtCwVZ/vihVAXUff9wWvSaeF2Z1DCfgr5S07qs31Qb2p0LZA+YgCWpaU7zqkeb3VZ4WCpZbrDA==",
"license": "MIT",
"dependencies": {
"@nivo/annotations": "0.99.0",
"@nivo/axes": "0.99.0",
"@nivo/canvas": "0.99.0",
"@nivo/colors": "0.99.0",
"@nivo/core": "0.99.0",
"@nivo/legends": "0.99.0",
"@nivo/scales": "0.99.0",
"@nivo/text": "0.99.0",
"@nivo/theming": "0.99.0",
"@nivo/tooltip": "0.99.0",
"@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0",
"@types/d3-scale": "^4.0.8",
"@types/d3-shape": "^3.1.6",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
"lodash": "^4.17.21"
},
"peerDependencies": {
"react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
}
},
"node_modules/@nivo/canvas": {
"version": "0.99.0",
"resolved": "https://registry.npmjs.org/@nivo/canvas/-/canvas-0.99.0.tgz",
"integrity": "sha512-UxA8zb+NPwqmNm81hoyUZSMAikgjU1ukLf4KybVNyV8ejcJM+BUFXsb8DxTcLdt4nmCFHqM56GaJQv2hnAHmzg==",
"license": "MIT"
},
"node_modules/@nivo/colors": {
"version": "0.99.0",
"resolved": "https://registry.npmjs.org/@nivo/colors/-/colors-0.99.0.tgz",
"integrity": "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w==",
"license": "MIT",
"dependencies": {
"@nivo/core": "0.99.0",
"@nivo/theming": "0.99.0",
"@types/d3-color": "^3.0.0",
"@types/d3-scale": "^4.0.8",
"@types/d3-scale-chromatic": "^3.0.0",
"d3-color": "^3.1.0",
"d3-scale": "^4.0.2",
"d3-scale-chromatic": "^3.0.0",
"lodash": "^4.17.21"
},
"peerDependencies": {
"react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
}
},
"node_modules/@nivo/core": {
"version": "0.99.0",
"resolved": "https://registry.npmjs.org/@nivo/core/-/core-0.99.0.tgz",
"integrity": "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g==",
"license": "MIT",
"dependencies": {
"@nivo/theming": "0.99.0",
"@nivo/tooltip": "0.99.0",
"@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0",
"@types/d3-shape": "^3.1.6",
"d3-color": "^3.1.0",
"d3-format": "^1.4.4",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-scale-chromatic": "^3.0.0",
"d3-shape": "^3.2.0",
"d3-time-format": "^3.0.0",
"lodash": "^4.17.21",
"react-virtualized-auto-sizer": "^1.0.26",
"use-debounce": "^10.0.4"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nivo/donate"
},
"peerDependencies": {
"react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
}
},
"node_modules/@nivo/core/node_modules/d3-array": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
"integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
"license": "BSD-3-Clause",
"dependencies": {
"internmap": "^1.0.0"
}
},
"node_modules/@nivo/core/node_modules/d3-format": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz",
"integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==",
"license": "BSD-3-Clause"
},
"node_modules/@nivo/core/node_modules/d3-time": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz",
"integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "2"
}
},
"node_modules/@nivo/core/node_modules/d3-time-format": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz",
"integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-time": "1 - 2"
}
},
"node_modules/@nivo/core/node_modules/internmap": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
"integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
"license": "ISC"
},
"node_modules/@nivo/legends": {
"version": "0.99.0",
"resolved": "https://registry.npmjs.org/@nivo/legends/-/legends-0.99.0.tgz",
"integrity": "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA==",
"license": "MIT",
"dependencies": {
"@nivo/colors": "0.99.0",
"@nivo/core": "0.99.0",
"@nivo/text": "0.99.0",
"@nivo/theming": "0.99.0",
"@types/d3-scale": "^4.0.8",
"d3-scale": "^4.0.2"
},
"peerDependencies": {
"react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
}
},
"node_modules/@nivo/pie": {
"version": "0.99.0",
"resolved": "https://registry.npmjs.org/@nivo/pie/-/pie-0.99.0.tgz",
"integrity": "sha512-zUbo8UdLndp2RMljrOqitAKKEnl7YypkJrOzjKLk8jQGU7qqUKtgFoJIPhiBsvNPs3xtX2KwgtS1+JKNTNns7A==",
"license": "MIT",
"dependencies": {
"@nivo/arcs": "0.99.0",
"@nivo/colors": "0.99.0",
"@nivo/core": "0.99.0",
"@nivo/legends": "0.99.0",
"@nivo/theming": "0.99.0",
"@nivo/tooltip": "0.99.0",
"@types/d3-shape": "^3.1.6",
"d3-shape": "^3.2.0"
},
"peerDependencies": {
"react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
}
},
"node_modules/@nivo/scales": {
"version": "0.99.0",
"resolved": "https://registry.npmjs.org/@nivo/scales/-/scales-0.99.0.tgz",
"integrity": "sha512-g/2K4L6L8si6E2BWAHtFVGahtDKbUcO6xHJtlIZMwdzaJc7yB16EpWLK8AfI/A42KadLhJSJqBK3mty+c7YZ+w==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "^3.0.4",
"@types/d3-scale": "^4.0.8",
"@types/d3-time": "^1.1.1",
"@types/d3-time-format": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-time": "^1.0.11",
"d3-time-format": "^3.0.0",
"lodash": "^4.17.21"
}
},
"node_modules/@nivo/scales/node_modules/@types/d3-time": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-1.1.4.tgz",
"integrity": "sha512-JIvy2HjRInE+TXOmIGN5LCmeO0hkFZx5f9FZ7kiN+D+YTcc8pptsiLiuHsvwxwC7VVKmJ2ExHUgNlAiV7vQM9g==",
"license": "MIT"
},
"node_modules/@nivo/scales/node_modules/@types/d3-time-format": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.4.tgz",
"integrity": "sha512-or9DiDnYI1h38J9hxKEsw513+KVuFbEVhl7qdxcaudoiqWWepapUen+2vAriFGexr6W5+P4l9+HJrB39GG+oRg==",
"license": "MIT"
},
"node_modules/@nivo/scales/node_modules/d3-time": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz",
"integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==",
"license": "BSD-3-Clause"
},
"node_modules/@nivo/scales/node_modules/d3-time-format": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz",
"integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-time": "1 - 2"
}
},
"node_modules/@nivo/text": {
"version": "0.99.0",
"resolved": "https://registry.npmjs.org/@nivo/text/-/text-0.99.0.tgz",
"integrity": "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ==",
"license": "MIT",
"dependencies": {
"@nivo/core": "0.99.0",
"@nivo/theming": "0.99.0",
"@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0"
},
"peerDependencies": {
"react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
}
},
"node_modules/@nivo/theming": {
"version": "0.99.0",
"resolved": "https://registry.npmjs.org/@nivo/theming/-/theming-0.99.0.tgz",
"integrity": "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.21"
},
"peerDependencies": {
"react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
}
},
"node_modules/@nivo/tooltip": {
"version": "0.99.0",
"resolved": "https://registry.npmjs.org/@nivo/tooltip/-/tooltip-0.99.0.tgz",
"integrity": "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg==",
"license": "MIT",
"dependencies": {
"@nivo/core": "0.99.0",
"@nivo/theming": "0.99.0",
"@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0"
},
"peerDependencies": {
"react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -7413,6 +7753,78 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@react-spring/animated": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.3.tgz",
"integrity": "sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==",
"license": "MIT",
"dependencies": {
"@react-spring/shared": "~10.0.3",
"@react-spring/types": "~10.0.3"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-spring/core": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.3.tgz",
"integrity": "sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==",
"license": "MIT",
"dependencies": {
"@react-spring/animated": "~10.0.3",
"@react-spring/shared": "~10.0.3",
"@react-spring/types": "~10.0.3"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-spring/donate"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-spring/rafz": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.0.3.tgz",
"integrity": "sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg==",
"license": "MIT"
},
"node_modules/@react-spring/shared": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.0.3.tgz",
"integrity": "sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q==",
"license": "MIT",
"dependencies": {
"@react-spring/rafz": "~10.0.3",
"@react-spring/types": "~10.0.3"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-spring/types": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.0.3.tgz",
"integrity": "sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ==",
"license": "MIT"
},
"node_modules/@react-spring/web": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.3.tgz",
"integrity": "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==",
"license": "MIT",
"dependencies": {
"@react-spring/animated": "~10.0.3",
"@react-spring/core": "~10.0.3",
"@react-spring/shared": "~10.0.3",
"@react-spring/types": "~10.0.3"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -10656,6 +11068,69 @@
"@types/node": "*"
}
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-format": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.4.5.tgz",
"integrity": "sha512-mLxrC1MSWupOSncXN/HOlWUAAIffAEBaI4+PKy2uMPsKe4FNZlk7qrbTjmzJXITQQqBHivaks4Td18azgqnotA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-time-format": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.3.4.tgz",
"integrity": "sha512-xdDXbpVO74EvadI3UDxjxTdR6QIxm1FKzEA/+F8tL4GWWUg/hgvBqf6chql64U5A9ZUGWo7pEu4eNlyLwbKdhg==",
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz",
@@ -14456,6 +14931,122 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz",
@@ -18236,6 +18827,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/globrex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
"dev": true,
"license": "MIT"
},
"node_modules/goober": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
@@ -18283,6 +18881,12 @@
"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": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -19409,6 +20013,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/interpret": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz",
@@ -21089,7 +21702,6 @@
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash-es": {
@@ -26229,6 +26841,16 @@
}
}
},
"node_modules/react-virtualized-auto-sizer": {
"version": "1.0.26",
"resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz",
"integrity": "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==",
"license": "MIT",
"peerDependencies": {
"react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/read-binary-file-arch": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
@@ -29689,6 +30311,27 @@
"node": ">=0.3.1"
}
},
"node_modules/tsconfck": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
"integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==",
"dev": true,
"license": "MIT",
"bin": {
"tsconfck": "bin/tsconfck.js"
},
"engines": {
"node": "^18 || >=20"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -30251,6 +30894,18 @@
}
}
},
"node_modules/use-debounce": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.1.tgz",
"integrity": "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ==",
"license": "MIT",
"engines": {
"node": ">= 16.0.0"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
@@ -30444,6 +31099,21 @@
}
}
},
"node_modules/vite-tsconfig-paths": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.1.1.tgz",
"integrity": "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"globrex": "^0.1.2",
"tsconfck": "^3.0.3"
},
"peerDependencies": {
"vite": "*"
}
},
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+25 -8
View File
@@ -10,6 +10,7 @@
"dev": "cross-env DEBUG=electron-forge:* electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
"make:win": "node scripts/build-windows.js",
"publish": "electron-forge publish",
"lint": "eslint .",
"format": "prettier --check .",
@@ -21,7 +22,7 @@
"test:all": "vitest run && playwright test",
"type-check": "tsc --noEmit"
},
"author": "Draculabo",
"author": "evandrodevbr",
"license": "CC-BY-NC-SA-4.0",
"devDependencies": {
"@electron-forge/cli": "^7.11.1",
@@ -47,7 +48,7 @@
"@semantic-release/github": "^12.0.6",
"@sentry/vite-plugin": "^4.8.0",
"@swc/core": "^1.15.8",
"@tailwindcss/vite": "^4.1.17",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/router-plugin": "^1.135.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
@@ -64,7 +65,7 @@
"code-inspector-plugin": "^1.3.3",
"conventional-changelog-conventionalcommits": "^9.1.0",
"cross-env": "^10.1.0",
"electron": "^37.3.1",
"electron": "^39.8.5",
"electron-devtools-installer": "^4.0.0",
"electron-packager-languages": "^0.6.0",
"electron-playwright-helpers": "^1.8.2",
@@ -89,7 +90,8 @@
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.3",
"unplugin-swc": "^1.5.9",
"vite": "^5.4.21",
"vite": "^6.4.2",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.8"
},
"dependencies": {
@@ -101,6 +103,9 @@
"@nestjs/microservices": "^11.1.18",
"@nestjs/platform-express": "^11.1.18",
"@nestjs/platform-fastify": "^11.1.18",
"@nivo/bar": "^0.99.0",
"@nivo/core": "^0.99.0",
"@nivo/pie": "^0.99.0",
"@orpc/client": "^1.13.14",
"@orpc/server": "^1.13.14",
"@radix-ui/react-avatar": "^1.1.11",
@@ -123,7 +128,6 @@
"@tanstack/react-query": "^5.90.7",
"@tanstack/react-router": "^1.139.3",
"@tanstack/react-router-devtools": "^1.135.0",
"@types/uuid": "^11.0.0",
"axios": "^1.15.0",
"better-sqlite3": "^12.4.6",
"class-transformer": "^0.5.1",
@@ -133,8 +137,9 @@
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.2",
"electron-squirrel-startup": "^1.0.1",
"fastify": "^5.8.4",
"fastify": "^5.8.5",
"find-process": "^2.0.0",
"gpt-tokenizer": "^3.4.0",
"i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0",
"keytar": "^7.9.0",
@@ -150,7 +155,7 @@
"tailwindcss-animate": "^1.0.7",
"undici": "^7.16.0",
"update-electron-app": "^3.1.1",
"uuid": "^13.0.0",
"uuid": "^14.0.0",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0",
"yaml": "^2.8.2",
@@ -162,5 +167,17 @@
"@sentry/cli",
"@swc/core",
"protobufjs"
]
],
"pnpm": {
"overrides": {
"fastify": "^5.8.5",
"tar": "^7.5.13",
"@octokit/request-error": "^5.1.1",
"@octokit/plugin-paginate-rest": "^9.2.2",
"@octokit/request": "^8.4.1",
"tmp": "^0.2.4",
"@tootallnate/once": "^3.0.1",
"esbuild": "^0.25.0"
}
}
}
+17902
View File
File diff suppressed because it is too large. Load diff
+12
View File
@@ -0,0 +1,12 @@
allowBuilds:
'@bitdisaster/exe-icon-extractor': true
'@nestjs/core': true
'@sentry/cli': true
'@swc/core': true
better-sqlite3: true
electron: true
electron-winstaller: true
esbuild: true
keytar: true
protobufjs: true
sharp: true
+50
View File
@@ -0,0 +1,50 @@
const { execSync, spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const WIX_ZIP = path.join(process.cwd(), 'wix314-binaries.zip');
const WIX_DIR = path.join(process.cwd(), 'tools', 'wix314');
function extractWix() {
if (!fs.existsSync(WIX_ZIP)) {
console.error(`WiX zip not found: ${WIX_ZIP}`);
process.exit(1);
}
if (fs.existsSync(WIX_DIR)) {
console.log(`WiX already extracted at ${WIX_DIR}`);
return;
}
console.log(`Extracting WiX binaries to ${WIX_DIR}...`);
fs.mkdirSync(WIX_DIR, { recursive: true });
// Use PowerShell Expand-Archive (built into Windows)
const psCommand = `Expand-Archive -Path "${WIX_ZIP}" -DestinationPath "${WIX_DIR}" -Force`;
execSync(psCommand, { shell: 'powershell.exe', stdio: 'inherit' });
console.log('WiX extraction complete.');
}
function setupWixPath() {
// electron-wix-msi's detect-wix.js runs execSync('candle -?') which relies on PATH
process.env.PATH = `${WIX_DIR};${process.env.PATH}`;
console.log(`Added ${WIX_DIR} to PATH`);
}
function runMake() {
console.log('Running electron-forge make...');
const make = spawn('npx', ['electron-forge', 'make'], {
stdio: 'inherit',
shell: true,
env: process.env,
});
make.on('exit', (code) => {
process.exit(code ?? 0);
});
}
extractWix();
setupWixPath();
runMake();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 659 B

After

Width:  |  Height:  |  Size: 2.1 KiB

+1 -1
View File
@@ -18,7 +18,7 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
const { data: isRunning, isLoading } = useQuery({
queryKey: ['process', 'status'],
queryFn: isProcessRunning,
refetchInterval: 10000,
refetchInterval: 30000,
});
const startMutation = useMutation({
+39
View File
@@ -0,0 +1,39 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { MonitorTab } from './advanced/MonitorTab';
import { ToolsTab } from './advanced/ToolsTab';
import { ReplayTab } from './advanced/ReplayTab';
import { CapabilitiesTab } from './advanced/CapabilitiesTab';
import type { ProxyConfig } from '@/types/config';
interface ProxyAdvancedTabProps {
proxyConfig: ProxyConfig;
}
export const ProxyAdvancedTab: React.FC<ProxyAdvancedTabProps> = () => {
const { t } = useTranslation();
return (
<Tabs defaultValue="monitor" className="space-y-5">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="monitor">{t('proxy.advanced.monitor')}</TabsTrigger>
<TabsTrigger value="tools">{t('proxy.advanced.tabs.tools')}</TabsTrigger>
<TabsTrigger value="replay">{t('proxy.advanced.tabs.replay')}</TabsTrigger>
<TabsTrigger value="capabilities">{t('proxy.advanced.tabs.capabilities')}</TabsTrigger>
</TabsList>
<TabsContent value="monitor">
<MonitorTab />
</TabsContent>
<TabsContent value="tools">
<ToolsTab />
</TabsContent>
<TabsContent value="replay">
<ReplayTab />
</TabsContent>
<TabsContent value="capabilities">
<CapabilitiesTab />
</TabsContent>
</Tabs>
);
};
@@ -0,0 +1,119 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { ipc } from '@/ipc/manager';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Loader2, Eye, Zap, Code, Mic, Image } from 'lucide-react';
interface ModelCapability {
id: string;
object: string;
capabilities: {
vision: boolean;
streaming: boolean;
jsonMode: boolean;
audio: boolean;
imageGeneration: boolean;
};
}
const CAPABILITY_ICONS = {
vision: Eye,
streaming: Zap,
jsonMode: Code,
audio: Mic,
imageGeneration: Image,
} as const;
const CAPABILITY_LABELS = {
vision: 'Vision',
streaming: 'Streaming',
jsonMode: 'JSON Mode',
audio: 'Audio',
imageGeneration: 'Image Generation',
} as const;
export const CapabilitiesTab: React.FC = () => {
const { t } = useTranslation();
const { data: capabilitiesData, isLoading } = useQuery({
queryKey: ['proxyAdvanced', 'modelCapabilities'],
queryFn: () => ipc.client.proxyAdvanced.getModelCapabilities(),
refetchInterval: 60000,
});
const models: ModelCapability[] = capabilitiesData?.success
? (capabilitiesData.data as ModelCapability[])
: [];
return (
<div className="space-y-5">
{/* Header */}
<div>
<h3 className="text-lg font-medium">{t('proxy.advanced.capabilities.title')}</h3>
<p className="text-sm text-muted-foreground">{t('proxy.advanced.capabilities.description')}</p>
</div>
{/* Capabilities Grid */}
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{models.map((model) => (
<Card key={model.id}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">{model.id}</CardTitle>
<Badge variant="outline">{model.object}</Badge>
</div>
<CardDescription>Model capabilities and features</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-2">
{Object.entries(model.capabilities).map(([key, supported]) => {
const iconKey = key as keyof typeof CAPABILITY_ICONS;
const Icon = CAPABILITY_ICONS[iconKey];
const label = CAPABILITY_LABELS[iconKey];
return (
<div
key={key}
className={`flex items-center gap-2 rounded-md border p-2 ${
supported
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950'
: 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-900'
}`}
>
<Icon
className={`h-4 w-4 ${
supported ? 'text-green-600 dark:text-green-400' : 'text-gray-400'
}`}
/>
<span
className={`text-xs font-medium ${
supported
? 'text-green-700 dark:text-green-300'
: 'text-gray-500 dark:text-gray-400'
}`}
>
{label}
</span>
{supported && (
<Badge variant="default" className="ml-auto h-5 bg-green-600 text-[10px]">
✓
</Badge>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
};
@@ -0,0 +1,209 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { ipc } from '@/ipc/manager';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useToast } from '@/components/ui/use-toast';
import { StatCard } from '@/components/usage/StatCard';
import { TrafficLogTable } from './TrafficLogTable';
import { Loader2, Zap, Clock, AlertTriangle, Network, HardDrive } from 'lucide-react';
export const MonitorTab: React.FC = () => {
const { t } = useTranslation();
const { toast } = useToast();
const [direction, setDirection] = useState<string>('all');
const { data: metricsData, isLoading: metricsLoading } = useQuery({
queryKey: ['proxyAdvanced', 'metrics'],
queryFn: () => ipc.client.proxyAdvanced.getProxyMetrics(),
refetchInterval: 30000,
});
const { data: cbData, isLoading: cbLoading } = useQuery({
queryKey: ['proxyAdvanced', 'circuitBreaker'],
queryFn: () => ipc.client.proxyAdvanced.getCircuitBreakerStatus(),
refetchInterval: 30000,
});
const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['proxyAdvanced', 'logs', direction],
queryFn: () =>
ipc.client.proxyAdvanced.getRecentTrafficLogs({
limit: 50,
direction: direction === 'all' ? undefined : direction,
}),
refetchInterval: 30000,
});
const { data: statsData } = useQuery({
queryKey: ['proxyAdvanced', 'stats'],
queryFn: () => ipc.client.proxyAdvanced.getTrafficLogStats(),
refetchInterval: 30000,
});
const metrics = metricsData?.success ? metricsData.data : undefined;
const circuitBreakers = cbData?.success ? cbData.data : undefined;
const logs = logsData?.success ? logsData.data : [];
const stats = statsData?.success ? statsData.data : undefined;
const handleExport = async (format: 'json' | 'csv') => {
const result = await ipc.client.proxyAdvanced.exportTrafficLogs({ format });
if (result.success && result.data) {
navigator.clipboard.writeText(result.data);
toast({ title: t('proxy.advanced.tools.copyConfig') });
} else {
toast({ title: t('common.error'), description: result.error });
}
};
const getCircuitBadgeVariant = (state: string): React.ComponentProps<typeof Badge>['variant'] => {
if (state === 'closed') return 'default';
if (state === 'open') return 'destructive';
return 'secondary';
};
return (
<div className="space-y-5">
{/* Metrics */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label={t('proxy.advanced.metrics.requestsPerMinute')}
value={metrics ? Math.round(metrics.requestsPerMinute) : 0}
icon={Zap}
isLoading={metricsLoading}
accent="amber"
/>
<StatCard
label={t('proxy.advanced.metrics.avgLatency')}
value={metrics ? Math.round(metrics.avgLatency) : 0}
icon={Clock}
isLoading={metricsLoading}
accent="blue"
/>
<StatCard
label={t('proxy.advanced.metrics.errorRate')}
value={metrics ? Math.round(metrics.errorRate * 100) : 0}
icon={AlertTriangle}
isLoading={metricsLoading}
accent="amber"
/>
<StatCard
label={t('proxy.advanced.metrics.activeConnections')}
value={metrics?.activeConnections ?? 0}
icon={Network}
isLoading={metricsLoading}
accent="green"
/>
<StatCard
label={t('proxy.advanced.metrics.cacheStatus')}
value={metrics?.cacheHitRate ? `${Math.round(metrics.cacheHitRate * 100)}%` : 'N/A'}
icon={HardDrive}
isLoading={metricsLoading}
accent="purple"
/>
</div>
{/* Circuit Breakers */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-semibold">
{t('proxy.advanced.circuitBreaker.title')}
</CardTitle>
</CardHeader>
<CardContent>
{cbLoading ? (
<div className="flex h-20 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : !circuitBreakers ? (
<p className="text-sm text-muted-foreground">No data</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Object.entries(circuitBreakers.states).map(([api, state]) => (
<Card key={api} className="bg-muted/30">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{api}</span>
<Badge variant={getCircuitBadgeVariant(state.state)}>
{state.state}
</Badge>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{t('proxy.advanced.circuitBreaker.failures')}: {state.failures}
</div>
{state.lastFailure && (
<div className="text-xs text-muted-foreground">
{t('proxy.advanced.circuitBreaker.lastFailure')}:{' '}
{new Date(state.lastFailure).toLocaleString()}
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
{/* Traffic Logs */}
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h3 className="text-sm font-semibold">
{t('proxy.advanced.trafficLog.title')}
</h3>
{stats && (
<span className="text-xs text-muted-foreground">
Total: {stats.total}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Select value={direction} onValueChange={setDirection}>
<SelectTrigger className="h-8 w-[140px] text-xs">
<SelectValue placeholder={t('proxy.advanced.trafficLog.direction')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('proxy.advanced.trafficLog.all')}
</SelectItem>
<SelectItem value="inbound">
{t('proxy.advanced.trafficLog.inbound')}
</SelectItem>
<SelectItem value="outbound">
{t('proxy.advanced.trafficLog.outbound')}
</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => handleExport('json')}
>
{t('proxy.advanced.trafficLog.exportJson')}
</Button>
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => handleExport('csv')}
>
{t('proxy.advanced.trafficLog.exportCsv')}
</Button>
</div>
</div>
<TrafficLogTable logs={logs || []} isLoading={logsLoading} />
</div>
</div>
);
};
+173
View File
@@ -0,0 +1,173 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ipc } from '@/ipc/manager';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/components/ui/use-toast';
import { Loader2, Play, RefreshCw, Clock, AlertCircle, CheckCircle } from 'lucide-react';
interface ReplayEntry {
id: string;
timestamp: number;
method: string;
endpoint: string;
status?: number;
duration?: number;
body?: unknown;
}
export const ReplayTab: React.FC = () => {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const [selectedRequestId, setSelectedRequestId] = useState<string | null>(null);
const {
data: requestsData,
isLoading: requestsLoading,
refetch: refetchRequests,
} = useQuery({
queryKey: ['proxyAdvanced', 'recentRequests'],
queryFn: () => ipc.client.proxyAdvanced.getRecentRequests(),
refetchInterval: 10000,
});
const replayMutation = useMutation({
mutationFn: (requestId: string) => ipc.client.proxyAdvanced.replayRequest({ requestId }),
onSuccess: (data) => {
if (data.success) {
toast({
title: t('proxy.advanced.replay.success'),
description: t('proxy.advanced.replay.replayedSuccessfully'),
});
} else {
toast({
title: t('proxy.advanced.replay.error'),
description: data.error || t('proxy.advanced.replay.unknownError'),
variant: 'destructive',
});
}
setSelectedRequestId(null);
},
onError: (error) => {
toast({
title: t('proxy.advanced.replay.error'),
description: error instanceof Error ? error.message : t('proxy.advanced.replay.unknownError'),
variant: 'destructive',
});
setSelectedRequestId(null);
},
});
const requests: ReplayEntry[] = requestsData?.success ? (requestsData.data as ReplayEntry[]) : [];
const handleReplay = (requestId: string) => {
setSelectedRequestId(requestId);
replayMutation.mutate(requestId);
};
const formatTimestamp = (timestamp: number): string => {
return new Date(timestamp).toLocaleTimeString();
};
const getStatusBadge = (status?: number) => {
if (!status) return <Badge variant="outline">{t('proxy.advanced.replay.pending')}</Badge>;
if (status >= 200 && status < 300) {
return (
<Badge variant="default" className="bg-green-600 hover:bg-green-700">
<CheckCircle className="mr-1 h-3 w-3" />
{status}
</Badge>
);
}
if (status >= 400) {
return (
<Badge variant="destructive">
<AlertCircle className="mr-1 h-3 w-3" />
{status}
</Badge>
);
}
return <Badge variant="secondary">{status}</Badge>;
};
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium">{t('proxy.advanced.replay.title')}</h3>
<p className="text-sm text-muted-foreground">{t('proxy.advanced.replay.description')}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetchRequests()}
disabled={requestsLoading}
>
<RefreshCw className={`mr-2 h-4 w-4 ${requestsLoading ? 'animate-spin' : ''}`} />
{t('proxy.advanced.replay.refresh')}
</Button>
</div>
{/* Requests List */}
<Card>
<CardHeader>
<CardTitle>{t('proxy.advanced.replay.recentRequests')}</CardTitle>
<CardDescription>{t('proxy.advanced.replay.clickToReplay')}</CardDescription>
</CardHeader>
<CardContent>
{requestsLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : requests.length === 0 ? (
<div className="flex h-32 flex-col items-center justify-center gap-2 rounded-lg border border-dashed">
<Clock className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{t('proxy.advanced.replay.noRequests')}
</p>
</div>
) : (
<div className="space-y-2">
{requests.map((request) => (
<div
key={request.id}
className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-3">
{getStatusBadge(request.status)}
<div className="flex flex-col">
<span className="text-sm font-medium">
{request.method} {request.endpoint}
</span>
<span className="text-xs text-muted-foreground">
{formatTimestamp(request.timestamp)}
{request.duration && ` · ${request.duration}ms`}
</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleReplay(request.id)}
disabled={replayMutation.isPending && selectedRequestId === request.id}
>
{replayMutation.isPending && selectedRequestId === request.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Play className="mr-2 h-4 w-4" />
)}
{t('proxy.advanced.replay.replay')}
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
};
+135
View File
@@ -0,0 +1,135 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueries } from '@tanstack/react-query';
import { ipc } from '@/ipc/manager';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/components/ui/use-toast';
import { StatCard } from '@/components/usage/StatCard';
import { Loader2, Copy, CheckCircle, ShieldAlert, XCircle, Terminal, Code2, Braces } from 'lucide-react';
const IDE_CONFIGS = [
{ key: 'vscode', label: 'VS Code', icon: Code2 },
{ key: 'cursor', label: 'Cursor', icon: Terminal },
{ key: 'claude-code', label: 'Claude Code', icon: Braces },
] as const;
export const ToolsTab: React.FC = () => {
const { t } = useTranslation();
const { toast } = useToast();
const { data: parityData, isLoading: parityLoading } = useQuery({
queryKey: ['proxyAdvanced', 'parityCounters'],
queryFn: () => ipc.client.proxyAdvanced.getParityCounters(),
refetchInterval: 30000,
});
const ideQueries = useQueries({
queries: [
{
queryKey: ['proxyAdvanced', 'ideConfig', 'vscode'],
queryFn: () => ipc.client.proxyAdvanced.generateIdeConfig({ ide: 'vscode' }),
},
{
queryKey: ['proxyAdvanced', 'ideConfig', 'cursor'],
queryFn: () => ipc.client.proxyAdvanced.generateIdeConfig({ ide: 'cursor' }),
},
],
});
const parity = parityData?.success ? parityData.data : undefined;
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast({ title: t('proxy.advanced.tools.copyConfig') });
};
const getIdeInstructions = (key: string): string => {
if (key === 'claude-code') {
return 'export ANTHROPIC_BASE_URL=http://localhost:8045/v1\nexport ANTHROPIC_API_KEY=YOUR_API_KEY';
}
const index = key === 'vscode' ? 0 : 1;
const result = ideQueries[index].data;
if (result?.success && result.data) {
const d = result.data;
return `${d.instructions}\n${d.proxySetting}: http://localhost:8045\n${d.apiKeySetting}: YOUR_API_KEY`;
}
return '';
};
const isIdeLoading = ideQueries.some((q) => q.isLoading);
return (
<div className="space-y-5">
{/* Parity Counters */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard
label={t('proxy.advanced.tools.totalRequests')}
value={parity?.totalRequests ?? 0}
icon={CheckCircle}
isLoading={parityLoading}
accent="blue"
/>
<StatCard
label={t('proxy.advanced.tools.matchedRequests')}
value={parity?.matchedRequests ?? 0}
icon={ShieldAlert}
isLoading={parityLoading}
accent="green"
/>
<StatCard
label={t('proxy.advanced.tools.parityViolations')}
value={parity?.parityViolations ?? 0}
icon={XCircle}
isLoading={parityLoading}
accent="amber"
/>
</div>
{/* IDE Quick Setup */}
<Card>
<CardHeader>
<CardTitle>{t('proxy.advanced.tools.ideSetup')}</CardTitle>
<CardDescription>Quick configuration for popular IDEs</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{isIdeLoading ? (
<div className="flex h-20 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : (
IDE_CONFIGS.map(({ key, label, icon: Icon }) => {
const instructions = getIdeInstructions(key);
return (
<div key={key} className="rounded-lg border">
<div className="flex items-center justify-between p-3">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{label}</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-8 text-xs"
onClick={() => copyToClipboard(instructions)}
>
<Copy className="mr-1.5 h-3.5 w-3.5" />
{t('proxy.advanced.tools.copyConfig')}
</Button>
</div>
{instructions && (
<pre className="overflow-x-auto border-t bg-gray-900 p-3 font-mono text-xs whitespace-pre-wrap text-gray-100">
{instructions}
</pre>
)}
</div>
);
})
)}
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,190 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, ArrowUp, ArrowDown } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
interface TrafficLogEntry {
timestamp: string;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
durationMs?: number;
}
interface TrafficLogTableProps {
logs: TrafficLogEntry[];
isLoading: boolean;
}
type SortKey = 'timestamp' | 'direction' | 'endpoint' | 'method' | 'status' | 'duration';
type SortDir = 'asc' | 'desc';
export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoading }) => {
const { t } = useTranslation();
const [sortKey, setSortKey] = useState<SortKey>('timestamp');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const toggleSort = (key: SortKey) => {
if (sortKey === key) {
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir('asc');
}
};
const sortedLogs = useMemo(() => {
const dir = sortDir === 'asc' ? 1 : -1;
return [...logs].sort((a, b) => {
switch (sortKey) {
case 'timestamp':
return (new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) * dir;
case 'direction':
return (a.direction || '').localeCompare(b.direction || '') * dir;
case 'endpoint':
return (a.endpoint || '').localeCompare(b.endpoint || '') * dir;
case 'method':
return (a.method || '').localeCompare(b.method || '') * dir;
case 'status': {
const aStatus = a.status ?? -1;
const bStatus = b.status ?? -1;
return (aStatus - bStatus) * dir;
}
case 'duration': {
const aDur = a.durationMs ?? -1;
const bDur = b.durationMs ?? -1;
return (aDur - bDur) * dir;
}
default:
return 0;
}
});
}, [logs, sortKey, sortDir]);
const SortIcon = ({ column }: { column: SortKey }) => {
if (sortKey !== column) return <span className="ml-1 inline-block w-3" />;
return sortDir === 'asc' ? (
<ArrowUp className="ml-1 inline h-3 w-3" />
) : (
<ArrowDown className="ml-1 inline h-3 w-3" />
);
};
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-semibold">
{t('proxy.advanced.trafficLog.title')}
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : logs.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t('proxy.advanced.trafficLog.noLogs')}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('timestamp')}
>
{t('proxy.advanced.trafficLog.timestamp')}
<SortIcon column="timestamp" />
</th>
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('direction')}
>
{t('proxy.advanced.trafficLog.direction')}
<SortIcon column="direction" />
</th>
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('endpoint')}
>
{t('proxy.advanced.trafficLog.endpoint')}
<SortIcon column="endpoint" />
</th>
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('method')}
>
{t('proxy.advanced.trafficLog.method')}
<SortIcon column="method" />
</th>
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('status')}
>
{t('proxy.advanced.trafficLog.status')}
<SortIcon column="status" />
</th>
<th
className="cursor-pointer pb-2 font-medium select-none"
onClick={() => toggleSort('duration')}
>
{t('proxy.advanced.trafficLog.duration')}
<SortIcon column="duration" />
</th>
</tr>
</thead>
<tbody>
{sortedLogs.map((log, i) => (
<tr
key={`${log.requestId}-${i}`}
className="border-b border-border/40 last:border-0"
>
<td className="py-2 pr-4 font-mono text-xs text-muted-foreground">
{new Date(log.timestamp).toLocaleTimeString()}
</td>
<td className="py-2 pr-4">
<Badge
variant="outline"
className={
log.direction === 'inbound'
? 'border-blue-500/30 text-blue-400'
: log.direction === 'outbound'
? 'border-emerald-500/30 text-emerald-400'
: 'border-slate-500/30 text-slate-400'
}
>
{log.direction}
</Badge>
</td>
<td className="py-2 pr-4 font-mono text-xs">{log.endpoint}</td>
<td className="py-2 pr-4 text-xs">{log.method || '—'}</td>
<td className="py-2 pr-4 text-xs">
{log.status != null ? (
<Badge
variant={log.status >= 400 ? 'destructive' : 'secondary'}
className="text-xs"
>
{log.status}
</Badge>
) : (
'—'
)}
</td>
<td className="py-2 text-xs">
{log.durationMs != null ? `${log.durationMs}ms` : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
};
+7
View File
@@ -0,0 +1,7 @@
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('bg-muted animate-pulse rounded-md', className)} {...props} />;
}
export { Skeleton };
+67
View File
@@ -0,0 +1,67 @@
import React from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { AlertTriangle, BarChart3, RotateCcw } from 'lucide-react';
interface ChartStatesProps {
isLoading: boolean;
error: Error | null;
isEmpty: boolean;
onRetry: () => void;
title: string;
emptyTitle: string;
emptyDescription: string;
errorMessage: string;
retryLabel: string;
children: React.ReactNode;
}
export const ChartCard: React.FC<ChartStatesProps> = ({
isLoading,
error,
isEmpty,
onRetry,
title,
emptyTitle,
emptyDescription,
errorMessage,
retryLabel,
children,
}) => {
return (
<Card className="border-border/60 bg-card/60 flex flex-col">
<CardContent className="flex flex-1 flex-col p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-foreground text-sm font-semibold tracking-tight">{title}</h3>
</div>
<div className="flex-1">
{isLoading ? (
<div className="flex h-[280px] flex-col gap-3">
<Skeleton className="h-full w-full" />
</div>
) : error ? (
<div className="flex h-[280px] flex-col items-center justify-center gap-3">
<AlertTriangle className="text-destructive h-5 w-5" />
<p className="text-muted-foreground text-sm">{errorMessage}</p>
<Button variant="outline" size="sm" onClick={onRetry}>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{retryLabel}
</Button>
</div>
) : isEmpty ? (
<div className="flex h-[280px] flex-col items-center justify-center gap-2">
<BarChart3 className="text-muted-foreground/50 h-6 w-6" />
<p className="text-foreground text-sm font-medium">{emptyTitle}</p>
<p className="text-muted-foreground max-w-[240px] text-center text-xs">
{emptyDescription}
</p>
</div>
) : (
children
)}
</div>
</CardContent>
</Card>
);
};
+60
View File
@@ -0,0 +1,60 @@
export const COLOR_PROMPT = 'var(--chart-2)';
export const COLOR_COMPLETION = 'var(--chart-1)';
export const COLOR_TOTAL = 'var(--chart-3)';
export const PIE_COLORS = [
'var(--chart-1)',
'var(--chart-2)',
'var(--chart-3)',
'var(--chart-4)',
'var(--chart-5)',
'var(--chart-1)',
'var(--chart-2)',
'var(--chart-3)',
];
const colorMap: Record<string, string> = {
Prompt: COLOR_PROMPT,
Completion: COLOR_COMPLETION,
Total: COLOR_TOTAL,
};
export function BarTooltip({ indexValue, data }: any) {
const keys = Object.keys(data).filter((k: string) => k !== 'name' && colorMap[k]);
return (
<div className="bg-popover border-border rounded-md border px-3 py-2 shadow-md">
<p className="text-foreground mb-1.5 text-xs font-medium">{indexValue}</p>
<div className="flex flex-col gap-1">
{keys.map((key: string) => (
<div key={key} className="flex items-center gap-2 text-xs">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: colorMap[key] }}
/>
<span className="text-muted-foreground">{key}:</span>
<span className="text-foreground font-mono font-medium">
{typeof data[key] === 'number' ? data[key].toLocaleString() : data[key]}
</span>
</div>
))}
</div>
</div>
);
}
export function PieTooltip({ datum }: any) {
return (
<div className="bg-popover border-border rounded-md border px-3 py-2 shadow-md">
<div className="flex items-center gap-2 text-xs">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: datum.color }}
/>
<span className="text-muted-foreground">{datum.id}:</span>
<span className="text-foreground font-mono font-medium">
{typeof datum.value === 'number' ? datum.value.toLocaleString() : datum.value}
</span>
</div>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import React from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import type { LucideIcon } from 'lucide-react';
interface StatCardProps {
label: string;
value: number | string;
icon: LucideIcon;
isLoading?: boolean;
className?: string;
accent?: 'blue' | 'green' | 'amber' | 'slate' | 'purple';
}
const ACCENT_MAP = {
blue: 'text-blue-400',
green: 'text-emerald-400',
amber: 'text-amber-400',
slate: 'text-slate-400',
purple: 'text-purple-400',
};
const BG_MAP = {
blue: 'bg-blue-500/5',
green: 'bg-emerald-500/5',
amber: 'bg-amber-500/5',
slate: 'bg-slate-500/5',
purple: 'bg-purple-500/5',
};
export const StatCard: React.FC<StatCardProps> = ({
label,
value,
icon: Icon,
isLoading,
className,
accent = 'slate',
}) => {
return (
<Card
className={cn(
'border-border/60 bg-card/60 hover:bg-card backdrop-blur-none transition-colors',
className,
)}
>
<CardContent className="flex items-center justify-between p-4">
<div className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{label}
</span>
{isLoading ? (
<Skeleton className="h-7 w-24" />
) : (
<span className="text-foreground font-mono text-2xl font-semibold tracking-tight">
{typeof value === 'number' ? value.toLocaleString() : value}
</span>
)}
</div>
<div
className={cn(
'flex h-9 w-9 shrink-0 items-center justify-center rounded-md',
BG_MAP[accent],
)}
>
<Icon className={cn('h-4 w-4', ACCENT_MAP[accent])} />
</div>
</CardContent>
</Card>
);
};
+48 -7
View File
@@ -59,7 +59,7 @@ function sleepSync(ms: number): void {
* Ensures that the cloud database file and schema exist.
* @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);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
@@ -68,6 +68,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
let db: Database.Database | null = null;
try {
db = new Database(dbPath);
db.pragma('journal_mode = WAL');
configureDatabase(db, { busyTimeoutMs: SQLITE_BUSY_TIMEOUT_MS });
// Create accounts table
@@ -126,6 +127,40 @@ function ensureDatabaseInitialized(dbPath: string): void {
value TEXT NOT NULL
);
`);
// Create token_usage table
db.exec(`
CREATE TABLE IF NOT EXISTS token_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL,
model TEXT NOT NULL,
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
timestamp INTEGER NOT NULL,
request_type TEXT
);
`);
// Migration: Check if request_type column exists on token_usage
const tokenUsageTableInfoRaw = db.pragma('table_info(token_usage)') as any[];
const tokenUsageTableInfo = parseRows(
TableInfoRowSchema,
tokenUsageTableInfoRaw,
'cloud.token_usage.tableInfo',
);
const hasRequestType = tokenUsageTableInfo.some((col) => col.name === 'request_type');
if (!hasRequestType) {
db.exec('ALTER TABLE token_usage ADD COLUMN request_type TEXT');
}
// Create indexes for token_usage
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_account ON token_usage(account_id);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_timestamp ON token_usage(timestamp);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_model ON token_usage(model);`);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_token_usage_account_timestamp ON token_usage(account_id, timestamp);`,
);
} catch (error) {
logger.error('Failed to initialize cloud database schema', error);
throw error;
@@ -137,7 +172,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
/**
* Gets a connection to the cloud accounts database.
*/
function getCloudDb(): {
export function getCloudDb(): {
raw: Database.Database;
orm: BetterSQLite3Database<typeof drizzleSchema>;
} {
@@ -521,12 +556,18 @@ export class CloudAccountRepo {
try {
const rows = orm.select().from(accounts).orderBy(desc(accounts.lastUsed)).all();
// DEBUG LOGS
const activeRows = rows.filter((r) => r.isActive);
logger.info(
`[DEBUG] getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`,
);
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`));
logger.debug(`getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`);
// Auto-activate the single account if none are active
if (rows.length > 0 && activeRows.length === 0) {
const targetRow = rows[0];
logger.info(
`No active account found. Auto-activating account: ${targetRow.email} (${targetRow.id})`,
);
orm.update(accounts).set({ isActive: 1 }).where(eq(accounts.id, targetRow.id)).run();
targetRow.isActive = 1;
}
const cloudAccounts: CloudAccount[] = [];
for (const normalizedRow of rows) {
+452
View File
@@ -0,0 +1,452 @@
import { logger } from '../../utils/logger';
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 { proxyRequestMetrics, proxyConnectionSnapshots, trafficLogs } from './schema';
export interface RecordProxyRequestParams {
endpoint: string;
statusCode: number;
durationMs: number;
timestamp: number;
accountId?: string | null;
tokensPrompt?: number | null;
tokensCompletion?: number | null;
errorMessage?: string | null;
}
let proxyMetricsDbConnection: {
raw: Database.Database;
orm: BetterSQLite3Database<typeof import('./schema')>;
} | null = null;
function getProxyMetricsDb() {
if (!proxyMetricsDbConnection) {
const dbPath = getCloudAccountsDbPath();
ensureDatabaseInitialized(dbPath);
const conn = openDrizzleConnection(
dbPath,
{ readonly: false, fileMustExist: false },
{ busyTimeoutMs: 3000 },
);
ensureProxyMetricsTable(conn.raw);
proxyMetricsDbConnection = conn;
}
return proxyMetricsDbConnection;
}
export function ensureProxyMetricsTable(rawDb: Database.Database): void {
try {
rawDb.exec(`
CREATE TABLE IF NOT EXISTS proxy_request_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
endpoint TEXT NOT NULL,
status_code INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
account_id TEXT,
tokens_prompt INTEGER,
tokens_completion INTEGER,
error_message TEXT
);
`);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_timestamp ON proxy_request_metrics(timestamp);`,
);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_status ON proxy_request_metrics(status_code);`,
);
rawDb.exec(`
CREATE TABLE IF NOT EXISTS proxy_connection_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
count INTEGER NOT NULL DEFAULT 0
);
`);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_proxy_conn_timestamp ON proxy_connection_snapshots(timestamp);`,
);
rawDb.exec(`
CREATE TABLE IF NOT EXISTS traffic_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
direction TEXT NOT NULL,
request_id TEXT NOT NULL,
endpoint TEXT NOT NULL,
method TEXT,
status INTEGER,
headers_json TEXT,
body_json TEXT,
chunk_json TEXT,
duration_ms INTEGER,
error_message TEXT,
error_stack TEXT,
error_status INTEGER,
metadata_json TEXT
);
`);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`,
);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`,
);
} catch (error) {
logger.error('Failed to ensure proxy_request_metrics table', error);
throw error;
}
}
export class ProxyMetricsRepo {
static recordRequest(params: RecordProxyRequestParams): boolean {
const { orm } = getProxyMetricsDb();
try {
orm
.insert(proxyRequestMetrics)
.values({
endpoint: params.endpoint,
statusCode: params.statusCode,
durationMs: params.durationMs,
timestamp: params.timestamp,
accountId: params.accountId ?? null,
tokensPrompt: params.tokensPrompt ?? null,
tokensCompletion: params.tokensCompletion ?? null,
errorMessage: params.errorMessage ?? null,
})
.run();
return true;
} catch (error) {
logger.error('Failed to record proxy request metric', error);
return false;
}
}
static getRecentAggregates(): {
requestsPerMinute: number;
avgLatencyMs: number;
errorRatePercent: number;
} {
const { raw } = getProxyMetricsDb();
const now = Date.now();
const oneMinuteAgo = now - 60_000;
const fiveMinutesAgo = now - 5 * 60_000;
try {
const requestsPerMinuteRow = raw
.prepare(`SELECT COUNT(*) as count FROM proxy_request_metrics WHERE timestamp >= ?`)
.get(oneMinuteAgo) as { count: number } | undefined;
const requestsPerMinute = requestsPerMinuteRow?.count ?? 0;
const aggregatesRow = raw
.prepare(
`SELECT
COUNT(*) as total,
AVG(duration_ms) as avgLatency,
CASE
WHEN COUNT(*) = 0 THEN 0
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
END as errorRate
FROM proxy_request_metrics
WHERE timestamp >= ?`,
)
.get(fiveMinutesAgo) as
| { total: number; avgLatency: number | null; errorRate: number }
| undefined;
const avgLatencyMs = aggregatesRow?.avgLatency ? Math.round(aggregatesRow.avgLatency) : 0;
const errorRatePercent = aggregatesRow?.errorRate ?? 0;
return {
requestsPerMinute,
avgLatencyMs,
errorRatePercent,
};
} catch (error) {
logger.error('Failed to get recent proxy metrics aggregates', error);
return {
requestsPerMinute: 0,
avgLatencyMs: 0,
errorRatePercent: 0,
};
}
}
static recordConnectionSnapshot(count: number): boolean {
const { orm } = getProxyMetricsDb();
try {
orm.insert(proxyConnectionSnapshots).values({ timestamp: Date.now(), count }).run();
return true;
} catch (error) {
logger.error('Failed to record connection snapshot', error);
return false;
}
}
static getHistoricalAggregates(windowMs: number = 60 * 60 * 1000): {
totalRequests: number;
requestsPerMinute: number;
avgLatencyMs: number;
errorRatePercent: number;
avgActiveConnections: number;
} {
const { raw } = getProxyMetricsDb();
const now = Date.now();
const cutoff = now - windowMs;
try {
const reqRow = raw
.prepare(
`SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency,
CASE WHEN COUNT(*) = 0 THEN 0
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
END as errorRate
FROM proxy_request_metrics WHERE timestamp >= ?`,
)
.get(cutoff) as
| { total: number; avgLatency: number | null; errorRate: number }
| undefined;
const totalRequests = reqRow?.total ?? 0;
const minutes = Math.max(windowMs / 60_000, 1);
const requestsPerMinute = Math.round((totalRequests / minutes) * 100) / 100;
const avgLatencyMs = reqRow?.avgLatency ? Math.round(reqRow.avgLatency) : 0;
const errorRatePercent = reqRow?.errorRate ?? 0;
const connRow = raw
.prepare(`SELECT AVG(count) as avg FROM proxy_connection_snapshots WHERE timestamp >= ?`)
.get(cutoff) as { avg: number | null } | undefined;
const avgActiveConnections = connRow?.avg ? Math.round(connRow.avg) : 0;
return {
totalRequests,
requestsPerMinute,
avgLatencyMs,
errorRatePercent,
avgActiveConnections,
};
} catch (error) {
logger.error('Failed to get historical proxy metrics aggregates', error);
return {
totalRequests: 0,
requestsPerMinute: 0,
avgLatencyMs: 0,
errorRatePercent: 0,
avgActiveConnections: 0,
};
}
}
static deleteOldRecords(cutoffTimestamp: number): boolean {
const { raw } = getProxyMetricsDb();
try {
raw.prepare(`DELETE FROM proxy_request_metrics WHERE timestamp < ?`).run(cutoffTimestamp);
raw.prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`).run(cutoffTimestamp);
return true;
} catch (error) {
logger.error('Failed to delete old proxy metrics records', error);
return false;
}
}
}
export interface InsertTrafficLogParams {
timestamp: number;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
headers?: Record<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: { message: string; stack?: string; status?: number; body?: unknown };
metadata?: Record<string, unknown>;
}
export class TrafficLogsRepo {
static insert(params: InsertTrafficLogParams): boolean {
const { orm } = getProxyMetricsDb();
try {
orm
.insert(trafficLogs)
.values({
timestamp: params.timestamp,
direction: params.direction,
requestId: params.requestId,
endpoint: params.endpoint,
method: params.method ?? null,
status: params.status ?? null,
headersJson: params.headers ? JSON.stringify(params.headers) : null,
bodyJson: params.body != null ? JSON.stringify(params.body) : null,
chunkJson: params.chunk != null ? JSON.stringify(params.chunk) : null,
durationMs: params.durationMs ?? null,
errorMessage: params.error?.message ?? null,
errorStack: params.error?.stack ?? null,
errorStatus: params.error?.status ?? null,
metadataJson: params.metadata ? JSON.stringify(params.metadata) : null,
})
.run();
return true;
} catch (error) {
logger.error('Failed to insert traffic log', error);
return false;
}
}
static getRecent(limit: number = 50, direction?: string): Array<{
timestamp: number;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
headers?: Record<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: { message: string; stack?: string; status?: number; body?: unknown };
metadata?: Record<string, unknown>;
}> {
const { raw } = getProxyMetricsDb();
try {
let sql = `SELECT * FROM traffic_logs`;
const args: (number | string)[] = [];
if (direction) {
sql += ` WHERE direction = ?`;
args.push(direction);
}
sql += ` ORDER BY timestamp DESC LIMIT ?`;
args.push(limit);
const rows = raw.prepare(sql).all(...args) as Array<{
timestamp: number;
direction: string;
request_id: string;
endpoint: string;
method: string | null;
status: number | null;
headers_json: string | null;
body_json: string | null;
chunk_json: string | null;
duration_ms: number | null;
error_message: string | null;
error_stack: string | null;
error_status: number | null;
metadata_json: string | null;
}>;
return rows.map((r) => ({
timestamp: r.timestamp,
direction: r.direction,
requestId: r.request_id,
endpoint: r.endpoint,
method: r.method ?? undefined,
status: r.status ?? undefined,
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
body: r.body_json ? JSON.parse(r.body_json) : undefined,
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
durationMs: r.duration_ms ?? undefined,
error:
r.error_message
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
: undefined,
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
}));
} catch (error) {
logger.error('Failed to get recent traffic logs', error);
return [];
}
}
static getStats(): { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number } {
const { raw } = getProxyMetricsDb();
try {
const rows = raw.prepare(`SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`).all() as Array<{
direction: string;
endpoint: string;
cnt: number;
}>;
const byDirection: Record<string, number> = {};
const byEndpoint: Record<string, number> = {};
let total = 0;
for (const r of rows) {
total += r.cnt;
byDirection[r.direction] = (byDirection[r.direction] || 0) + r.cnt;
byEndpoint[r.endpoint] = (byEndpoint[r.endpoint] || 0) + r.cnt;
}
return { byDirection, byEndpoint, total };
} catch (error) {
logger.error('Failed to get traffic log stats', error);
return { byDirection: {}, byEndpoint: {}, total: 0 };
}
}
static getAllForExport(): Array<{
timestamp: number;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
headers?: Record<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: { message: string; stack?: string; status?: number; body?: unknown };
metadata?: Record<string, unknown>;
}> {
const { raw } = getProxyMetricsDb();
try {
const rows = raw.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`).all() as Array<{
timestamp: number;
direction: string;
request_id: string;
endpoint: string;
method: string | null;
status: number | null;
headers_json: string | null;
body_json: string | null;
chunk_json: string | null;
duration_ms: number | null;
error_message: string | null;
error_stack: string | null;
error_status: number | null;
metadata_json: string | null;
}>;
return rows.map((r) => ({
timestamp: r.timestamp,
direction: r.direction,
requestId: r.request_id,
endpoint: r.endpoint,
method: r.method ?? undefined,
status: r.status ?? undefined,
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
body: r.body_json ? JSON.parse(r.body_json) : undefined,
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
durationMs: r.duration_ms ?? undefined,
error:
r.error_message
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
: undefined,
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
}));
} catch (error) {
logger.error('Failed to get all traffic logs for export', error);
return [];
}
}
static deleteOld(cutoffTimestamp: number): boolean {
const { raw } = getProxyMetricsDb();
try {
raw.prepare(`DELETE FROM traffic_logs WHERE timestamp < ?`).run(cutoffTimestamp);
return true;
} catch (error) {
logger.error('Failed to delete old traffic logs', error);
return false;
}
}
}
+48
View File
@@ -27,3 +27,51 @@ export const itemTable = sqliteTable('ItemTable', {
key: text('key').primaryKey(),
value: text('value'),
});
export const tokenUsage = sqliteTable('token_usage', {
id: integer('id').primaryKey({ autoIncrement: true }),
accountId: text('account_id').notNull(),
model: text('model').notNull(),
promptTokens: integer('prompt_tokens').notNull().default(0),
completionTokens: integer('completion_tokens').notNull().default(0),
totalTokens: integer('total_tokens').notNull().default(0),
timestamp: integer('timestamp').notNull(),
requestType: text('request_type'),
isEstimated: integer('is_estimated'),
});
export const proxyRequestMetrics = sqliteTable('proxy_request_metrics', {
id: integer('id').primaryKey({ autoIncrement: true }),
endpoint: text('endpoint').notNull(),
statusCode: integer('status_code').notNull(),
durationMs: integer('duration_ms').notNull(),
timestamp: integer('timestamp').notNull(),
accountId: text('account_id'),
tokensPrompt: integer('tokens_prompt'),
tokensCompletion: integer('tokens_completion'),
errorMessage: text('error_message'),
});
export const proxyConnectionSnapshots = sqliteTable('proxy_connection_snapshots', {
id: integer('id').primaryKey({ autoIncrement: true }),
timestamp: integer('timestamp').notNull(),
count: integer('count').notNull().default(0),
});
export const trafficLogs = sqliteTable('traffic_logs', {
id: integer('id').primaryKey({ autoIncrement: true }),
timestamp: integer('timestamp').notNull(),
direction: text('direction').notNull(),
requestId: text('request_id').notNull(),
endpoint: text('endpoint').notNull(),
method: text('method'),
status: integer('status'),
headersJson: text('headers_json'),
bodyJson: text('body_json'),
chunkJson: text('chunk_json'),
durationMs: integer('duration_ms'),
errorMessage: text('error_message'),
errorStack: text('error_stack'),
errorStatus: integer('error_status'),
metadataJson: text('metadata_json'),
});
+274
View File
@@ -0,0 +1,274 @@
import { logger } from '../../utils/logger';
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 {
HourlyUsageSchema,
DailyUsageSchema,
WeeklyUsageSchema,
MonthlyUsageSchema,
ModelUsageSchema,
} from '../../types/db';
export interface RecordUsageParams {
accountId: string;
model: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
timestamp: number;
requestType?: string;
isEstimated?: boolean;
}
let usageDbConnection: {
raw: Database.Database;
orm: BetterSQLite3Database<typeof import('./schema')>;
} | null = null;
function getUsageDb() {
if (!usageDbConnection) {
const dbPath = getCloudAccountsDbPath();
ensureDatabaseInitialized(dbPath);
usageDbConnection = openDrizzleConnection(
dbPath,
{ readonly: false, fileMustExist: false },
{ busyTimeoutMs: 3000 },
);
}
return usageDbConnection;
}
function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
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 {
static recordUsage(params: RecordUsageParams): boolean {
const { orm } = getUsageDb();
try {
orm
.insert(tokenUsage)
.values({
accountId: params.accountId,
model: params.model,
promptTokens: params.promptTokens,
completionTokens: params.completionTokens,
totalTokens: params.totalTokens,
timestamp: params.timestamp,
requestType: params.requestType ?? null,
isEstimated: params.isEstimated ? 1 : null,
})
.run();
return true;
} catch (error) {
logger.error('Failed to record token usage', error);
return false;
}
}
static getUsageByHour(
accountId?: string,
start?: number,
end?: number,
): Array<{
bucket: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
strftime('%Y-%m-%d %H:00', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY bucket
ORDER BY bucket
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => HourlyUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get hourly usage', error);
return [];
}
}
static getUsageByDay(
accountId?: string,
start?: number,
end?: number,
): Array<{
bucket: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY bucket
ORDER BY bucket
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => DailyUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get daily usage', error);
return [];
}
}
static getUsageByWeek(
accountId?: string,
start?: number,
end?: number,
): Array<{
bucket: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
strftime('%Y-%W', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY bucket
ORDER BY bucket
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => WeeklyUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get weekly usage', error);
return [];
}
}
static getUsageByMonth(
accountId?: string,
start?: number,
end?: number,
): Array<{
bucket: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
strftime('%Y-%m', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY bucket
ORDER BY bucket
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => MonthlyUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get monthly usage', error);
return [];
}
}
static getUsageByModel(
accountId?: string,
start?: number,
end?: number,
): Array<{
model: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
model,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY model
ORDER BY totalTokens DESC
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => ModelUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get model usage', error);
return [];
}
}
}
+10 -10
View File
@@ -132,20 +132,20 @@ export async function isProcessRunning(): Promise<boolean> {
if (platform === 'darwin') {
// macOS: Check for Gemini Nexus.app in path
if (cmd.includes('geminiNexus.app')) {
if (cmd.includes('gemininexus.app')) {
logger.debug(
`Found Gemini Nexus process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
);
return true;
}
// Also check if the process name is exactly 'Gemini Nexus' (main process)
if (name === 'geminiNexus' && !isHelperProcess(name, cmd)) {
// Also check if the process name is exactly 'gemininexus' (main process)
if (name === 'gemininexus' && !isHelperProcess(name, cmd)) {
logger.debug(`Found Gemini Nexus process: PID=${proc.pid}, name=${name}`);
return true;
}
} else if (platform === 'win32') {
// Windows: Check for Gemini Nexus.exe
if (name === 'geminiNexus.exe' || name === 'geminiNexus') {
// Windows: Check for gemininexus.exe
if (name === 'gemininexus.exe' || name === 'gemininexus') {
logger.debug(`Found Gemini Nexus process: PID=${proc.pid}, name=${name}`);
return true;
}
@@ -155,11 +155,11 @@ export async function isProcessRunning(): Promise<boolean> {
if (nameLower === 'electron') {
// Stricter check for AUR/Electron wrapper:
// Must include geminiNexus in command line but NOT manager or tools
// Must include gemininexus in command line but NOT manager or tools
const isGeminiNexusApp =
(cmdLower.includes('/geminiNexus') ||
cmdLower.includes(' geminiNexus') ||
cmdLower.endsWith('geminiNexus')) &&
(cmdLower.includes('/gemininexus') ||
cmdLower.includes(' gemininexus') ||
cmdLower.endsWith('gemininexus')) &&
!cmdLower.includes('manager') &&
!cmdLower.includes('tools');
@@ -172,7 +172,7 @@ export async function isProcessRunning(): Promise<boolean> {
}
if (
(name.includes('geminiNexus') || cmd.includes('/geminiNexus')) &&
(name.includes('gemininexus') || cmd.includes('/gemininexus')) &&
!name.includes('tools')
) {
logger.debug(
+380
View File
@@ -0,0 +1,380 @@
import { logger } from '../../utils/logger';
import { proxyAdvancedRegistry } from './service-registry';
import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service';
import { TrafficLogsRepo } from '../database/proxyMetricsHandler';
export interface TrafficLogEntryParsed {
timestamp: string;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
headers?: Record<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: {
message: string;
stack?: string;
status?: number;
body?: unknown;
};
metadata?: Record<string, unknown>;
}
export async function getRecentTrafficLogs(
limit: number = 50,
direction?: string,
): Promise<{ success: boolean; data?: TrafficLogEntryParsed[]; error?: string }> {
try {
const rows = TrafficLogsRepo.getRecent(limit, direction);
const entries: TrafficLogEntryParsed[] = rows.map((r) => ({
timestamp: new Date(r.timestamp).toISOString(),
direction: r.direction,
requestId: r.requestId,
endpoint: r.endpoint,
method: r.method,
status: r.status,
headers: r.headers,
body: r.body,
chunk: r.chunk,
durationMs: r.durationMs,
error: r.error,
metadata: r.metadata,
}));
return { success: true, data: entries };
} catch (error) {
logger.error('Failed to get recent traffic logs', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
export async function getTrafficLogStats(): Promise<{
success: boolean;
data?: { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number };
error?: string;
}> {
try {
const stats = TrafficLogsRepo.getStats();
return { success: true, data: stats };
} catch (error) {
logger.error('Failed to get traffic log stats', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
export async function exportTrafficLogs(
format: 'json' | 'csv',
): Promise<{ success: boolean; data?: string; error?: string }> {
try {
const rows = TrafficLogsRepo.getAllForExport();
if (format === 'json') {
return { success: true, data: JSON.stringify(rows, null, 2) };
}
// CSV format
const headers = ['timestamp', 'direction', 'requestId', 'endpoint', 'method', 'status', 'durationMs'];
const rowsCsv = rows.map((e) =>
[
new Date(e.timestamp).toISOString(),
e.direction,
e.requestId,
e.endpoint,
e.method || '',
e.status != null ? e.status : '',
e.durationMs != null ? e.durationMs : '',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(','),
);
return { success: true, data: [headers.join(','), ...rowsCsv].join('\n') };
} catch (error) {
logger.error('Failed to export traffic logs', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
export async function getParityCounters(): Promise<{
success: boolean;
data?: { totalRequests: number; matchedRequests: number; parityViolations: number; lastUpdated: string };
error?: string;
}> {
try {
const tokenManager = proxyAdvancedRegistry.tokenManager;
if (!tokenManager) {
return {
success: true,
data: {
totalRequests: 0,
matchedRequests: 0,
parityViolations: 0,
lastUpdated: new Date().toISOString(),
},
};
}
return {
success: true,
data: tokenManager.getParityCounters(),
};
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
export async function getCircuitBreakerStatus(): Promise<{
success: boolean;
data?: { states: Record<string, { state: string; failures: number; lastFailure: string | null }>; globalEnabled: boolean };
error?: string;
}> {
try {
const tokenManager = proxyAdvancedRegistry.tokenManager;
if (!tokenManager) {
return {
success: true,
data: {
states: {},
globalEnabled: true,
},
};
}
const rawStatus = tokenManager.getCircuitBreakerStatus();
const states: Record<string, { state: string; failures: number; lastFailure: string | null }> = {};
for (const [accountId, entry] of Object.entries(rawStatus)) {
states[accountId] = {
state: entry.state,
failures: entry.failureCount,
lastFailure: entry.lastFailureTime ? new Date(entry.lastFailureTime).toISOString() : null,
};
}
return {
success: true,
data: {
states,
globalEnabled: true,
},
};
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
export async function getProxyMetrics(): Promise<{
success: boolean;
data?: {
uptimeSeconds: number;
totalRequests: number;
activeConnections: number;
avgLatency: number;
errorRate: number;
requestsPerMinute: number;
cacheHitRate: number;
};
error?: string;
}> {
try {
const proxyMetrics = proxyAdvancedRegistry.proxyMetrics;
if (!proxyMetrics) {
return {
success: true,
data: {
uptimeSeconds: 0,
totalRequests: 0,
activeConnections: 0,
avgLatency: 0,
errorRate: 0,
requestsPerMinute: 0,
cacheHitRate: 0,
},
};
}
const metrics = proxyMetrics.getMetrics();
return {
success: true,
data: {
uptimeSeconds: Math.floor(process.uptime()),
totalRequests: metrics.totalRequests,
activeConnections: metrics.activeConnections,
avgLatency: metrics.avgLatency,
errorRate: metrics.errorRate,
requestsPerMinute: metrics.requestsPerMinute,
cacheHitRate: metrics.cacheHitRate,
},
};
} catch (error) {
logger.error('Failed to get proxy metrics', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
export async function getRecentRequests(): Promise<{ success: boolean; data?: unknown[]; error?: string }> {
try {
const proxyReplay = proxyAdvancedRegistry.proxyReplay;
if (!proxyReplay) {
return {
success: true,
data: [],
};
}
return {
success: true,
data: proxyReplay.getRecentRequests(20),
};
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
export async function replayRequest(
requestId: string,
): Promise<{ success: boolean; data?: { original: unknown; newResponse: unknown }; error?: string }> {
try {
const proxyReplay = proxyAdvancedRegistry.proxyReplay;
if (!proxyReplay) {
return { success: false, error: 'Replay service not available' };
}
const result = await proxyReplay.replayRequest(requestId);
return { success: true, data: result };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
export async function getModelCapabilities(): Promise<{
success: boolean;
data?: { id: string; object: string; capabilities: { vision: boolean; streaming: boolean; jsonMode: boolean; audio: boolean; imageGeneration: boolean } }[];
error?: string;
}> {
try {
const capabilitiesMap: Record<string, { vision: boolean; streaming: boolean; jsonMode: boolean; audio: boolean; imageGeneration: boolean }> = {
'gemini-3-flash': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
'gemini-3.1-pro-low': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
'gemini-3.1-pro-high': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
'claude-sonnet-4-6-thinking': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
'claude-opus-4-6-thinking': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
};
const models = [
{ id: 'gemini-3-flash', object: 'model' },
{ id: 'gemini-3.1-pro-low', object: 'model' },
{ id: 'gemini-3.1-pro-high', object: 'model' },
{ id: 'claude-sonnet-4-6-thinking', object: 'model' },
{ id: 'claude-opus-4-6-thinking', object: 'model' },
];
const data = models.map((m) => {
const baseId = Object.keys(capabilitiesMap).find((key) => m.id.includes(key)) ?? 'gemini-3-flash';
return {
id: m.id,
object: m.object,
capabilities: capabilitiesMap[baseId] ?? capabilitiesMap['gemini-3-flash'],
};
});
return { success: true, data };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
function mapIdeConfigResult(
result: IdeConfigResult,
): {
name: string;
settingsPath: string;
proxySetting: string;
apiKeySetting: string;
instructions: string;
} {
switch (result.ide) {
case 'vscode':
return {
name: 'VS Code',
settingsPath: 'settings.json',
proxySetting: 'openai.url',
apiKeySetting: 'openai.apiKey',
instructions: 'Add these settings to your VS Code settings.json',
};
case 'cursor':
return {
name: 'Cursor',
settingsPath: 'settings.json',
proxySetting: 'OPENAI_BASE_URL',
apiKeySetting: 'OPENAI_API_KEY',
instructions: 'Set these environment variables for Cursor',
};
case 'claude-code':
return {
name: 'Claude Code',
settingsPath: 'Shell environment',
proxySetting: 'ANTHROPIC_BASE_URL',
apiKeySetting: 'ANTHROPIC_API_KEY',
instructions: 'Run these export commands before starting Claude Code',
};
case 'jetbrains':
return {
name: 'JetBrains',
settingsPath: 'IDE Settings',
proxySetting: 'Base URL',
apiKeySetting: 'API Key',
instructions: String(result.content),
};
case 'opencide':
return {
name: 'Opencide',
settingsPath: '.env',
proxySetting: 'OPENAI_BASE_URL',
apiKeySetting: 'OPENAI_API_KEY',
instructions: 'Add these lines to your .env file',
};
default: {
const exhaustiveCheck: never = result.ide;
throw new Error(`Unsupported IDE: ${String(exhaustiveCheck)}`);
}
}
}
export async function generateIdeConfig(
ide: string,
): Promise<{
success: boolean;
data?: {
name: string;
settingsPath: string;
proxySetting: string;
apiKeySetting: string;
instructions: string;
};
error?: string;
}> {
try {
const proxyIdeConfig = proxyAdvancedRegistry.proxyIdeConfig;
if (!proxyIdeConfig) {
return { success: false, error: 'IDE config service not available' };
}
const supportedIdes = ['cursor', 'vscode', 'claude-code', 'jetbrains', 'opencide'] as const;
const normalizedIde = ide.toLowerCase() as typeof supportedIdes[number];
if (!supportedIdes.includes(normalizedIde)) {
return { success: false, error: `Unsupported IDE: ${ide}` };
}
const result = proxyIdeConfig.generateConfigForCurrentServer(normalizedIde);
if (!result) {
return { success: false, error: 'Failed to generate IDE config: server not configured' };
}
return {
success: true,
data: mapIdeConfigResult(result),
};
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
+217
View File
@@ -0,0 +1,217 @@
import { z } from 'zod';
import { os } from '@orpc/server';
import {
getRecentTrafficLogs,
getTrafficLogStats,
exportTrafficLogs,
getParityCounters,
getCircuitBreakerStatus,
getProxyMetrics,
getRecentRequests,
generateIdeConfig,
replayRequest,
getModelCapabilities,
} from './handler';
const TrafficLogEntrySchema = z.object({
timestamp: z.string(),
direction: z.string(),
requestId: z.string(),
endpoint: z.string(),
method: z.string().optional(),
status: z.number().optional(),
headers: z.record(z.string(), z.unknown()).optional(),
body: z.unknown().optional(),
chunk: z.unknown().optional(),
durationMs: z.number().optional(),
error: z
.object({
message: z.string(),
stack: z.string().optional(),
status: z.number().optional(),
body: z.unknown().optional(),
})
.optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
const TrafficLogsResponseSchema = z.object({
success: z.boolean(),
data: z.array(TrafficLogEntrySchema).optional(),
error: z.string().optional(),
});
const TrafficLogStatsResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
byDirection: z.record(z.string(), z.number()),
byEndpoint: z.record(z.string(), z.number()),
total: z.number(),
})
.optional(),
error: z.string().optional(),
});
const ExportTrafficLogsResponseSchema = z.object({
success: z.boolean(),
data: z.string().optional(),
error: z.string().optional(),
});
const ParityCountersResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
totalRequests: z.number(),
matchedRequests: z.number(),
parityViolations: z.number(),
lastUpdated: z.string(),
})
.optional(),
error: z.string().optional(),
});
const CircuitBreakerStatusResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
states: z.record(
z.string(),
z.object({
state: z.string(),
failures: z.number(),
lastFailure: z.string().nullable(),
}),
),
globalEnabled: z.boolean(),
})
.optional(),
error: z.string().optional(),
});
const ProxyMetricsResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
uptimeSeconds: z.number(),
totalRequests: z.number(),
activeConnections: z.number(),
avgLatency: z.number(),
errorRate: z.number(),
requestsPerMinute: z.number(),
cacheHitRate: z.number(),
})
.optional(),
error: z.string().optional(),
});
const RecentRequestsResponseSchema = z.object({
success: z.boolean(),
data: z.array(z.unknown()).optional(),
error: z.string().optional(),
});
const ReplayRequestResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
original: z.unknown(),
newResponse: z.unknown(),
})
.optional(),
error: z.string().optional(),
});
const ModelCapabilitiesResponseSchema = z.object({
success: z.boolean(),
data: z
.array(
z.object({
id: z.string(),
object: z.string(),
capabilities: z.object({
vision: z.boolean(),
streaming: z.boolean(),
jsonMode: z.boolean(),
audio: z.boolean(),
imageGeneration: z.boolean(),
}),
}),
)
.optional(),
error: z.string().optional(),
});
const IdeConfigResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
name: z.string(),
settingsPath: z.string(),
proxySetting: z.string(),
apiKeySetting: z.string(),
instructions: z.string(),
})
.optional(),
error: z.string().optional(),
});
export const proxyAdvancedRouter = os.router({
getRecentTrafficLogs: os
.input(
z.object({
limit: z.number().int().min(1).max(1000).optional(),
direction: z.string().optional(),
}),
)
.output(TrafficLogsResponseSchema)
.handler(async ({ input }) => {
return getRecentTrafficLogs(input.limit, input.direction);
}),
getTrafficLogStats: os.output(TrafficLogStatsResponseSchema).handler(async () => {
return getTrafficLogStats();
}),
exportTrafficLogs: os
.input(z.object({ format: z.enum(['json', 'csv']) }))
.output(ExportTrafficLogsResponseSchema)
.handler(async ({ input }) => {
return exportTrafficLogs(input.format);
}),
getParityCounters: os.output(ParityCountersResponseSchema).handler(async () => {
return getParityCounters();
}),
getCircuitBreakerStatus: os.output(CircuitBreakerStatusResponseSchema).handler(async () => {
return getCircuitBreakerStatus();
}),
getProxyMetrics: os.output(ProxyMetricsResponseSchema).handler(async () => {
return getProxyMetrics();
}),
getRecentRequests: os.output(RecentRequestsResponseSchema).handler(async () => {
return getRecentRequests();
}),
generateIdeConfig: os
.input(z.object({ ide: z.string() }))
.output(IdeConfigResponseSchema)
.handler(async ({ input }) => {
return generateIdeConfig(input.ide);
}),
replayRequest: os
.input(z.object({ requestId: z.string() }))
.output(ReplayRequestResponseSchema)
.handler(async ({ input }) => {
return replayRequest(input.requestId);
}),
getModelCapabilities: os.output(ModelCapabilitiesResponseSchema).handler(async () => {
return getModelCapabilities();
}),
});
@@ -0,0 +1,31 @@
/**
* Service registry for proxy-advanced IPC handlers to access
* NestJS service instances without DI coupling.
*
* Services auto-register on construction.
*/
import type { TokenManagerService } from '../../server/modules/proxy/token-manager.service';
import type { ProxyMetricsService } from '../../server/modules/proxy/proxy-metrics.service';
import type { ProxyReplayService } from '../../server/modules/proxy/proxy-replay.service';
import type { ProxyIdeConfigService } from '../../server/modules/proxy/proxy-ide-config.service';
interface ProxyAdvancedRegistry {
tokenManager: TokenManagerService | null;
proxyMetrics: ProxyMetricsService | null;
proxyReplay: ProxyReplayService | null;
proxyIdeConfig: ProxyIdeConfigService | null;
}
export const proxyAdvancedRegistry: ProxyAdvancedRegistry = {
tokenManager: null,
proxyMetrics: null,
proxyReplay: null,
proxyIdeConfig: null,
};
export function registerProxyAdvancedService<K extends keyof ProxyAdvancedRegistry>(
key: K,
instance: ProxyAdvancedRegistry[K],
): void {
proxyAdvancedRegistry[key] = instance;
}
+4
View File
@@ -6,6 +6,8 @@ import { accountRouter } from './account/router';
import { cloudRouter } from './cloud/router';
import { configRouter } from './config/router';
import { gatewayRouter } from './gateway/router';
import { usageRouter } from './usage/router';
import { proxyAdvancedRouter } from './proxy-advanced/router';
import { os } from '@orpc/server';
import { z } from 'zod';
@@ -53,5 +55,7 @@ export const router = os.use(logMiddleware).router({
cloud: cloudRouter,
config: configRouter,
gateway: gatewayRouter,
usage: usageRouter,
proxyAdvanced: proxyAdvancedRouter,
system: systemHandler,
});
+70
View File
@@ -0,0 +1,70 @@
import { z } from 'zod';
import { os } from '@orpc/server';
import { TokenUsageRepo } from '../database/usageHandler';
import {
HourlyUsageSchema,
DailyUsageSchema,
WeeklyUsageSchema,
MonthlyUsageSchema,
ModelUsageSchema,
} from '../../types/db';
const RecordUsageInputSchema = z.object({
accountId: z.string(),
model: z.string(),
promptTokens: z.number().int().min(0),
completionTokens: z.number().int().min(0),
totalTokens: z.number().int().min(0),
timestamp: z.number().int(),
requestType: z.string().optional(),
});
const UsageQueryInputSchema = z.object({
accountId: z.string().optional(),
start: z.number().int().optional(),
end: z.number().int().optional(),
});
export const usageRouter = os.router({
recordUsage: os
.input(RecordUsageInputSchema)
.output(z.void())
.handler(async ({ input }) => {
TokenUsageRepo.recordUsage(input);
}),
getUsageByHour: os
.input(UsageQueryInputSchema)
.output(z.array(HourlyUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByHour(input.accountId, input.start, input.end);
}),
getUsageByDay: os
.input(UsageQueryInputSchema)
.output(z.array(DailyUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByDay(input.accountId, input.start, input.end);
}),
getUsageByWeek: os
.input(UsageQueryInputSchema)
.output(z.array(WeeklyUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByWeek(input.accountId, input.start, input.end);
}),
getUsageByMonth: os
.input(UsageQueryInputSchema)
.output(z.array(MonthlyUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByMonth(input.accountId, input.start, input.end);
}),
getUsageByModel: os
.input(UsageQueryInputSchema)
.output(z.array(ModelUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByModel(input.accountId, input.start, input.end);
}),
});
+6
View File
@@ -9,6 +9,7 @@ import {
ChevronLeft,
ChevronRight,
RefreshCw,
BarChart3,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
@@ -40,6 +41,11 @@ export const MainLayout: React.FC = () => {
icon: LayoutDashboard,
label: t('nav.accounts'),
},
{
to: '/usage',
icon: BarChart3,
label: t('nav.usage'),
},
{
to: '/proxy',
icon: Network,
+11 -2
View File
@@ -694,7 +694,13 @@ function buildGenerationConfig(
if (maxTokens !== undefined) {
config.maxOutputTokens = maxTokens;
}
const defaultStopSequences = ['<|user|>', '<|endoftext|>', '<|end_of_turn|>', '[DONE]', '\n\nHuman:'];
const defaultStopSequences = [
'<|user|>',
'<|endoftext|>',
'<|end_of_turn|>',
'[DONE]',
'\n\nHuman:',
];
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
config.stopSequences = [...new Set([...claudeReq.stop_sequences, ...defaultStopSequences])];
} else {
@@ -705,7 +711,10 @@ function buildGenerationConfig(
if (claudeReq.response_format) {
if (claudeReq.response_format.type === 'json_object') {
config.responseMimeType = 'application/json';
} else if (claudeReq.response_format.type === 'json_schema' && claudeReq.response_format.json_schema) {
} else if (
claudeReq.response_format.type === 'json_schema' &&
claudeReq.response_format.json_schema
) {
config.responseMimeType = 'application/json';
config.responseSchema = cleanJsonSchema(claudeReq.response_format.json_schema);
}
+4 -1
View File
@@ -254,7 +254,10 @@ class NonStreamingProcessor {
/**
* Public API: Transform Gemini Response to Claude Response
*/
export function transformResponse(geminiResponse: GeminiResponse, modelName?: string): ClaudeResponse {
export function transformResponse(
geminiResponse: GeminiResponse,
modelName?: string,
): ClaudeResponse {
const processor = new NonStreamingProcessor();
return processor.process(geminiResponse, modelName);
}
+61
View File
@@ -0,0 +1,61 @@
import type { PartialTheme } from '@nivo/theming';
export const nivoTheme: PartialTheme = {
background: 'transparent',
text: {
fontFamily: "Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif",
fontSize: 11,
fill: '#b8b8b8',
},
axis: {
domain: {
line: {
stroke: 'rgba(255,255,255,0.1)',
strokeWidth: 1,
},
},
ticks: {
line: {
stroke: 'transparent',
strokeWidth: 0,
},
text: {
fontFamily: "Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif",
fontSize: 11,
fill: '#b8b8b8',
},
},
legend: {
text: {
fontFamily: "Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif",
fontSize: 12,
fill: '#b8b8b8',
},
},
},
grid: {
line: {
stroke: 'rgba(255,255,255,0.06)',
strokeWidth: 1,
strokeDasharray: '4 4',
},
},
legends: {
text: {
fontFamily: "Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif",
fontSize: 12,
fill: '#b8b8b8',
},
},
tooltip: {
container: {
background: '#2e2e2e',
color: '#fff',
fontFamily: "Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif",
fontSize: 12,
borderRadius: 6,
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
padding: '8px 12px',
},
},
};
+386
View File
@@ -58,6 +58,7 @@ i18n
},
nav: {
accounts: 'Accounts',
usage: 'Usage',
proxy: 'API Proxy',
settings: 'Settings',
},
@@ -488,10 +489,138 @@ i18n
maps_to: 'Maps to',
restore: 'Restore Defaults',
},
routing: {
currentMappings: 'Current Model Mappings',
modelMapping: 'Anthropic model routing configuration',
noMapping: 'No model mappings configured.',
upstreamProxy: 'Outgoing proxy configuration',
status: 'Status',
enabled: 'Enabled',
disabled: 'Disabled',
url: 'URL',
},
examples: {
title: 'Usage Examples',
description: 'Example commands to call the local API proxy.',
},
advanced: {
title: 'Advanced',
monitor: 'Monitor',
metrics: {
title: 'Performance Metrics',
requestsPerMinute: 'Requests/min',
avgLatency: 'Avg Latency',
errorRate: 'Error Rate',
activeConnections: 'Active Connections',
cacheStatus: 'Cache Hit Rate',
},
circuitBreaker: {
title: 'Circuit Breaker Status',
healthy: 'Healthy',
degraded: 'Degraded',
unhealthy: 'Unhealthy',
failureRate: 'Failure Rate',
lastFailure: 'Last Failure',
failures: 'Failures',
},
trafficLog: {
title: 'Traffic Logs',
direction: 'Filter by direction',
all: 'All directions',
inbound: 'Inbound',
outbound: 'Outbound',
exportJson: 'Export JSON',
exportCsv: 'Export CSV',
noLogs: 'No traffic logs found',
timestamp: 'Timestamp',
endpoint: 'Endpoint',
method: 'Method',
status: 'Status',
duration: 'Duration',
},
tools: {
totalRequests: 'Total Requests',
matchedRequests: 'Matched Requests',
parityViolations: 'Parity Violations',
ideSetup: 'IDE Quick Setup',
copyConfig: 'Copy Config',
requestReplay: 'Request Replay',
comingSoon: 'Coming soon',
},
trafficLogs: {
title: 'Traffic Logs',
filterDirection: 'Filter by direction',
allDirections: 'All directions',
exportJson: 'Export JSON',
exportCsv: 'Export CSV',
noLogs: 'No traffic logs found',
},
parity: {
title: 'Parity Counters',
requests: 'Parity Requests',
errors: 'Parity Errors',
shadowComparisons: 'Shadow Comparisons',
shadowMismatches: 'Shadow Mismatches',
noGoBlocked: 'No-Go Blocked',
},
ideSetup: {
title: 'IDE Quick Setup',
cursor: 'Cursor',
vscode: 'VS Code',
claudeCode: 'Claude Code',
copyConfig: 'Copy Config',
configCopied: 'Config copied!',
},
replay: {
title: 'Request Replay',
description: 'Replay recent requests for debugging',
refresh: 'Refresh',
recentRequests: 'Recent Requests',
clickToReplay: 'Click the play button to replay any request',
noRequests: 'No recent requests',
replay: 'Replay',
success: 'Replay Successful',
replayedSuccessfully: 'Request was replayed successfully',
error: 'Replay Failed',
unknownError: 'An unknown error occurred',
pending: 'Pending',
comingSoon: 'Coming soon',
},
capabilities: {
title: 'Model Capabilities',
description: 'Discover what each model can do',
},
tabs: {
tools: 'Tools',
replay: 'Replay',
capabilities: 'Capabilities',
},
},
},
usage: {
title: 'Token Usage',
description: 'Monitor your API token consumption over time.',
range24h: 'Last 24 Hours',
range7d: 'Last 7 Days',
range30d: 'Last 30 Days',
totalTokens: 'Total Tokens',
promptTokens: 'Prompt Tokens',
completionTokens: 'Completion Tokens',
totalRequests: 'Total Requests',
dailyUsage: 'Daily Usage',
byModel: 'Usage by Model',
hourlyUsage: 'Hourly Usage',
loadFailed: 'Failed to load usage data.',
retry: 'Retry',
allAccounts: 'All Accounts',
noData: 'No data available',
emptyStateTitle: 'No usage data yet',
emptyStateDescription:
'Start making API requests to see your token usage statistics here.',
promptLabel: 'Prompt',
completionLabel: 'Completion',
totalLabel: 'Total',
requestsLabel: 'Requests',
},
},
},
@@ -540,6 +669,7 @@ i18n
},
nav: {
accounts: '账号',
usage: '用量',
proxy: 'API 反代',
settings: '设置',
},
@@ -963,10 +1093,137 @@ i18n
maps_to: '映射到',
restore: '恢复默认',
},
routing: {
currentMappings: '当前模型映射',
modelMapping: 'Anthropic 模型路由配置',
noMapping: '未配置模型映射。',
upstreamProxy: '出站代理配置',
status: '状态',
enabled: '已启用',
disabled: '已禁用',
url: 'URL',
},
examples: {
title: '使用示例',
description: '调用本地 API 代理的示例命令。',
},
advanced: {
title: '高级',
monitor: '监控',
metrics: {
title: '性能指标',
requestsPerMinute: '请求/分钟',
avgLatency: '平均延迟',
errorRate: '错误率',
activeConnections: '活跃连接',
cacheStatus: '缓存命中率',
},
circuitBreaker: {
title: '熔断器状态',
healthy: '健康',
degraded: '降级',
unhealthy: '不健康',
failureRate: '失败率',
lastFailure: '上次失败',
failures: '失败次数',
},
trafficLog: {
title: '流量日志',
direction: '按方向筛选',
all: '所有方向',
inbound: '入站',
outbound: '出站',
exportJson: '导出 JSON',
exportCsv: '导出 CSV',
noLogs: '暂无流量日志',
timestamp: '时间戳',
endpoint: '端点',
method: '方法',
status: '状态',
duration: '耗时',
},
tools: {
totalRequests: '总请求数',
matchedRequests: '匹配请求数',
parityViolations: '一致性违规',
ideSetup: 'IDE 快速配置',
copyConfig: '复制配置',
requestReplay: '请求重放',
comingSoon: '即将推出',
},
trafficLogs: {
title: '流量日志',
filterDirection: '按方向筛选',
allDirections: '所有方向',
exportJson: '导出 JSON',
exportCsv: '导出 CSV',
noLogs: '暂无流量日志',
},
parity: {
title: '一致性计数器',
requests: '一致性请求',
errors: '一致性错误',
shadowComparisons: '影子对比',
shadowMismatches: '影子不匹配',
noGoBlocked: '禁止通过',
},
ideSetup: {
title: 'IDE 快速配置',
cursor: 'Cursor',
vscode: 'VS Code',
claudeCode: 'Claude Code',
copyConfig: '复制配置',
configCopied: '配置已复制!',
},
replay: {
title: '请求重放',
description: '重放最近的请求以进行调试',
refresh: '刷新',
recentRequests: '最近请求',
clickToReplay: '点击播放按钮重放任何请求',
noRequests: '暂无最近请求',
replay: '重放',
success: '重放成功',
replayedSuccessfully: '请求已成功重放',
error: '重放失败',
unknownError: '发生未知错误',
pending: '待定',
comingSoon: '即将推出',
},
capabilities: {
title: '模型能力',
description: '发现每个模型可以做什么',
},
tabs: {
tools: '工具',
replay: '重放',
capabilities: '能力',
},
},
},
usage: {
title: 'Token 用量',
description: '监控您的 API Token 消耗情况。',
range24h: '最近 24 小时',
range7d: '最近 7 天',
range30d: '最近 30 天',
totalTokens: '总 Token 数',
promptTokens: 'Prompt Token 数',
completionTokens: 'Completion Token 数',
totalRequests: '总请求数',
dailyUsage: '每日用量',
byModel: '按模型统计',
hourlyUsage: '每小时用量',
loadFailed: '加载用量数据失败。',
retry: '重试',
allAccounts: '所有账号',
noData: '暂无数据',
emptyStateTitle: '暂无用量数据',
emptyStateDescription: '开始发起 API 请求后,此处将显示您的 Token 用量统计。',
promptLabel: 'Prompt',
completionLabel: 'Completion',
totalLabel: '总计',
requestsLabel: '请求数',
},
},
},
@@ -1012,6 +1269,7 @@ i18n
},
nav: {
accounts: 'Аккаунты',
usage: 'Использование',
proxy: 'API Прокси',
settings: 'Настройки',
},
@@ -1444,10 +1702,138 @@ i18n
maps_to: '->',
restore: 'Сброс',
},
routing: {
currentMappings: 'Текущие сопоставления моделей',
modelMapping: 'Конфигурация маршрутизации моделей Anthropic',
noMapping: 'Сопоставления моделей не настроены.',
upstreamProxy: 'Конфигурация исходящего прокси',
status: 'Статус',
enabled: 'Включено',
disabled: 'Отключено',
url: 'URL',
},
examples: {
title: 'Примеры',
description: 'Примеры использования локального прокси.',
},
advanced: {
title: 'Расширенные',
monitor: 'Монитор',
metrics: {
title: 'Метрики производительности',
requestsPerMinute: 'Запросов/мин',
avgLatency: 'Средняя задержка',
errorRate: 'Частота ошибок',
activeConnections: 'Активные соединения',
cacheStatus: 'Попадание в кэш',
},
circuitBreaker: {
title: 'Состояние предохранителя',
healthy: 'Здоровый',
degraded: 'Деградированный',
unhealthy: 'Нездоровый',
failureRate: 'Частота отказов',
lastFailure: 'Последний сбой',
failures: 'Сбои',
},
trafficLog: {
title: 'Журналы трафика',
direction: 'Фильтр по направлению',
all: 'Все направления',
inbound: 'Входящий',
outbound: 'Исходящий',
exportJson: 'Экспорт JSON',
exportCsv: 'Экспорт CSV',
noLogs: 'Журналы трафика не найдены',
timestamp: 'Время',
endpoint: 'Конечная точка',
method: 'Метод',
status: 'Статус',
duration: 'Длительность',
},
tools: {
totalRequests: 'Всего запросов',
matchedRequests: 'Совпавшие запросы',
parityViolations: 'Нарушения паритета',
ideSetup: 'Быстрая настройка IDE',
copyConfig: 'Копировать конфиг',
requestReplay: 'Повтор запросов',
comingSoon: 'Скоро',
},
trafficLogs: {
title: 'Журналы трафика',
filterDirection: 'Фильтр по направлению',
allDirections: 'Все направления',
exportJson: 'Экспорт JSON',
exportCsv: 'Экспорт CSV',
noLogs: 'Журналы трафика не найдены',
},
parity: {
title: 'Счетчики паритета',
requests: 'Запросы паритета',
errors: 'Ошибки паритета',
shadowComparisons: 'Теневые сравнения',
shadowMismatches: 'Теневые несоответствия',
noGoBlocked: 'Блокировка No-Go',
},
ideSetup: {
title: 'Быстрая настройка IDE',
cursor: 'Cursor',
vscode: 'VS Code',
claudeCode: 'Claude Code',
copyConfig: 'Копировать конфиг',
configCopied: 'Конфиг скопирован!',
},
replay: {
title: 'Повтор запросов',
description: 'Повтор недавних запросов для отладки',
refresh: 'Обновить',
recentRequests: 'Недавние запросы',
clickToReplay: 'Нажмите кнопку воспроизведения, чтобы повторить любой запрос',
noRequests: 'Нет недавних запросов',
replay: 'Повторить',
success: 'Повтор успешен',
replayedSuccessfully: 'Запрос успешно повторен',
error: 'Ошибка повтора',
unknownError: 'Произошла неизвестная ошибка',
pending: 'В ожидании',
comingSoon: 'Скоро',
},
capabilities: {
title: 'Возможности моделей',
description: 'Узнайте, что может каждая модель',
},
tabs: {
tools: 'Инструменты',
replay: 'Повтор',
capabilities: 'Возможности',
},
},
},
usage: {
title: 'Использование токенов',
description: 'Мониторинг потребления API токенов.',
range24h: 'Последние 24 часа',
range7d: 'Последние 7 дней',
range30d: 'Последние 30 дней',
totalTokens: 'Всего токенов',
promptTokens: 'Prompt токены',
completionTokens: 'Completion токены',
totalRequests: 'Всего запросов',
dailyUsage: 'Ежедневное использование',
byModel: 'По моделям',
hourlyUsage: 'Почасовое использование',
loadFailed: 'Не удалось загрузить данные об использовании.',
retry: 'Повторить',
allAccounts: 'Все аккаунты',
noData: 'Нет данных',
emptyStateTitle: 'Данных об использовании пока нет',
emptyStateDescription:
'Начните отправлять API-запросы, чтобы увидеть статистику использования токенов.',
promptLabel: 'Prompt',
completionLabel: 'Completion',
totalLabel: 'Всего',
requestsLabel: 'Запросы',
},
},
},
+74
View File
@@ -39,6 +39,7 @@ const vi = {
},
nav: {
accounts: 'Tài khoản',
usage: 'Sử dụng',
proxy: 'API Proxy',
settings: 'Cài đặt',
},
@@ -471,6 +472,79 @@ const vi = {
title: 'Ví dụ sử dụng',
description: 'Ví dụ lệnh để gọi local API proxy.',
},
advanced: {
title: 'Nâng cao',
monitor: 'Giám sát',
routing: 'Định tuyến',
tools: 'Công cụ',
metrics: {
title: 'Chỉ số hiệu suất',
requestsPerMinute: 'Yêu cầu/phút',
avgLatency: 'Độ trễ TB',
errorRate: 'Tỷ lệ lỗi',
activeConnections: 'Kết nối hoạt động',
},
circuitBreaker: {
title: 'Trạng thái Circuit Breaker',
healthy: 'Khỏe',
degraded: 'Suy giảm',
unhealthy: 'Không khỏe',
failureRate: 'Tỷ lệ thất bại',
lastFailure: 'Lỗi gần nhất',
},
trafficLogs: {
title: 'Nhật ký lưu lượng',
filterDirection: 'Lọc theo hướng',
allDirections: 'Tất cả hướng',
exportJson: 'Xuất JSON',
exportCsv: 'Xuất CSV',
noLogs: 'Không tìm thấy nhật ký',
},
parity: {
title: 'Bộ đếm Parity',
requests: 'Yêu cầu Parity',
errors: 'Lỗi Parity',
shadowComparisons: 'So sánh Shadow',
shadowMismatches: 'Không khớp Shadow',
noGoBlocked: 'No-Go đã chặn',
},
ideSetup: {
title: 'Thiết lập IDE nhanh',
cursor: 'Cursor',
vscode: 'VS Code',
claudeCode: 'Claude Code',
copyConfig: 'Sao chép cấu hình',
configCopied: 'Đã sao chép cấu hình!',
},
replay: {
title: 'Phát lại yêu cầu',
comingSoon: 'Sắp ra mắt',
},
},
},
usage: {
title: 'Token Usage',
description: 'Theo dõi mức tiêu thụ API token của bạn.',
range24h: '24 giờ qua',
range7d: '7 ngày qua',
range30d: '30 ngày qua',
totalTokens: 'Tổng Token',
promptTokens: 'Prompt Token',
completionTokens: 'Completion Token',
totalRequests: 'Tổng Request',
dailyUsage: 'Sử dụng hàng ngày',
byModel: 'Theo Model',
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',
noData: 'Không có dữ liệu',
emptyStateTitle: 'Chưa có dữ liệu sử dụng',
emptyStateDescription: 'Bắt đầu gửi yêu cầu API để xem thống kê sử dụng token tại đây.',
promptLabel: 'Prompt',
completionLabel: 'Completion',
totalLabel: 'Tổng',
requestsLabel: 'Yêu cầu',
},
};
+21 -3
View File
@@ -9,10 +9,16 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as UsageRouteImport } from './routes/usage'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as ProxyRouteImport } from './routes/proxy'
import { Route as IndexRouteImport } from './routes/index'
const UsageRoute = UsageRouteImport.update({
id: '/usage',
path: '/usage',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
@@ -33,34 +39,45 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/proxy': typeof ProxyRoute
'/settings': typeof SettingsRoute
'/usage': typeof UsageRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/proxy': typeof ProxyRoute
'/settings': typeof SettingsRoute
'/usage': typeof UsageRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/proxy': typeof ProxyRoute
'/settings': typeof SettingsRoute
'/usage': typeof UsageRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/proxy' | '/settings'
fullPaths: '/' | '/proxy' | '/settings' | '/usage'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/proxy' | '/settings'
id: '__root__' | '/' | '/proxy' | '/settings'
to: '/' | '/proxy' | '/settings' | '/usage'
id: '__root__' | '/' | '/proxy' | '/settings' | '/usage'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ProxyRoute: typeof ProxyRoute
SettingsRoute: typeof SettingsRoute
UsageRoute: typeof UsageRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/usage': {
id: '/usage'
path: '/usage'
fullPath: '/usage'
preLoaderRoute: typeof UsageRouteImport
parentRoute: typeof rootRouteImport
}
'/settings': {
id: '/settings'
path: '/settings'
@@ -89,6 +106,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ProxyRoute: ProxyRoute,
SettingsRoute: SettingsRoute,
UsageRoute: UsageRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+623 -415
View File
File diff suppressed because it is too large. Load diff
+497
View File
@@ -0,0 +1,497 @@
import { useMemo, useState } from 'react';
import { createFileRoute } from '@tanstack/react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { ResponsiveBar } from '@nivo/bar';
import { ResponsivePie } from '@nivo/pie';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ipc } from '@/ipc/manager';
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
import { StatCard } from '@/components/usage/StatCard';
import { ChartCard } from '@/components/usage/ChartCard';
import {
COLOR_PROMPT,
COLOR_COMPLETION,
COLOR_TOTAL,
PIE_COLORS,
BarTooltip,
PieTooltip,
} from '@/components/usage/NivoTooltip';
import { nivoTheme } from '@/lib/nivo-theme';
import {
Activity,
ArrowUpRight,
ArrowDownRight,
Layers,
Loader2,
MessageSquare,
Zap,
} from 'lucide-react';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type TimeRange = '24h' | '7d' | '30d';
interface UsageBucket {
bucket: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}
interface ModelBucket {
model: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function getTimeRange(r: TimeRange): { start: number; end: number } {
const now = Date.now();
if (r === '24h') {
return { start: now - 24 * 60 * 60 * 1000, end: now };
}
if (r === '7d') {
return { start: now - 7 * 24 * 60 * 60 * 1000, end: now };
}
return { start: now - 30 * 24 * 60 * 60 * 1000, end: now };
}
function formatBucketLabel(bucket: string, range: TimeRange): string {
if (range === '24h') {
// bucket is "YYYY-MM-DD HH:00"
const parts = bucket.split(' ');
if (parts.length === 2) {
return parts[1]; // "HH:00"
}
}
if (range === '7d') {
// bucket is "YYYY-MM-DD"
const d = new Date(bucket + 'T00:00:00');
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
// 30d
const d = new Date(bucket + 'T00:00:00');
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function barColor(bar: any): string {
if (bar.id === 'Prompt') return COLOR_PROMPT;
if (bar.id === 'Completion') return COLOR_COMPLETION;
if (bar.id === 'Total') return COLOR_TOTAL;
return 'var(--chart-3)';
}
/* ------------------------------------------------------------------ */
/* Main Component */
/* ------------------------------------------------------------------ */
function UsagePage() {
const { t } = useTranslation();
const [range, setRange] = useState<TimeRange>('7d');
const ALL_ACCOUNTS = '__all__';
const [selectedAccountId, setSelectedAccountId] = useState<string>(ALL_ACCOUNTS);
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
const {
data: dailyData,
isLoading: dailyLoading,
error: dailyError,
refetch: refetchDaily,
} = useQuery<UsageBucket[]>({
queryKey: ['usage', 'day', range, selectedAccountId],
queryFn: () => {
const { start, end } = getTimeRange(range);
return ipc.client.usage.getUsageByDay({ accountId: selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId, start, end });
},
refetchInterval: 30000,
refetchIntervalInBackground: false,
staleTime: 60_000,
});
const {
data: modelData,
isLoading: modelLoading,
error: modelError,
refetch: refetchModel,
} = useQuery<ModelBucket[]>({
queryKey: ['usage', 'model', range, selectedAccountId],
queryFn: () => {
const { start, end } = getTimeRange(range);
return ipc.client.usage.getUsageByModel({ accountId: selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId, start, end });
},
refetchInterval: 30000,
refetchIntervalInBackground: false,
staleTime: 60_000,
});
const {
data: hourlyData,
isLoading: hourlyLoading,
error: hourlyError,
refetch: refetchHourly,
} = useQuery<UsageBucket[]>({
queryKey: ['usage', 'hour', range, selectedAccountId],
queryFn: () => {
const { start, end } = getTimeRange(range);
return ipc.client.usage.getUsageByHour({ accountId: selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId, start, end });
},
enabled: range === '24h',
refetchInterval: 30000,
refetchIntervalInBackground: false,
staleTime: 60_000,
});
/* -------------------------- Derived data -------------------------- */
const totals = useMemo(() => {
const source = dailyData || [];
return source.reduce(
(acc, row) => ({
promptTokens: acc.promptTokens + row.promptTokens,
completionTokens: acc.completionTokens + row.completionTokens,
totalTokens: acc.totalTokens + row.totalTokens,
requests: acc.requests + row.requests,
}),
{ promptTokens: 0, completionTokens: 0, totalTokens: 0, requests: 0 },
);
}, [dailyData]);
const promptRatio = useMemo(() => {
if (totals.totalTokens === 0) return 0;
return Math.round((totals.promptTokens / totals.totalTokens) * 100);
}, [totals]);
const completionRatio = useMemo(() => {
if (totals.totalTokens === 0) return 0;
return Math.round((totals.completionTokens / totals.totalTokens) * 100);
}, [totals]);
const chartData = useMemo(() => {
return (dailyData || []).map((d) => ({
name: formatBucketLabel(d.bucket, range),
Prompt: d.promptTokens,
Completion: d.completionTokens,
}));
}, [dailyData, range]);
const pieData = useMemo(() => {
return (modelData || []).map((d) => ({
id: d.model,
value: d.totalTokens,
}));
}, [modelData]);
const hourlyChartData = useMemo(() => {
return (hourlyData || []).map((d) => ({
name: formatBucketLabel(d.bucket, '24h'),
Prompt: d.promptTokens,
Completion: d.completionTokens,
Total: d.totalTokens,
}));
}, [hourlyData]);
const hourlyTickValues = useMemo(() => {
return hourlyChartData.filter((_, i) => i % 3 === 0).map((d) => d.name);
}, [hourlyChartData]);
const isDailyEmpty = !dailyLoading && !dailyError && (!dailyData || dailyData.length === 0);
const isModelEmpty = !modelLoading && !modelError && (!modelData || modelData.length === 0);
const isHourlyEmpty = !hourlyLoading && !hourlyError && (!hourlyData || hourlyData.length === 0);
/* ----------------------------- Render ----------------------------- */
return (
<div className="flex h-full flex-col overflow-auto">
{/* Header */}
<div className="border-border/40 flex flex-col gap-4 border-b px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-foreground text-xl font-bold tracking-tight">{t('usage.title')}</h1>
<p className="text-muted-foreground mt-0.5 text-sm">{t('usage.description')}</p>
</div>
<div className="flex items-center gap-3">
{/* Account filter */}
<Select
value={selectedAccountId}
onValueChange={(value) => setSelectedAccountId(value)}
disabled={accountsLoading}
>
<SelectTrigger className="h-9 w-[200px] text-xs">
{accountsLoading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t('common.loading')}
</span>
) : (
<SelectValue placeholder={t('usage.allAccounts')} />
)}
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_ACCOUNTS}>{t('usage.allAccounts')}</SelectItem>
{accounts?.map((acc) => (
<SelectItem key={acc.id} value={acc.id}>
{acc.email}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Time range tabs */}
<Tabs value={range} onValueChange={(v) => setRange(v as TimeRange)}>
<TabsList className="h-9">
<TabsTrigger value="24h" className="px-3 text-xs">
{t('usage.range24h')}
</TabsTrigger>
<TabsTrigger value="7d" className="px-3 text-xs">
{t('usage.range7d')}
</TabsTrigger>
<TabsTrigger value="30d" className="px-3 text-xs">
{t('usage.range30d')}
</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
<div className="flex-1 space-y-5 px-6 py-5">
{/* Summary Stats */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label={t('usage.totalTokens')}
value={totals.totalTokens}
icon={Zap}
isLoading={dailyLoading}
accent="amber"
/>
<StatCard
label={t('usage.promptTokens')}
value={totals.promptTokens}
icon={MessageSquare}
isLoading={dailyLoading}
accent="blue"
/>
<StatCard
label={t('usage.completionTokens')}
value={totals.completionTokens}
icon={Activity}
isLoading={dailyLoading}
accent="green"
/>
<StatCard
label={t('usage.totalRequests')}
value={totals.requests}
icon={Layers}
isLoading={dailyLoading}
accent="slate"
/>
</div>
{/* Trend indicators row */}
{!dailyLoading && totals.totalTokens > 0 && (
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-1.5 text-xs">
<ArrowUpRight className="h-3.5 w-3.5 text-blue-400" />
<span className="text-muted-foreground">{t('usage.promptLabel')}:</span>
<span className="text-foreground font-mono font-medium">{promptRatio}%</span>
</div>
<div className="flex items-center gap-1.5 text-xs">
<ArrowDownRight className="h-3.5 w-3.5 text-emerald-400" />
<span className="text-muted-foreground">{t('usage.completionLabel')}:</span>
<span className="text-foreground font-mono font-medium">{completionRatio}%</span>
</div>
<div className="bg-border/40 h-3 w-px" />
<div className="text-muted-foreground text-xs">
{t('usage.requestsLabel')}:{' '}
<span className="text-foreground font-mono font-medium">
{totals.requests.toLocaleString()}
</span>
</div>
</div>
)}
{/* Charts */}
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{/* Daily Usage Bar Chart */}
<ChartCard
title={t('usage.dailyUsage')}
isLoading={dailyLoading}
error={dailyError}
isEmpty={isDailyEmpty}
onRetry={() => refetchDaily()}
emptyTitle={t('usage.emptyStateTitle')}
emptyDescription={t('usage.emptyStateDescription')}
errorMessage={t('usage.loadFailed')}
retryLabel={t('usage.retry')}
>
<div className="h-[280px]">
<ResponsiveBar
data={chartData}
keys={['Prompt', 'Completion']}
indexBy="name"
groupMode="grouped"
theme={nivoTheme}
colors={barColor}
margin={{ top: 10, right: 10, bottom: 40, left: 50 }}
padding={0.2}
innerPadding={2}
borderRadius={2}
enableGridY={true}
enableGridX={false}
gridYValues={5}
axisBottom={{
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
}}
axisLeft={{
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
format: (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`),
}}
tooltip={BarTooltip}
legends={[
{
dataFrom: 'keys',
anchor: 'bottom',
direction: 'row',
justify: false,
translateX: 0,
translateY: 30,
itemsSpacing: 16,
itemWidth: 80,
itemHeight: 16,
itemDirection: 'left-to-right',
symbolSize: 8,
symbolShape: 'circle',
},
]}
animate={true}
/>
</div>
</ChartCard>
{/* Model Usage Donut Chart */}
<ChartCard
title={t('usage.byModel')}
isLoading={modelLoading}
error={modelError}
isEmpty={isModelEmpty}
onRetry={() => refetchModel()}
emptyTitle={t('usage.emptyStateTitle')}
emptyDescription={t('usage.emptyStateDescription')}
errorMessage={t('usage.loadFailed')}
retryLabel={t('usage.retry')}
>
<div className="h-[280px]">
<ResponsivePie
data={pieData}
innerRadius={0.55}
padAngle={2}
colors={PIE_COLORS}
borderWidth={0}
theme={nivoTheme}
margin={{ top: 10, right: 10, bottom: 40, left: 10 }}
tooltip={PieTooltip}
legends={[
{
anchor: 'bottom',
direction: 'row',
justify: false,
translateX: 0,
translateY: 30,
itemsSpacing: 16,
itemWidth: 80,
itemHeight: 16,
itemDirection: 'left-to-right',
symbolSize: 8,
symbolShape: 'circle',
},
]}
animate={true}
/>
</div>
</ChartCard>
</div>
{/* Hourly Usage — 24h only */}
{range === '24h' && (
<ChartCard
title={t('usage.hourlyUsage')}
isLoading={hourlyLoading}
error={hourlyError}
isEmpty={isHourlyEmpty}
onRetry={() => refetchHourly()}
emptyTitle={t('usage.emptyStateTitle')}
emptyDescription={t('usage.emptyStateDescription')}
errorMessage={t('usage.loadFailed')}
retryLabel={t('usage.retry')}
>
<div className="h-[280px]">
<ResponsiveBar
data={hourlyChartData}
keys={['Prompt', 'Completion', 'Total']}
indexBy="name"
groupMode="grouped"
theme={nivoTheme}
colors={barColor}
margin={{ top: 10, right: 10, bottom: 40, left: 50 }}
padding={0.2}
innerPadding={2}
borderRadius={2}
enableGridY={true}
enableGridX={false}
gridYValues={5}
axisBottom={{
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
tickValues: hourlyTickValues,
}}
axisLeft={{
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
format: (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`),
}}
tooltip={BarTooltip}
legends={[
{
dataFrom: 'keys',
anchor: 'bottom',
direction: 'row',
justify: false,
translateX: 0,
translateY: 30,
itemsSpacing: 16,
itemWidth: 80,
itemHeight: 16,
itemDirection: 'left-to-right',
symbolSize: 8,
symbolShape: 'circle',
},
]}
animate={true}
/>
</div>
</ChartCard>
)}
</div>
</div>
);
}
export const Route = createFileRoute('/usage')({
component: UsagePage,
});
+7 -2
View File
@@ -82,12 +82,17 @@ export function safeApiLogValue(value: unknown, maxLength = 50_000): unknown {
const sanitized = sanitizeObject(value);
if (typeof sanitized === 'string' && sanitized.length > maxLength) {
return sanitized.substring(0, maxLength) + `... [truncated ${sanitized.length - maxLength} chars]`;
return (
sanitized.substring(0, maxLength) + `... [truncated ${sanitized.length - maxLength} chars]`
);
}
const stringified = JSON.stringify(sanitized);
if (stringified.length > maxLength) {
return stringified.substring(0, maxLength) + `... [truncated ${stringified.length - maxLength} chars]`;
return (
stringified.substring(0, maxLength) +
`... [truncated ${stringified.length - maxLength} chars]`
);
}
return sanitized;
+3 -7
View File
@@ -22,13 +22,9 @@ export async function bootstrapNestServer(config: ProxyConfig): Promise<boolean>
setServerConfig(config);
try {
app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ bodyLimit: 10 * 1024 * 1024 }),
{
logger: ['error', 'warn', 'log'],
},
);
app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), {
logger: ['error', 'warn', 'log'],
});
// Enable CORS
app.enableCors();
@@ -184,6 +184,8 @@ export interface OpenAIChatResponse {
completion_tokens: number;
total_tokens: number;
};
/** Optional identifier for the model's configuration snapshot. Included for OpenAI-spec compliance. */
system_fingerprint?: string;
}
export interface OpenAIChoice {
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { Subject, Observable } from 'rxjs';
export interface ProxyEvent {
type: 'account_switch' | 'rate_limit' | 'error' | 'metric' | 'heartbeat';
timestamp: number;
data: unknown;
}
@Injectable()
export class ProxyEventBus {
private readonly subject = new Subject<ProxyEvent>();
emit(event: ProxyEvent): void {
this.subject.next(event);
}
listen(): Observable<ProxyEvent> {
return this.subject.asObservable();
}
}
@@ -0,0 +1,126 @@
import { Injectable, Logger } from '@nestjs/common';
import { networkInterfaces } from 'os';
import { getServerConfig } from '../../server-config';
import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry';
export type SupportedIde = 'cursor' | 'vscode' | 'claude-code' | 'jetbrains' | 'opencide';
export interface IdeConfigResult {
ide: SupportedIde;
format: 'json' | 'shell' | 'env' | 'instructions';
content: Record<string, string> | string;
}
@Injectable()
export class ProxyIdeConfigService {
private readonly logger = new Logger(ProxyIdeConfigService.name);
constructor() {
registerProxyAdvancedService('proxyIdeConfig', this);
}
/**
* Return the first non-internal IPv4 address found on the machine.
* Falls back to '127.0.0.1' if no external interface is available.
*/
getLocalIp(): string {
const interfaces = networkInterfaces();
for (const iface of Object.values(interfaces)) {
if (!iface) {
continue;
}
for (const entry of iface) {
if (entry.family === 'IPv4' && !entry.internal) {
return entry.address;
}
}
}
return '127.0.0.1';
}
/**
* Generate a ready-to-paste IDE configuration for the given IDE.
*/
generateIdeConfig(
ide: SupportedIde,
port: number,
apiKey: string,
localIp: string,
): IdeConfigResult {
const baseUrl = `http://${localIp}:${port}/v1`;
switch (ide) {
case 'cursor': {
return {
ide,
format: 'json',
content: {
OPENAI_API_KEY: apiKey,
OPENAI_BASE_URL: baseUrl,
},
};
}
case 'vscode': {
return {
ide,
format: 'json',
content: {
'openai.url': baseUrl,
'openai.apiKey': apiKey,
},
};
}
case 'claude-code': {
return {
ide,
format: 'shell',
content: `export OPENAI_API_KEY=${apiKey} && export OPENAI_BASE_URL=${baseUrl}`,
};
}
case 'jetbrains': {
return {
ide,
format: 'instructions',
content: [
'JetBrains AI Assistant Setup:',
'1. Open Settings (File > Settings or Ctrl+Alt+S).',
'2. Navigate to Languages & Frameworks > AI Assistant.',
'3. Under "Custom OpenAI Endpoint", set:',
` - Base URL: ${baseUrl}`,
` - API Key: ${apiKey}`,
'4. Click "Apply" and restart the IDE if prompted.',
].join('\n'),
};
}
case 'opencide': {
return {
ide,
format: 'env',
content: `OPENAI_API_KEY=${apiKey}\nOPENAI_BASE_URL=${baseUrl}`,
};
}
default: {
// Exhaustive fallback to satisfy TypeScript
const exhaustiveCheck: never = ide;
this.logger.warn(`Unsupported IDE requested: ${String(exhaustiveCheck)}`);
throw new Error(`Unsupported IDE: ${String(exhaustiveCheck)}`);
}
}
}
/**
* Convenience helper that reads the current server config and local IP,
* then delegates to generateIdeConfig.
*/
generateConfigForCurrentServer(ide: SupportedIde): IdeConfigResult | null {
const config = getServerConfig();
if (!config) {
this.logger.warn('Server config not available; cannot generate IDE config');
return null;
}
const port = config.port;
const apiKey = config.api_key;
const localIp = this.getLocalIp();
return this.generateIdeConfig(ide, port, apiKey, localIp);
}
}
@@ -0,0 +1,113 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry';
import { ProxyMetricsRepo } from '../../../ipc/database/proxyMetricsHandler';
interface RequestRecord {
timestamp: number;
duration: number;
status: number;
endpoint: string;
}
const HISTORICAL_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const CONNECTION_SNAPSHOT_INTERVAL_MS = 30_000; // 30 seconds
const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
const RECORDS_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
@Injectable()
export class ProxyMetricsService implements OnModuleInit {
private readonly maxRecords = 100;
private records: RequestRecord[] = [];
private activeConnectionsCount = 0;
private tokenEvents: Array<{ timestamp: number; tokens: number }> = [];
constructor() {
registerProxyAdvancedService('proxyMetrics', this);
}
onModuleInit(): void {
this.cleanupOldRecords();
setInterval(() => this.cleanupOldRecords(), CLEANUP_INTERVAL_MS);
this.recordConnectionSnapshot();
setInterval(() => this.recordConnectionSnapshot(), CONNECTION_SNAPSHOT_INTERVAL_MS);
}
incrementActiveConnections(): void {
this.activeConnectionsCount++;
}
decrementActiveConnections(): void {
this.activeConnectionsCount = Math.max(0, this.activeConnectionsCount - 1);
}
recordRequest(record: RequestRecord): void {
this.records.push(record);
if (this.records.length > this.maxRecords) {
this.records.shift();
}
ProxyMetricsRepo.recordRequest({
endpoint: record.endpoint,
statusCode: record.status,
durationMs: record.duration,
timestamp: record.timestamp,
accountId: null,
tokensPrompt: null,
tokensCompletion: null,
errorMessage: null,
});
}
recordTokens(tokens: number): void {
if (tokens <= 0) {
return;
}
this.tokenEvents.push({ timestamp: Date.now(), tokens });
this.trimTokenEvents();
}
getMetrics(): {
totalRequests: number;
requestsPerMinute: number;
avgLatency: number;
errorRate: number;
activeConnections: number;
tokenThroughput: number;
cacheHitRate: number;
} {
const aggregates = ProxyMetricsRepo.getHistoricalAggregates(HISTORICAL_WINDOW_MS);
this.trimTokenEvents();
const tokenThroughput = this.tokenEvents.reduce((sum, e) => sum + e.tokens, 0);
return {
totalRequests: aggregates.totalRequests,
requestsPerMinute: Math.round(aggregates.requestsPerMinute * 100) / 100,
avgLatency: Math.round(aggregates.avgLatencyMs * 100) / 100,
errorRate: Math.round(aggregates.errorRatePercent * 100) / 100,
activeConnections: Math.round(aggregates.avgActiveConnections * 100) / 100,
tokenThroughput,
cacheHitRate: 0,
};
}
cleanupOldRecords(): void {
const cutoff = Date.now() - RECORDS_TTL_MS;
ProxyMetricsRepo.deleteOldRecords(cutoff);
}
private recordConnectionSnapshot(): void {
ProxyMetricsRepo.recordConnectionSnapshot(this.activeConnectionsCount);
}
private trimTokenEvents(): void {
const cutoff = Date.now() - 60_000;
const index = this.tokenEvents.findIndex((e) => e.timestamp >= cutoff);
if (index > 0) {
this.tokenEvents = this.tokenEvents.slice(index);
} else if (index === -1 && this.tokenEvents.length > 0) {
this.tokenEvents = [];
}
}
}
@@ -0,0 +1,299 @@
import { Injectable, Logger, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry';
import { ProxyService } from './proxy.service';
import { v4 as uuidv4 } from 'uuid';
import {
OpenAIChatRequest,
AnthropicChatRequest,
GeminiRequest,
} from './interfaces/request-interfaces';
export interface ReplayEntry {
requestId: string;
timestamp: number;
endpoint: string;
method: string;
body: unknown;
headers: Record<string, unknown>;
accountId?: string;
model?: string;
responseSnapshot?: unknown;
}
@Injectable()
export class ProxyReplayService {
private readonly logger = new Logger(ProxyReplayService.name);
private readonly buffer: ReplayEntry[] = [];
private readonly maxSize = 20;
constructor(
@Inject(ProxyService) private readonly proxyService: ProxyService,
) {
registerProxyAdvancedService('proxyReplay', this);
}
recordRequest(entry: ReplayEntry): void {
if (this.buffer.length >= this.maxSize) {
this.buffer.shift();
}
this.buffer.push(entry);
this.logger.debug(`Recorded request ${entry.requestId} for ${entry.endpoint}`);
}
getRecentRequests(limit?: number): ReplayEntry[] {
const recent = [...this.buffer].reverse();
if (limit && limit > 0) {
return recent.slice(0, limit);
}
return recent;
}
async replayRequest(requestId: string): Promise<{ original: ReplayEntry; newResponse: unknown }> {
const entry = this.buffer.find((e) => e.requestId === requestId);
if (!entry) {
throw new NotFoundException(`Request ${requestId} not found in replay buffer`);
}
this.logger.log(`Replaying request ${requestId} for ${entry.endpoint}`);
const newResponse = await this.executeReplay(entry);
return { original: entry, newResponse };
}
private async executeReplay(entry: ReplayEntry): Promise<unknown> {
switch (entry.endpoint) {
case '/v1/chat/completions':
case '/v1/messages/chat/completions': {
const body = entry.body as OpenAIChatRequest;
return this.proxyService.handleChatCompletions(body);
}
case '/v1/completions': {
const originalBody = entry.body as {
model?: string;
prompt?: string | string[];
max_tokens?: number;
temperature?: number;
top_p?: number;
stream?: boolean;
};
const request: OpenAIChatRequest = {
model: originalBody.model ?? 'gemini-3-flash',
messages: [
{
role: 'user',
content: Array.isArray(originalBody.prompt)
? originalBody.prompt.join('\n')
: (originalBody.prompt ?? ''),
},
],
max_tokens: originalBody.max_tokens,
temperature: originalBody.temperature,
top_p: originalBody.top_p,
stream: originalBody.stream,
};
return this.proxyService.handleChatCompletions(request);
}
case '/v1/messages': {
const body = entry.body as AnthropicChatRequest;
return this.proxyService.handleAnthropicMessages(body);
}
case '/v1/responses': {
const originalBody = entry.body as {
model?: string;
instructions?: string;
input?: unknown;
tools?: OpenAIChatRequest['tools'];
max_output_tokens?: number;
temperature?: number;
top_p?: number;
stream?: boolean;
};
const messages: OpenAIChatRequest['messages'] = [];
if (
typeof originalBody.instructions === 'string' &&
originalBody.instructions.trim().length > 0
) {
messages.push({ role: 'system', content: originalBody.instructions });
}
const input = originalBody.input;
if (typeof input === 'string') {
messages.push({ role: 'user', content: input });
} else if (Array.isArray(input)) {
for (const item of input) {
if (typeof item === 'string') {
messages.push({ role: 'user', content: item });
} else if (item && typeof item === 'object') {
const obj = item as Record<string, unknown>;
if (obj.type === 'message' && typeof obj.content === 'string') {
messages.push({
role: typeof obj.role === 'string' ? obj.role : 'user',
content: obj.content,
});
} else if (obj.type === 'message') {
messages.push({
role: typeof obj.role === 'string' ? obj.role : 'user',
content: JSON.stringify(obj.content),
});
}
}
}
}
if (messages.length === 0) {
messages.push({ role: 'user', content: '' });
}
const request: OpenAIChatRequest = {
model: originalBody.model ?? 'gemini-3-flash',
messages,
tools: originalBody.tools,
max_tokens: originalBody.max_output_tokens,
temperature: originalBody.temperature,
top_p: originalBody.top_p,
stream: originalBody.stream,
};
return this.proxyService.handleChatCompletions(request);
}
case '/v1/images/generations': {
const originalBody = entry.body as {
model?: string;
prompt?: string;
size?: string;
quality?: string;
};
const request: OpenAIChatRequest = {
model: originalBody.model ?? 'gemini-3-pro-image',
messages: [{ role: 'user', content: originalBody.prompt ?? '' }],
stream: false,
size: originalBody.size,
quality: originalBody.quality,
};
return this.proxyService.handleChatCompletions(request);
}
case '/v1/images/edits': {
const originalBody = entry.body as {
model?: string;
prompt?: string;
size?: string;
quality?: string;
image?: string | { data?: string; mimeType?: string };
reference_images?: Array<string | { data?: string; mimeType?: string }>;
mask?: string | { data?: string; mimeType?: string };
};
const imageParts = this.collectImageContentParts([
originalBody.image,
originalBody.mask,
...(originalBody.reference_images ?? []),
]);
const request: OpenAIChatRequest = {
model: originalBody.model ?? 'gemini-3-pro-image',
messages: [
{
role: 'user',
content:
imageParts.length > 0
? [
{
type: 'text' as const,
text:
originalBody.prompt ??
'Please edit this image based on the provided instruction.',
},
...imageParts,
]
: (originalBody.prompt ??
'Please edit this image based on the provided instruction.'),
},
],
stream: false,
size: originalBody.size,
quality: originalBody.quality,
};
return this.proxyService.handleChatCompletions(request);
}
case '/v1/audio/transcriptions': {
const originalBody = entry.body as {
model?: string;
prompt?: string;
file?: string | { data?: string; mimeType?: string };
audio?: string | { data?: string; mimeType?: string };
};
const inlineAudio = this.resolveInlineData(originalBody.file ?? originalBody.audio);
if (!inlineAudio) {
throw new BadRequestException("Missing 'file' or 'audio' input for replay");
}
return this.proxyService.handleGeminiGenerateContent(
originalBody.model ?? 'gemini-3-flash',
{
contents: [
{
role: 'user',
parts: [
{
text:
originalBody.prompt ??
'Please transcribe the provided speech audio accurately.',
},
{ inlineData: inlineAudio },
],
},
],
} as GeminiRequest,
);
}
default:
throw new BadRequestException(`Replay not supported for endpoint ${entry.endpoint}`);
}
}
private collectImageContentParts(
entries: Array<string | { data?: string; mimeType?: string } | undefined>,
): Array<{ type: 'image_url'; image_url: { url: string } }> {
const parts: Array<{ type: 'image_url'; image_url: { url: string } }> = [];
for (const entry of entries) {
const inlineData = this.resolveInlineData(entry);
if (!inlineData) {
continue;
}
parts.push({
type: 'image_url',
image_url: {
url: `data:${inlineData.mimeType};base64,${inlineData.data}`,
},
});
}
return parts;
}
private resolveInlineData(input: unknown): { mimeType: string; data: string } | null {
if (!input) {
return null;
}
if (typeof input === 'string') {
const dataUri = input.match(/^data:(?<mime>[^;]+);base64,(?<data>[A-Za-z0-9+/=]+)$/);
if (dataUri?.groups?.mime && dataUri.groups.data) {
return {
mimeType: dataUri.groups.mime,
data: dataUri.groups.data,
};
}
const cleaned = input.replace(/\s+/g, '');
if (cleaned.length > 0) {
return {
mimeType: 'audio/mpeg',
data: cleaned,
};
}
return null;
}
const inputRecord = input as Record<string, unknown>;
const data = typeof inputRecord.data === 'string' ? inputRecord.data : undefined;
if (!data) {
return null;
}
return {
mimeType: typeof inputRecord.mimeType === 'string' ? inputRecord.mimeType : 'audio/mpeg',
data,
};
}
}
@@ -0,0 +1,56 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { FastifyRequest } from 'fastify';
import { isString } from 'lodash-es';
@Injectable()
export class ProxyTransformInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const request = http.getRequest<FastifyRequest>();
const body = request.body as Record<string, unknown>;
if (body && typeof body === 'object') {
// Normalize model field to string
if (body.model !== undefined && !isString(body.model)) {
body.model = String(body.model);
}
// Ensure messages array exists and elements have required fields
if (Array.isArray(body.messages)) {
body.messages = body.messages.map((msg: Record<string, unknown>) => ({
role: isString(msg.role) ? msg.role : 'user',
content: msg.content !== undefined ? msg.content : '',
...(msg.tool_calls !== undefined ? { tool_calls: msg.tool_calls } : {}),
...(msg.tool_call_id !== undefined ? { tool_call_id: msg.tool_call_id } : {}),
}));
}
// Normalize temperature to number if present
if (body.temperature !== undefined && typeof body.temperature !== 'number') {
const parsed = parseFloat(String(body.temperature));
if (!isNaN(parsed)) {
body.temperature = parsed;
}
}
// Normalize top_p to number if present
if (body.top_p !== undefined && typeof body.top_p !== 'number') {
const parsed = parseFloat(String(body.top_p));
if (!isNaN(parsed)) {
body.top_p = parsed;
}
}
// Normalize max_tokens to number if present
if (body.max_tokens !== undefined && typeof body.max_tokens !== 'number') {
const parsed = parseInt(String(body.max_tokens), 10);
if (!isNaN(parsed)) {
body.max_tokens = parsed;
}
}
}
return next.handle();
}
}
File diff suppressed because it is too large. Load diff
+18 -1
View File
@@ -5,11 +5,28 @@ import { TokenManagerService } from './token-manager.service';
import { GeminiClient } from './clients/gemini.client';
import { GeminiController } from './gemini.controller';
import { ProxyGuard } from './proxy.guard';
import { TokenUsageService } from '../usage/token-usage.service';
import { ProxyReplayService } from './proxy-replay.service';
import { ProxyMetricsService } from './proxy-metrics.service';
import { ProxyIdeConfigService } from './proxy-ide-config.service';
import { ProxyEventBus } from './proxy-event-bus.service';
import { ProxyTransformInterceptor } from './proxy-transform.interceptor';
@Module({
imports: [],
controllers: [ProxyController, GeminiController],
providers: [ProxyService, TokenManagerService, GeminiClient, ProxyGuard],
providers: [
ProxyService,
TokenManagerService,
GeminiClient,
ProxyGuard,
TokenUsageService,
ProxyReplayService,
ProxyMetricsService,
ProxyIdeConfigService,
ProxyEventBus,
ProxyTransformInterceptor,
],
exports: [TokenManagerService],
})
export class ProxyModule {}
+450 -136
View File
@@ -2,6 +2,7 @@ import { Injectable, Logger, Inject } from '@nestjs/common';
import { isEmpty, isNil, isNumber, isPlainObject, isString } from 'lodash-es';
import { TokenManagerService } from './token-manager.service';
import { GeminiClient } from './clients/gemini.client';
import { TokenUsageService } from '../usage/token-usage.service';
import { v4 as uuidv4 } from 'uuid';
import { Observable } from 'rxjs';
import { transformClaudeRequestIn } from '../../../lib/geminiNexus/ClaudeRequestMapper';
@@ -33,20 +34,90 @@ import {
import { getMaxOutputTokens, getThinkingBudget } from '../../../lib/geminiNexus/ModelSpecs';
import { resolveRequestUserAgent } from './request-user-agent';
import { UpstreamRequestError } from './clients/upstream-error';
import { trafficLogger } from '../../../utils/traffic-logger';
import { encode } from 'gpt-tokenizer';
export interface ProxyRequestContext {
requestId?: string;
accountId?: string;
retryCount?: number;
model?: string;
cacheStatus?: 'HIT' | 'MISS';
}
export interface ProxyConfigOverride {
accountId?: string;
schedulingMode?: 'cache-first' | 'balance' | 'performance-first';
maxRetries?: number;
timeout?: number;
}
@Injectable()
export class ProxyService {
private readonly logger = new Logger(ProxyService.name);
private readonly responseCache = new Map<string, { response: OpenAIChatResponse; expiry: number }>();
private readonly CACHE_TTL_MS = 60000; // 1 minute
constructor(
@Inject(TokenManagerService) private readonly tokenManager: TokenManagerService,
@Inject(GeminiClient) private readonly geminiClient: GeminiClient,
@Inject(TokenUsageService) private readonly tokenUsageService: TokenUsageService,
) {}
private recordUsage(
accountId: string,
model: string,
usage:
| { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number }
| undefined
| null,
requestType: string,
isEstimated?: boolean,
): void {
if (!usage) {
return;
}
this.tokenUsageService.recordUsage({
accountId,
model,
promptTokens: usage.promptTokenCount ?? 0,
completionTokens: usage.candidatesTokenCount ?? 0,
totalTokens: usage.totalTokenCount ?? 0,
timestamp: Date.now(),
requestType,
isEstimated,
});
}
private estimateTokens(text: string): number {
try {
return encode(text).length;
} catch {
return Math.ceil(text.length / 4);
}
}
private 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,
};
}
// --- Anthropic Handlers ---
async handleAnthropicMessages(
request: AnthropicChatRequest,
context?: ProxyRequestContext,
configOverride?: ProxyConfigOverride,
): Promise<AnthropicChatResponse | Observable<string>> {
const sessionKey = this.extractAnthropicSessionKey(request);
@@ -72,11 +143,17 @@ export class ProxyService {
sessionKey,
excludeAccountIds: Array.from(attemptedAccountIds),
model: targetModel,
accountId: configOverride?.accountId,
});
if (!token) {
throw new Error('No available accounts');
}
attemptedAccountIds.add(token.id);
if (context) {
context.accountId = token.id;
context.retryCount = i;
context.model = targetModel;
}
const effectiveTargetModel = this.tokenManager.resolveDynamicModelForAccount(
token.id,
targetModel,
@@ -100,7 +177,7 @@ export class ProxyService {
token.token.upstream_proxy_url,
extraHeaders,
);
return this.processAnthropicInternalStream(stream, geminiBody.model);
return this.processAnthropicInternalStream(stream, geminiBody.model, token.id);
} else {
const response = await this.generateInternalWithStreamFallback(
geminiBody,
@@ -108,6 +185,7 @@ export class ProxyService {
token.token.upstream_proxy_url,
extraHeaders,
);
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'anthropic');
return this.toAnthropicChatResponse(transformResponse(response));
}
} catch (error) {
@@ -131,7 +209,7 @@ export class ProxyService {
token.token.upstream_proxy_url,
extraHeaders,
);
return this.processAnthropicInternalStream(stream, fallbackBody.model);
return this.processAnthropicInternalStream(stream, fallbackBody.model, token.id);
} else {
const response = await this.generateInternalWithStreamFallback(
fallbackBody,
@@ -139,6 +217,7 @@ export class ProxyService {
token.token.upstream_proxy_url,
extraHeaders,
);
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'anthropic');
return this.toAnthropicChatResponse(transformResponse(response));
}
} catch (fallbackErr) {
@@ -169,7 +248,7 @@ export class ProxyService {
token.token.upstream_proxy_url,
extraHeaders,
);
return this.processAnthropicInternalStream(stream, downgradedBody.model);
return this.processAnthropicInternalStream(stream, downgradedBody.model, token.id);
} else {
const response = await this.generateInternalWithStreamFallback(
downgradedBody,
@@ -177,6 +256,7 @@ export class ProxyService {
token.token.upstream_proxy_url,
extraHeaders,
);
this.recordUsage(token.id, 'gemini-3-flash', response.usageMetadata, 'anthropic');
const transformed = this.toAnthropicChatResponse(transformResponse(response));
return {
...transformed,
@@ -204,6 +284,7 @@ export class ProxyService {
private processAnthropicInternalStream(
upstreamStream: NodeJS.ReadableStream,
_model: string,
accountId: string,
): Observable<string> {
return new Observable<string>((subscriber) => {
const decoder = new TextDecoder();
@@ -214,6 +295,7 @@ export class ProxyService {
let lastFinishReason: string | undefined;
let lastUsageMetadata: Record<string, unknown> | undefined;
let accumulatedText = '';
let receivedData = false;
@@ -250,6 +332,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
@@ -271,6 +356,23 @@ export class ProxyService {
const finishChunks = state.emitFinish(lastFinishReason, lastUsageMetadata);
finishChunks.forEach((c) => subscriber.next(c));
const usage = this.extractUsageFromMetadata(lastUsageMetadata);
if (usage) {
this.recordUsage(accountId, _model, usage, 'anthropic');
} else if (accumulatedText) {
const estimated = this.estimateTokens(accumulatedText);
this.recordUsage(
accountId,
_model,
{
promptTokenCount: 0,
candidatesTokenCount: estimated,
totalTokenCount: estimated,
},
'anthropic',
true,
);
}
subscriber.complete();
});
@@ -338,6 +440,7 @@ export class ProxyService {
extraHeaders,
);
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'gemini');
return this.normalizeGeminiGenerateResponse(response);
} catch (err) {
if (err instanceof Error && this.isProjectContextError(err.message)) {
@@ -360,6 +463,7 @@ export class ProxyService {
token.token.upstream_proxy_url,
extraHeaders,
);
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'gemini');
return this.normalizeGeminiGenerateResponse(response);
} catch (fallbackErr) {
lastError = fallbackErr;
@@ -433,7 +537,7 @@ export class ProxyService {
token.token.upstream_proxy_url,
extraHeaders,
);
return this.passthroughSseStream(stream);
return this.passthroughSseStream(stream, token.id, effectiveTargetModel);
} catch (err) {
if (err instanceof Error && this.isProjectContextError(err.message)) {
this.logger.warn(
@@ -455,7 +559,7 @@ export class ProxyService {
token.token.upstream_proxy_url,
extraHeaders,
);
return this.passthroughSseStream(stream);
return this.passthroughSseStream(stream, token.id, effectiveTargetModel);
} catch (fallbackErr) {
lastError = fallbackErr;
}
@@ -476,14 +580,44 @@ export class ProxyService {
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) => {
const decoder = new TextDecoder();
let buffer = '';
let receivedData = false;
let lastUsageMetadata: Record<string, unknown> | undefined;
let accumulatedText = '';
upstreamStream.on('data', (chunk: Buffer) => {
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);
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', () => {
@@ -491,6 +625,23 @@ export class ProxyService {
subscriber.error(new Error('Empty response stream'));
return;
}
const usage = this.extractUsageFromMetadata(lastUsageMetadata);
if (usage) {
this.recordUsage(accountId, model, usage, 'gemini');
} else if (accumulatedText) {
const estimated = this.estimateTokens(accumulatedText);
this.recordUsage(
accountId,
model,
{
promptTokenCount: 0,
candidatesTokenCount: estimated,
totalTokenCount: estimated,
},
'gemini',
true,
);
}
subscriber.complete();
});
@@ -691,19 +842,35 @@ export class ProxyService {
async handleChatCompletions(
request: OpenAIChatRequest,
_requestId?: string,
context?: ProxyRequestContext,
configOverride?: ProxyConfigOverride,
): Promise<OpenAIChatResponse | Observable<string>> {
const sessionKey = this.extractOpenAISessionKey(request);
const targetModel = this.resolveTargetModel(request.model);
const extraHeaders = this.createModelSpecificHeaders(request.model);
this.logger.log(
`OpenAI-compatible request received: model=${request.model}, mappedModel=${targetModel}, stream=${request.stream}`,
`Anthropic request received: model=${request.model}, mappedModel=${targetModel}, stream=${request.stream}`,
);
const sessionKey = this.extractOpenAISessionKey(request);
const requestId = uuidv4();
// Retry loop for account selection
// Cache lookup for non-streaming requests
if (!request.stream) {
const cacheKey = this.buildCacheKey(request);
const cached = this.getCachedResponse(cacheKey);
if (cached) {
if (context) {
context.cacheStatus = 'HIT';
context.accountId = 'cache';
context.model = request.model;
context.retryCount = 0;
}
return cached;
}
}
// Retry loop
let lastError: unknown = null;
const maxRetries = 3;
const maxRetries = configOverride?.maxRetries ?? 3;
const attemptedAccountIds = new Set<string>();
for (let i = 0; i < maxRetries; i++) {
@@ -729,6 +896,12 @@ export class ProxyService {
token.id,
targetModel,
);
if (context) {
context.accountId = token.id;
context.retryCount = i;
context.model = effectiveTargetModel;
context.requestId = requestId;
}
try {
const claudeRequest = this.convertOpenAIToClaude(request);
@@ -741,13 +914,26 @@ export class ProxyService {
// Use v1internal API (same as Anthropic handler)
if (request.stream) {
try {
trafficLogger.logUpstream(
requestId || 'unknown',
'/v1/chat/completions',
geminiBody,
extraHeaders,
{ model: effectiveTargetModel },
);
const stream = await this.geminiClient.streamGenerateInternal(
geminiBody,
token.token.access_token,
token.token.upstream_proxy_url,
extraHeaders,
);
return this.processStreamResponse(stream, request.model, request.stream_options);
return this.processStreamResponse(
stream,
request.model,
requestId,
token.id,
effectiveTargetModel,
);
} catch (streamError) {
this.logger.warn(
`Stream path failed for model=${request.model}; falling back to non-stream generation: ${
@@ -755,12 +941,27 @@ export class ProxyService {
}`,
);
trafficLogger.logUpstream(
requestId || 'unknown',
'/v1/chat/completions',
geminiBody,
extraHeaders,
{ model: effectiveTargetModel },
);
const response = await this.generateInternalWithStreamFallback(
geminiBody,
token.token.access_token,
token.token.upstream_proxy_url,
extraHeaders,
);
trafficLogger.logUpstreamResponse(
requestId || 'unknown',
'/v1/chat/completions',
200,
response,
0,
);
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai');
this.logger.log(
`Upstream response snippet after stream fallback: ${JSON.stringify(response).substring(0, 500)}`,
);
@@ -781,12 +982,19 @@ export class ProxyService {
this.logger.log(
`Upstream response snippet (non-stream): ${JSON.stringify(response).substring(0, 500)}`,
);
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai');
// Transform Gemini response to OpenAI format
const claudeResponse = transformResponse(response);
this.logger.log(
`Transformed Claude response snippet: ${JSON.stringify(claudeResponse).substring(0, 500)}`,
);
return this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
const openaiResponse = this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
const transformedResponse = this.applyResponseFormatTransformation(openaiResponse, request);
this.setCachedResponse(this.buildCacheKey(request), transformedResponse);
if (context) {
context.cacheStatus = 'MISS';
}
return transformedResponse;
}
} catch (err) {
if (err instanceof Error && this.isProjectContextError(err.message)) {
@@ -800,23 +1008,57 @@ export class ProxyService {
fallbackBody.model = effectiveTargetModel;
this.applyInternalGenerationConstraints(fallbackBody, effectiveTargetModel, token.id);
if (request.stream) {
trafficLogger.logUpstream(
requestId || 'unknown',
'/v1/chat/completions',
fallbackBody,
extraHeaders,
{ model: effectiveTargetModel },
);
const stream = await this.geminiClient.streamGenerateInternal(
fallbackBody,
token.token.access_token,
token.token.upstream_proxy_url,
extraHeaders,
);
return this.processStreamResponse(stream, request.model, request.stream_options);
return this.processStreamResponse(
stream,
request.model,
requestId,
token.id,
effectiveTargetModel,
);
}
trafficLogger.logUpstream(
requestId || 'unknown',
'/v1/chat/completions',
fallbackBody,
extraHeaders,
{ model: effectiveTargetModel },
);
const response = await this.generateInternalWithStreamFallback(
fallbackBody,
token.token.access_token,
token.token.upstream_proxy_url,
extraHeaders,
);
trafficLogger.logUpstreamResponse(
requestId || 'unknown',
'/v1/chat/completions',
200,
response,
0,
);
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai');
const claudeResponse = transformResponse(response);
return this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
const openaiResponse = this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
const transformedResponse = this.applyResponseFormatTransformation(openaiResponse, request);
this.setCachedResponse(this.buildCacheKey(request), transformedResponse);
if (context) {
context.cacheStatus = 'MISS';
}
return transformedResponse;
} catch (fallbackErr) {
lastError = fallbackErr;
}
@@ -953,38 +1195,44 @@ export class ProxyService {
private processStreamResponse(
upstreamStream: NodeJS.ReadableStream,
model: string,
streamOptions?: { include_usage?: boolean },
requestId: string | undefined,
accountId: string,
effectiveTargetModel: string,
): Observable<string> {
return new Observable<string>((subscriber) => {
const decoder = new TextDecoder();
let buffer = '';
let hasEmittedChunk = false;
let hasSentRoleChunk = false;
let hasSentDone = false;
let isThinking = false;
let toolCallIndex = 0;
let lastUsageMetadata: GeminiResponse['usageMetadata'];
let lastUsageMetadata: Record<string, unknown> | undefined;
let accumulatedText = '';
const streamId = `chatcmpl-${uuidv4()}`;
const created = Math.floor(Date.now() / 1000);
// Emit initial role chunk immediately to prevent strict clients (like Hermes) from timing out during long TTFT
const initialChunk = {
id: streamId,
object: 'chat.completion.chunk',
created: created,
model: model,
choices: [
{
index: 0,
delta: { role: 'assistant', content: '' },
finish_reason: null,
},
],
const pushRoleChunk = (): void => {
if (hasSentRoleChunk) return;
hasSentRoleChunk = true;
const roleChunk = {
id: streamId,
object: 'chat.completion.chunk',
created,
model,
choices: [
{
index: 0,
delta: { role: 'assistant', content: '' },
finish_reason: null,
},
],
};
subscriber.next(`data: ${JSON.stringify(roleChunk)}\n\n`);
};
subscriber.next(`data: ${JSON.stringify(initialChunk)}\n\n`);
hasEmittedChunk = true;
const pushChunk = (payload: Record<string, unknown>): void => {
hasEmittedChunk = true;
subscriber.next(`data: ${JSON.stringify(payload)}\n\n`);
};
@@ -1002,20 +1250,27 @@ export class ProxyService {
try {
const json = JSON.parse(dataStr);
const candidate = json.candidates?.[0];
const parts = candidate?.content?.parts || [];
if (json.usageMetadata) {
lastUsageMetadata = json.usageMetadata;
trafficLogger.logUpstreamResponse(
requestId || 'unknown',
'/v1/chat/completions',
200,
json,
0,
{ rawLine: trimmed },
);
// v1internal API wraps response in {"response": {"candidates": [...]}}
const responsePayload = json.response ?? json;
if (responsePayload.usageMetadata) {
lastUsageMetadata = responsePayload.usageMetadata;
}
const candidate = responsePayload.candidates?.[0];
const parts = candidate?.content?.parts || [];
for (const part of parts) {
if (part.thought && part.text) {
let textToEmit = part.text;
if (!isThinking) {
isThinking = true;
textToEmit = `<think>\n${textToEmit}`;
}
accumulatedText += part.text;
// Reasoning/thinking parts: emit via reasoning_content delta field
// so clients that support it can display thinking separately.
const reasoningChunk = {
id: streamId,
object: 'chat.completion.chunk',
@@ -1024,22 +1279,20 @@ export class ProxyService {
choices: [
{
index: 0,
delta: { content: textToEmit },
delta: { reasoning_content: part.text },
finish_reason: null,
},
],
};
pushRoleChunk();
pushChunk(reasoningChunk);
continue;
}
if (part.functionCall) {
pushRoleChunk();
const currentToolIndex = toolCallIndex++;
const toolCallId = part.functionCall.id || `${part.functionCall.name}-${uuidv4()}`;
const argsJson = JSON.stringify(part.functionCall.args || {});
// First delta: id, type, name, empty arguments
pushChunk({
const toolCallChunk = {
id: streamId,
object: 'chat.completion.chunk',
created: created,
@@ -1051,11 +1304,11 @@ export class ProxyService {
tool_calls: [
{
index: currentToolIndex,
id: toolCallId,
id: part.functionCall.id || `${part.functionCall.name}-${uuidv4()}`,
type: 'function',
function: {
name: part.functionCall.name,
arguments: '',
arguments: JSON.stringify(part.functionCall.args || {}),
},
},
],
@@ -1063,35 +1316,8 @@ export class ProxyService {
finish_reason: null,
},
],
});
// Subsequent deltas: only arguments (split if large to respect SSE framing)
const ARG_CHUNK_SIZE = 1000;
for (let i = 0; i < argsJson.length; i += ARG_CHUNK_SIZE) {
const slice = argsJson.slice(i, i + ARG_CHUNK_SIZE);
pushChunk({
id: streamId,
object: 'chat.completion.chunk',
created: created,
model: model,
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: currentToolIndex,
function: {
arguments: slice,
},
},
],
},
finish_reason: null,
},
],
});
}
};
pushChunk(toolCallChunk);
continue;
}
@@ -1112,16 +1338,14 @@ export class ProxyService {
},
],
};
pushRoleChunk();
pushChunk(imageChunk);
continue;
}
if (part.text) {
let textToEmit = part.text;
if (isThinking && !part.thought) {
isThinking = false;
textToEmit = `\n</think>\n\n${textToEmit}`;
}
accumulatedText += part.text;
pushRoleChunk();
const contentChunk = {
id: streamId,
object: 'chat.completion.chunk',
@@ -1130,7 +1354,7 @@ export class ProxyService {
choices: [
{
index: 0,
delta: { content: textToEmit },
delta: { content: part.text },
finish_reason: null,
},
],
@@ -1140,23 +1364,13 @@ export class ProxyService {
}
if (candidate?.finishReason) {
if (isThinking) {
isThinking = false;
const closingChunk = {
id: streamId,
object: 'chat.completion.chunk',
created: created,
model: model,
choices: [
{
index: 0,
delta: { content: '\n</think>\n\n' },
finish_reason: null,
},
],
};
pushChunk(closingChunk);
}
// Per the OpenAI spec, finish_reason must be "tool_calls" when the
// response contains function calls. Gemini always sends "STOP" even
// for tool call responses, so we infer from toolCallIndex.
const mappedFinishReason =
toolCallIndex > 0
? 'tool_calls'
: this.mapGeminiFinishReasonToOpenAIFinishReason(candidate.finishReason);
const finishChunk = {
id: streamId,
@@ -1167,19 +1381,16 @@ export class ProxyService {
{
index: 0,
delta: {},
finish_reason: this.mapGeminiFinishReasonToOpenAIFinishReason(
candidate.finishReason,
),
finish_reason: mappedFinishReason,
},
],
};
pushChunk(finishChunk);
subscriber.next('data: [DONE]\n\n');
hasSentDone = true;
subscriber.complete();
}
} catch {
// ignore parse errors
// ignore parse errors on individual SSE lines
}
}
});
@@ -1200,41 +1411,36 @@ export class ProxyService {
],
});
}
if (streamOptions?.include_usage && lastUsageMetadata) {
const usageChunk = {
id: streamId,
object: 'chat.completion.chunk',
created,
model,
choices: [],
usage: {
prompt_tokens: lastUsageMetadata.promptTokenCount || 0,
completion_tokens: lastUsageMetadata.candidatesTokenCount || 0,
total_tokens:
(lastUsageMetadata.promptTokenCount || 0) +
(lastUsageMetadata.candidatesTokenCount || 0),
},
};
pushChunk(usageChunk);
}
if (!hasSentDone) {
subscriber.next('data: [DONE]\n\n');
hasSentDone = true;
}
const usage = this.extractUsageFromMetadata(lastUsageMetadata);
if (usage) {
this.recordUsage(accountId, effectiveTargetModel, usage, 'openai');
} else if (accumulatedText) {
const estimated = this.estimateTokens(accumulatedText);
this.recordUsage(
accountId,
effectiveTargetModel,
{
promptTokenCount: 0,
candidatesTokenCount: estimated,
totalTokenCount: estimated,
},
'openai',
true,
);
}
subscriber.complete();
});
upstreamStream.on('error', (err) => {
this.logger.error('OpenAI-compatible stream error:', err);
upstreamStream.on('error', (err: unknown) => {
// Convert to clean Error to avoid circular reference issues (socket objects)
const cleanError = err instanceof Error ? new Error(err.message) : new Error(String(err));
this.logger.error(`OpenAI-compatible stream error: ${cleanError.message}`);
subscriber.error(cleanError);
});
return () => {
if (typeof (upstreamStream as any).destroy === 'function') {
(upstreamStream as any).destroy();
}
};
});
}
@@ -1245,10 +1451,42 @@ export class ProxyService {
const model = response.model;
const choice = response.choices?.[0];
const finishReason = choice?.finish_reason ?? 'stop';
const toolCalls = choice?.message?.tool_calls;
const content =
choice?.message && isString(choice.message.content) ? choice.message.content : '';
const chunkSize = 80;
// Emit tool_call deltas first when the fallback non-streaming response contains tool calls.
// Without this, tool calls are silently dropped when the primary stream path fails.
if (toolCalls && toolCalls.length > 0) {
for (let tcIndex = 0; tcIndex < toolCalls.length; tcIndex++) {
const tc = toolCalls[tcIndex];
const tcChunk = {
id: streamId,
object: 'chat.completion.chunk',
created,
model,
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: tcIndex,
id: tc.id,
type: tc.type,
function: tc.function,
},
],
},
finish_reason: null,
},
],
};
subscriber.next(`data: ${JSON.stringify(tcChunk)}\n\n`);
}
}
if (content.length === 0) {
const finishChunk = {
id: streamId,
@@ -1404,6 +1642,10 @@ export class ProxyService {
});
}
if (request.response_format?.type === 'json_object' || request.response_format?.type === 'json_schema') {
systemPromptParts.push('You must format your entire response as a single valid JSON object. Do not include any markdown formatting, explanations, or text outside the JSON structure.');
}
const systemPrompt = systemPromptParts.length > 0 ? systemPromptParts.join('\n') : undefined;
return {
@@ -1655,13 +1897,16 @@ export class ProxyService {
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: model,
system_fingerprint: 'fp_gemininexus',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: finalContent ? finalContent : toolCalls.length > 0 ? null : '',
// When tool_calls are present, content must be null per OpenAI spec.
content: toolCalls.length > 0 ? null : finalContent || '',
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
reasoning_content: reasoningContent || undefined,
},
finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason(
claudeResponse.stop_reason,
@@ -1882,6 +2127,75 @@ export class ProxyService {
);
}
private applyResponseFormatTransformation(
response: OpenAIChatResponse,
request: OpenAIChatRequest,
): OpenAIChatResponse {
if (!request.response_format?.type) {
return response;
}
const type = request.response_format.type;
if (type !== 'json_object' && type !== 'json_schema') {
return response;
}
const content = response.choices?.[0]?.message?.content;
if (!isString(content)) {
return response;
}
// Try to extract JSON from markdown code blocks
let jsonText = content;
const codeBlockMatch = content.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (codeBlockMatch?.[1]) {
jsonText = codeBlockMatch[1].trim();
}
try {
// Validate it's parseable JSON
JSON.parse(jsonText);
// Replace the content with the extracted JSON string
const newResponse = { ...response };
newResponse.choices = [...response.choices];
newResponse.choices[0] = { ...response.choices[0] };
newResponse.choices[0].message = { ...response.choices[0].message };
newResponse.choices[0].message.content = jsonText;
return newResponse;
} catch {
// If not valid JSON, return original unchanged
return response;
}
}
private buildCacheKey(request: OpenAIChatRequest): string {
const keyBody = {
model: request.model,
messages: request.messages,
temperature: request.temperature,
top_p: request.top_p,
max_tokens: request.max_tokens,
tools: request.tools,
response_format: request.response_format,
};
return JSON.stringify(keyBody);
}
private getCachedResponse(key: string): OpenAIChatResponse | undefined {
const entry = this.responseCache.get(key);
if (!entry) {
return undefined;
}
if (Date.now() > entry.expiry) {
this.responseCache.delete(key);
return undefined;
}
return entry.response;
}
private setCachedResponse(key: string, response: OpenAIChatResponse): void {
this.responseCache.set(key, { response, expiry: Date.now() + this.CACHE_TTL_MS });
}
private extractAnthropicSessionKey(request: AnthropicChatRequest): string | undefined {
const metadata = request.metadata;
const sessionCandidate =
@@ -6,6 +6,7 @@ import { GoogleAPIService } from '../../../services/GoogleAPIService';
import { getServerConfig } from '../../server-config';
import { RateLimitReason, RateLimitTracker } from './rate-limit-tracker';
import { updateDynamicForwardingRules } from '../../../lib/geminiNexus/ModelMapping';
import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry';
interface TokenData {
email: string;
@@ -32,10 +33,19 @@ interface GetNextTokenOptions {
sessionKey?: string;
excludeAccountIds?: string[];
model?: string;
accountId?: string;
}
type TokenEntry = [string, TokenData];
interface CircuitBreakerEntry {
failureCount: number;
lastFailureTime: number | null;
state: 'healthy' | 'degraded' | 'unhealthy';
failureRate: number;
requestLog: Array<{ timestamp: number; success: boolean }>;
}
function normalizeProjectId(projectId: string | null | undefined): string | undefined {
if (!isString(projectId)) {
return undefined;
@@ -89,6 +99,11 @@ export class TokenManagerService implements OnModuleInit {
private accountCooldowns: Map<string, number> = new Map();
private sessionBindings: Map<string, { accountId: string; expiresAt: number }> = new Map();
private rateLimitTracker = new RateLimitTracker();
private circuitBreakerStates = new Map<string, CircuitBreakerEntry>();
private readonly circuitBreakerWindowMs = 5 * 60 * 1000;
private readonly circuitBreakerUnhealthyThreshold = 5;
private readonly circuitBreakerDegradedThreshold = 2;
private readonly circuitBreakerFailureRateThreshold = 0.5;
private shadowComparisonCount = 0;
private shadowMismatchCount = 0;
@@ -96,6 +111,10 @@ export class TokenManagerService implements OnModuleInit {
private parityErrorCount = 0;
private noGoBlocked = false;
constructor() {
registerProxyAdvancedService('tokenManager', this);
}
async onModuleInit() {
await this.loadAccounts();
}
@@ -115,6 +134,13 @@ export class TokenManagerService implements OnModuleInit {
}
}
// Prune circuit breaker states for removed accounts
for (const accountId of this.circuitBreakerStates.keys()) {
if (!this.tokens.has(accountId)) {
this.circuitBreakerStates.delete(accountId);
}
}
this.logger.log(`Token manager loaded ${count} cloud accounts into cache`);
return count;
} catch (e) {
@@ -314,10 +340,26 @@ export class TokenManagerService implements OnModuleInit {
const sessionKey = options?.sessionKey?.trim();
const model = options?.model;
const excludedAccountIds = new Set(options?.excludeAccountIds ?? []);
const requestedAccountId = options?.accountId?.trim();
this.clearExpiredSessionBindings(now);
this.rateLimitTracker.cleanupExpired();
// If a specific account is requested, try to use it first
if (requestedAccountId && !excludedAccountIds.has(requestedAccountId)) {
const requestedToken = this.tokens.get(requestedAccountId);
if (requestedToken) {
const isHealthy = this.isAccountHealthy(requestedAccountId, requestedToken, nowSeconds, model);
if (isHealthy) {
this.logger.log(`Using requested account: ${requestedAccountId}`);
return this.buildCloudAccount(requestedAccountId, requestedToken);
}
this.logger.warn(`Requested account ${requestedAccountId} is not healthy, falling back to selection`);
} else {
this.logger.warn(`Requested account ${requestedAccountId} not found, falling back to selection`);
}
}
const fullAccountPool = Array.from(this.tokens.entries());
const filteredAccountPool = fullAccountPool.filter(
([accountId]) => !excludedAccountIds.has(accountId),
@@ -331,18 +373,24 @@ export class TokenManagerService implements OnModuleInit {
);
}
if (candidateAccountPool.length === 0) {
// Circuit breaker: skip unhealthy accounts unless all are unhealthy
const healthyPool = candidateAccountPool.filter(
([accountId]) => this.getOrCreateCircuitBreakerEntry(accountId).state !== 'unhealthy',
);
const finalCandidatePool = healthyPool.length > 0 ? healthyPool : candidateAccountPool;
if (finalCandidatePool.length === 0) {
this.logger.warn('No eligible account found after exclusion filtering');
return null;
}
if (this.shouldExecuteShadowComparison()) {
this.executeShadowComparison(candidateAccountPool, sessionKey, model);
this.executeShadowComparison(finalCandidatePool, sessionKey, model);
}
const selectedTokenEntry = this.isParitySchedulingEnabled()
? await this.selectParityTokenCandidate(candidateAccountPool, sessionKey, model, now)
: this.selectLegacyTokenCandidate(candidateAccountPool, sessionKey, now);
? await this.selectParityTokenCandidate(finalCandidatePool, sessionKey, model, now)
: this.selectLegacyTokenCandidate(finalCandidatePool, sessionKey, now);
if (!selectedTokenEntry) {
return null;
@@ -914,6 +962,42 @@ export class TokenManagerService implements OnModuleInit {
}
}
private isAccountHealthy(accountId: string, tokenData: TokenData, nowSeconds: number, model?: string): boolean {
const entry = this.getOrCreateCircuitBreakerEntry(accountId);
if (entry.state === 'unhealthy') {
return false;
}
if (this.rateLimitTracker.isRateLimited(accountId, model)) {
return false;
}
if (nowSeconds >= tokenData.expiry_timestamp) {
return false;
}
return true;
}
private buildCloudAccount(accountId: string, tokenData: TokenData): CloudAccount {
const timestamp = Date.now();
return {
id: accountId,
provider: 'google',
email: tokenData.email,
token: {
access_token: tokenData.access_token,
refresh_token: tokenData.refresh_token,
token_type: tokenData.token_type,
expires_in: tokenData.expires_in,
expiry_timestamp: tokenData.expiry_timestamp,
project_id: tokenData.project_id,
oauth_client_key: tokenData.oauth_client_key,
session_id: tokenData.session_id,
upstream_proxy_url: tokenData.upstream_proxy_url,
},
created_at: timestamp,
last_used: timestamp,
};
}
private resolveAccountId(accountIdOrEmail: string): string | null {
if (this.tokens.has(accountIdOrEmail)) {
return accountIdOrEmail;
@@ -978,6 +1062,95 @@ export class TokenManagerService implements OnModuleInit {
return this.tokens.size;
}
recordSuccess(accountId: string): void {
const resolvedId = this.resolveAccountId(accountId) ?? accountId;
const entry = this.getOrCreateCircuitBreakerEntry(resolvedId);
entry.failureCount = 0;
entry.requestLog.push({ timestamp: Date.now(), success: true });
this.computeCircuitBreakerState(entry);
}
recordFailure(accountId: string, error?: string): void {
const resolvedId = this.resolveAccountId(accountId) ?? accountId;
const entry = this.getOrCreateCircuitBreakerEntry(resolvedId);
entry.failureCount++;
entry.lastFailureTime = Date.now();
entry.requestLog.push({ timestamp: Date.now(), success: false });
this.computeCircuitBreakerState(entry);
if (entry.state === 'unhealthy') {
this.logger.warn(
`Account ${resolvedId} circuit breaker opened: failureCount=${entry.failureCount}, failureRate=${entry.failureRate.toFixed(2)}${error ? `, error=${error}` : ''}`,
);
} else if (entry.state === 'degraded') {
this.logger.warn(
`Account ${resolvedId} circuit breaker degraded: failureCount=${entry.failureCount}, failureRate=${entry.failureRate.toFixed(2)}${error ? `, error=${error}` : ''}`,
);
}
}
getCircuitBreakerStatus(): Record<string, { state: 'healthy' | 'degraded' | 'unhealthy'; failureCount: number; lastFailureTime: number | null; failureRate: number }> {
const result: Record<string, { state: 'healthy' | 'degraded' | 'unhealthy'; failureCount: number; lastFailureTime: number | null; failureRate: number }> = {};
for (const accountId of this.tokens.keys()) {
const entry = this.getOrCreateCircuitBreakerEntry(accountId);
this.computeCircuitBreakerState(entry);
result[accountId] = {
state: entry.state,
failureCount: entry.failureCount,
lastFailureTime: entry.lastFailureTime,
failureRate: entry.failureRate,
};
}
return result;
}
getParityCounters(): { totalRequests: number; matchedRequests: number; parityViolations: number; lastUpdated: string } {
return {
totalRequests: this.parityRequestCount,
matchedRequests: this.shadowComparisonCount,
parityViolations: this.parityErrorCount,
lastUpdated: new Date().toISOString(),
};
}
private getOrCreateCircuitBreakerEntry(accountId: string): CircuitBreakerEntry {
let entry = this.circuitBreakerStates.get(accountId);
if (!entry) {
entry = {
failureCount: 0,
lastFailureTime: null,
state: 'healthy',
failureRate: 0,
requestLog: [],
};
this.circuitBreakerStates.set(accountId, entry);
}
return entry;
}
private pruneCircuitBreakerLog(entry: CircuitBreakerEntry): void {
const cutoff = Date.now() - this.circuitBreakerWindowMs;
entry.requestLog = entry.requestLog.filter((r) => r.timestamp > cutoff);
}
private computeCircuitBreakerState(entry: CircuitBreakerEntry): void {
this.pruneCircuitBreakerLog(entry);
const total = entry.requestLog.length;
const failures = entry.requestLog.filter((r) => !r.success).length;
entry.failureRate = total > 0 ? failures / total : 0;
if (entry.failureCount >= this.circuitBreakerUnhealthyThreshold) {
entry.state = 'unhealthy';
} else if (
entry.failureCount >= this.circuitBreakerDegradedThreshold &&
entry.failureRate >= this.circuitBreakerFailureRateThreshold
) {
entry.state = 'degraded';
} else {
entry.state = 'healthy';
}
}
private normalizeRefreshedOauthClientKey(
currentToken: { oauth_client_key?: string; project_id?: string },
refreshedClientKey?: string,
@@ -0,0 +1,49 @@
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 {
recordUsage(params: RecordUsageParams): void {
RecordUsageInputSchema.parse(params);
// Fire-and-forget: don't block the proxy request cycle
setImmediate(() => {
try {
TokenUsageRepo.recordUsage(params);
} catch {
// Silently ignore: usage recording should not break proxy requests
}
});
}
getUsageByHour(accountId?: string, start?: number, end?: number) {
return TokenUsageRepo.getUsageByHour(accountId, start, end);
}
getUsageByDay(accountId?: string, start?: number, end?: number) {
return TokenUsageRepo.getUsageByDay(accountId, start, end);
}
getUsageByWeek(accountId?: string, start?: number, end?: number) {
return TokenUsageRepo.getUsageByWeek(accountId, start, end);
}
getUsageByMonth(accountId?: string, start?: number, end?: number) {
return TokenUsageRepo.getUsageByMonth(accountId, start, end);
}
getUsageByModel(accountId?: string, start?: number, end?: number) {
return TokenUsageRepo.getUsageByModel(accountId, start, end);
}
}
+29 -28
View File
@@ -848,6 +848,34 @@ export class GoogleAPIService {
): Promise<{ credits: number; expiryDate: string } | null> {
try {
const fetchOptions = this.getFetchOptions(proxyUrl);
// Try loadCodeAssist first — fetchCredits returns 404 for many accounts
// and loadCodeAssist provides the same paid tier credit data.
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
const loadAssistResponse = await fetch(URLS.DAILY_LOAD_PROJECT, {
method: 'POST',
headers: buildInternalApiHeaders(accessToken),
body: JSON.stringify({
metadata: {
ide_type: 'ANTIGRAVITY',
ide_version: discoveryVersion,
ide_name: 'geminiNexus',
},
}),
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...fetchOptions,
});
if (loadAssistResponse.ok) {
const loadAssistData = (await loadAssistResponse.json()) as LoadProjectResponse;
const credits = extractAiCreditsFromProjectContext(loadAssistData);
if (credits) {
return credits;
}
}
// Fallback to the legacy fetchCredits endpoint if loadCodeAssist
// did not return usable credit data.
const response = await fetch(URLS.FETCH_CREDITS, {
method: 'POST',
headers: buildInternalApiHeaders(accessToken),
@@ -867,34 +895,7 @@ export class GoogleAPIService {
return toAiCredits(data);
}
if (response.status !== 404) {
return null;
}
// `fetchCredits` has started returning 404 for some accounts. Reuse the
// internal `loadCodeAssist` payload, which also exposes paid tier credits.
logger.warn('[GoogleAPIService] fetchCredits returned 404, falling back to loadCodeAssist');
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
const fallbackResponse = await fetch(URLS.DAILY_LOAD_PROJECT, {
method: 'POST',
headers: buildInternalApiHeaders(accessToken),
body: JSON.stringify({
metadata: {
ide_type: 'ANTIGRAVITY',
ide_version: discoveryVersion,
ide_name: 'geminiNexus',
},
}),
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...fetchOptions,
});
if (!fallbackResponse.ok) {
return null;
}
const fallbackData = (await fallbackResponse.json()) as LoadProjectResponse;
return extractAiCreditsFromProjectContext(fallbackData);
return null;
} catch {
return null;
}
@@ -0,0 +1,59 @@
{
"model": "claude-3-sonnet",
"system": "You are a helpful assistant.",
"temperature": 0.7,
"max_tokens": 1024,
"tools": [
{
"name": "get_weather",
"description": "Get weather info",
"input_schema": {
"type": "object",
"properties": {
"location": {
"type": "string"
}
}
}
}
],
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "What's the weather in NYC?"
}
]
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "Let me check."
},
{
"type": "tool_use",
"id": "call_1",
"name": "get_weather",
"input": {
"location": "NYC"
}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "call_1",
"content": "Sunny, 25C",
"is_error": false
}
]
}
]
}
@@ -0,0 +1,51 @@
{
"model": "claude-3-sonnet",
"temperature": 0.7,
"max_tokens": 1024,
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather info",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string"
}
}
}
}
}
],
"messages": [
{
"role": "system",
"content": "You are a helpful assistant."
},
{
"role": "user",
"content": "What's the weather in NYC?"
},
{
"role": "assistant",
"content": "Let me check.",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\":\"NYC\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_1",
"content": "Sunny, 25C"
}
]
}
@@ -0,0 +1,22 @@
{
"model": "claude-3-sonnet",
"message": {
"content": null,
"tool_calls": [
{
"id": "tool_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\":\"NYC\"}"
}
}
]
},
"finish_reason": "tool_calls",
"usage": {
"prompt_tokens": 10,
"completion_tokens": 20,
"total_tokens": 30
}
}
@@ -0,0 +1,21 @@
{
"id": "msg_123",
"type": "message",
"role": "assistant",
"model": "claude-3-sonnet",
"content": [
{
"type": "tool_use",
"id": "tool_1",
"name": "get_weather",
"input": {
"location": "NYC"
}
}
],
"stop_reason": "tool_use",
"usage": {
"input_tokens": 10,
"output_tokens": 20
}
}
@@ -0,0 +1,8 @@
{
"contains": [
"chat.completion.chunk",
"Hello world",
"finish_reason",
"data: [DONE]"
]
}
@@ -0,0 +1,18 @@
{
"model": "gemini-pro",
"upstream": {
"candidates": [
{
"content": {
"role": "model",
"parts": [
{
"text": "Hello world"
}
]
},
"finishReason": "STOP"
}
]
}
}
@@ -19,9 +19,13 @@ const mockGeminiClient: any = {
generateInternal: async () => undefined,
};
const mockTokenUsageService: any = {
recordUsage: () => undefined,
};
class TestableProxyService extends ProxyService {
constructor() {
super(mockTokenManager, mockGeminiClient);
super(mockTokenManager, mockGeminiClient, mockTokenUsageService);
}
public createGeminiInternal(
@@ -255,7 +259,11 @@ async function validateRuntimeAnthropicRequestFromRealTokenManager(): Promise<vo
};
try {
const service = new ProxyService(tokenManagerProxy as any, mockGeminiClient as any);
const service = new ProxyService(
tokenManagerProxy as any,
mockGeminiClient as any,
mockTokenUsageService as any,
);
await service.handleAnthropicMessages({
model: 'claude-sonnet-4-5',
stream: false,
+6 -1
View File
@@ -779,7 +779,12 @@ describe('cloud oauth client key backfill', () => {
const { listCloudAccounts } = await import('../../ipc/cloud/handler');
await listCloudAccounts();
expect(updateTokenMock).not.toHaveBeenCalled();
expect(updateTokenMock).toHaveBeenCalledWith(
'acc-legacy',
expect.objectContaining({
oauth_client_key: 'gemininexus_enterprise',
}),
);
expect(setSettingMock).toHaveBeenCalledWith('oauth_client_key_backfill_v1_done', true);
});
});
@@ -0,0 +1,242 @@
import React from 'react';
import { screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderWithProviders } from '@/tests/unit/test-utils';
import { AccountCard } from '@/components/AccountCard';
import type { Account } from '@/types/account';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options) {
const entries = Object.entries(options);
if (entries.length > 0) {
return `${key}:${JSON.stringify(options)}`;
}
}
return key;
},
i18n: { language: 'en' },
}),
}));
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children, asChild }: { children: React.ReactNode; asChild?: boolean }) => {
if (asChild) {
return <>{children}</>;
}
return <div data-testid="dropdown-trigger">{children}</div>;
},
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dropdown-content">{children}</div>
),
DropdownMenuItem: ({
children,
onClick,
disabled,
}: {
children: React.ReactNode;
onClick?: (e: React.MouseEvent) => void;
disabled?: boolean;
}) => (
<button
data-testid="dropdown-item"
onClick={onClick}
disabled={disabled}
type="button"
>
{children}
</button>
),
}));
describe('AccountCard', () => {
const createMockAccount = (overrides?: Partial<Account>): Account => ({
id: 'acc-1',
name: 'Test User',
email: '[email protected]',
backup_file: '/path/to/backup',
avatar_url: 'https://example.com/avatar.png',
deviceProfile: {
machineId: 'machine-1',
macMachineId: 'mac-1',
devDeviceId: 'dev-1',
sqmId: 'sqm-1',
},
created_at: '2026-04-20T00:00:00Z',
last_used: '2026-04-23T00:00:00Z',
...overrides,
});
const defaultProps = {
account: createMockAccount(),
isCurrent: false,
onSwitch: vi.fn(),
onDelete: vi.fn(),
isSwitching: false,
isDeleting: false,
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders account name and email', () => {
renderWithProviders(<AccountCard {...defaultProps} />);
expect(screen.getByText('Test User')).toBeInTheDocument();
expect(screen.getByText('[email protected]')).toBeInTheDocument();
});
it('shows avatar image element when avatar_url is provided', () => {
renderWithProviders(<AccountCard {...defaultProps} />);
const avatarImage = document.querySelector('img');
if (avatarImage) {
expect(avatarImage).toHaveAttribute('src', 'https://example.com/avatar.png');
} else {
// Radix Avatar may render fallback before image loads; verify src attribute is present in DOM
expect(screen.getByText('TU')).toBeInTheDocument();
}
});
it('shows avatar fallback with initials when avatar_url is missing', () => {
renderWithProviders(
<AccountCard
{...defaultProps}
account={createMockAccount({ avatar_url: undefined })}
/>,
);
expect(screen.getByText('TU')).toBeInTheDocument();
});
it('shows email initial as fallback when name is empty', () => {
renderWithProviders(
<AccountCard
{...defaultProps}
account={createMockAccount({ name: '', avatar_url: undefined })}
/>,
);
expect(screen.getByText('T')).toBeInTheDocument();
});
it('displays current badge when isCurrent is true', () => {
renderWithProviders(<AccountCard {...defaultProps} isCurrent={true} />);
expect(screen.getByText('account.current')).toBeInTheDocument();
});
it('does not display current badge when isCurrent is false', () => {
renderWithProviders(<AccountCard {...defaultProps} isCurrent={false} />);
expect(screen.queryByText('account.current')).not.toBeInTheDocument();
});
it('handles delete action from dropdown menu', () => {
const onDelete = vi.fn();
renderWithProviders(<AccountCard {...defaultProps} onDelete={onDelete} />);
const deleteButton = screen.getByTestId('dropdown-item');
fireEvent.click(deleteButton);
expect(onDelete).toHaveBeenCalledTimes(1);
expect(onDelete).toHaveBeenCalledWith('acc-1');
});
it('handles switch action when card is clicked', () => {
const onSwitch = vi.fn();
renderWithProviders(<AccountCard {...defaultProps} onSwitch={onSwitch} />);
const card = document.querySelector('[class*="cursor-pointer"]');
expect(card).toBeInTheDocument();
if (card) {
fireEvent.click(card);
}
expect(onSwitch).toHaveBeenCalledTimes(1);
expect(onSwitch).toHaveBeenCalledWith('acc-1');
});
it('does not trigger switch when isCurrent is true', () => {
const onSwitch = vi.fn();
renderWithProviders(
<AccountCard {...defaultProps} isCurrent={true} onSwitch={onSwitch} />,
);
const card = document.querySelector('[class*="cursor-pointer"]');
expect(card).not.toBeInTheDocument();
expect(onSwitch).not.toHaveBeenCalled();
});
it('does not trigger switch when isSwitching is true', () => {
const onSwitch = vi.fn();
renderWithProviders(
<AccountCard {...defaultProps} isSwitching={true} onSwitch={onSwitch} />,
);
const card = document.querySelector('[class*="pointer-events-none"]');
expect(card).toBeInTheDocument();
});
it('does not trigger switch when isDeleting is true', () => {
const onSwitch = vi.fn();
renderWithProviders(
<AccountCard {...defaultProps} isDeleting={true} onSwitch={onSwitch} />,
);
const card = document.querySelector('[class*="pointer-events-none"]');
expect(card).toBeInTheDocument();
});
it('applies reduced opacity and pointer-events-none when switching', () => {
const { container } = renderWithProviders(
<AccountCard {...defaultProps} isSwitching={true} />,
);
const card = container.querySelector('[class*="opacity-60"]');
expect(card).toBeInTheDocument();
});
it('applies reduced opacity and pointer-events-none when deleting', () => {
const { container } = renderWithProviders(
<AccountCard {...defaultProps} isDeleting={true} />,
);
const card = container.querySelector('[class*="opacity-60"]');
expect(card).toBeInTheDocument();
});
it('applies primary border styling when isCurrent is true', () => {
const { container } = renderWithProviders(
<AccountCard {...defaultProps} isCurrent={true} />,
);
const card = container.querySelector('[class*="border-primary/50"]');
expect(card).toBeInTheDocument();
});
it('shows spinning refresh icon when isSwitching is true', () => {
renderWithProviders(<AccountCard {...defaultProps} isSwitching={true} />);
const spinIcon = document.querySelector('.animate-spin');
expect(spinIcon).toBeInTheDocument();
});
it('handles mouse move to update card transform', () => {
const { container } = renderWithProviders(<AccountCard {...defaultProps} />);
const wrapper = container.firstChild as HTMLElement;
fireEvent.mouseMove(wrapper, { clientX: 100, clientY: 50 });
expect(wrapper.style.transform).toContain('perspective(1000px)');
expect(wrapper.style.transform).toContain('rotateX');
expect(wrapper.style.transform).toContain('rotateY');
});
it('handles mouse leave to reset card transform', () => {
const { container } = renderWithProviders(<AccountCard {...defaultProps} />);
const wrapper = container.firstChild as HTMLElement;
fireEvent.mouseMove(wrapper, { clientX: 100, clientY: 50 });
expect(wrapper.style.transform).not.toContain('rotateX(0deg)');
fireEvent.mouseLeave(wrapper);
expect(wrapper.style.transform).toContain('rotateX(0deg)');
expect(wrapper.style.transform).toContain('rotateY(0deg)');
});
it('renders last used text', () => {
renderWithProviders(<AccountCard {...defaultProps} />);
expect(screen.getByText(/account\.lastUsed/)).toBeInTheDocument();
});
});
@@ -0,0 +1,140 @@
import React from 'react';
import { screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { renderWithProviders } from '@/tests/unit/test-utils';
import { ProviderGroup } from '@/components/ProviderGroup';
import type { ProviderStats } from '@/utils/provider-grouping';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options && 'count' in options) {
return `${key} (${options.count})`;
}
return key;
},
}),
}));
describe('ProviderGroup', () => {
const createMockStats = (overrides?: Partial<ProviderStats>): ProviderStats => ({
providerKey: 'gemini-',
providerInfo: {
name: 'Gemini',
company: 'Google',
color: '#4285F4',
},
models: [
{ id: 'models/gemini-pro', percentage: 75, resetTime: '2026-04-24T00:00:00Z' },
{ id: 'models/gemini-ultra', percentage: 50, resetTime: '2026-04-24T00:00:00Z' },
],
visibleModels: [
{ id: 'models/gemini-pro', percentage: 75, resetTime: '2026-04-24T00:00:00Z' },
{ id: 'models/gemini-ultra', percentage: 50, resetTime: '2026-04-24T00:00:00Z' },
],
avgPercentage: 62.5,
earliestReset: '2026-04-24T00:00:00Z',
...overrides,
});
const defaultProps = {
stats: createMockStats(),
isCollapsed: false,
onToggleCollapse: vi.fn(),
getQuotaTextColorClass: (percentage: number) =>
percentage > 50 ? 'text-green-500' : 'text-yellow-500',
getQuotaBarColorClass: (percentage: number) =>
percentage > 50 ? 'bg-green-500' : 'bg-yellow-500',
formatQuotaLabel: (percentage: number) => `${percentage}%`,
formatResetTimeLabel: (resetTime?: string) => (resetTime ? '24h' : '—'),
formatResetTimeTitle: (resetTime?: string) =>
resetTime ? new Date(resetTime).toLocaleString() : undefined,
leftLabel: 'left',
};
it('renders provider name', () => {
renderWithProviders(<ProviderGroup {...defaultProps} />);
expect(screen.getByText('Gemini')).toBeInTheDocument();
});
it('displays model count', () => {
renderWithProviders(<ProviderGroup {...defaultProps} />);
expect(
screen.getByText(/settings\.providerGroupings\.models/),
).toBeInTheDocument();
});
it('shows quota bars and average percentage', () => {
renderWithProviders(<ProviderGroup {...defaultProps} />);
expect(screen.getByText('62.5%')).toBeInTheDocument();
expect(screen.getByText('settings.providerGroupings.avgLabel')).toBeInTheDocument();
});
it('shows reset time label', () => {
renderWithProviders(<ProviderGroup {...defaultProps} />);
expect(screen.getAllByText('24h').length).toBeGreaterThanOrEqual(1);
});
it('shows individual model rows when expanded', () => {
renderWithProviders(<ProviderGroup {...defaultProps} isCollapsed={false} />);
expect(screen.getByText('gemini-pro')).toBeInTheDocument();
expect(screen.getByText('gemini-ultra')).toBeInTheDocument();
});
it('hides individual model rows when collapsed', () => {
renderWithProviders(<ProviderGroup {...defaultProps} isCollapsed={true} />);
expect(screen.queryByText('gemini-pro')).not.toBeInTheDocument();
expect(screen.queryByText('gemini-ultra')).not.toBeInTheDocument();
});
it('calls onToggleCollapse when header button is clicked', () => {
const onToggleCollapse = vi.fn();
renderWithProviders(
<ProviderGroup {...defaultProps} onToggleCollapse={onToggleCollapse} />,
);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(onToggleCollapse).toHaveBeenCalledTimes(1);
});
it('renders color dot with provider color', () => {
const { container } = renderWithProviders(
<ProviderGroup {...defaultProps} />,
);
const dot = container.querySelector('span[style*="background-color"]');
expect(dot).toHaveAttribute('style', expect.stringContaining('#4285F4'));
});
it('shows chevron right when collapsed', () => {
renderWithProviders(<ProviderGroup {...defaultProps} isCollapsed={true} />);
expect(document.querySelector('svg')).toBeInTheDocument();
});
it('shows chevron down when expanded', () => {
renderWithProviders(<ProviderGroup {...defaultProps} isCollapsed={false} />);
expect(document.querySelector('svg')).toBeInTheDocument();
});
it('renders nothing when visibleModels is empty', () => {
const { container } = renderWithProviders(
<ProviderGroup
{...defaultProps}
stats={createMockStats({ visibleModels: [] })}
/>,
);
expect(container.firstChild).toBeNull();
});
it('displays model-specific percentages in expanded rows', () => {
renderWithProviders(<ProviderGroup {...defaultProps} isCollapsed={false} />);
expect(screen.getByText('75%')).toBeInTheDocument();
expect(screen.getByText('50%')).toBeInTheDocument();
});
it('shows leftLabel next to model percentages when percentage > 0', () => {
renderWithProviders(<ProviderGroup {...defaultProps} isCollapsed={false} />);
const leftLabels = screen.getAllByText('left');
expect(leftLabels.length).toBe(2);
});
});
@@ -0,0 +1,72 @@
import React from 'react';
import { screen, fireEvent } from '@testing-library/react';
import { renderWithProviders } from '@/tests/unit/test-utils';
import { Button, buttonVariants } from '@/components/ui/button';
describe('Button', () => {
it('renders with default variant and size', () => {
renderWithProviders(<Button>Click me</Button>);
const button = screen.getByRole('button', { name: /click me/i });
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('data-slot', 'button');
});
it('renders all variant styles', () => {
const variants = ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'] as const;
variants.forEach((variant) => {
const { container } = renderWithProviders(
<Button variant={variant}>{variant}</Button>,
);
expect(container.querySelector('button')).toBeInTheDocument();
});
});
it('renders all size styles', () => {
const sizes = ['default', 'sm', 'lg', 'icon'] as const;
sizes.forEach((size) => {
const { container } = renderWithProviders(
<Button size={size}>{size}</Button>,
);
expect(container.querySelector('button')).toBeInTheDocument();
});
});
it('handles click events', () => {
const handleClick = vi.fn();
renderWithProviders(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('supports disabled state', () => {
renderWithProviders(<Button disabled>Disabled</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('renders as child element when asChild is true', () => {
renderWithProviders(
<Button asChild>
<a href="/test">Link Button</a>
</Button>,
);
const link = screen.getByRole('link', { name: /link button/i });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', '/test');
});
it('applies custom className', () => {
renderWithProviders(<Button className="custom-class">Styled</Button>);
expect(screen.getByRole('button')).toHaveClass('custom-class');
});
it('buttonVariants function returns expected classes for default variant', () => {
const classes = buttonVariants({ variant: 'default', size: 'default' });
expect(classes).toContain('bg-primary');
expect(classes).toContain('text-primary-foreground');
});
it('buttonVariants function returns expected classes for destructive variant', () => {
const classes = buttonVariants({ variant: 'destructive' });
expect(classes).toContain('bg-destructive');
});
});
+107
View File
@@ -0,0 +1,107 @@
import React from 'react';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '@/tests/unit/test-utils';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from '@/components/ui/card';
describe('Card', () => {
it('renders Card with content', () => {
renderWithProviders(
<Card data-testid="card">
<span>Card content</span>
</Card>,
);
expect(screen.getByTestId('card')).toBeInTheDocument();
expect(screen.getByText('Card content')).toBeInTheDocument();
});
it('applies custom className to Card', () => {
renderWithProviders(<Card className="my-card">Content</Card>);
expect(screen.getByText('Content')).toHaveClass('my-card');
});
it('forwards ref correctly', () => {
const ref = React.createRef<HTMLDivElement>();
renderWithProviders(<Card ref={ref}>Ref test</Card>);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
});
});
describe('CardHeader', () => {
it('renders header with title and description', () => {
renderWithProviders(
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Description</CardDescription>
</CardHeader>
</Card>,
);
expect(screen.getByText('Title')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
});
it('CardTitle renders as h3', () => {
renderWithProviders(<CardTitle>Heading</CardTitle>);
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Heading');
});
it('CardDescription has correct text styling classes', () => {
const { container } = renderWithProviders(<CardDescription>Desc</CardDescription>);
expect(container.querySelector('p')).toHaveClass('text-muted-foreground');
});
});
describe('CardContent', () => {
it('renders content area', () => {
renderWithProviders(
<Card>
<CardContent>Main content</CardContent>
</Card>,
);
expect(screen.getByText('Main content')).toBeInTheDocument();
});
});
describe('CardFooter', () => {
it('renders footer with actions', () => {
renderWithProviders(
<Card>
<CardFooter>
<button>Action</button>
</CardFooter>
</Card>,
);
expect(screen.getByRole('button')).toBeInTheDocument();
});
});
describe('Full Card composition', () => {
it('renders complete card with all sections', () => {
renderWithProviders(
<Card className="w-96">
<CardHeader>
<CardTitle>Account Stats</CardTitle>
<CardDescription>Overview of usage</CardDescription>
</CardHeader>
<CardContent>
<p>Tokens used: 1,234</p>
</CardContent>
<CardFooter>
<span>Last updated: now</span>
</CardFooter>
</Card>,
);
expect(screen.getByRole('heading', { name: /account stats/i })).toBeInTheDocument();
expect(screen.getByText(/overview of usage/i)).toBeInTheDocument();
expect(screen.getByText(/tokens used/i)).toBeInTheDocument();
expect(screen.getByText(/last updated/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,66 @@
import React from 'react';
import { screen, fireEvent } from '@testing-library/react';
import { renderWithProviders } from '@/tests/unit/test-utils';
import { Input } from '@/components/ui/input';
describe('Input', () => {
it('renders an input element', () => {
renderWithProviders(<Input placeholder="Enter text" />);
expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument();
});
it('accepts and displays text value', () => {
renderWithProviders(<Input />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'Hello world' } });
expect(input).toHaveValue('Hello world');
});
it('supports different input types', () => {
const { rerender } = renderWithProviders(<Input type="text" />);
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text');
rerender(<Input type="password" data-testid="password-input" />);
expect(screen.getByTestId('password-input')).toHaveAttribute('type', 'password');
rerender(<Input type="email" data-testid="email-input" />);
expect(screen.getByTestId('email-input')).toHaveAttribute('type', 'email');
});
it('supports disabled state', () => {
renderWithProviders(<Input disabled />);
expect(screen.getByRole('textbox')).toBeDisabled();
});
it('forwards ref correctly', () => {
const ref = React.createRef<HTMLInputElement>();
renderWithProviders(<Input ref={ref} />);
expect(ref.current).toBeInstanceOf(HTMLInputElement);
});
it('applies custom className', () => {
renderWithProviders(<Input className="custom-input" />);
expect(screen.getByRole('textbox')).toHaveClass('custom-input');
});
it('handles focus and blur events', () => {
const onFocus = vi.fn();
const onBlur = vi.fn();
renderWithProviders(<Input onFocus={onFocus} onBlur={onBlur} />);
const input = screen.getByRole('textbox');
fireEvent.focus(input);
expect(onFocus).toHaveBeenCalledTimes(1);
fireEvent.blur(input);
expect(onBlur).toHaveBeenCalledTimes(1);
});
it('has expected base styling classes', () => {
const { container } = renderWithProviders(<Input />);
const input = container.querySelector('input');
expect(input).toHaveClass('rounded-md');
expect(input).toHaveClass('border');
expect(input).toHaveClass('bg-transparent');
});
});
@@ -0,0 +1,94 @@
import React from 'react';
import { screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { renderWithProviders } from '@/tests/unit/test-utils';
import { ChartCard } from '@/components/usage/ChartCard';
describe('ChartCard', () => {
const defaultProps = {
isLoading: false,
error: null,
isEmpty: false,
onRetry: vi.fn(),
title: 'Test Chart',
emptyTitle: 'No Data',
emptyDescription: 'There is no data to display.',
errorMessage: 'Failed to load chart.',
retryLabel: 'Retry',
children: <div data-testid="chart-content">Chart Content</div>,
};
it('renders loading state with skeleton', () => {
renderWithProviders(
<ChartCard {...defaultProps} isLoading={true} />,
);
expect(screen.getByText('Test Chart')).toBeInTheDocument();
expect(document.querySelector('.animate-pulse')).toBeInTheDocument();
expect(screen.queryByTestId('chart-content')).not.toBeInTheDocument();
});
it('renders error state with retry button and calls onRetry when clicked', () => {
const onRetry = vi.fn();
renderWithProviders(
<ChartCard
{...defaultProps}
error={new Error('Network error')}
onRetry={onRetry}
/>,
);
expect(screen.getByText('Failed to load chart.')).toBeInTheDocument();
const retryButton = screen.getByRole('button', { name: /retry/i });
expect(retryButton).toBeInTheDocument();
fireEvent.click(retryButton);
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('renders empty state with title and description', () => {
renderWithProviders(
<ChartCard {...defaultProps} isEmpty={true} />,
);
expect(screen.getByText('No Data')).toBeInTheDocument();
expect(
screen.getByText('There is no data to display.'),
).toBeInTheDocument();
expect(screen.queryByTestId('chart-content')).not.toBeInTheDocument();
});
it('renders children when not loading, error, or empty', () => {
renderWithProviders(<ChartCard {...defaultProps} />);
expect(screen.getByTestId('chart-content')).toBeInTheDocument();
expect(screen.getByText('Chart Content')).toBeInTheDocument();
expect(document.querySelector('.animate-pulse')).not.toBeInTheDocument();
});
it('prioritizes loading over error state', () => {
renderWithProviders(
<ChartCard
{...defaultProps}
isLoading={true}
error={new Error('Some error')}
/>,
);
expect(document.querySelector('.animate-pulse')).toBeInTheDocument();
expect(screen.queryByText('Failed to load chart.')).not.toBeInTheDocument();
});
it('prioritizes error over empty state', () => {
renderWithProviders(
<ChartCard
{...defaultProps}
error={new Error('Some error')}
isEmpty={true}
/>,
);
expect(screen.getByText('Failed to load chart.')).toBeInTheDocument();
expect(screen.queryByText('No Data')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,125 @@
import React from 'react';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '@/tests/unit/test-utils';
import { StatCard } from '@/components/usage/StatCard';
import { Activity, TrendingUp, Users, Zap } from 'lucide-react';
import { vi, beforeAll, afterAll } from 'vitest';
describe('StatCard', () => {
beforeAll(() => {
vi.spyOn(Number.prototype, 'toLocaleString').mockImplementation(function (this: number) {
return this.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
});
});
afterAll(() => {
vi.restoreAllMocks();
});
it('renders label and value', () => {
renderWithProviders(
<StatCard label="Total Tokens" value={12345} icon={Activity} />,
);
expect(screen.getByText('Total Tokens')).toBeInTheDocument();
expect(screen.getByText('12,345')).toBeInTheDocument();
});
it('renders string values correctly', () => {
renderWithProviders(
<StatCard label="Status" value="Active" icon={Zap} />,
);
expect(screen.getByText('Active')).toBeInTheDocument();
});
it('formats number values with locale string', () => {
renderWithProviders(
<StatCard label="Requests" value={1000000} icon={TrendingUp} />,
);
expect(screen.getByText('1,000,000')).toBeInTheDocument();
});
it('shows skeleton when isLoading is true', () => {
renderWithProviders(
<StatCard label="Loading" value={0} icon={Users} isLoading />,
);
expect(screen.queryByText('0')).not.toBeInTheDocument();
expect(document.querySelector('.animate-pulse')).toBeInTheDocument();
});
it('does not show skeleton when isLoading is false', () => {
renderWithProviders(
<StatCard label="Loaded" value={42} icon={Activity} isLoading={false} />,
);
expect(screen.getByText('42')).toBeInTheDocument();
expect(document.querySelector('.animate-pulse')).not.toBeInTheDocument();
});
it('renders icon element', () => {
renderWithProviders(
<StatCard label="With Icon" value={100} icon={Zap} />,
);
expect(document.querySelector('svg')).toBeInTheDocument();
});
it('applies custom className', () => {
renderWithProviders(
<StatCard label="Styled" value={1} icon={Activity} className="my-stat" />,
);
expect(screen.getByText('Styled').closest('[class*="border-border"]')).toHaveClass('my-stat');
});
it('applies default slate accent when accent prop is not provided', () => {
const { container } = renderWithProviders(
<StatCard label="Default Accent" value={1} icon={Activity} />,
);
const iconContainer = container.querySelector('.rounded-md');
expect(iconContainer).toHaveClass('bg-slate-500/5');
});
it('applies blue accent', () => {
const { container } = renderWithProviders(
<StatCard label="Blue" value={1} icon={Activity} accent="blue" />,
);
const iconContainer = container.querySelector('.rounded-md');
expect(iconContainer).toHaveClass('bg-blue-500/5');
});
it('applies green accent', () => {
const { container } = renderWithProviders(
<StatCard label="Green" value={1} icon={Activity} accent="green" />,
);
const iconContainer = container.querySelector('.rounded-md');
expect(iconContainer).toHaveClass('bg-emerald-500/5');
});
it('applies amber accent', () => {
const { container } = renderWithProviders(
<StatCard label="Amber" value={1} icon={Activity} accent="amber" />,
);
const iconContainer = container.querySelector('.rounded-md');
expect(iconContainer).toHaveClass('bg-amber-500/5');
});
it('applies purple accent', () => {
const { container } = renderWithProviders(
<StatCard label="Purple" value={1} icon={Activity} accent="purple" />,
);
const iconContainer = container.querySelector('.rounded-md');
expect(iconContainer).toHaveClass('bg-purple-500/5');
});
it('has uppercase label styling', () => {
renderWithProviders(
<StatCard label="Uppercase Test" value={1} icon={Activity} />,
);
const label = screen.getByText('Uppercase Test');
expect(label).toHaveClass('uppercase');
});
it('has mono font for value', () => {
renderWithProviders(
<StatCard label="Mono" value={999} icon={Activity} />,
);
const value = screen.getByText('999');
expect(value).toHaveClass('font-mono');
});
});
+58 -40
View File
@@ -1,6 +1,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { isString } from 'lodash-es';
vi.mock('@/server/modules/proxy/request-user-agent', () => ({
resolveRequestUserAgent: vi.fn().mockResolvedValue('test-agent/1.0.0'),
buildUserAgent: vi.fn().mockReturnValue('test-agent/1.0.0'),
resolveLocalInstalledVersion: vi.fn().mockReturnValue('1.0.0'),
FALLBACK_VERSION: '1.22.2',
}));
// Mock module to test timeout behavior
describe('GoogleAPIService Timeout', () => {
beforeEach(() => {
@@ -114,7 +121,7 @@ describe('GoogleAPIService OAuth clients', () => {
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
const clients = GoogleAPIService.listOAuthClients();
expect(clients.find((client) => client.key === 'geminiNexus_enterprise')).toBeDefined();
expect(clients.some((client) => client.is_builtin)).toBe(true);
expect(clients.find((client) => client.key === 'custom_a')?.label).toBe('Custom A');
expect(clients.find((client) => client.key === 'custom_b')?.is_active).toBe(true);
});
@@ -173,7 +180,9 @@ describe('GoogleAPIService user info parsing', () => {
id: 'google-user-1',
email: '[email protected]',
name: 'Example User',
family_name: undefined,
given_name: 'Example',
picture: 'https://example.com/avatar.png',
verified_email: true,
}),
);
});
@@ -213,27 +222,23 @@ describe('GoogleAPIService fetchQuota fallback policy', () => {
vi.resetModules();
});
it('falls back to loadCodeAssist credits when fetchCredits returns 404', async () => {
it('falls back to fetchCredits when loadCodeAssist returns no usable credits', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: false,
status: 404,
text: vi.fn().mockResolvedValue('NOT_FOUND'),
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({
// No paidTier.availableCredits — no usable credits
freeTier: {},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({
paidTier: {
availableCredits: [
{
creditType: 'GOOGLE_ONE_AI',
creditAmount: '1000',
minimumCreditAmountForUsage: '50',
},
],
},
credits: 500,
expiryDate: '2025-12-31',
}),
});
@@ -249,31 +254,18 @@ describe('GoogleAPIService fetchQuota fallback policy', () => {
} as any);
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
const { FALLBACK_VERSION, resolveLocalInstalledVersion } =
await import('../../server/modules/proxy/request-user-agent');
const expectedVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
credits: 1000,
expiryDate: '',
credits: 500,
expiryDate: '2025-12-31',
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'https://cloudcode-pa.googleapis.com/v1internal:fetchCredits',
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
);
expect(fetchMock.mock.calls[1]?.[1]?.method).toBe('POST');
expect(fetchMock.mock.calls[1]?.[1]?.body).toBe(
JSON.stringify({
metadata: {
ide_type: 'ANTIGRAVITY',
ide_version: expectedVersion,
ide_name: 'geminiNexus',
},
}),
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'https://cloudcode-pa.googleapis.com/v1internal:fetchCredits',
);
});
@@ -313,9 +305,19 @@ describe('GoogleAPIService fetchQuota fallback policy', () => {
process.env.NO_PROXY = 'localhost,127.0.0.1,::1';
const fetchMock = vi.fn().mockResolvedValueOnce({
ok: false,
status: 502,
text: vi.fn().mockResolvedValue('BAD_GATEWAY'),
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({
paidTier: {
availableCredits: [
{
creditType: 'GOOGLE_ONE_AI',
creditAmount: '1000',
minimumCreditAmountForUsage: '50',
},
],
},
}),
});
vi.stubGlobal('fetch', fetchMock);
@@ -331,7 +333,10 @@ describe('GoogleAPIService fetchQuota fallback policy', () => {
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toBeNull();
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
credits: 1000,
expiryDate: '',
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
@@ -345,9 +350,19 @@ describe('GoogleAPIService fetchQuota fallback policy', () => {
process.env.ELECTRON_PROXY_SERVER = 'http://127.0.0.1:9090';
const fetchMock = vi.fn().mockResolvedValueOnce({
ok: false,
status: 502,
text: vi.fn().mockResolvedValue('BAD_GATEWAY'),
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({
paidTier: {
availableCredits: [
{
creditType: 'GOOGLE_ONE_AI',
creditAmount: '1000',
minimumCreditAmountForUsage: '50',
},
],
},
}),
});
vi.stubGlobal('fetch', fetchMock);
@@ -363,7 +378,10 @@ describe('GoogleAPIService fetchQuota fallback policy', () => {
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toBeNull();
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
credits: 1000,
expiryDate: '',
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
@@ -0,0 +1,269 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(() => ({ data: undefined, isLoading: false, error: null })),
useMutation: vi.fn(() => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false })),
useQueryClient: vi.fn(),
}));
vi.mock('@/actions/cloud', () => ({
listCloudAccounts: vi.fn(),
addGoogleAccount: vi.fn(),
deleteCloudAccount: vi.fn(),
refreshAccountQuota: vi.fn(),
setAccountProxy: vi.fn(),
listOAuthClients: vi.fn(),
setActiveOAuthClient: vi.fn(),
switchCloudAccount: vi.fn(),
getAutoSwitchEnabled: vi.fn(),
setAutoSwitchEnabled: vi.fn(),
forcePollCloudMonitor: vi.fn(),
syncLocalAccount: vi.fn(),
exportCloudAccounts: vi.fn(),
importCloudAccounts: vi.fn(),
}));
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as cloudActions from '@/actions/cloud';
import {
useCloudAccounts,
useAddGoogleAccount,
useDeleteCloudAccount,
useRefreshQuota,
useSwitchCloudAccount,
useSetAccountProxy,
useAutoSwitchEnabled,
useSetAutoSwitchEnabled,
useOAuthClients,
useSetActiveOAuthClient,
useForcePollCloudMonitor,
useSyncLocalAccount,
useExportCloudAccounts,
useImportCloudAccounts,
QUERY_KEYS,
AUTO_SWITCH_KEY,
} from '@/hooks/useCloudAccounts';
import type { CloudAccount } from '@/types/cloudAccount';
const mockedUseQuery = vi.mocked(useQuery);
const mockedUseMutation = vi.mocked(useMutation);
const mockedUseQueryClient = vi.mocked(useQueryClient);
function getLastQueryOptions() {
return mockedUseQuery.mock.lastCall?.[0];
}
function getLastMutationOptions() {
return mockedUseMutation.mock.lastCall?.[0];
}
describe('useCloudAccounts hooks', () => {
const mockInvalidateQueries = vi.fn();
const mockSetQueryData = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockedUseQueryClient.mockReturnValue({
invalidateQueries: mockInvalidateQueries,
setQueryData: mockSetQueryData,
} as any);
});
describe('useCloudAccounts', () => {
it('calls useQuery with cloudAccounts key and listCloudAccounts fn', () => {
renderHook(() => useCloudAccounts());
const options = getLastQueryOptions();
expect(options?.queryKey).toEqual(QUERY_KEYS.cloudAccounts);
expect(options?.queryFn).toBe(cloudActions.listCloudAccounts);
expect(options?.staleTime).toBe(1000 * 60);
});
});
describe('useAddGoogleAccount', () => {
it('calls useMutation with addGoogleAccount and invalidates cloudAccounts on success', () => {
renderHook(() => useAddGoogleAccount());
const options = getLastMutationOptions();
expect(options?.mutationFn).toBe(cloudActions.addGoogleAccount);
options?.onSuccess?.();
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.cloudAccounts });
});
});
describe('useDeleteCloudAccount', () => {
it('calls useMutation with deleteCloudAccount and invalidates cloudAccounts on success', () => {
renderHook(() => useDeleteCloudAccount());
const options = getLastMutationOptions();
expect(options?.mutationFn).toBe(cloudActions.deleteCloudAccount);
options?.onSuccess?.();
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.cloudAccounts });
});
});
describe('useRefreshQuota', () => {
it('calls useMutation with refreshAccountQuota', () => {
renderHook(() => useRefreshQuota());
const options = getLastMutationOptions();
expect(options?.mutationFn).toBe(cloudActions.refreshAccountQuota);
});
it('optimistically updates existing account on success', () => {
renderHook(() => useRefreshQuota());
const options = getLastMutationOptions();
const updatedAccount: CloudAccount = { id: 'acc-2' } as CloudAccount;
const oldData: CloudAccount[] = [
{ id: 'acc-1' } as CloudAccount,
{ id: 'acc-2', name: 'Old' } as CloudAccount,
];
options?.onSuccess?.(updatedAccount);
const updater = mockSetQueryData.mock.calls[0][1] as Function;
const result = updater(oldData);
expect(result).toEqual([oldData[0], updatedAccount]);
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.cloudAccounts });
});
it('creates array with updated account when old data is undefined', () => {
renderHook(() => useRefreshQuota());
const options = getLastMutationOptions();
const updatedAccount: CloudAccount = { id: 'acc-1' } as CloudAccount;
options?.onSuccess?.(updatedAccount);
const updater = mockSetQueryData.mock.calls[0][1] as Function;
const result = updater(undefined);
expect(result).toEqual([updatedAccount]);
});
});
describe('useSwitchCloudAccount', () => {
it('calls useMutation with switchCloudAccount and invalidates correct keys on success', () => {
renderHook(() => useSwitchCloudAccount());
const options = getLastMutationOptions();
expect(options?.mutationFn).toBe(cloudActions.switchCloudAccount);
options?.onSuccess?.();
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.cloudAccounts });
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['currentAccount'] });
});
});
describe('useSetAccountProxy', () => {
it('calls useMutation with setAccountProxy and invalidates cloudAccounts on success', () => {
renderHook(() => useSetAccountProxy());
const options = getLastMutationOptions();
expect(options?.mutationFn).toBe(cloudActions.setAccountProxy);
options?.onSuccess?.();
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.cloudAccounts });
});
it('logs error on mutation failure', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
renderHook(() => useSetAccountProxy());
const options = getLastMutationOptions();
const error = new Error('proxy failed');
options?.onError?.(error);
expect(consoleSpy).toHaveBeenCalledWith('[Mutation] setAccountProxy failed:', error);
consoleSpy.mockRestore();
});
});
describe('useAutoSwitchEnabled', () => {
it('calls useQuery with autoSwitchEnabled key and getAutoSwitchEnabled fn', () => {
renderHook(() => useAutoSwitchEnabled());
const options = getLastQueryOptions();
expect(options?.queryKey).toEqual(AUTO_SWITCH_KEY);
expect(options?.queryFn).toBe(cloudActions.getAutoSwitchEnabled);
expect(options?.staleTime).toBe(Infinity);
});
});
describe('useSetAutoSwitchEnabled', () => {
it('calls useMutation with setAutoSwitchEnabled and updates query data on success', () => {
renderHook(() => useSetAutoSwitchEnabled());
const options = getLastMutationOptions();
expect(options?.mutationFn).toBe(cloudActions.setAutoSwitchEnabled);
options?.onSuccess?.({}, { enabled: true });
expect(mockSetQueryData).toHaveBeenCalledWith(AUTO_SWITCH_KEY, true);
});
});
describe('useOAuthClients', () => {
it('calls useQuery with oauthClients key and listOAuthClients fn', () => {
renderHook(() => useOAuthClients());
const options = getLastQueryOptions();
expect(options?.queryKey).toEqual(QUERY_KEYS.oauthClients);
expect(options?.queryFn).toBe(cloudActions.listOAuthClients);
expect(options?.staleTime).toBe(1000 * 60 * 5);
});
});
describe('useSetActiveOAuthClient', () => {
it('calls useMutation with setActiveOAuthClient and invalidates oauthClients on success', () => {
renderHook(() => useSetActiveOAuthClient());
const options = getLastMutationOptions();
expect(options?.mutationFn).toBe(cloudActions.setActiveOAuthClient);
options?.onSuccess?.();
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.oauthClients });
});
});
describe('useForcePollCloudMonitor', () => {
it('calls useMutation with forcePollCloudMonitor and invalidates cloudAccounts on success', () => {
renderHook(() => useForcePollCloudMonitor());
const options = getLastMutationOptions();
expect(options?.mutationFn).toBe(cloudActions.forcePollCloudMonitor);
options?.onSuccess?.();
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.cloudAccounts });
});
});
describe('useSyncLocalAccount', () => {
it('calls useMutation with syncLocalAccount and invalidates cloudAccounts on success', () => {
renderHook(() => useSyncLocalAccount());
const options = getLastMutationOptions();
expect(options?.mutationFn).toBe(cloudActions.syncLocalAccount);
options?.onSuccess?.();
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.cloudAccounts });
});
});
describe('useExportCloudAccounts', () => {
it('calls useMutation with exportCloudAccounts and no invalidation', () => {
renderHook(() => useExportCloudAccounts());
const options = getLastMutationOptions();
expect(options?.mutationFn).toBe(cloudActions.exportCloudAccounts);
expect(options?.onSuccess).toBeUndefined();
});
});
describe('useImportCloudAccounts', () => {
it('calls useMutation with importCloudAccounts and invalidates cloudAccounts on success', () => {
renderHook(() => useImportCloudAccounts());
const options = getLastMutationOptions();
expect(options?.mutationFn).toBe(cloudActions.importCloudAccounts);
options?.onSuccess?.();
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.cloudAccounts });
});
it('logs error on mutation failure', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
renderHook(() => useImportCloudAccounts());
const options = getLastMutationOptions();
const error = new Error('import failed');
options?.onError?.(error);
expect(consoleSpy).toHaveBeenCalledWith('[Mutation] importCloudAccounts failed:', error);
consoleSpy.mockRestore();
});
});
});
@@ -0,0 +1,183 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useProviderGrouping } from '@/hooks/useProviderGrouping';
import type { CloudAccount } from '@/types/cloudAccount';
vi.mock('@/hooks/useAppConfig', () => ({
useAppConfig: vi.fn(),
}));
import { useAppConfig } from '@/hooks/useAppConfig';
const mockUseAppConfig = vi.mocked(useAppConfig);
function createMockAccount(overrides?: Partial<CloudAccount>): CloudAccount {
return {
id: 'acc-1',
provider: 'google',
email: '[email protected]',
token: {} as CloudAccount['token'],
created_at: Date.now(),
last_used: Date.now(),
...overrides,
};
}
describe('useProviderGrouping', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns enabled=false when provider_groupings_enabled is undefined', () => {
mockUseAppConfig.mockReturnValue({ config: {} } as any);
const { result } = renderHook(() => useProviderGrouping());
expect(result.current.enabled).toBe(false);
});
it('returns enabled=true when provider_groupings_enabled is true', () => {
mockUseAppConfig.mockReturnValue({ config: { provider_groupings_enabled: true } } as any);
const { result } = renderHook(() => useProviderGrouping());
expect(result.current.enabled).toBe(true);
});
describe('getAccountStats', () => {
it('returns correct stats for an account with models', () => {
mockUseAppConfig.mockReturnValue({
config: { provider_groupings_enabled: true, model_visibility: {} },
} as any);
const account = createMockAccount({
quota: {
models: {
'gemini-1.5-pro': { percentage: 80, resetTime: '' },
'claude-3-5-sonnet': { percentage: 60, resetTime: '' },
},
},
});
const { result } = renderHook(() => useProviderGrouping());
const stats = result.current.getAccountStats(account);
expect(stats.providers).toHaveLength(2);
expect(stats.totalModels).toBe(2);
expect(stats.visibleModels).toBe(2);
expect(stats.overallPercentage).toBe(70);
});
it('respects model visibility settings', () => {
mockUseAppConfig.mockReturnValue({
config: {
provider_groupings_enabled: true,
model_visibility: { 'claude-3-5-sonnet': false },
},
} as any);
const account = createMockAccount({
quota: {
models: {
'gemini-1.5-pro': { percentage: 80, resetTime: '' },
'claude-3-5-sonnet': { percentage: 60, resetTime: '' },
},
},
});
const { result } = renderHook(() => useProviderGrouping());
const stats = result.current.getAccountStats(account);
expect(stats.visibleModels).toBe(1);
expect(stats.overallPercentage).toBe(80);
});
it('handles accounts with no quota', () => {
mockUseAppConfig.mockReturnValue({
config: { provider_groupings_enabled: true, model_visibility: {} },
} as any);
const account = createMockAccount({ quota: undefined });
const { result } = renderHook(() => useProviderGrouping());
const stats = result.current.getAccountStats(account);
expect(stats.providers).toHaveLength(0);
expect(stats.totalModels).toBe(0);
});
});
describe('toggleProviderCollapse', () => {
it('toggles provider collapse state on and off', () => {
mockUseAppConfig.mockReturnValue({
config: { provider_groupings_enabled: true, model_visibility: {} },
} as any);
const { result } = renderHook(() => useProviderGrouping());
expect(result.current.isProviderCollapsed('acc-1', 'claude-')).toBe(false);
act(() => {
result.current.toggleProviderCollapse('acc-1', 'claude-');
});
expect(result.current.isProviderCollapsed('acc-1', 'claude-')).toBe(true);
act(() => {
result.current.toggleProviderCollapse('acc-1', 'claude-');
});
expect(result.current.isProviderCollapsed('acc-1', 'claude-')).toBe(false);
});
it('tracks collapse per account independently', () => {
mockUseAppConfig.mockReturnValue({
config: { provider_groupings_enabled: true, model_visibility: {} },
} as any);
const { result } = renderHook(() => useProviderGrouping());
act(() => {
result.current.toggleProviderCollapse('acc-1', 'gemini-');
});
expect(result.current.isProviderCollapsed('acc-1', 'gemini-')).toBe(true);
expect(result.current.isProviderCollapsed('acc-2', 'gemini-')).toBe(false);
});
});
describe('toggleAccountCollapse', () => {
it('toggles account collapse state on and off', () => {
mockUseAppConfig.mockReturnValue({
config: { provider_groupings_enabled: true, model_visibility: {} },
} as any);
const { result } = renderHook(() => useProviderGrouping());
expect(result.current.isAccountCollapsed('acc-1')).toBe(false);
act(() => {
result.current.toggleAccountCollapse('acc-1');
});
expect(result.current.isAccountCollapsed('acc-1')).toBe(true);
act(() => {
result.current.toggleAccountCollapse('acc-1');
});
expect(result.current.isAccountCollapsed('acc-1')).toBe(false);
});
it('tracks collapse for multiple accounts independently', () => {
mockUseAppConfig.mockReturnValue({
config: { provider_groupings_enabled: true, model_visibility: {} },
} as any);
const { result } = renderHook(() => useProviderGrouping());
act(() => {
result.current.toggleAccountCollapse('acc-1');
result.current.toggleAccountCollapse('acc-2');
});
expect(result.current.isAccountCollapsed('acc-1')).toBe(true);
expect(result.current.isAccountCollapsed('acc-2')).toBe(true);
act(() => {
result.current.toggleAccountCollapse('acc-1');
});
expect(result.current.isAccountCollapsed('acc-1')).toBe(false);
expect(result.current.isAccountCollapsed('acc-2')).toBe(true);
});
});
});
+230
View File
@@ -0,0 +1,230 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CloudAccountRepo } from '../../../ipc/database/cloudHandler';
const mockRun = vi.fn();
const mockAll = vi.fn();
const mockGet = vi.fn();
const mockClose = vi.fn();
const mockPrepare = vi.fn(() => ({ run: mockRun, all: mockAll, get: mockGet }));
const mockFrom = vi.fn(() => ({
where: vi.fn(() => ({ all: mockAll })),
orderBy: vi.fn(() => ({ all: mockAll })),
all: mockAll,
}));
const mockSet = vi.fn(() => ({
where: vi.fn(() => ({ run: mockRun })),
run: mockRun,
}));
const mockValues = vi.fn(() => ({
onConflictDoUpdate: vi.fn(() => ({ run: mockRun })),
run: mockRun,
}));
const mockOrm = {
select: vi.fn(() => ({ from: mockFrom })),
update: vi.fn(() => ({ set: mockSet })),
delete: vi.fn(() => ({ where: vi.fn(() => ({ run: mockRun })) })),
insert: vi.fn(() => ({ values: mockValues })),
transaction: vi.fn((fn) =>
fn({
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => ({ run: mockRun })),
run: mockRun,
})),
})),
insert: vi.fn(() => ({
values: vi.fn(() => ({
onConflictDoUpdate: vi.fn(() => ({ run: mockRun })),
run: mockRun,
})),
})),
delete: vi.fn(() => ({ where: vi.fn(() => ({ run: mockRun })) })),
}),
),
};
vi.mock('better-sqlite3', () => ({
default: class MockDatabase {
exec = vi.fn();
prepare = vi.fn(() => ({ run: vi.fn(), get: vi.fn(), all: vi.fn() }));
close = vi.fn();
pragma = vi.fn(() => []);
},
}));
vi.mock('../../../ipc/database/dbConnection', () => ({
openDrizzleConnection: vi.fn(() => ({
raw: { prepare: mockPrepare, close: mockClose },
orm: mockOrm,
})),
configureDatabase: 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(),
},
}));
vi.mock('../../../utils/security', () => ({
encrypt: vi.fn(async (text: string) => text),
decryptWithMigration: vi.fn(async (text: string) => ({ value: text })),
}));
function createMockAccountRow(overrides?: Partial<Record<string, unknown>>) {
return {
id: 'acc-1',
provider: 'google',
email: '[email protected]',
name: 'Test User',
avatarUrl: null,
tokenJson: JSON.stringify({
access_token: 'tok1',
refresh_token: 'ref1',
expires_in: 3600,
expiry_timestamp: 1234567890,
token_type: 'Bearer',
}),
quotaJson: null,
deviceProfileJson: null,
deviceHistoryJson: null,
createdAt: 1234567890,
lastUsed: 1234567890,
status: 'active',
statusReason: null,
isActive: 1,
proxyUrl: null,
...overrides,
};
}
describe('CloudAccountRepo', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('addAccount (create)', () => {
it('should insert a new account into the database', async () => {
const account = {
id: 'acc-1',
provider: 'google' as const,
email: '[email protected]',
token: {
access_token: 'tok1',
refresh_token: 'ref1',
expires_in: 3600,
expiry_timestamp: 1234567890,
token_type: 'Bearer',
},
created_at: 1234567890,
last_used: 1234567890,
is_active: false,
};
await CloudAccountRepo.addAccount(account);
expect(mockOrm.transaction).toHaveBeenCalled();
expect(mockRun).toHaveBeenCalledTimes(1);
});
});
describe('getAccountByEmail (findByEmail)', () => {
it('should return the matching account by email', async () => {
mockAll.mockReturnValue([createMockAccountRow()]);
const result = await CloudAccountRepo.getAccountByEmail('[email protected]');
expect(result).not.toBeNull();
expect(result?.email).toBe('[email protected]');
});
it('should return null when no account matches the email', async () => {
mockAll.mockReturnValue([]);
const result = await CloudAccountRepo.getAccountByEmail('[email protected]');
expect(result).toBeNull();
});
});
describe('getAccounts (findAll)', () => {
it('should return all accounts from the database', async () => {
mockAll.mockReturnValue([
createMockAccountRow({ id: 'acc-1', email: '[email protected]' }),
createMockAccountRow({ id: 'acc-2', email: '[email protected]' }),
]);
const result = await CloudAccountRepo.getAccounts();
expect(result).toHaveLength(2);
expect(result[0].email).toBe('[email protected]');
expect(result[1].email).toBe('[email protected]');
});
it('should auto-activate the first account when none are active', async () => {
mockAll.mockReturnValue([createMockAccountRow({ isActive: 0 })]);
const result = await CloudAccountRepo.getAccounts();
expect(mockOrm.update).toHaveBeenCalled();
expect(result[0].is_active).toBe(true);
});
});
describe('updateToken (update)', () => {
it('should modify token fields for an existing account', async () => {
mockRun.mockReturnValue({ changes: 1 });
await CloudAccountRepo.updateToken('acc-1', {
access_token: 'new-tok',
refresh_token: 'ref1',
expires_in: 3600,
expiry_timestamp: 1234567890,
token_type: 'Bearer',
});
expect(mockOrm.update).toHaveBeenCalled();
expect(mockRun).toHaveBeenCalled();
});
});
describe('removeAccount (delete)', () => {
it('should remove the account by id', async () => {
await CloudAccountRepo.removeAccount('acc-1');
expect(mockOrm.delete).toHaveBeenCalled();
expect(mockRun).toHaveBeenCalled();
});
});
describe('updateQuota', () => {
it('should update quota fields for an existing account', async () => {
mockRun.mockReturnValue({ changes: 1 });
await CloudAccountRepo.updateQuota('acc-1', { models: {} });
expect(mockOrm.update).toHaveBeenCalled();
expect(mockRun).toHaveBeenCalled();
});
});
describe('updateLastUsed', () => {
it('should update the last_used timestamp for an account', () => {
CloudAccountRepo.updateLastUsed('acc-1');
expect(mockOrm.update).toHaveBeenCalled();
expect(mockRun).toHaveBeenCalled();
});
});
});
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { loadConfig, saveConfig } from '../../../ipc/config/handlers';
import { ConfigManager } from '../../../ipc/config/manager';
import { syncAutoStart } from '../../../utils/autoStart';
import { setServerConfig } from '../../../server/server-config';
import { logger } from '../../../utils/logger';
import { DEFAULT_APP_CONFIG } from '../../../types/config';
vi.mock('../../../ipc/config/manager', () => ({
ConfigManager: {
loadConfig: vi.fn(),
getCachedConfig: vi.fn(),
saveConfig: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock('../../../utils/autoStart', () => ({
syncAutoStart: vi.fn(),
}));
vi.mock('../../../server/server-config', () => ({
setServerConfig: vi.fn(),
}));
vi.mock('../../../utils/logger', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
setErrorReportingEnabled: vi.fn(),
},
}));
describe('Config Handlers', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(ConfigManager.loadConfig).mockReturnValue(DEFAULT_APP_CONFIG);
vi.mocked(ConfigManager.getCachedConfig).mockReturnValue(null);
});
describe('loadConfig', () => {
it('should return config from ConfigManager', () => {
const config = loadConfig();
expect(config).toEqual(DEFAULT_APP_CONFIG);
expect(ConfigManager.loadConfig).toHaveBeenCalled();
});
});
describe('saveConfig', () => {
it('should save config through ConfigManager', async () => {
const newConfig = {
...DEFAULT_APP_CONFIG,
language: 'en-US',
};
await saveConfig(newConfig);
expect(ConfigManager.saveConfig).toHaveBeenCalledWith(newConfig);
});
it('should update server config after save', async () => {
const newConfig = {
...DEFAULT_APP_CONFIG,
proxy: { ...DEFAULT_APP_CONFIG.proxy, port: 9999 },
};
await saveConfig(newConfig);
expect(setServerConfig).toHaveBeenCalledWith(newConfig.proxy);
});
it('should enable error reporting when config has it enabled', async () => {
const newConfig = {
...DEFAULT_APP_CONFIG,
error_reporting_enabled: true,
};
await saveConfig(newConfig);
expect(logger.setErrorReportingEnabled).toHaveBeenCalledWith(true);
});
it('should sync auto start when auto_startup changes from false to true', async () => {
vi.mocked(ConfigManager.getCachedConfig).mockReturnValueOnce({
...DEFAULT_APP_CONFIG,
auto_startup: false,
});
const newConfig = {
...DEFAULT_APP_CONFIG,
auto_startup: true,
};
await saveConfig(newConfig);
expect(syncAutoStart).toHaveBeenCalledWith(newConfig);
});
it('should not sync auto start when auto_startup unchanged', async () => {
vi.mocked(ConfigManager.getCachedConfig).mockReturnValueOnce({
...DEFAULT_APP_CONFIG,
auto_startup: true,
});
const newConfig = {
...DEFAULT_APP_CONFIG,
auto_startup: true,
};
await saveConfig(newConfig);
expect(syncAutoStart).not.toHaveBeenCalled();
});
it('should handle nested proxy settings correctly', async () => {
const newConfig = {
...DEFAULT_APP_CONFIG,
proxy: {
...DEFAULT_APP_CONFIG.proxy,
enabled: true,
upstream_proxy: {
enabled: true,
url: 'http://proxy.example.com:8080',
},
},
};
await saveConfig(newConfig);
expect(ConfigManager.saveConfig).toHaveBeenCalledWith(newConfig);
expect(setServerConfig).toHaveBeenCalledWith(newConfig.proxy);
});
});
});
+256
View File
@@ -0,0 +1,256 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
initDatabase,
getDatabaseConnection,
getCurrentAccountInfo,
backupAccount,
restoreAccount,
} from '../../../ipc/database/handler';
import { openDrizzleConnection } from '../../../ipc/database/dbConnection';
import { getGeminiNexusDbPaths } from '../../../utils/paths';
import fs from 'fs';
const mockClose = vi.fn();
const mockRun = vi.fn();
function createMockOrm(rows: Record<string, unknown>[] = []) {
let callIndex = 0;
const chain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
all: vi.fn().mockImplementation(() => {
const row = rows[callIndex] ?? null;
callIndex++;
return row ? [row] : [];
}),
values: vi.fn().mockReturnThis(),
onConflictDoUpdate: vi.fn().mockReturnThis(),
run: mockRun,
};
return {
select: vi.fn().mockReturnValue(chain),
insert: vi.fn().mockReturnValue(chain),
transaction: vi.fn().mockImplementation((fn: (...args: any[]) => any) => {
const tx = {
insert: vi.fn().mockReturnValue(chain),
};
return fn(tx);
}),
};
}
vi.mock('../../../utils/logger', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock('../../../utils/paths', () => ({
getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/test.vscdb']),
}));
vi.mock('../../../ipc/database/dbConnection', () => ({
openDrizzleConnection: vi.fn().mockImplementation(() => ({
raw: { close: mockClose },
orm: createMockOrm(),
})),
}));
vi.mock('fs', () => ({
default: {
existsSync: vi.fn().mockReturnValue(true),
mkdirSync: vi.fn(),
},
existsSync: vi.fn().mockReturnValue(true),
mkdirSync: vi.fn(),
}));
vi.mock('../../../utils/sqlite', () => ({
parseRow: vi.fn().mockImplementation((_schema, row) => row ?? null),
}));
vi.mock('../../../ipc/database/schema', () => ({
itemTable: {},
}));
vi.mock('drizzle-orm', () => ({
eq: vi.fn().mockReturnValue({}),
}));
describe('Database Handler', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getGeminiNexusDbPaths).mockReturnValue(['/tmp/test.vscdb']);
vi.mocked(openDrizzleConnection).mockImplementation(() => ({
raw: { close: mockClose },
orm: createMockOrm() as any,
}));
vi.mocked(fs.existsSync).mockReturnValue(true);
});
describe('initDatabase', () => {
it('should initialize database without error', () => {
expect(() => initDatabase()).not.toThrow();
});
it('should return early when no db paths exist', () => {
vi.mocked(getGeminiNexusDbPaths).mockReturnValueOnce([]);
expect(() => initDatabase()).not.toThrow();
});
});
describe('getDatabaseConnection', () => {
it('should create connection when db exists', () => {
const conn = getDatabaseConnection();
expect(conn).toBeDefined();
expect(conn.raw).toBeDefined();
expect(conn.orm).toBeDefined();
});
it('should close connection when done', () => {
const conn = getDatabaseConnection();
conn.raw.close();
expect(mockClose).toHaveBeenCalled();
});
it('should throw when no database path is found', () => {
vi.mocked(getGeminiNexusDbPaths).mockReturnValueOnce([]);
expect(() => getDatabaseConnection()).toThrow('No Gemini Nexus database path found');
});
it('should throw SQLITE_BUSY error as user-friendly message', () => {
vi.mocked(openDrizzleConnection).mockImplementationOnce(() => {
const err = new Error('Database is locked') as Error & { code: string };
err.code = 'SQLITE_BUSY';
throw err;
});
expect(() => getDatabaseConnection()).toThrow('Database is locked. Please close Gemini Nexus before proceeding.');
});
it('should throw SQLITE_LOCKED error as user-friendly message', () => {
vi.mocked(openDrizzleConnection).mockImplementationOnce(() => {
const err = new Error('Database is locked') as Error & { code: string };
err.code = 'SQLITE_LOCKED';
throw err;
});
expect(() => getDatabaseConnection()).toThrow('Database is locked. Please close Gemini Nexus before proceeding.');
});
});
describe('getCurrentAccountInfo', () => {
it('should return authenticated account info from geminiNexusAuthStatus', () => {
vi.mocked(openDrizzleConnection).mockReturnValueOnce({
raw: { close: mockClose },
orm: createMockOrm([
{ value: JSON.stringify({ user: { email: '[email protected]', name: 'Auth User' } }) },
]) as any,
});
const info = getCurrentAccountInfo();
expect(info.email).toBe('[email protected]');
expect(info.name).toBe('Auth User');
expect(info.isAuthenticated).toBe(true);
expect(mockClose).toHaveBeenCalled();
});
it('should fallback to jetskiStateSync.agentManagerInitState', () => {
vi.mocked(openDrizzleConnection).mockReturnValueOnce({
raw: { close: mockClose },
orm: createMockOrm([
{ value: null },
{ value: JSON.stringify({ user: { email: '[email protected]', name: 'Init User' } }) },
]) as any,
});
const info = getCurrentAccountInfo();
expect(info.email).toBe('[email protected]');
expect(info.name).toBe('Init User');
expect(info.isAuthenticated).toBe(true);
});
it('should return empty info when no keys found', () => {
vi.mocked(openDrizzleConnection).mockReturnValueOnce({
raw: { close: mockClose },
orm: createMockOrm([{ value: null }, { value: null }, { value: null }, { value: null }]) as any,
});
const info = getCurrentAccountInfo();
expect(info.email).toBe('');
expect(info.name).toBe('');
expect(info.isAuthenticated).toBe(false);
});
});
describe('backupAccount', () => {
it('should backup account data with metadata', () => {
vi.mocked(openDrizzleConnection).mockReturnValueOnce({
raw: { close: mockClose },
orm: createMockOrm([
{ value: JSON.stringify({ status: 'active' }) },
{ value: JSON.stringify({ state: 'ready' }) },
{ value: JSON.stringify({ token: 'abc' }) },
]) as any,
});
const account = {
id: 'acc-1',
name: 'Test',
email: '[email protected]',
created_at: new Date().toISOString(),
last_used: new Date().toISOString(),
};
const backup = backupAccount(account);
expect(backup.version).toBe('1.0');
expect(backup.account).toEqual(account);
expect(backup.data['geminiNexusAuthStatus']).toEqual({ status: 'active' });
expect(backup.data['jetskiStateSync.agentManagerInitState']).toEqual({ state: 'ready' });
expect(backup.data['geminiNexusUnifiedStateSync.oauthToken']).toEqual({ token: 'abc' });
expect(backup.data['account_email']).toBe('[email protected]');
expect(backup.data['backup_time']).toBeDefined();
expect(mockClose).toHaveBeenCalled();
});
});
describe('restoreAccount', () => {
it('should restore keys to existing database', () => {
vi.mocked(openDrizzleConnection).mockReturnValueOnce({
raw: { close: mockClose },
orm: createMockOrm() as any,
});
const backup = {
version: '1.0',
account: {
id: 'acc-1',
name: 'Test',
email: '[email protected]',
created_at: new Date().toISOString(),
last_used: new Date().toISOString(),
},
data: {
geminiNexusAuthStatus: { status: 'restored' },
'jetskiStateSync.agentManagerInitState': { state: 'restored' },
},
};
expect(() => restoreAccount(backup as any)).not.toThrow();
expect(mockRun).toHaveBeenCalled();
expect(mockClose).toHaveBeenCalled();
});
it('should throw when no database paths found', () => {
vi.mocked(getGeminiNexusDbPaths).mockReturnValueOnce([]);
expect(() =>
restoreAccount({
version: '1.0',
account: {} as any,
data: {},
}),
).toThrow('No Gemini Nexus database paths found');
});
});
});
@@ -0,0 +1,337 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
getRecentTrafficLogs,
getTrafficLogStats,
exportTrafficLogs,
getParityCounters,
getCircuitBreakerStatus,
getProxyMetrics,
getRecentRequests,
replayRequest,
getModelCapabilities,
generateIdeConfig,
} from '../../../ipc/proxy-advanced/handler';
import { TrafficLogsRepo } from '../../../ipc/database/proxyMetricsHandler';
import { proxyAdvancedRegistry } from '../../../ipc/proxy-advanced/service-registry';
vi.mock('../../../utils/logger', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock('../../../ipc/database/proxyMetricsHandler', () => ({
TrafficLogsRepo: {
getRecent: vi.fn().mockReturnValue([]),
getStats: vi.fn().mockReturnValue({ byDirection: {}, byEndpoint: {}, total: 0 }),
getAllForExport: vi.fn().mockReturnValue([]),
},
}));
vi.mock('../../../ipc/proxy-advanced/service-registry', () => ({
proxyAdvancedRegistry: {
tokenManager: null,
proxyMetrics: null,
proxyReplay: null,
proxyIdeConfig: null,
},
}));
describe('Proxy Advanced Handler', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getRecentTrafficLogs', () => {
it('should return empty logs when no records', async () => {
const result = await getRecentTrafficLogs();
expect(result.success).toBe(true);
expect(result.data).toEqual([]);
});
it('should return parsed logs with default limit', async () => {
vi.mocked(TrafficLogsRepo.getRecent).mockReturnValueOnce([
{
timestamp: Date.now(),
direction: 'outbound',
requestId: 'req-1',
endpoint: '/v1/chat',
method: 'POST',
status: 200,
headers: { 'content-type': 'application/json' },
body: { message: 'hello' },
durationMs: 120,
},
]);
const result = await getRecentTrafficLogs();
expect(result.success).toBe(true);
expect(result.data).toHaveLength(1);
expect(result.data?.[0].direction).toBe('outbound');
expect(result.data?.[0].endpoint).toBe('/v1/chat');
});
it('should filter by direction when provided', async () => {
await getRecentTrafficLogs(10, 'inbound');
expect(TrafficLogsRepo.getRecent).toHaveBeenCalledWith(10, 'inbound');
});
it('should handle errors gracefully', async () => {
vi.mocked(TrafficLogsRepo.getRecent).mockImplementationOnce(() => {
throw new Error('DB error');
});
const result = await getRecentTrafficLogs();
expect(result.success).toBe(false);
expect(result.error).toBe('DB error');
});
});
describe('getTrafficLogStats', () => {
it('should return stats from repo', async () => {
vi.mocked(TrafficLogsRepo.getStats).mockReturnValueOnce({
byDirection: { outbound: 5, inbound: 3 },
byEndpoint: { '/v1/chat': 8 },
total: 8,
});
const result = await getTrafficLogStats();
expect(result.success).toBe(true);
expect(result.data?.total).toBe(8);
expect(result.data?.byDirection.outbound).toBe(5);
});
it('should handle errors gracefully', async () => {
vi.mocked(TrafficLogsRepo.getStats).mockImplementationOnce(() => {
throw new Error('stats error');
});
const result = await getTrafficLogStats();
expect(result.success).toBe(false);
expect(result.error).toBe('stats error');
});
});
describe('exportTrafficLogs', () => {
it('should export as JSON', async () => {
vi.mocked(TrafficLogsRepo.getAllForExport).mockReturnValueOnce([
{ timestamp: 1000, direction: 'outbound', requestId: 'r1', endpoint: '/chat' },
]);
const result = await exportTrafficLogs('json');
expect(result.success).toBe(true);
const parsed = JSON.parse(result.data ?? '[]');
expect(parsed).toHaveLength(1);
});
it('should export as CSV', async () => {
vi.mocked(TrafficLogsRepo.getAllForExport).mockReturnValueOnce([
{ timestamp: 1000, direction: 'outbound', requestId: 'r1', endpoint: '/chat', method: 'POST', status: 200, durationMs: 50 },
]);
const result = await exportTrafficLogs('csv');
expect(result.success).toBe(true);
expect(result.data).toContain('timestamp,direction,requestId,endpoint,method,status,durationMs');
expect(result.data).toContain('outbound');
});
it('should handle errors gracefully', async () => {
vi.mocked(TrafficLogsRepo.getAllForExport).mockImplementationOnce(() => {
throw new Error('export error');
});
const result = await exportTrafficLogs('json');
expect(result.success).toBe(false);
expect(result.error).toBe('export error');
});
});
describe('getParityCounters', () => {
it('should return zero counters when tokenManager is null', async () => {
const result = await getParityCounters();
expect(result.success).toBe(true);
expect(result.data?.totalRequests).toBe(0);
expect(result.data?.matchedRequests).toBe(0);
expect(result.data?.parityViolations).toBe(0);
});
it('should return counters from tokenManager', async () => {
vi.mocked(proxyAdvancedRegistry).tokenManager = {
getParityCounters: vi.fn().mockReturnValue({
totalRequests: 10,
matchedRequests: 8,
parityViolations: 2,
lastUpdated: new Date().toISOString(),
}),
} as any;
const result = await getParityCounters();
expect(result.success).toBe(true);
expect(result.data?.totalRequests).toBe(10);
vi.mocked(proxyAdvancedRegistry).tokenManager = null;
});
});
describe('getCircuitBreakerStatus', () => {
it('should return empty states when tokenManager is null', async () => {
const result = await getCircuitBreakerStatus();
expect(result.success).toBe(true);
expect(result.data?.states).toEqual({});
expect(result.data?.globalEnabled).toBe(true);
});
it('should return mapped status from tokenManager', async () => {
vi.mocked(proxyAdvancedRegistry).tokenManager = {
getCircuitBreakerStatus: vi.fn().mockReturnValue({
acc1: { state: 'open', failureCount: 3, lastFailureTime: Date.now() },
}),
} as any;
const result = await getCircuitBreakerStatus();
expect(result.success).toBe(true);
expect(result.data?.states.acc1.state).toBe('open');
expect(result.data?.states.acc1.failures).toBe(3);
vi.mocked(proxyAdvancedRegistry).tokenManager = null;
});
});
describe('getProxyMetrics', () => {
it('should return zero metrics when proxyMetrics is null', async () => {
const result = await getProxyMetrics();
expect(result.success).toBe(true);
expect(result.data?.totalRequests).toBe(0);
expect(result.data?.activeConnections).toBe(0);
});
it('should return metrics from proxyMetrics service', async () => {
vi.mocked(proxyAdvancedRegistry).proxyMetrics = {
getMetrics: vi.fn().mockReturnValue({
totalRequests: 100,
activeConnections: 5,
avgLatency: 120,
errorRate: 0.02,
requestsPerMinute: 10,
cacheHitRate: 0.8,
}),
} as any;
const result = await getProxyMetrics();
expect(result.success).toBe(true);
expect(result.data?.totalRequests).toBe(100);
expect(result.data?.avgLatency).toBe(120);
vi.mocked(proxyAdvancedRegistry).proxyMetrics = null;
});
});
describe('getRecentRequests', () => {
it('should return empty array when proxyReplay is null', async () => {
const result = await getRecentRequests();
expect(result.success).toBe(true);
expect(result.data).toEqual([]);
});
it('should return recent requests from proxyReplay', async () => {
vi.mocked(proxyAdvancedRegistry).proxyReplay = {
getRecentRequests: vi.fn().mockReturnValue([{ id: 'req-1' }]),
} as any;
const result = await getRecentRequests();
expect(result.success).toBe(true);
expect(result.data).toHaveLength(1);
vi.mocked(proxyAdvancedRegistry).proxyReplay = null;
});
});
describe('replayRequest', () => {
it('should return error when proxyReplay is null', async () => {
const result = await replayRequest('req-1');
expect(result.success).toBe(false);
expect(result.error).toBe('Replay service not available');
});
it('should replay request through proxyReplay', async () => {
vi.mocked(proxyAdvancedRegistry).proxyReplay = {
replayRequest: vi.fn().mockResolvedValue({
original: { id: 'req-1' },
newResponse: { status: 200 },
}),
} as any;
const result = await replayRequest('req-1');
expect(result.success).toBe(true);
expect(result.data?.original).toEqual({ id: 'req-1' });
vi.mocked(proxyAdvancedRegistry).proxyReplay = null;
});
});
describe('getModelCapabilities', () => {
it('should return capabilities for all models', async () => {
const result = await getModelCapabilities();
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
expect(result.data!.length).toBeGreaterThan(0);
const model = result.data!.find((m) => m.id === 'gemini-3-flash');
expect(model).toBeDefined();
expect(model!.capabilities.vision).toBe(true);
expect(model!.capabilities.streaming).toBe(true);
});
});
describe('generateIdeConfig', () => {
it('should return error when proxyIdeConfig is null', async () => {
const result = await generateIdeConfig('vscode');
expect(result.success).toBe(false);
expect(result.error).toBe('IDE config service not available');
});
it('should return error for unsupported IDE', async () => {
vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = {} as any;
const result = await generateIdeConfig('unknown-ide');
expect(result.success).toBe(false);
expect(result.error).toContain('Unsupported IDE');
vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = null;
});
it('should generate config for VS Code', async () => {
vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = {
generateConfigForCurrentServer: vi.fn().mockReturnValue({
ide: 'vscode',
content: null,
}),
} as any;
const result = await generateIdeConfig('vscode');
expect(result.success).toBe(true);
expect(result.data?.name).toBe('VS Code');
expect(result.data?.proxySetting).toBe('openai.url');
vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = null;
});
it('should generate config for Cursor', async () => {
vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = {
generateConfigForCurrentServer: vi.fn().mockReturnValue({
ide: 'cursor',
content: null,
}),
} as any;
const result = await generateIdeConfig('cursor');
expect(result.success).toBe(true);
expect(result.data?.name).toBe('Cursor');
expect(result.data?.proxySetting).toBe('OPENAI_BASE_URL');
vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = null;
});
it('should handle null result from config generator', async () => {
vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = {
generateConfigForCurrentServer: vi.fn().mockReturnValue(null),
} as any;
const result = await generateIdeConfig('vscode');
expect(result.success).toBe(false);
expect(result.error).toBe('Failed to generate IDE config: server not configured');
vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = null;
});
});
});
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ProxyMetricsRepo } from '../../../ipc/database/proxyMetricsHandler';
const mockRun = vi.fn();
const mockAll = vi.fn();
const mockGet = vi.fn();
const mockClose = vi.fn();
const mockPrepare = vi.fn(() => ({ run: mockRun, all: mockAll, get: mockGet }));
const mockOrm = {
insert: vi.fn(() => ({
values: vi.fn(() => ({ run: mockRun })),
})),
};
vi.mock('../../../ipc/database/dbConnection', () => ({
openDrizzleConnection: vi.fn(() => ({
raw: { prepare: mockPrepare, close: mockClose, exec: vi.fn() },
orm: mockOrm,
})),
}));
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('ProxyMetricsRepo', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('recordRequest (record)', () => {
it('should insert proxy request metrics into the database', () => {
const params = {
endpoint: '/v1/chat',
statusCode: 200,
durationMs: 150,
timestamp: Date.now(),
accountId: 'acc-1',
tokensPrompt: 10,
tokensCompletion: 20,
errorMessage: null,
};
const result = ProxyMetricsRepo.recordRequest(params);
expect(result).toBe(true);
expect(mockOrm.insert).toHaveBeenCalled();
expect(mockRun).toHaveBeenCalled();
});
it('should return false when recording fails', () => {
mockRun.mockImplementationOnce(() => {
throw new Error('DB error');
});
const result = ProxyMetricsRepo.recordRequest({
endpoint: '/v1/chat',
statusCode: 500,
durationMs: 100,
timestamp: Date.now(),
});
expect(result).toBe(false);
});
});
describe('getRecentAggregates (getRecent)', () => {
it('should return time-filtered aggregate metrics', () => {
mockGet
.mockReturnValueOnce({ count: 5 })
.mockReturnValueOnce({ total: 10, avgLatency: 120, errorRate: 10 });
const result = ProxyMetricsRepo.getRecentAggregates();
expect(mockPrepare).toHaveBeenCalledTimes(2);
expect(result.requestsPerMinute).toBe(5);
expect(result.avgLatencyMs).toBe(120);
expect(result.errorRatePercent).toBe(10);
});
it('should return zeros when no recent data exists', () => {
mockGet.mockReturnValueOnce(undefined).mockReturnValueOnce(undefined);
const result = ProxyMetricsRepo.getRecentAggregates();
expect(result.requestsPerMinute).toBe(0);
expect(result.avgLatencyMs).toBe(0);
expect(result.errorRatePercent).toBe(0);
});
});
describe('getHistoricalAggregates (getAggregate)', () => {
it('should return summary aggregates for the given window', () => {
mockGet
.mockReturnValueOnce({ total: 100, avgLatency: 200, errorRate: 5 })
.mockReturnValueOnce({ avg: 12 });
const result = ProxyMetricsRepo.getHistoricalAggregates();
expect(mockPrepare).toHaveBeenCalledTimes(2);
expect(result.totalRequests).toBe(100);
expect(result.avgLatencyMs).toBe(200);
expect(result.errorRatePercent).toBe(5);
expect(result.avgActiveConnections).toBe(12);
});
it('should return zeros when an error occurs', () => {
mockPrepare.mockImplementationOnce(() => {
throw new Error('DB error');
});
const result = ProxyMetricsRepo.getHistoricalAggregates();
expect(result.totalRequests).toBe(0);
expect(result.avgLatencyMs).toBe(0);
expect(result.errorRatePercent).toBe(0);
expect(result.avgActiveConnections).toBe(0);
});
});
describe('deleteOldRecords (cleanup)', () => {
it('should remove old records from metrics and snapshots tables', () => {
const result = ProxyMetricsRepo.deleteOldRecords(Date.now() - 86400000);
expect(result).toBe(true);
expect(mockPrepare).toHaveBeenCalledTimes(2);
expect(mockRun).toHaveBeenCalledTimes(2);
});
it('should return false when cleanup fails', () => {
mockRun.mockImplementationOnce(() => {
throw new Error('DB error');
});
const result = ProxyMetricsRepo.deleteOldRecords(0);
expect(result).toBe(false);
});
});
});
+70
View File
@@ -0,0 +1,70 @@
import { vi } from 'vitest';
/**
* Mock Database class for better-sqlite3.
* Use this to create in-memory-like database behavior for tests.
*/
export class MockDatabase {
data: Map<string, any[]> = new Map();
prepare = vi.fn().mockImplementation((sql: string) => {
const table = this._extractTable(sql);
return {
run: vi.fn().mockImplementation((params: any) => {
const rows = this.data.get(table) || [];
if (sql.toLowerCase().includes('insert')) {
rows.push(params);
this.data.set(table, rows);
return { lastInsertRowid: rows.length, changes: 1 };
}
if (sql.toLowerCase().includes('update')) {
return { changes: 1 };
}
if (sql.toLowerCase().includes('delete')) {
return { changes: 1 };
}
return { changes: 0 };
}),
get: vi.fn().mockImplementation((params: any) => {
const rows = this.data.get(table) || [];
return rows.find((row) =>
Object.keys(params).every((key) => row[key] === params[key]),
);
}),
all: vi.fn().mockImplementation((params?: any) => {
const rows = this.data.get(table) || [];
if (!params) return rows;
return rows.filter((row) =>
Object.keys(params).every((key) => row[key] === params[key]),
);
}),
};
});
exec = vi.fn().mockImplementation((sql: string) => {
// Mock schema creation / raw execution
});
transaction = vi.fn().mockImplementation((fn: (...args: any[]) => any) => {
return (...args: any[]) => fn(...args);
});
close = vi.fn();
private _extractTable(sql: string): string {
const lower = sql.toLowerCase();
const match =
lower.match(/from\s+(\w+)/) ||
lower.match(/into\s+(\w+)/) ||
lower.match(/update\s+(\w+)/) ||
lower.match(/table\s+(\w+)/);
return match ? match[1] : 'default';
}
}
/**
* Factory for creating a fresh mock DB instance.
*/
export function createMockDb() {
return new MockDatabase();
}
+88
View File
@@ -0,0 +1,88 @@
import { vi } from 'vitest';
/**
* Creates a mock Electron IPC main handle function.
*/
export function createIpcMock() {
const handlers = new Map<string, (...args: any[]) => any>();
return {
handle: vi.fn((channel: string, handler: (...args: any[]) => any) => {
handlers.set(channel, handler);
}),
handleOnce: vi.fn((channel: string, handler: (...args: any[]) => any) => {
handlers.set(channel, handler);
}),
removeHandler: vi.fn((channel: string) => {
handlers.delete(channel);
}),
invoke: vi.fn(async (channel: string, ...args: any[]) => {
const handler = handlers.get(channel);
if (!handler) {
throw new Error(`No handler registered for channel: ${channel}`);
}
return handler(...args);
}),
send: vi.fn(),
on: vi.fn(),
once: vi.fn(),
off: vi.fn(),
_handlers: handlers,
};
}
/**
* Mock Electron BrowserWindow.
*/
export function createWindowMock() {
return {
id: 1,
webContents: {
send: vi.fn(),
isDestroyed: vi.fn().mockReturnValue(false),
},
isDestroyed: vi.fn().mockReturnValue(false),
isVisible: vi.fn().mockReturnValue(true),
show: vi.fn(),
hide: vi.fn(),
close: vi.fn(),
focus: vi.fn(),
blur: vi.fn(),
minimize: vi.fn(),
maximize: vi.fn(),
restore: vi.fn(),
setSize: vi.fn(),
getSize: vi.fn().mockReturnValue([1200, 800]),
setPosition: vi.fn(),
getPosition: vi.fn().mockReturnValue([0, 0]),
on: vi.fn(),
once: vi.fn(),
removeListener: vi.fn(),
};
}
/**
* Mock app module.
*/
export function createAppMock() {
return {
getPath: vi.fn((name: string) => {
const paths: Record<string, string> = {
userData: '/tmp/test-user-data',
appData: '/tmp/test-app-data',
home: '/tmp/test-home',
temp: '/tmp',
logs: '/tmp/logs',
};
return paths[name] || `/tmp/${name}`;
}),
getVersion: vi.fn().mockReturnValue('1.0.0-test'),
getName: vi.fn().mockReturnValue('GeminiNexusTest'),
isPackaged: false,
on: vi.fn(),
once: vi.fn(),
quit: vi.fn(),
exit: vi.fn(),
whenReady: vi.fn().mockResolvedValue(undefined),
};
}
+19
View File
@@ -0,0 +1,19 @@
export function getPassword(service: string, account: string): Promise<string | null> {
return Promise.resolve(`mock-password-for-${account}`);
}
export function setPassword(service: string, account: string, password: string): Promise<void> {
return Promise.resolve();
}
export function deletePassword(service: string, account: string): Promise<void> {
return Promise.resolve();
}
export function findPassword(service: string): Promise<string | null> {
return Promise.resolve('mock-found-password');
}
export function findCredentials(service: string): Promise<Array<{ account: string; password: string }>> {
return Promise.resolve([]);
}
+28
View File
@@ -0,0 +1,28 @@
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
/**
* Creates a test QueryClient with disabled retries and cache.
*/
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
staleTime: 0,
},
mutations: {
retry: false,
},
},
});
}
/**
* Wrapper component for tests using TanStack Query.
*/
export function TestQueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = React.useState(() => createTestQueryClient());
return React.createElement(QueryClientProvider, { client: queryClient }, children);
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock find-process module
vi.mock('find-process', () => ({
default: vi.fn(),
ProcessInfo: class {},
}));
// Mock logger to avoid console output during tests
@@ -47,7 +48,7 @@ describe('Process Handler', () => {
const result = await isProcessRunning();
expect(result).toBe(true);
expect(mockFindProcess).toHaveBeenCalledWith('name', 'Gemini Nexus', true);
expect(mockFindProcess).toHaveBeenCalledWith('name', 'Gemini Nexus', false);
});
it('should return false when only helper processes are found', async () => {
@@ -11,14 +11,19 @@ function createReplyMock() {
return reply;
}
function createReqMock() {
return { headers: {} } as any;
}
describe('ProxyController Integration', () => {
it('routes Claude OpenAI requests to protocol parity path', async () => {
const proxyService = {
handleChatCompletions: vi.fn().mockResolvedValue({ ok: true }),
handleAnthropicMessages: vi.fn(),
};
const controller = new ProxyController(proxyService as any);
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock();
const req = createReqMock();
await controller.chatCompletions(
{
@@ -26,6 +31,7 @@ describe('ProxyController Integration', () => {
stream: false,
messages: [{ role: 'user', content: 'hello' }],
} as any,
req,
reply as any,
);
@@ -40,8 +46,9 @@ describe('ProxyController Integration', () => {
handleChatCompletions: vi.fn().mockResolvedValue(stream),
handleAnthropicMessages: vi.fn(),
};
const controller = new ProxyController(proxyService as any);
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock();
const req = createReqMock();
await controller.chatCompletions(
{
@@ -49,6 +56,7 @@ describe('ProxyController Integration', () => {
stream: true,
messages: [{ role: 'user', content: 'hello' }],
} as any,
req,
reply as any,
);
@@ -83,8 +91,9 @@ describe('ProxyController Integration', () => {
}),
handleAnthropicMessages: vi.fn(),
};
const controller = new ProxyController(proxyService as any);
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock();
const req = createReqMock();
await controller.completions(
{
@@ -92,6 +101,7 @@ describe('ProxyController Integration', () => {
prompt: 'hello world',
stream: false,
},
req,
reply as any,
);
@@ -100,6 +110,7 @@ describe('ProxyController Integration', () => {
model: 'gpt-4o',
messages: [{ role: 'user', content: 'hello world' }],
}),
{},
);
expect(reply.status).toHaveBeenCalledWith(200);
expect(reply.send).toHaveBeenCalledWith(
@@ -140,8 +151,9 @@ describe('ProxyController Integration', () => {
}),
};
const controller = new ProxyController(proxyService as any);
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock();
const req = createReqMock();
await controller.responses(
{
@@ -166,6 +178,7 @@ describe('ProxyController Integration', () => {
},
],
},
req,
reply as any,
);
@@ -206,8 +219,9 @@ describe('ProxyController Integration', () => {
handleChatCompletions: vi.fn().mockResolvedValue(stream),
};
const controller = new ProxyController(proxyService as any);
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock();
const req = createReqMock();
await controller.responses(
{
@@ -216,6 +230,7 @@ describe('ProxyController Integration', () => {
input: 'hello',
stream: true,
},
req,
reply as any,
);
@@ -237,8 +252,9 @@ describe('ProxyController Integration', () => {
}),
};
const controller = new ProxyController(proxyService as any);
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock();
const req = createReqMock();
await controller.responses(
{
@@ -256,6 +272,7 @@ describe('ProxyController Integration', () => {
},
],
},
req,
reply as any,
);
@@ -283,14 +300,16 @@ describe('ProxyController Integration', () => {
],
}),
};
const controller = new ProxyController(proxyService as any);
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock();
const req = createReqMock();
await controller.imageGenerations(
{
model: 'gemini-3-pro-image',
prompt: 'draw a cat',
},
req,
reply as any,
);
@@ -310,14 +329,16 @@ describe('ProxyController Integration', () => {
const proxyService = {
handleChatCompletions: vi.fn().mockRejectedValue(new Error('429 quota exceeded')),
};
const controller = new ProxyController(proxyService as any);
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock();
const req = createReqMock();
await controller.imageGenerations(
{
model: 'gemini-3-pro-image',
prompt: 'draw a dog',
},
req,
reply as any,
);
@@ -350,14 +371,16 @@ describe('ProxyController Integration', () => {
],
}),
};
const controller = new ProxyController(proxyService as any);
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock();
const req = createReqMock();
await controller.imageGenerations(
{
model: 'gemini-3-pro-image',
prompt: 'draw a fox',
},
req,
reply as any,
);
@@ -387,7 +410,7 @@ describe('ProxyController Integration', () => {
],
}),
};
const controller = new ProxyController(proxyService as any);
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock();
await controller.imageEdits(
@@ -414,7 +437,7 @@ describe('ProxyController Integration', () => {
const proxyService = {
handleChatCompletions: vi.fn(),
};
const controller = new ProxyController(proxyService as any);
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock();
await controller.imageEdits(
@@ -448,7 +471,7 @@ describe('ProxyController Integration', () => {
],
}),
};
const controller = new ProxyController(proxyService as any);
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock();
await controller.audioTranscriptions(
@@ -473,7 +496,7 @@ describe('ProxyController Integration', () => {
const proxyService = {
handleGeminiGenerateContent: vi.fn(),
};
const controller = new ProxyController(proxyService as any);
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock();
await controller.audioTranscriptions(
@@ -501,8 +524,9 @@ describe('ProxyController Integration', () => {
type: 'message',
}),
};
const controller = new ProxyController(proxyService as any);
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock();
const req = createReqMock();
await controller.anthropicMessages(
{
@@ -510,6 +534,7 @@ describe('ProxyController Integration', () => {
stream: false,
messages: [{ role: 'user', content: 'hello' }],
} as any,
req,
reply as any,
);
+2 -1
View File
@@ -15,10 +15,11 @@ const mockTokenManager = {
recordParityError: vi.fn(),
};
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
const mockTokenUsageService = { recordUsage: vi.fn() };
class TestableProxyService extends ProxyService {
constructor() {
super(mockTokenManager as any, mockGeminiClient as any);
super(mockTokenManager as any, mockGeminiClient as any, mockTokenUsageService as any);
}
public toAnthropic(request: any): any {
+5 -2
View File
@@ -16,16 +16,17 @@ const mockTokenManager = {
resolveDynamicModelForAccount: vi.fn((_token: unknown, model: string) => model),
};
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
const mockTokenUsageService = { recordUsage: vi.fn() };
// Subclass to access private method
class TestableProxyService extends ProxyService {
constructor() {
super(mockTokenManager as any, mockGeminiClient as any);
super(mockTokenManager as any, mockGeminiClient as any, mockTokenUsageService as any);
}
public testProcessStream(stream: any, model: string = 'model'): Observable<string> {
// Access private method using type assertion
return (this as any).processAnthropicInternalStream(stream, model);
return (this as any).processAnthropicInternalStream(stream, model, 'acc-test');
}
public testPassthroughStream(stream: any): Observable<string> {
@@ -648,6 +649,7 @@ describe('ProxyService Protocol Parity Fixtures', () => {
});
const output = chunks.join('');
expect(output).toContain('"role":"assistant"');
expect(output).toContain('"reasoning_content":"reasoning text"');
expect(output).toContain('"tool_calls"');
expect(output).toContain('"content":"final answer"');
@@ -689,6 +691,7 @@ describe('ProxyService Protocol Parity Fixtures', () => {
expect(streamResult.completed).not.toBe(true);
expect(streamResult.error?.message).toContain('socket hang up');
expect(chunks.join('')).toContain('"role":"assistant"');
expect(chunks.join('')).toContain('"content":"partial output"');
expect(chunks.join('')).not.toContain('data: [DONE]');
});
+330
View File
@@ -0,0 +1,330 @@
import { describe, it, expect, vi } from 'vitest';
import { EventEmitter } from 'events';
import { ProxyService } from '../../server/modules/proxy/proxy.service';
// Minimal mocks so ProxyService can instantiate
const mockTokenManager = {
getNextToken: vi.fn(),
markAsRateLimited: vi.fn(),
markAsForbidden: vi.fn(),
forceRefreshToken: vi.fn(),
markFromUpstreamError: vi.fn(),
recordParityError: vi.fn(),
getModelOutputLimitForAccount: vi.fn(),
getModelThinkingBudgetForAccount: vi.fn(),
resolveDynamicModelForAccount: vi.fn((_token: unknown, model: string) => model),
};
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
const mockTokenUsageService = { recordUsage: vi.fn() };
class TestableProxyService extends ProxyService {
constructor() {
super(mockTokenManager as any, mockGeminiClient as any, mockTokenUsageService as any);
}
public testProcessStreamResponse(
stream: any,
model: string = 'gemini-3.1-pro-low',
requestId?: string,
) {
return (this as any).processStreamResponse(stream, model, requestId);
}
}
function collectChunks(observable: any): Promise<string[]> {
return new Promise((resolve, reject) => {
const chunks: string[] = [];
observable.subscribe({
next: (chunk: string) => chunks.push(chunk),
error: reject,
complete: () => resolve(chunks),
});
});
}
function emitSseLine(stream: EventEmitter, payload: object): void {
stream.emit('data', Buffer.from(`data: ${JSON.stringify(payload)}\n`));
}
describe('ProxyService Stream Debug', () => {
it('emits role chunk followed by content for a simple text response', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
const promise = collectChunks(observable);
emitSseLine(stream, {
candidates: [
{
content: {
parts: [{ text: 'Hello world' }],
},
},
],
});
emitSseLine(stream, {
candidates: [
{
content: {
parts: [{ text: '!' }],
},
finishReason: 'STOP',
},
],
});
stream.emit('end');
const chunks = await promise;
console.log('=== Simple text response chunks ===');
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
// Should have at least role + content + finish + DONE
expect(chunks.length).toBeGreaterThanOrEqual(4);
expect(chunks[0]).toContain('"role":"assistant"');
expect(chunks.some((c) => c.includes('"content":"Hello world"'))).toBe(true);
expect(chunks.some((c) => c.includes('"finish_reason":"stop"'))).toBe(true);
expect(chunks[chunks.length - 1]).toContain('[DONE]');
});
it('handles empty parts array gracefully', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
const promise = collectChunks(observable);
emitSseLine(stream, {
candidates: [
{
content: {
parts: [],
},
finishReason: 'STOP',
},
],
});
stream.emit('end');
const chunks = await promise;
console.log('=== Empty parts response chunks ===');
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
// With empty parts, only finish + DONE should be emitted
expect(chunks.length).toBeGreaterThanOrEqual(2);
expect(chunks.some((c) => c.includes('"finish_reason":"stop"'))).toBe(true);
expect(chunks[chunks.length - 1]).toContain('[DONE]');
});
it('handles reasoning content (thought parts)', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
const promise = collectChunks(observable);
emitSseLine(stream, {
candidates: [
{
content: {
parts: [{ thought: true, text: 'Let me think...' }],
},
},
],
});
emitSseLine(stream, {
candidates: [
{
content: {
parts: [{ text: 'Final answer' }],
},
finishReason: 'STOP',
},
],
});
stream.emit('end');
const chunks = await promise;
console.log('=== Reasoning response chunks ===');
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
expect(chunks.some((c) => c.includes('"reasoning_content":"Let me think..."'))).toBe(true);
expect(chunks.some((c) => c.includes('"content":"Final answer"'))).toBe(true);
});
it('handles function call parts', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
const promise = collectChunks(observable);
emitSseLine(stream, {
candidates: [
{
content: {
parts: [
{
functionCall: {
id: 'fc-1',
name: 'get_weather',
args: { city: 'London' },
},
},
],
},
finishReason: 'STOP',
},
],
});
stream.emit('end');
const chunks = await promise;
console.log('=== Function call response chunks ===');
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
expect(chunks.some((c) => c.includes('"tool_calls"'))).toBe(true);
expect(chunks.some((c) => c.includes('"finish_reason":"tool_calls"'))).toBe(true);
});
it('handles multiple SSE lines in a single buffer', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
const promise = collectChunks(observable);
// Simulate two complete SSE lines in one data chunk
const payload1 = JSON.stringify({
candidates: [{ content: { parts: [{ text: 'First' }] } }],
});
const payload2 = JSON.stringify({
candidates: [{ content: { parts: [{ text: 'Second' }] }, finishReason: 'STOP' }],
});
stream.emit('data', Buffer.from(`data: ${payload1}\ndata: ${payload2}\n`));
stream.emit('end');
const chunks = await promise;
console.log('=== Multi-line buffer chunks ===');
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
expect(chunks.some((c) => c.includes('"content":"First"'))).toBe(true);
expect(chunks.some((c) => c.includes('"content":"Second"'))).toBe(true);
expect(chunks[chunks.length - 1]).toContain('[DONE]');
});
it('handles partial SSE lines across multiple data events', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
const promise = collectChunks(observable);
const payload = JSON.stringify({
candidates: [{ content: { parts: [{ text: 'Split' }] }, finishReason: 'STOP' }],
});
const line = `data: ${payload}\n`;
// Split the line across two data events
const mid = Math.floor(line.length / 2);
stream.emit('data', Buffer.from(line.slice(0, mid)));
stream.emit('data', Buffer.from(line.slice(mid)));
stream.emit('end');
const chunks = await promise;
console.log('=== Split-line buffer chunks ===');
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
expect(chunks.some((c) => c.includes('"content":"Split"'))).toBe(true);
expect(chunks[chunks.length - 1]).toContain('[DONE]');
});
it('handles Gemini v1internal wrapped response format (response.candidates)', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
const promise = collectChunks(observable);
// v1internal API wraps the payload in {"response": {"candidates": [...]}}
stream.emit(
'data',
Buffer.from(
`data: ${JSON.stringify({
response: {
candidates: [
{
content: {
parts: [{ text: 'Hello from wrapped' }],
},
},
],
},
traceId: 'abc123',
metadata: {},
})}\n`,
),
);
stream.emit(
'data',
Buffer.from(
`data: ${JSON.stringify({
response: {
candidates: [
{
content: {
parts: [{ text: ' response' }],
},
finishReason: 'STOP',
},
],
},
traceId: 'abc123',
metadata: {},
})}\n`,
),
);
stream.emit('end');
const chunks = await promise;
console.log('=== Wrapped response format chunks ===');
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
expect(chunks.some((c) => c.includes('"content":"Hello from wrapped"'))).toBe(true);
expect(chunks.some((c) => c.includes('"content":" response"'))).toBe(true);
expect(chunks[chunks.length - 1]).toContain('[DONE]');
});
it('handles Gemini streaming chunks with no content field', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
const promise = collectChunks(observable);
// Sometimes Gemini sends candidates without content at all
emitSseLine(stream, {
candidates: [{ index: 0 }],
});
emitSseLine(stream, {
candidates: [{ content: { parts: [{ text: 'Late content' }] }, finishReason: 'STOP' }],
});
stream.emit('end');
const chunks = await promise;
console.log('=== No-content-then-late-content chunks ===');
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
expect(chunks.some((c) => c.includes('"content":"Late content"'))).toBe(true);
});
});
-173
View File
@@ -1,173 +0,0 @@
import { describe, expect, it } from 'vitest';
import { EventEmitter } from 'events';
import { ProxyService } from '../../server/modules/proxy/proxy.service';
import { Observable } from 'rxjs';
// Subclass to access private method
class TestableProxyService extends ProxyService {
constructor() {
super({} as any, {} as any);
}
public testProcessStream(
stream: any,
model: string = 'model',
streamOptions?: { include_usage?: boolean },
): Observable<string> {
return (this as any).processStreamResponse(stream, model, streamOptions);
}
}
function parseSseChunk(raw: string): Record<string, unknown> {
const lines = raw.split('\n').filter((l) => l.trim().startsWith('data: '));
if (lines.length === 0) return {};
const payload = lines[0].slice(6).trim();
try {
return JSON.parse(payload) as Record<string, unknown>;
} catch {
return {};
}
}
describe('ProxyService Tool Streaming (OpenAI format)', () => {
it('should emit tool call deltas with id+name first, then arguments separately', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStream(stream, 'gpt-4o');
const chunks: string[] = [];
const done = new Promise<void>((resolve, reject) => {
observable.subscribe({
next: (chunk) => chunks.push(chunk),
error: reject,
complete: resolve,
});
});
const payload = JSON.stringify({
candidates: [
{
content: {
parts: [
{ functionCall: { id: 'call_123', name: 'get_weather', args: { location: 'NYC' } } },
],
},
finishReason: 'STOP',
},
],
});
stream.emit('data', Buffer.from(`data: ${payload}\n`));
stream.emit('end');
await done;
const toolCallChunks = chunks.filter((c) => c.includes('"tool_calls"'));
expect(toolCallChunks.length).toBeGreaterThanOrEqual(2);
const first = parseSseChunk(toolCallChunks[0]);
const firstDeltas = (first.choices as any)?.[0]?.delta?.tool_calls as any[];
expect(firstDeltas).toBeDefined();
expect(firstDeltas[0].index).toBe(0);
expect(firstDeltas[0].id).toBe('call_123');
expect(firstDeltas[0].type).toBe('function');
expect(firstDeltas[0].function.name).toBe('get_weather');
expect(firstDeltas[0].function.arguments).toBe('');
const second = parseSseChunk(toolCallChunks[1]);
const secondDeltas = (second.choices as any)?.[0]?.delta?.tool_calls as any[];
expect(secondDeltas).toBeDefined();
expect(secondDeltas[0].index).toBe(0);
expect(secondDeltas[0].function.arguments).toBe('{"location":"NYC"}');
// Subsequent deltas must NOT repeat id/type/name to keep strict clients happy
expect(secondDeltas[0].id).toBeUndefined();
expect(secondDeltas[0].type).toBeUndefined();
expect(secondDeltas[0].function.name).toBeUndefined();
});
it('should split large tool arguments into multiple argument deltas', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStream(stream, 'gpt-4o');
const chunks: string[] = [];
const done = new Promise<void>((resolve, reject) => {
observable.subscribe({
next: (chunk) => chunks.push(chunk),
error: reject,
complete: resolve,
});
});
const bigArgs = { code: 'a'.repeat(5000) };
const payload = JSON.stringify({
candidates: [
{
content: {
parts: [{ functionCall: { id: 'call_456', name: 'write_file', args: bigArgs } }],
},
finishReason: 'STOP',
},
],
});
stream.emit('data', Buffer.from(`data: ${payload}\n`));
stream.emit('end');
await done;
const toolCallChunks = chunks.filter((c) => c.includes('"tool_calls"'));
// At least: 1st (id+name) + one or more argument deltas
expect(toolCallChunks.length).toBeGreaterThanOrEqual(2);
const first = parseSseChunk(toolCallChunks[0]);
expect((first.choices as any)[0].delta.tool_calls[0].function.arguments).toBe('');
// Concatenate all argument deltas
let argsConcat = '';
for (let i = 1; i < toolCallChunks.length; i++) {
const parsed = parseSseChunk(toolCallChunks[i]);
const argDelta = (parsed.choices as any)?.[0]?.delta?.tool_calls?.[0]?.function?.arguments;
if (typeof argDelta === 'string') {
argsConcat += argDelta;
}
}
expect(argsConcat).toBe(JSON.stringify(bigArgs));
});
it('should not break normal text streaming when no tools are present', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStream(stream, 'gpt-4o');
const chunks: string[] = [];
const done = new Promise<void>((resolve, reject) => {
observable.subscribe({
next: (chunk) => chunks.push(chunk),
error: reject,
complete: resolve,
});
});
const payload = JSON.stringify({
candidates: [
{
content: {
parts: [{ text: 'hello world' }],
},
finishReason: 'STOP',
},
],
});
stream.emit('data', Buffer.from(`data: ${payload}\n`));
stream.emit('end');
await done;
const output = chunks.join('');
expect(output).toContain('"content":"hello world"');
expect(output).toContain('data: [DONE]');
expect(output).not.toContain('"tool_calls"');
});
});
Loaded 100 of 339 files, more files were not shown because too many files have changed in this diff. Show more