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
331 changed files with 78169 additions and 620 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`
+33 -23
View File
@@ -57,6 +57,7 @@ Crie ou edite o arquivo de configuração (recomendado: `~/.config/opencode/open
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
@@ -73,6 +74,7 @@ POST {baseURL}/chat/completions
```
**Headers enviados:**
- `Authorization: Bearer <sua-api-key>`
- `Content-Type: application/json`
- Headers customizados que você definir em `options.headers`
@@ -95,12 +97,15 @@ POST {baseURL}/chat/completions
"stream": true,
"temperature": 0.7,
"max_tokens": 8192,
"tools": [ /* array de tools se o agente estiver usando ferramentas */ ],
"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`
@@ -165,19 +170,23 @@ Se sua API suportar `tools`, o OpenCode pode:
```json
{
"choices": [{
"message": {
"role": "assistant",
"tool_calls": [{
"id": "call_123",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\"path\": \"src/app.ts\"}"
}
}]
"choices": [
{
"message": {
"role": "assistant",
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\"path\": \"src/app.ts\"}"
}
}
]
}
}
}]
]
}
```
@@ -198,15 +207,15 @@ O OpenCode usa o TUI (Terminal User Interface) baseado em React/Ink para renderi
### 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 |
| 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`
@@ -256,6 +265,7 @@ O OpenCode usa o TUI (Terminal User Interface) baseado em React/Ink para renderi
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
@@ -263,4 +273,4 @@ O OpenCode vai:
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ê.
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**
+1 -2
View File
@@ -449,8 +449,7 @@ const config: ForgeConfig = {
makers: [
new MakerSquirrel({
setupIcon: 'images/icon.ico',
iconUrl:
'https://raw.githubusercontent.com/evandrodevbr/GeminiNexus/main/images/icon.ico',
iconUrl: 'https://raw.githubusercontent.com/evandrodevbr/GeminiNexus/main/images/icon.ico',
}),
...(process.platform === 'win32' && process.arch === 'x64'
? [
+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",
+23 -7
View File
@@ -48,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",
@@ -65,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",
@@ -90,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": {
@@ -102,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",
@@ -124,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",
@@ -134,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",
@@ -151,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",
@@ -163,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
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

+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>
);
};
+38 -5
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>;
} {
@@ -522,9 +557,7 @@ export class CloudAccountRepo {
const rows = orm.select().from(accounts).orderBy(desc(accounts.lastUsed)).all();
const activeRows = rows.filter((r) => r.isActive);
logger.debug(
`getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`,
);
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) {
+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;
@@ -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();
}
}
+700 -22
View File
@@ -10,12 +10,16 @@ import {
Req,
Logger,
Optional,
Param,
Query,
Sse,
} from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
import { isEmpty, isFunction, isNil, isObjectLike, isPlainObject, isString } from 'lodash-es';
import { v4 as uuidv4 } from 'uuid';
import { ProxyService } from './proxy.service';
import { Observable } from 'rxjs';
import { ProxyService, ProxyRequestContext, ProxyConfigOverride } from './proxy.service';
import { ProxyReplayService } from './proxy-replay.service';
import { Observable, interval, map } from 'rxjs';
import { trafficLogger } from '../../../utils/traffic-logger';
import {
OpenAIChatRequest,
@@ -33,6 +37,7 @@ import {
} from '../../../lib/geminiNexus/ModelMapping';
import { getServerConfig } from '../../server-config';
import { TokenManagerService } from './token-manager.service';
import { ProxyMetricsService } from './proxy-metrics.service';
@Controller('v1')
@UseGuards(ProxyGuard)
@@ -41,11 +46,15 @@ export class ProxyController {
constructor(
@Inject(ProxyService) private readonly proxyService: ProxyService,
@Optional() @Inject(ProxyReplayService) private readonly proxyReplayService?: ProxyReplayService,
@Optional() @Inject(TokenManagerService) private readonly tokenManager?: TokenManagerService,
@Optional() @Inject(ProxyMetricsService) private readonly proxyMetrics?: ProxyMetricsService,
) {}
@Get('models')
listModels(@Res() res: FastifyReply) {
this.proxyMetrics?.incrementActiveConnections();
const startTime = Date.now();
try {
const config = getServerConfig();
const customMapping = config?.custom_mapping ?? {};
@@ -65,6 +74,12 @@ export class ProxyController {
object: 'list',
data,
});
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.OK,
endpoint: '/v1/models',
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to list models';
this.logger.error(message, error instanceof Error ? error.stack : undefined);
@@ -80,15 +95,283 @@ export class ProxyController {
res.status(status).send({
error: errorPayload,
});
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status,
endpoint: '/v1/models',
});
} finally {
this.proxyMetrics?.decrementActiveConnections();
}
}
@Get('status')
async getStatus(@Res() res: FastifyReply) {
const startTime = Date.now();
try {
const metrics = this.proxyMetrics?.getMetrics();
const circuitBreakerStatus = this.tokenManager?.getCircuitBreakerStatus() ?? {};
const accountCount = Object.keys(circuitBreakerStatus).length;
const healthyAccounts = Object.values(circuitBreakerStatus).filter(s => s.state === 'healthy').length;
res.status(HttpStatus.OK).send({
status: 'healthy',
version: '1.0.0',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
accounts: {
total: accountCount,
healthy: healthyAccounts,
},
metrics: {
requestsPerMinute: metrics?.requestsPerMinute ?? 0,
avgLatency: metrics?.avgLatency ?? 0,
errorRate: metrics?.errorRate ?? 0,
activeConnections: metrics?.activeConnections ?? 0,
},
});
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.OK,
endpoint: '/v1/status',
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to get status';
this.logger.error(message, error instanceof Error ? error.stack : undefined);
res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({
error: {
message,
type: 'internal_server_error',
},
});
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.INTERNAL_SERVER_ERROR,
endpoint: '/v1/status',
});
}
}
@Get('replay/requests')
async getReplayRequests(@Query('limit') limit?: string, @Res() res?: FastifyReply) {
if (!res) {
throw new Error('Response object is required');
}
const startTime = Date.now();
try {
const requests = this.proxyReplayService?.getRecentRequests(limit ? parseInt(limit, 10) : undefined) ?? [];
this.setProxyHeaders(res, { requestId: uuidv4(), accountId: '', retryCount: 0, model: '' });
res.status(HttpStatus.OK).send({
object: 'list',
data: requests,
});
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.OK,
endpoint: '/v1/replay/requests',
});
} catch (error) {
this.sendOpenAIErrorResponse(res, '/v1/replay/requests', error);
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.INTERNAL_SERVER_ERROR,
endpoint: '/v1/replay/requests',
});
}
}
@Post('replay/:requestId')
async replayRequest(@Param('requestId') requestId: string, @Res() res: FastifyReply) {
const startTime = Date.now();
try {
if (!this.proxyReplayService) {
throw new Error('Replay service not available');
}
const result = await this.proxyReplayService.replayRequest(requestId);
this.setProxyHeaders(res, { requestId: uuidv4(), accountId: '', retryCount: 0, model: '' });
res.status(HttpStatus.OK).send(result);
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.OK,
endpoint: '/v1/replay/:requestId',
});
} catch (error) {
this.sendOpenAIErrorResponse(res, '/v1/replay/:requestId', error);
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.INTERNAL_SERVER_ERROR,
endpoint: '/v1/replay/:requestId',
});
}
}
@Get('models/capabilities')
listModelCapabilities(@Res() res: FastifyReply) {
const startTime = Date.now();
try {
const config = getServerConfig();
const customMapping = config?.custom_mapping ?? {};
const modelIds = getAllDynamicModels(
customMapping,
this.tokenManager?.getAllCollectedModels(),
);
const capabilitiesMap: Record<string, { vision: boolean; streaming: boolean; jsonMode: boolean; audio: boolean; imageGeneration: boolean }> = {
'gemini-3-pro': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: true },
'gemini-3-pro-image': { vision: true, streaming: false, jsonMode: false, audio: false, imageGeneration: true },
'gemini-3-flash': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
'gemini-3-flash-lite': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
'claude-3-5-sonnet': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
'claude-3-5-haiku': { vision: false, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
'claude-3-opus': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
};
const data = modelIds.map((id) => {
const baseId = Object.keys(capabilitiesMap).find((key) => id.includes(key)) ?? 'gemini-3-flash';
return {
id,
object: 'model_capability',
capabilities: capabilitiesMap[baseId] ?? capabilitiesMap['gemini-3-flash'],
};
});
res.status(HttpStatus.OK).send({
object: 'list',
data,
});
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.OK,
endpoint: '/v1/models/capabilities',
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to list model capabilities';
this.logger.error(message, error instanceof Error ? error.stack : undefined);
res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ error: { message, type: 'internal_server_error' } });
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.INTERNAL_SERVER_ERROR,
endpoint: '/v1/models/capabilities',
});
}
}
@Post('batch/completions')
async batchCompletions(
@Body() body: { requests: OpenAIChatRequest[] },
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
) {
const startTime = Date.now();
const endpoint = '/v1/batch/completions';
this.proxyMetrics?.incrementActiveConnections();
try {
const requests = Array.isArray(body?.requests) ? body.requests : [];
const responses: unknown[] = [];
for (let i = 0; i < requests.length; i++) {
const request = requests[i];
const proxyContext: ProxyRequestContext = {};
try {
const result = await this.proxyService.handleChatCompletions(
request,
proxyContext,
);
if (this.isObservableLike(result)) {
responses.push({
index: i,
error: { message: 'Streaming not supported in batch mode', type: 'invalid_request_error' },
});
} else {
responses.push({
index: i,
response: result,
});
}
} catch (error) {
responses.push({
index: i,
error: {
message: error instanceof Error ? error.message : 'Request failed',
type: 'api_error',
},
});
}
}
this.setProxyHeaders(res, { requestId: uuidv4(), accountId: '', retryCount: 0, model: '' });
res.status(HttpStatus.OK).send({
object: 'list',
data: responses,
});
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.OK,
endpoint,
});
} catch (error) {
this.sendOpenAIErrorResponse(res, endpoint, error);
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.INTERNAL_SERVER_ERROR,
endpoint,
});
} finally {
this.proxyMetrics?.decrementActiveConnections();
}
}
@Sse('events')
proxyEvents() {
const startTime = Date.now();
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: 0,
status: HttpStatus.OK,
endpoint: '/v1/events',
});
return interval(5000).pipe(
map((tick) => {
const metrics = this.proxyMetrics?.getMetrics();
return {
data: {
type: 'heartbeat',
tick,
timestamp: Date.now(),
metrics,
},
};
}),
);
}
@Post('chat/completions')
async chatCompletions(
@Body() body: OpenAIChatRequest,
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
) {
this.proxyReplayService?.recordRequest({
requestId: uuidv4(),
timestamp: Date.now(),
endpoint: '/v1/chat/completions',
method: 'POST',
body,
headers: req.headers as Record<string, unknown>,
model: body.model,
});
await this.respondOpenAIChatCompletions(body, res, req);
}
@@ -103,6 +386,15 @@ export class ProxyController {
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
) {
this.proxyReplayService?.recordRequest({
requestId: uuidv4(),
timestamp: Date.now(),
endpoint: '/v1/messages/chat/completions',
method: 'POST',
body,
headers: req.headers as Record<string, unknown>,
model: body.model,
});
await this.respondOpenAIChatCompletions(body, res, req);
}
@@ -117,8 +409,20 @@ export class ProxyController {
top_p?: number;
stream?: boolean;
},
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
) {
this.proxyReplayService?.recordRequest({
requestId: uuidv4(),
timestamp: Date.now(),
endpoint: '/v1/completions',
method: 'POST',
body,
headers: req.headers as Record<string, unknown>,
model: body.model,
});
this.proxyMetrics?.incrementActiveConnections();
const startTime = Date.now();
const request: OpenAIChatRequest = {
model: body.model ?? 'gemini-3-flash',
messages: [
@@ -132,17 +436,43 @@ export class ProxyController {
top_p: body.top_p,
stream: body.stream,
};
const proxyContext: ProxyRequestContext = {};
let isStream = false;
try {
const result = await this.proxyService.handleChatCompletions(request);
if (body.stream && this.isObservableLike(result)) {
this.writeSseResponse(res, result);
const result = await this.proxyService.handleChatCompletions(request, proxyContext);
isStream = !!(body.stream && this.isObservableLike(result));
if (isStream) {
this.writeSseResponse(res, result as Observable<unknown>, undefined, '/v1/completions', startTime, proxyContext);
return;
}
const response = result as OpenAIChatResponse;
const duration = Date.now() - startTime;
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration,
status: HttpStatus.OK,
endpoint: '/v1/completions',
});
if (response.usage?.total_tokens) {
this.proxyMetrics?.recordTokens(response.usage.total_tokens);
}
this.setProxyHeaders(res, proxyContext);
res.status(HttpStatus.OK).send(this.toLegacyTextCompletionsResponse(response));
} catch (error) {
const duration = Date.now() - startTime;
const status = this.resolveErrorHttpStatus(error instanceof Error ? error.message : '');
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration,
status,
endpoint: '/v1/completions',
});
this.sendOpenAIErrorResponse(res, '/v1/completions', error);
} finally {
if (!isStream) {
this.proxyMetrics?.decrementActiveConnections();
}
}
}
@@ -159,21 +489,59 @@ export class ProxyController {
top_p?: number;
stream?: boolean;
},
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
) {
this.proxyReplayService?.recordRequest({
requestId: uuidv4(),
timestamp: Date.now(),
endpoint: '/v1/responses',
method: 'POST',
body,
headers: req.headers as Record<string, unknown>,
model: body.model,
});
this.proxyMetrics?.incrementActiveConnections();
const startTime = Date.now();
const request = this.buildResponsesChatRequest(body);
const proxyContext: ProxyRequestContext = {};
let isStream = false;
try {
const result = await this.proxyService.handleChatCompletions(request);
if (body.stream && this.isObservableLike(result)) {
this.writeSseResponse(res, result);
const result = await this.proxyService.handleChatCompletions(request, proxyContext);
isStream = !!(body.stream && this.isObservableLike(result));
if (isStream) {
this.writeSseResponse(res, result as Observable<unknown>, undefined, '/v1/responses', startTime, proxyContext);
return;
}
const response = result as OpenAIChatResponse;
const duration = Date.now() - startTime;
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration,
status: HttpStatus.OK,
endpoint: '/v1/responses',
});
if (response.usage?.total_tokens) {
this.proxyMetrics?.recordTokens(response.usage.total_tokens);
}
this.setProxyHeaders(res, proxyContext);
res.status(HttpStatus.OK).send(this.toLegacyTextCompletionsResponse(response));
} catch (error) {
const duration = Date.now() - startTime;
const status = this.resolveErrorHttpStatus(error instanceof Error ? error.message : '');
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration,
status,
endpoint: '/v1/responses',
});
this.sendOpenAIErrorResponse(res, '/v1/responses', error);
} finally {
if (!isStream) {
this.proxyMetrics?.decrementActiveConnections();
}
}
}
@@ -186,8 +554,18 @@ export class ProxyController {
size?: string;
quality?: string;
},
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
) {
this.proxyReplayService?.recordRequest({
requestId: uuidv4(),
timestamp: Date.now(),
endpoint: '/v1/images/generations',
method: 'POST',
body,
headers: req.headers as Record<string, unknown>,
model: body.model,
});
const request: OpenAIChatRequest = {
model: body.model ?? 'gemini-3-pro-image',
messages: [
@@ -219,6 +597,15 @@ export class ProxyController {
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
) {
this.proxyReplayService?.recordRequest({
requestId: uuidv4(),
timestamp: Date.now(),
endpoint: '/v1/images/edits',
method: 'POST',
body,
headers: req.headers as Record<string, unknown>,
model: body.model,
});
if (!this.hasMultipartBoundary(req)) {
res
.status(HttpStatus.BAD_REQUEST)
@@ -270,7 +657,26 @@ export class ProxyController {
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
) {
this.proxyMetrics?.incrementActiveConnections();
const startTime = Date.now();
const endpoint = '/v1/audio/transcriptions';
this.proxyReplayService?.recordRequest({
requestId: uuidv4(),
timestamp: Date.now(),
endpoint: '/v1/audio/transcriptions',
method: 'POST',
body,
headers: req.headers as Record<string, unknown>,
model: body.model,
});
if (!this.hasMultipartBoundary(req)) {
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.BAD_REQUEST,
endpoint,
});
this.proxyMetrics?.decrementActiveConnections();
res
.status(HttpStatus.BAD_REQUEST)
.send('Invalid `boundary` for `multipart/form-data` request');
@@ -279,6 +685,13 @@ export class ProxyController {
const inlineAudio = this.resolveInlineData(body.file ?? body.audio);
if (!inlineAudio) {
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.BAD_REQUEST,
endpoint,
});
this.proxyMetrics?.decrementActiveConnections();
res.status(HttpStatus.BAD_REQUEST).send({
error: {
message: "Missing 'file' or 'audio' input. Provide base64 content or a data URL.",
@@ -313,22 +726,90 @@ export class ProxyController {
.join('')
.trim();
const duration = Date.now() - startTime;
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration,
status: HttpStatus.OK,
endpoint,
});
if (result.usageMetadata?.totalTokenCount) {
this.proxyMetrics?.recordTokens(result.usageMetadata.totalTokenCount);
}
res.status(HttpStatus.OK).send({
text: text ?? '',
});
} catch (error) {
this.sendOpenAIErrorResponse(res, '/v1/audio/transcriptions', error);
const duration = Date.now() - startTime;
const status = this.resolveErrorHttpStatus(error instanceof Error ? error.message : '');
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration,
status,
endpoint,
});
this.sendOpenAIErrorResponse(res, endpoint, error);
} finally {
this.proxyMetrics?.decrementActiveConnections();
}
}
private setProxyHeaders(res: FastifyReply, context: ProxyRequestContext): void {
if (context.accountId) {
res.header('X-Proxy-Account-Used', context.accountId);
}
if (typeof context.retryCount === 'number') {
res.header('X-Proxy-Retry-Count', String(context.retryCount));
}
if (context.model) {
res.header('X-Proxy-Model-Used', context.model);
}
if (context.requestId) {
res.header('X-Proxy-Request-Id', context.requestId);
}
if (context?.cacheStatus) {
res.header('X-Proxy-Cache-Status', context.cacheStatus);
}
}
private extractConfigOverride(req: FastifyRequest): ProxyConfigOverride {
const headers = req.headers as Record<string, string | undefined>;
const override: ProxyConfigOverride = {};
if (headers['x-proxy-account-id']) {
override.accountId = headers['x-proxy-account-id'];
}
if (headers['x-proxy-scheduling-mode']) {
const mode = headers['x-proxy-scheduling-mode'];
if (mode === 'cache-first' || mode === 'balance' || mode === 'performance-first') {
override.schedulingMode = mode;
}
}
if (headers['x-proxy-max-retries']) {
const maxRetries = parseInt(headers['x-proxy-max-retries'], 10);
if (!isNaN(maxRetries) && maxRetries >= 0) {
override.maxRetries = maxRetries;
}
}
if (headers['x-proxy-timeout']) {
const timeout = parseInt(headers['x-proxy-timeout'], 10);
if (!isNaN(timeout) && timeout > 0) {
override.timeout = timeout;
}
}
return override;
}
private async respondOpenAIChatCompletions(
body: OpenAIChatRequest,
res: FastifyReply,
req?: FastifyRequest,
) {
this.proxyMetrics?.incrementActiveConnections();
const requestId = uuidv4();
const startTime = Date.now();
trafficLogger.logInbound(requestId, '/v1/chat/completions', body, undefined, { stream: body.stream });
trafficLogger.logInbound(requestId, '/v1/chat/completions', body, undefined, {
stream: body.stream,
});
// Extract x-session-affinity header so OpenCode session stickiness works even when
// the request body does not carry an explicit session_id field.
@@ -336,36 +817,114 @@ export class ProxyController {
? (req.headers['x-session-affinity'] as string | undefined)
: undefined;
const proxyContext: ProxyRequestContext = {};
let isStream = false;
try {
const result = await this.proxyService.handleChatCompletions(body, requestId, headerSessionKey);
const result = await this.proxyService.handleChatCompletions(
body,
proxyContext,
);
if (body.stream && this.isObservableLike(result)) {
this.writeSseResponse(res, result, requestId, '/v1/chat/completions', startTime);
isStream = !!(body.stream && this.isObservableLike(result));
if (isStream) {
this.writeSseResponse(res, result as Observable<unknown>, requestId, '/v1/chat/completions', startTime, proxyContext);
return;
} else {
const duration = Date.now() - startTime;
trafficLogger.logOutbound(requestId, '/v1/chat/completions', HttpStatus.OK, result, duration);
trafficLogger.logOutbound(
requestId,
'/v1/chat/completions',
HttpStatus.OK,
result,
duration,
);
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration,
status: HttpStatus.OK,
endpoint: '/v1/chat/completions',
});
const response = result as OpenAIChatResponse;
if (response.usage?.total_tokens) {
this.proxyMetrics?.recordTokens(response.usage.total_tokens);
}
this.setProxyHeaders(res, proxyContext);
res.status(HttpStatus.OK).send(result);
}
} catch (error) {
trafficLogger.logError(requestId, '/v1/chat/completions', error);
const duration = Date.now() - startTime;
const status = this.resolveErrorHttpStatus(error instanceof Error ? error.message : '');
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration,
status,
endpoint: '/v1/chat/completions',
});
this.sendOpenAIErrorResponse(res, '/v1/chat/completions', error);
} finally {
if (!isStream) {
this.proxyMetrics?.decrementActiveConnections();
}
}
}
@Post('messages')
async anthropicMessages(@Body() body: AnthropicChatRequest, @Res() res: FastifyReply) {
async anthropicMessages(
@Body() body: AnthropicChatRequest,
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
) {
this.proxyReplayService?.recordRequest({
requestId: uuidv4(),
timestamp: Date.now(),
endpoint: '/v1/messages',
method: 'POST',
body,
headers: req.headers as Record<string, unknown>,
model: body.model,
});
this.proxyMetrics?.incrementActiveConnections();
const startTime = Date.now();
let isStream = false;
const proxyContext: ProxyRequestContext = {
requestId: uuidv4(),
accountId: '',
retryCount: 0,
model: body.model || '',
};
try {
const result = await this.proxyService.handleAnthropicMessages(body);
const result = await this.proxyService.handleAnthropicMessages(body, proxyContext);
isStream = !!(body.stream && this.isObservableLike(result));
if (body.stream && this.isObservableLike(result)) {
this.writeSseResponse(res, result);
if (isStream) {
this.writeSseResponse(res, result as Observable<unknown>, undefined, '/v1/messages', startTime, proxyContext);
return;
} else {
const duration = Date.now() - startTime;
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration,
status: HttpStatus.OK,
endpoint: '/v1/messages',
});
this.setProxyHeaders(res, proxyContext);
res.status(HttpStatus.OK).send(result);
}
} catch (error) {
const duration = Date.now() - startTime;
const status = this.resolveErrorHttpStatus(error instanceof Error ? error.message : '');
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration,
status,
endpoint: '/v1/messages',
});
this.sendAnthropicErrorResponse(res, '/v1/messages', error);
} finally {
if (!isStream) {
this.proxyMetrics?.decrementActiveConnections();
}
}
}
@@ -762,18 +1321,69 @@ export class ProxyController {
return isObjectLike(value) && isFunction((value as { subscribe?: unknown }).subscribe);
}
private extractAndRecordTokens(payload: string): void {
try {
const lines = payload.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data: ')) {
continue;
}
const jsonStr = trimmed.slice(6).trim();
if (jsonStr === '[DONE]') {
continue;
}
const parsed = JSON.parse(jsonStr);
const usageMetadata = parsed?.usageMetadata;
if (usageMetadata && typeof usageMetadata.totalTokenCount === 'number') {
this.proxyMetrics?.recordTokens(usageMetadata.totalTokenCount);
return;
}
const usage = parsed?.usage;
if (usage && typeof usage.total_tokens === 'number') {
this.proxyMetrics?.recordTokens(usage.total_tokens);
return;
}
}
} catch {
// Ignore parse errors for token extraction
}
}
private writeSseResponse(
res: FastifyReply,
stream: Observable<unknown>,
requestId?: string,
endpoint?: string,
startTime?: number,
proxyContext?: ProxyRequestContext,
): void {
const extraHeaders: Record<string, string> = {};
if (proxyContext?.accountId) {
extraHeaders['X-Proxy-Account-Used'] = proxyContext.accountId;
}
if (typeof proxyContext?.retryCount === 'number') {
extraHeaders['X-Proxy-Retry-Count'] = String(proxyContext.retryCount);
}
if (proxyContext?.model) {
extraHeaders['X-Proxy-Model-Used'] = proxyContext.model;
}
if (proxyContext?.requestId) {
extraHeaders['X-Proxy-Request-Id'] = proxyContext.requestId;
}
if (proxyContext?.cacheStatus) {
extraHeaders['X-Proxy-Cache-Status'] = proxyContext.cacheStatus;
}
if (!res.raw || !isFunction(res.raw.writeHead) || !isFunction(res.raw.write)) {
res.header('Content-Type', 'text/event-stream');
res.header('Cache-Control', 'no-cache');
res.header('Connection', 'keep-alive');
for (const [key, value] of Object.entries(extraHeaders)) {
res.header(key, value);
}
res.send(stream);
this.proxyMetrics?.decrementActiveConnections();
return;
}
@@ -785,12 +1395,30 @@ export class ProxyController {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
...extraHeaders,
});
if (isFunction(res.raw.flushHeaders)) {
res.raw.flushHeaders();
}
let finished = false;
const finish = (status: number) => {
if (finished) {
return;
}
finished = true;
if (startTime && endpoint) {
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status,
endpoint,
});
}
this.proxyMetrics?.decrementActiveConnections();
};
const subscription = stream.subscribe({
next: (chunk) => {
if (res.raw.writableEnded) {
@@ -801,6 +1429,7 @@ export class ProxyController {
if (requestId && endpoint) {
trafficLogger.logSseChunk(requestId, endpoint, chunk);
}
this.extractAndRecordTokens(payload);
if (isFunction((res.raw as any).flush)) {
(res.raw as any).flush();
}
@@ -826,16 +1455,19 @@ export class ProxyController {
\n\n`,
);
res.raw.end();
finish(status);
},
complete: () => {
if (!res.raw.writableEnded) {
res.raw.end();
}
finish(HttpStatus.OK);
},
});
res.raw.on('close', () => {
subscription.unsubscribe();
finish(HttpStatus.OK);
});
}
@@ -844,14 +1476,23 @@ export class ProxyController {
prompt: string,
res: FastifyReply,
): Promise<void> {
this.proxyMetrics?.incrementActiveConnections();
const startTime = Date.now();
const endpoint = '/v1/images/generations';
try {
const result = await this.proxyService.handleChatCompletions(request);
if (result instanceof Observable) {
this.logProxyEndpointError(
'/v1/images/generations',
endpoint,
HttpStatus.INTERNAL_SERVER_ERROR,
'Streaming image generation is not supported by this endpoint',
);
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.INTERNAL_SERVER_ERROR,
endpoint,
});
res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({
error: {
message: 'Streaming image generation is not supported by this endpoint',
@@ -865,10 +1506,16 @@ export class ProxyController {
const image = this.extractInlineBase64Image(isString(content) ? content : '');
if (!image) {
this.logProxyEndpointError(
'/v1/images/generations',
endpoint,
HttpStatus.BAD_GATEWAY,
'Upstream did not return inline image data',
);
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.BAD_GATEWAY,
endpoint,
});
res.status(HttpStatus.BAD_GATEWAY).send({
error: {
message: 'Upstream did not return inline image data',
@@ -878,6 +1525,15 @@ export class ProxyController {
return;
}
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.OK,
endpoint,
});
if (result.usage?.total_tokens) {
this.proxyMetrics?.recordTokens(result.usage.total_tokens);
}
res.status(HttpStatus.OK).send({
created: Math.floor(Date.now() / 1000),
data: [
@@ -898,6 +1554,15 @@ export class ProxyController {
);
const fallbackImage = this.extractInlineBase64ImageFromGeminiResponse(geminiResult);
if (fallbackImage) {
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.OK,
endpoint,
});
if (geminiResult.usageMetadata?.totalTokenCount) {
this.proxyMetrics?.recordTokens(geminiResult.usageMetadata.totalTokenCount);
}
res.status(HttpStatus.OK).send({
created: Math.floor(Date.now() / 1000),
data: [
@@ -914,7 +1579,16 @@ export class ProxyController {
}
}
this.sendOpenAIErrorResponse(res, '/v1/images/generations', error, message);
const status = this.resolveErrorHttpStatus(message);
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status,
endpoint,
});
this.sendOpenAIErrorResponse(res, endpoint, error, message);
} finally {
this.proxyMetrics?.decrementActiveConnections();
}
}
@@ -1132,7 +1806,11 @@ export class ProxyController {
if (status === HttpStatus.SERVICE_UNAVAILABLE) {
return { type: 'server_error', code: 'temporarily_unavailable' };
}
if (lowered.includes('invalid') || lowered.includes('malformed') || lowered.includes('bad request')) {
if (
lowered.includes('invalid') ||
lowered.includes('malformed') ||
lowered.includes('bad request')
) {
return { type: 'invalid_request_error', code: 'invalid_request_error' };
}
return { type: 'server_error', code: 'internal_error' };
+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 {}
+371 -32
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';
@@ -34,20 +35,89 @@ import { getMaxOutputTokens, getThinkingBudget } from '../../../lib/geminiNexus/
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);
@@ -73,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,
@@ -101,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,
@@ -109,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) {
@@ -132,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,
@@ -140,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) {
@@ -170,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,
@@ -178,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,
@@ -205,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();
@@ -215,6 +295,7 @@ export class ProxyService {
let lastFinishReason: string | undefined;
let lastUsageMetadata: Record<string, unknown> | undefined;
let accumulatedText = '';
let receivedData = false;
@@ -251,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
@@ -272,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();
});
@@ -339,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)) {
@@ -361,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;
@@ -434,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(
@@ -456,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;
}
@@ -477,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', () => {
@@ -492,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();
});
@@ -692,23 +842,35 @@ export class ProxyService {
async handleChatCompletions(
request: OpenAIChatRequest,
requestId?: string,
headerSessionKey?: string,
context?: ProxyRequestContext,
configOverride?: ProxyConfigOverride,
): Promise<OpenAIChatResponse | Observable<string>> {
// Prefer the x-session-affinity header value (sent by OpenCode) over body-level session ids.
const sessionKey =
(headerSessionKey ? `openai:${headerSessionKey}` : undefined) ??
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++) {
@@ -734,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);
@@ -746,14 +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 });
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, requestId);
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: ${
@@ -761,14 +941,27 @@ export class ProxyService {
}`,
);
trafficLogger.logUpstream(requestId || 'unknown', '/v1/chat/completions', geminiBody, extraHeaders, { model: effectiveTargetModel });
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);
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)}`,
);
@@ -789,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)) {
@@ -808,26 +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 });
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, requestId);
return this.processStreamResponse(
stream,
request.model,
requestId,
token.id,
effectiveTargetModel,
);
}
trafficLogger.logUpstream(requestId || 'unknown', '/v1/chat/completions', fallbackBody, extraHeaders, { model: effectiveTargetModel });
trafficLogger.logUpstream(
requestId || 'unknown',
'/v1/chat/completions',
fallbackBody,
extraHeaders,
{ model: effectiveTargetModel },
);
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);
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;
}
@@ -964,7 +1195,9 @@ export class ProxyService {
private processStreamResponse(
upstreamStream: NodeJS.ReadableStream,
model: string,
requestId?: string,
requestId: string | undefined,
accountId: string,
effectiveTargetModel: string,
): Observable<string> {
return new Observable<string>((subscriber) => {
const decoder = new TextDecoder();
@@ -973,6 +1206,8 @@ export class ProxyService {
let hasSentRoleChunk = false;
let hasSentDone = false;
let toolCallIndex = 0;
let lastUsageMetadata: Record<string, unknown> | undefined;
let accumulatedText = '';
const streamId = `chatcmpl-${uuidv4()}`;
const created = Math.floor(Date.now() / 1000);
@@ -1015,14 +1250,25 @@ export class ProxyService {
try {
const json = JSON.parse(dataStr);
trafficLogger.logUpstreamResponse(requestId || 'unknown', '/v1/chat/completions', 200, json, 0, { rawLine: trimmed });
trafficLogger.logUpstreamResponse(
requestId || 'unknown',
'/v1/chat/completions',
200,
json,
0,
{ rawLine: trimmed },
);
// v1internal API wraps response in {"response": {"candidates": [...]}}
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) {
accumulatedText += part.text;
// Reasoning/thinking parts: emit via reasoning_content delta field
// so clients that support it can display thinking separately.
const reasoningChunk = {
@@ -1098,6 +1344,7 @@ export class ProxyService {
}
if (part.text) {
accumulatedText += part.text;
pushRoleChunk();
const contentChunk = {
id: streamId,
@@ -1141,7 +1388,6 @@ export class ProxyService {
pushChunk(finishChunk);
subscriber.next('data: [DONE]\n\n');
hasSentDone = true;
subscriber.complete();
}
} catch {
// ignore parse errors on individual SSE lines
@@ -1169,6 +1415,23 @@ export class ProxyService {
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();
});
@@ -1379,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 {
@@ -1637,8 +1904,9 @@ export class ProxyService {
message: {
role: 'assistant',
// When tool_calls are present, content must be null per OpenAI spec.
content: toolCalls.length > 0 ? null : (finalContent || ''),
content: toolCalls.length > 0 ? null : finalContent || '',
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
reasoning_content: reasoningContent || undefined,
},
finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason(
claudeResponse.stop_reason,
@@ -1705,7 +1973,9 @@ export class ProxyService {
if (error instanceof UpstreamRequestError) {
const status = error.status;
if (status === 401) {
this.logger.warn(`Upstream returned 401 for account ${accountId}; attempting token refresh`);
this.logger.warn(
`Upstream returned 401 for account ${accountId}; attempting token refresh`,
);
const refreshed = await this.tokenManager.forceRefreshToken(accountId);
if (refreshed) {
this.logger.log(`Token refreshed successfully for account ${accountId}; retry permitted`);
@@ -1857,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);
}
}
@@ -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 () => {
@@ -21,7 +21,7 @@ describe('ProxyController Integration', () => {
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();
@@ -46,7 +46,7 @@ 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();
@@ -91,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(
{
@@ -100,6 +101,7 @@ describe('ProxyController Integration', () => {
prompt: 'hello world',
stream: false,
},
req,
reply as any,
);
@@ -108,6 +110,7 @@ describe('ProxyController Integration', () => {
model: 'gpt-4o',
messages: [{ role: 'user', content: 'hello world' }],
}),
{},
);
expect(reply.status).toHaveBeenCalledWith(200);
expect(reply.send).toHaveBeenCalledWith(
@@ -148,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(
{
@@ -174,6 +178,7 @@ describe('ProxyController Integration', () => {
},
],
},
req,
reply as any,
);
@@ -214,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(
{
@@ -224,6 +230,7 @@ describe('ProxyController Integration', () => {
input: 'hello',
stream: true,
},
req,
reply as any,
);
@@ -245,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(
{
@@ -264,6 +272,7 @@ describe('ProxyController Integration', () => {
},
],
},
req,
reply as any,
);
@@ -291,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,
);
@@ -318,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,
);
@@ -358,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,
);
@@ -395,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(
@@ -422,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(
@@ -456,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(
@@ -481,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(
@@ -509,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(
{
@@ -518,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 {
+3 -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> {
+2 -1
View File
@@ -15,10 +15,11 @@ const mockTokenManager = {
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);
super(mockTokenManager as any, mockGeminiClient as any, mockTokenUsageService as any);
}
public testProcessStreamResponse(
@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import {
hasConfiguredApiKey,
extractApiKeyToken,
type RequestHeaders,
} from '@/server/guards/api-key-auth.util';
describe('api-key-auth.util', () => {
describe('hasConfiguredApiKey', () => {
it('should return true for non-empty string', () => {
expect(hasConfiguredApiKey('test-key')).toBe(true);
expect(hasConfiguredApiKey(' key ')).toBe(true);
});
it('should return false for empty or whitespace-only strings', () => {
expect(hasConfiguredApiKey('')).toBe(false);
expect(hasConfiguredApiKey(' ')).toBe(false);
});
it('should return false for undefined', () => {
expect(hasConfiguredApiKey(undefined)).toBe(false);
});
});
describe('extractApiKeyToken', () => {
it('should extract Bearer token from Authorization header', () => {
const headers: RequestHeaders = { authorization: 'Bearer test-token-123' };
expect(extractApiKeyToken(headers)).toBe('test-token-123');
});
it('should handle Authorization header with extra spaces', () => {
const headers: RequestHeaders = { authorization: ' Bearer token-456 ' };
expect(extractApiKeyToken(headers)).toBe('token-456');
});
it('should reject malformed Bearer tokens with extra parts', () => {
const headers: RequestHeaders = { authorization: 'Bearer token extra' };
expect(extractApiKeyToken(headers)).toBeNull();
});
it('should be case-insensitive for Bearer scheme', () => {
const headers: RequestHeaders = { authorization: 'bearer lower-case-token' };
expect(extractApiKeyToken(headers)).toBe('lower-case-token');
});
it('should extract x-api-key header', () => {
const headers: RequestHeaders = { 'x-api-key': 'api-key-value' };
expect(extractApiKeyToken(headers)).toBe('api-key-value');
});
it('should extract x-goog-api-key header', () => {
const headers: RequestHeaders = { 'x-goog-api-key': 'goog-key-value' };
expect(extractApiKeyToken(headers)).toBe('goog-key-value');
});
it('should prefer Authorization over x-api-key', () => {
const headers: RequestHeaders = {
authorization: 'Bearer auth-token',
'x-api-key': 'api-token',
};
expect(extractApiKeyToken(headers)).toBe('auth-token');
});
it('should handle array header values', () => {
const headers: RequestHeaders = { authorization: ['Bearer token-1', 'Bearer token-2'] };
expect(extractApiKeyToken(headers)).toBe('token-1');
});
it('should skip empty strings in array values', () => {
const headers: RequestHeaders = { authorization: ['', ' ', 'Bearer valid-token'] };
expect(extractApiKeyToken(headers)).toBe('valid-token');
});
it('should return null when no auth header present', () => {
const headers: RequestHeaders = {};
expect(extractApiKeyToken(headers)).toBeNull();
});
it('should return null for unsupported scheme', () => {
const headers: RequestHeaders = { authorization: 'Basic dXNlcjpwYXNz' };
expect(extractApiKeyToken(headers)).toBeNull();
});
});
});
@@ -0,0 +1,205 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AutoSwitchService } from '@/services/AutoSwitchService';
import { CloudAccountRepo } from '@/ipc/database/cloudHandler';
import { switchCloudAccount } from '@/ipc/cloud/handler';
vi.mock('@/ipc/database/cloudHandler', () => ({
CloudAccountRepo: {
getAccounts: vi.fn(),
getSetting: vi.fn(),
},
}));
vi.mock('@/ipc/cloud/handler', () => ({
switchCloudAccount: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
describe('AutoSwitchService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
function createAccount(
overrides?: Partial<{
id: string;
email: string;
status: string;
is_active: boolean;
quota: { models: Record<string, { percentage: number }> } | null;
}>,
) {
return {
id: 'acc-1',
email: '[email protected]',
status: 'active',
is_active: false,
quota: {
models: {
'gemini-pro': { percentage: 80 },
'gemini-flash': { percentage: 60 },
},
},
...overrides,
};
}
describe('findBestAccount', () => {
it('should return null when no accounts exist', async () => {
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue([]);
const result = await AutoSwitchService.findBestAccount('current');
expect(result).toBeNull();
});
it('should skip the current account', async () => {
const accounts = [
createAccount({ id: 'current', email: '[email protected]', is_active: true }),
createAccount({ id: 'other', email: '[email protected]' }),
];
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(accounts as any);
const result = await AutoSwitchService.findBestAccount('current');
expect(result?.id).toBe('other');
});
it('should skip inactive accounts', async () => {
const accounts = [
createAccount({ id: 'current', is_active: true }),
createAccount({ id: 'inactive', status: 'expired' }),
createAccount({ id: 'active', status: 'active' }),
];
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(accounts as any);
const result = await AutoSwitchService.findBestAccount('current');
expect(result?.id).toBe('active');
});
it('should skip accounts with depleted quota', async () => {
const accounts = [
createAccount({ id: 'current', is_active: true }),
createAccount({
id: 'depleted',
quota: { models: { 'gemini-pro': { percentage: 2 } } },
}),
createAccount({
id: 'healthy',
quota: { models: { 'gemini-pro': { percentage: 50 } } },
}),
];
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(accounts as any);
const result = await AutoSwitchService.findBestAccount('current');
expect(result?.id).toBe('healthy');
});
it('should skip accounts without quota data', async () => {
const accounts = [
createAccount({ id: 'current', is_active: true }),
createAccount({ id: 'no-quota', quota: null }),
createAccount({ id: 'has-quota' }),
];
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(accounts as any);
const result = await AutoSwitchService.findBestAccount('current');
expect(result?.id).toBe('has-quota');
});
it('should select account with highest average quota', async () => {
const accounts = [
createAccount({ id: 'current', is_active: true }),
createAccount({
id: 'low',
quota: { models: { a: { percentage: 30 }, b: { percentage: 40 } } },
}),
createAccount({
id: 'high',
quota: { models: { a: { percentage: 90 }, b: { percentage: 80 } } },
}),
];
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(accounts as any);
const result = await AutoSwitchService.findBestAccount('current');
expect(result?.id).toBe('high');
});
});
describe('checkAndSwitchIfNeeded', () => {
it('should return false when auto-switch is disabled', async () => {
vi.mocked(CloudAccountRepo.getSetting).mockReturnValue(false);
const result = await AutoSwitchService.checkAndSwitchIfNeeded();
expect(result).toBe(false);
});
it('should return false when no active account', async () => {
vi.mocked(CloudAccountRepo.getSetting).mockReturnValue(true);
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue([]);
const result = await AutoSwitchService.checkAndSwitchIfNeeded();
expect(result).toBe(false);
});
it('should switch when current account is depleted', async () => {
vi.mocked(CloudAccountRepo.getSetting).mockReturnValue(true);
const accounts = [
createAccount({
id: 'current',
is_active: true,
quota: { models: { 'gemini-pro': { percentage: 2 } } },
}),
createAccount({ id: 'backup' }),
];
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(accounts as any);
const result = await AutoSwitchService.checkAndSwitchIfNeeded();
expect(result).toBe(true);
expect(switchCloudAccount).toHaveBeenCalledWith('backup');
});
it('should switch when current account is rate limited', async () => {
vi.mocked(CloudAccountRepo.getSetting).mockReturnValue(true);
const accounts = [
createAccount({ id: 'current', is_active: true, status: 'rate_limited' }),
createAccount({ id: 'backup' }),
];
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(accounts as any);
const result = await AutoSwitchService.checkAndSwitchIfNeeded();
expect(result).toBe(true);
});
it('should not switch when current account is healthy', async () => {
vi.mocked(CloudAccountRepo.getSetting).mockReturnValue(true);
const accounts = [createAccount({ id: 'current', is_active: true })];
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(accounts as any);
const result = await AutoSwitchService.checkAndSwitchIfNeeded();
expect(result).toBe(false);
expect(switchCloudAccount).not.toHaveBeenCalled();
});
});
describe('isAccountDepleted', () => {
it('should return false for healthy account', () => {
const account = createAccount();
expect(AutoSwitchService.isAccountDepleted(account as any)).toBe(false);
});
it('should return true when any model is below 5%', () => {
const account = createAccount({
quota: { models: { 'gemini-pro': { percentage: 10 }, 'gemini-flash': { percentage: 3 } } },
});
expect(AutoSwitchService.isAccountDepleted(account as any)).toBe(true);
});
it('should return false when no quota data', () => {
const account = createAccount({ quota: null });
expect(AutoSwitchService.isAccountDepleted(account as any)).toBe(false);
});
});
});
@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GoogleAPIService, UserInfoSchema } from '@/services/GoogleAPIService';
vi.mock('@/ipc/config/manager', () => ({
ConfigManager: {
loadConfig: vi.fn().mockReturnValue({}),
},
}));
vi.mock('@/ipc/cloud/authServer', () => ({
AuthServer: {
getRedirectUri: vi.fn().mockReturnValue('http://localhost:3000/auth/callback'),
},
}));
vi.mock('@/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('@/server/modules/proxy/request-user-agent', () => ({
buildUserAgent: vi.fn().mockReturnValue('GeminiNexus/1.0'),
FALLBACK_VERSION: '1.0.0',
resolveLocalInstalledVersion: vi.fn().mockReturnValue('1.0.0'),
}));
describe('GoogleAPIService', () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.ANTIGRAVITY_OAUTH_CLIENTS;
delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
});
afterEach(() => {
delete process.env.ANTIGRAVITY_OAUTH_CLIENTS;
delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
});
describe('OAuth client registry', () => {
beforeEach(() => {
vi.resetModules();
delete process.env.ANTIGRAVITY_OAUTH_CLIENTS;
delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
});
it('should list built-in OAuth clients', async () => {
const { GoogleAPIService } = await import('@/services/GoogleAPIService');
const clients = GoogleAPIService.listOAuthClients();
expect(clients.length).toBeGreaterThanOrEqual(1);
expect(clients[0]).toMatchObject({
key: expect.any(String),
label: expect.any(String),
client_id: expect.any(String),
is_active: expect.any(Boolean),
is_builtin: true,
});
});
it('should parse extra OAuth clients from environment', async () => {
process.env.ANTIGRAVITY_OAUTH_CLIENTS = 'custom1|id1|secret1|Custom One;custom2|id2|secret2';
const { GoogleAPIService } = await import('@/services/GoogleAPIService');
const clients = GoogleAPIService.listOAuthClients();
const custom1 = clients.find((c) => c.key === 'custom1');
expect(custom1).toBeDefined();
expect(custom1?.label).toBe('Custom One');
expect(custom1?.is_builtin).toBe(false);
});
it('should skip invalid OAuth client entries', async () => {
process.env.ANTIGRAVITY_OAUTH_CLIENTS = 'invalid|no_secret;valid|id|secret|Label';
const { GoogleAPIService } = await import('@/services/GoogleAPIService');
const clients = GoogleAPIService.listOAuthClients();
expect(clients.some((c) => c.key === 'valid')).toBe(true);
expect(clients.some((c) => c.key === 'invalid')).toBe(false);
});
it('should allow setting active OAuth client key', async () => {
const { GoogleAPIService } = await import('@/services/GoogleAPIService');
GoogleAPIService.setActiveOAuthClientKey('gemininexus_enterprise');
expect(GoogleAPIService.getActiveOAuthClientKey()).toBe('gemininexus_enterprise');
});
it('should throw for unknown OAuth client key', async () => {
const { GoogleAPIService } = await import('@/services/GoogleAPIService');
expect(() => GoogleAPIService.setActiveOAuthClientKey('nonexistent')).toThrow(
"Unknown OAuth client key 'nonexistent'",
);
});
});
describe('getAuthUrl', () => {
it('should generate an OAuth authorization URL', () => {
const url = GoogleAPIService.getAuthUrl();
expect(url).toContain('accounts.google.com/o/oauth2/v2/auth');
expect(url).toContain('response_type=code');
expect(url).toContain('access_type=offline');
expect(url).toContain('prompt=consent');
});
it('should include requested scopes', () => {
const url = GoogleAPIService.getAuthUrl();
expect(url).toContain(encodeURIComponent('https://www.googleapis.com/auth/cloud-platform'));
expect(url).toContain(encodeURIComponent('https://www.googleapis.com/auth/userinfo.email'));
});
});
describe('UserInfoSchema', () => {
it('should validate valid user info', () => {
const data = {
id: '123',
email: '[email protected]',
verified_email: true,
name: 'Test User',
};
expect(UserInfoSchema.parse(data)).toEqual(data);
});
it('should default verified_email to false', () => {
const data = { id: '123', email: '[email protected]' };
const parsed = UserInfoSchema.parse(data);
expect(parsed.verified_email).toBe(false);
});
it('should reject missing id', () => {
expect(() => UserInfoSchema.parse({ email: '[email protected]' })).toThrow();
});
});
describe('normalizeRefreshedOAuthClientKey', () => {
it('should return normalized key when present', () => {
const result = GoogleAPIService.normalizeRefreshedOAuthClientKey(
{ oauth_client_key: 'MyKey' },
'OtherKey',
);
expect(result).toBe('otherkey');
});
it('should fallback to current token key', () => {
const result = GoogleAPIService.normalizeRefreshedOAuthClientKey(
{ oauth_client_key: 'MyKey' },
undefined,
);
expect(result).toBe('mykey');
});
it('should unset enterprise key for legacy accounts without project_id', () => {
const result = GoogleAPIService.normalizeRefreshedOAuthClientKey(
{},
'gemininexus_enterprise',
);
expect(result).toBe('gemininexus_enterprise');
});
});
});
@@ -0,0 +1,72 @@
import { describe, it, expect, vi } from 'vitest';
import { ProxyEventBus, ProxyEvent } from '@/server/modules/proxy/proxy-event-bus.service';
import { take } from 'rxjs/operators';
describe('ProxyEventBus', () => {
let eventBus: ProxyEventBus;
beforeEach(() => {
eventBus = new ProxyEventBus();
});
it('should emit events to subscribers', () => {
const listener = vi.fn();
eventBus.listen().subscribe(listener);
const event: ProxyEvent = {
type: 'account_switch',
timestamp: Date.now(),
data: { accountId: 'acc-1' },
};
eventBus.emit(event);
expect(listener).toHaveBeenCalledWith(event);
});
it('should emit multiple event types', () => {
const events: ProxyEvent[] = [];
eventBus.listen().subscribe((e) => events.push(e));
eventBus.emit({ type: 'rate_limit', timestamp: 1, data: {} });
eventBus.emit({ type: 'error', timestamp: 2, data: { message: 'fail' } });
eventBus.emit({ type: 'metric', timestamp: 3, data: { latency: 100 } });
eventBus.emit({ type: 'heartbeat', timestamp: 4, data: {} });
expect(events).toHaveLength(4);
expect(events.map((e) => e.type)).toEqual(['rate_limit', 'error', 'metric', 'heartbeat']);
});
it('should support multiple subscribers', () => {
const listener1 = vi.fn();
const listener2 = vi.fn();
eventBus.listen().subscribe(listener1);
eventBus.listen().subscribe(listener2);
const event: ProxyEvent = { type: 'account_switch', timestamp: Date.now(), data: {} };
eventBus.emit(event);
expect(listener1).toHaveBeenCalledWith(event);
expect(listener2).toHaveBeenCalledWith(event);
});
it('should allow RxJS operators on the observable', async () => {
const promise = eventBus.listen().pipe(take(1)).toPromise();
const event: ProxyEvent = { type: 'metric', timestamp: Date.now(), data: {} };
eventBus.emit(event);
const result = await promise;
expect(result).toEqual(event);
});
it('should not receive events before subscription', () => {
const listener = vi.fn();
eventBus.emit({ type: 'heartbeat', timestamp: Date.now(), data: {} });
eventBus.listen().subscribe(listener);
eventBus.emit({ type: 'heartbeat', timestamp: Date.now(), data: {} });
expect(listener).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,147 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ProxyIdeConfigService, SupportedIde } from '@/server/modules/proxy/proxy-ide-config.service';
vi.mock('@/ipc/proxy-advanced/service-registry', () => ({
registerProxyAdvancedService: vi.fn(),
}));
vi.mock('os', async (importOriginal) => {
const actual = await importOriginal<typeof import('os')>();
const mockedNetworkInterfaces = vi.fn();
return {
...actual,
default: {
...actual,
networkInterfaces: mockedNetworkInterfaces,
},
networkInterfaces: mockedNetworkInterfaces,
};
});
vi.mock('@/server/server-config', () => ({
getServerConfig: vi.fn().mockReturnValue({
port: 3000,
api_key: 'test-api-key-12345',
}),
}));
import { networkInterfaces } from 'os';
import { getServerConfig } from '@/server/server-config';
describe('ProxyIdeConfigService', () => {
let service: ProxyIdeConfigService;
beforeEach(() => {
vi.clearAllMocks();
service = new ProxyIdeConfigService();
});
describe('getLocalIp', () => {
it('should return the first non-internal IPv4 address', () => {
vi.mocked(networkInterfaces).mockReturnValue({
eth0: [
{ family: 'IPv4', internal: false, address: '192.168.1.100' } as any,
{ family: 'IPv6', internal: false, address: 'fe80::1' } as any,
],
});
expect(service.getLocalIp()).toBe('192.168.1.100');
});
it('should skip internal interfaces', () => {
vi.mocked(networkInterfaces).mockReturnValue({
lo: [
{ family: 'IPv4', internal: true, address: '127.0.0.1' } as any,
],
eth0: [
{ family: 'IPv4', internal: false, address: '10.0.0.5' } as any,
],
});
expect(service.getLocalIp()).toBe('10.0.0.5');
});
it('should fallback to 127.0.0.1 when no external interface exists', () => {
vi.mocked(networkInterfaces).mockReturnValue({
lo: [
{ family: 'IPv4', internal: true, address: '127.0.0.1' } as any,
],
});
expect(service.getLocalIp()).toBe('127.0.0.1');
});
it('should handle empty network interfaces', () => {
vi.mocked(networkInterfaces).mockReturnValue({});
expect(service.getLocalIp()).toBe('127.0.0.1');
});
it('should handle null interface entries', () => {
vi.mocked(networkInterfaces).mockReturnValue({
eth0: undefined as any,
});
expect(service.getLocalIp()).toBe('127.0.0.1');
});
});
describe('generateIdeConfig', () => {
const testCases: Array<{ ide: SupportedIde; expectedFormat: string }> = [
{ ide: 'cursor', expectedFormat: 'json' },
{ ide: 'vscode', expectedFormat: 'json' },
{ ide: 'claude-code', expectedFormat: 'shell' },
{ ide: 'jetbrains', expectedFormat: 'instructions' },
{ ide: 'opencide', expectedFormat: 'env' },
];
testCases.forEach(({ ide, expectedFormat }) => {
it(`should generate ${ide} config in ${expectedFormat} format`, () => {
const result = service.generateIdeConfig(ide, 3000, 'test-key', '192.168.1.100');
expect(result.ide).toBe(ide);
expect(result.format).toBe(expectedFormat);
expect(result.content).toBeDefined();
});
});
it('should include correct base URL in all configs', () => {
const result = service.generateIdeConfig('cursor', 8080, 'key', '10.0.0.1');
if (typeof result.content === 'object') {
const values = Object.values(result.content);
expect(values.some((v) => v.includes('http://10.0.0.1:8080/v1'))).toBe(true);
} else {
expect(result.content).toContain('http://10.0.0.1:8080/v1');
}
});
it('should include API key in all configs', () => {
const result = service.generateIdeConfig('opencide', 3000, 'my-secret-key', '127.0.0.1');
expect(result.content).toContain('my-secret-key');
});
it('should throw for unsupported IDE at runtime', () => {
const unsupportedIde = 'unknown-ide' as SupportedIde;
expect(() => service.generateIdeConfig(unsupportedIde, 3000, 'key', '127.0.0.1')).toThrow(
'Unsupported IDE: unknown-ide',
);
});
});
describe('generateConfigForCurrentServer', () => {
it('should generate config using current server settings', () => {
const result = service.generateConfigForCurrentServer('cursor');
expect(result).not.toBeNull();
expect(result?.content).toBeDefined();
});
it('should return null when server config is unavailable', () => {
vi.mocked(getServerConfig).mockReturnValueOnce(null as any);
const result = service.generateConfigForCurrentServer('cursor');
expect(result).toBeNull();
});
});
});
@@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ProxyMetricsService } from '@/server/modules/proxy/proxy-metrics.service';
import { ProxyMetricsRepo } from '@/ipc/database/proxyMetricsHandler';
vi.mock('@/ipc/database/proxyMetricsHandler', () => ({
ProxyMetricsRepo: {
recordRequest: vi.fn(),
recordConnectionSnapshot: vi.fn(),
getHistoricalAggregates: vi.fn().mockReturnValue({
totalRequests: 100,
requestsPerMinute: 10.5,
avgLatencyMs: 120.5,
errorRatePercent: 2.5,
avgActiveConnections: 5,
}),
deleteOldRecords: vi.fn(),
},
}));
vi.mock('@/ipc/proxy-advanced/service-registry', () => ({
registerProxyAdvancedService: vi.fn(),
}));
describe('ProxyMetricsService', () => {
let service: ProxyMetricsService;
beforeEach(() => {
vi.useFakeTimers();
service = new ProxyMetricsService();
service.onModuleInit();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
describe('connection tracking', () => {
it('should increment active connections', () => {
service.incrementActiveConnections();
service.incrementActiveConnections();
const metrics = service.getMetrics();
expect(metrics.activeConnections).toBeDefined();
});
it('should decrement active connections without going below zero', () => {
service.decrementActiveConnections();
service.decrementActiveConnections();
const metrics = service.getMetrics();
expect(metrics.activeConnections).toBeDefined();
});
it('should track connection changes accurately', () => {
service.incrementActiveConnections();
service.incrementActiveConnections();
service.incrementActiveConnections();
service.decrementActiveConnections();
const metrics = service.getMetrics();
expect(metrics.activeConnections).toBeDefined();
});
});
describe('request recording', () => {
it('should record a request and persist to repo', () => {
const record = {
timestamp: Date.now(),
duration: 150,
status: 200,
endpoint: '/v1/chat/completions',
};
service.recordRequest(record);
expect(ProxyMetricsRepo.recordRequest).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: record.endpoint,
statusCode: record.status,
durationMs: record.duration,
timestamp: record.timestamp,
}),
);
});
it('should cap in-memory records at max size', () => {
for (let i = 0; i < 110; i++) {
service.recordRequest({
timestamp: Date.now(),
duration: i,
status: 200,
endpoint: '/v1/chat/completions',
});
}
expect(ProxyMetricsRepo.recordRequest).toHaveBeenCalledTimes(110);
});
});
describe('token throughput', () => {
it('should record positive token events', () => {
service.recordTokens(150);
service.recordTokens(250);
const metrics = service.getMetrics();
expect(metrics.tokenThroughput).toBe(400);
});
it('should ignore zero or negative token values', () => {
service.recordTokens(100);
service.recordTokens(0);
service.recordTokens(-50);
const metrics = service.getMetrics();
expect(metrics.tokenThroughput).toBe(100);
});
it('should trim token events older than 60 seconds', () => {
const now = Date.now();
service.recordTokens(100);
vi.advanceTimersByTime(61_000);
service.recordTokens(200);
const metrics = service.getMetrics();
expect(metrics.tokenThroughput).toBe(200);
});
});
describe('metrics aggregation', () => {
it('should return aggregated metrics from repo', () => {
const metrics = service.getMetrics();
expect(metrics).toMatchObject({
totalRequests: 100,
requestsPerMinute: 10.5,
avgLatency: 120.5,
errorRate: 2.5,
cacheHitRate: 0,
});
expect(metrics.tokenThroughput).toBe(0);
});
it('should round numeric values to 2 decimal places', () => {
vi.mocked(ProxyMetricsRepo.getHistoricalAggregates).mockReturnValueOnce({
totalRequests: 99,
requestsPerMinute: 10.55555,
avgLatencyMs: 120.99999,
errorRatePercent: 2.33333,
avgActiveConnections: 5.12345,
});
const metrics = service.getMetrics();
expect(metrics.requestsPerMinute).toBe(10.56);
expect(metrics.avgLatency).toBe(121);
expect(metrics.errorRate).toBe(2.33);
expect(metrics.activeConnections).toBe(5.12);
});
});
describe('cleanup scheduling', () => {
it('should schedule periodic cleanup', () => {
vi.advanceTimersByTime(24 * 60 * 60 * 1000);
expect(ProxyMetricsRepo.deleteOldRecords).toHaveBeenCalled();
});
it('should schedule connection snapshots', () => {
vi.advanceTimersByTime(30_000);
expect(ProxyMetricsRepo.recordConnectionSnapshot).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,231 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ProxyReplayService, ReplayEntry } from '@/server/modules/proxy/proxy-replay.service';
import { ProxyService } from '@/server/modules/proxy/proxy.service';
import { NotFoundException, BadRequestException } from '@nestjs/common';
vi.mock('@/ipc/proxy-advanced/service-registry', () => ({
registerProxyAdvancedService: vi.fn(),
}));
const mockProxyService = {
handleChatCompletions: vi.fn().mockResolvedValue({ choices: [{ message: { content: 'test' } }] }),
handleAnthropicMessages: vi.fn().mockResolvedValue({ content: [{ text: 'test' }] }),
handleGeminiGenerateContent: vi.fn().mockResolvedValue({ candidates: [{ content: { parts: [{ text: 'test' }] } }] }),
} as unknown as ProxyService;
describe('ProxyReplayService', () => {
let service: ProxyReplayService;
beforeEach(() => {
vi.clearAllMocks();
service = new ProxyReplayService(mockProxyService);
});
describe('recordRequest', () => {
it('should record a request entry', () => {
const entry: ReplayEntry = {
requestId: 'req-1',
timestamp: Date.now(),
endpoint: '/v1/chat/completions',
method: 'POST',
body: { model: 'gemini-pro', messages: [] },
headers: { 'Content-Type': 'application/json' },
};
service.recordRequest(entry);
const recent = service.getRecentRequests(1);
expect(recent).toHaveLength(1);
expect(recent[0].requestId).toBe('req-1');
});
it('should cap buffer at max size of 20', () => {
for (let i = 0; i < 25; i++) {
service.recordRequest({
requestId: `req-${i}`,
timestamp: Date.now(),
endpoint: '/v1/chat/completions',
method: 'POST',
body: {},
headers: {},
});
}
const all = service.getRecentRequests();
expect(all).toHaveLength(20);
});
it('should return requests in reverse chronological order', () => {
service.recordRequest({
requestId: 'req-1',
timestamp: 1000,
endpoint: '/v1/chat/completions',
method: 'POST',
body: {},
headers: {},
});
service.recordRequest({
requestId: 'req-2',
timestamp: 2000,
endpoint: '/v1/chat/completions',
method: 'POST',
body: {},
headers: {},
});
const recent = service.getRecentRequests();
expect(recent[0].requestId).toBe('req-2');
expect(recent[1].requestId).toBe('req-1');
});
it('should respect limit parameter', () => {
for (let i = 0; i < 5; i++) {
service.recordRequest({
requestId: `req-${i}`,
timestamp: Date.now(),
endpoint: '/v1/chat/completions',
method: 'POST',
body: {},
headers: {},
});
}
expect(service.getRecentRequests(2)).toHaveLength(2);
expect(service.getRecentRequests(10)).toHaveLength(5);
});
});
describe('replayRequest', () => {
it('should replay a chat completion request', async () => {
const entry: ReplayEntry = {
requestId: 'req-chat',
timestamp: Date.now(),
endpoint: '/v1/chat/completions',
method: 'POST',
body: { model: 'gemini-pro', messages: [{ role: 'user', content: 'hi' }] },
headers: {},
};
service.recordRequest(entry);
const result = await service.replayRequest('req-chat');
expect(mockProxyService.handleChatCompletions).toHaveBeenCalledWith(
expect.objectContaining({ model: 'gemini-pro' }),
);
expect(result.original).toEqual(entry);
expect(result.newResponse).toBeDefined();
});
it('should replay an anthropic messages request', async () => {
const entry: ReplayEntry = {
requestId: 'req-anthropic',
timestamp: Date.now(),
endpoint: '/v1/messages',
method: 'POST',
body: { model: 'claude-3', messages: [{ role: 'user', content: 'hi' }] },
headers: {},
};
service.recordRequest(entry);
await service.replayRequest('req-anthropic');
expect(mockProxyService.handleAnthropicMessages).toHaveBeenCalled();
});
it('should replay a responses request', async () => {
const entry: ReplayEntry = {
requestId: 'req-responses',
timestamp: Date.now(),
endpoint: '/v1/responses',
method: 'POST',
body: {
model: 'gemini-pro',
instructions: 'Be helpful',
input: 'Hello',
},
headers: {},
};
service.recordRequest(entry);
await service.replayRequest('req-responses');
expect(mockProxyService.handleChatCompletions).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({ role: 'system', content: 'Be helpful' }),
expect.objectContaining({ role: 'user', content: 'Hello' }),
]),
}),
);
});
it('should replay an image generation request', async () => {
const entry: ReplayEntry = {
requestId: 'req-image',
timestamp: Date.now(),
endpoint: '/v1/images/generations',
method: 'POST',
body: { prompt: 'A cat', size: '1024x1024' },
headers: {},
};
service.recordRequest(entry);
await service.replayRequest('req-image');
expect(mockProxyService.handleChatCompletions).toHaveBeenCalledWith(
expect.objectContaining({
messages: [{ role: 'user', content: 'A cat' }],
size: '1024x1024',
}),
);
});
it('should replay an audio transcription request', async () => {
const entry: ReplayEntry = {
requestId: 'req-audio',
timestamp: Date.now(),
endpoint: '/v1/audio/transcriptions',
method: 'POST',
body: {
model: 'gemini-pro',
prompt: 'Transcribe this',
file: { data: 'base64data', mimeType: 'audio/mp3' },
},
headers: {},
};
service.recordRequest(entry);
await service.replayRequest('req-audio');
expect(mockProxyService.handleGeminiGenerateContent).toHaveBeenCalled();
});
it('should throw NotFoundException for unknown request id', async () => {
await expect(service.replayRequest('nonexistent')).rejects.toThrow(NotFoundException);
});
it('should throw BadRequestException for unsupported endpoint', async () => {
const entry: ReplayEntry = {
requestId: 'req-unknown',
timestamp: Date.now(),
endpoint: '/v1/unknown',
method: 'GET',
body: {},
headers: {},
};
service.recordRequest(entry);
await expect(service.replayRequest('req-unknown')).rejects.toThrow(BadRequestException);
});
it('should throw BadRequestException for missing audio data', async () => {
const entry: ReplayEntry = {
requestId: 'req-audio-missing',
timestamp: Date.now(),
endpoint: '/v1/audio/transcriptions',
method: 'POST',
body: { model: 'gemini-pro' },
headers: {},
};
service.recordRequest(entry);
await expect(service.replayRequest('req-audio-missing')).rejects.toThrow(BadRequestException);
});
});
});
Loaded 100 of 331 files, more files were not shown because too many files have changed in this diff. Show more