Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5e98b02b1 |
No files matched your search
@@ -1,29 +1,17 @@
|
||||
name: Check formatting
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Prettier Format Check
|
||||
tests-unit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v4
|
||||
- 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
|
||||
@@ -1,29 +1,17 @@
|
||||
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:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
@@ -1,53 +1,22 @@
|
||||
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:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests
|
||||
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
|
||||
run: npm run test:unit
|
||||
|
||||
# NOTE: E2E tests are disabled in CI because Electron apps require a graphical environment.
|
||||
# Run E2E tests locally with: npm run test:e2e
|
||||
@@ -1,29 +0,0 @@
|
||||
## 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
|
||||
@@ -1,58 +0,0 @@
|
||||
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.
|
||||
@@ -1,102 +0,0 @@
|
||||
---
|
||||
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_
|
||||
@@ -1,92 +0,0 @@
|
||||
# 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
|
||||
@@ -1,81 +0,0 @@
|
||||
# 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
|
||||
@@ -1,125 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,156 +0,0 @@
|
||||
# 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
|
||||
@@ -1,132 +0,0 @@
|
||||
# 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`
|
||||
@@ -1,141 +0,0 @@
|
||||
# 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`
|
||||
@@ -1,276 +0,0 @@
|
||||
**✅ Documento Completo: Integração com Custom Endpoint do OpenCode (Provedores Custom / OpenAI-Compatible)**
|
||||
|
||||
OpenCode (https://opencode.ai) é um agente de IA para terminal que usa o **Vercel AI SDK** para se comunicar com provedores de LLM. Ele suporta nativamente provedores customizados via o pacote `@ai-sdk/openai-compatible`, que segue o padrão **OpenAI Chat Completions API** (`/v1/chat/completions`).
|
||||
|
||||
Isso significa que **sua API precisa implementar exatamente o formato OpenAI-compatible** para que o OpenCode consiga conversar com ela diretamente.
|
||||
|
||||
---
|
||||
|
||||
### 1. Como Configurar um Provedor Custom no OpenCode
|
||||
|
||||
Crie ou edite o arquivo de configuração (recomendado: `~/.config/opencode/opencode.json` ou `opencode.json` na raiz do projeto):
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"minha-ia-custom": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Minha IA Custom (OpenCode)",
|
||||
"options": {
|
||||
"baseURL": "https://sua-api.com/v1",
|
||||
"apiKey": "sk-sua-chave-aqui-ou-{env:MINHA_API_KEY}",
|
||||
"headers": {
|
||||
"X-Custom-Header": "valor-opcional"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"meu-modelo-principal": {
|
||||
"name": "Meu Modelo Principal",
|
||||
"limit": {
|
||||
"context": 128000,
|
||||
"output": 8192
|
||||
}
|
||||
},
|
||||
"meu-modelo-rapido": {
|
||||
"name": "Modelo Rápido",
|
||||
"limit": {
|
||||
"context": 32000,
|
||||
"output": 4096
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"model": "minha-ia-custom/meu-modelo-principal"
|
||||
}
|
||||
```
|
||||
|
||||
**Campos importantes:**
|
||||
|
||||
- `npm`: Sempre use `"@ai-sdk/openai-compatible"` para APIs que expõem `/v1/chat/completions`.
|
||||
- `baseURL`: URL base da sua API (deve terminar com `/v1`).
|
||||
- `apiKey`: Pode ser valor direto ou `{env:NOME_VARIAVEL}`.
|
||||
- `models`: Defina os modelos que sua API expõe. O `limit` ajuda o OpenCode a gerenciar contexto.
|
||||
- `model`: Define o modelo padrão no formato `provider_id/model_id`.
|
||||
|
||||
Depois de salvar, reinicie o OpenCode ou use `/models` para selecionar.
|
||||
|
||||
**Comando rápido para adicionar chave:**
|
||||
|
||||
```bash
|
||||
opencode /connect
|
||||
# Escolha "Other" → digite o provider_id (ex: minha-ia-custom) → cole a API key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Como o OpenCode Envia Requisições
|
||||
|
||||
O OpenCode (via AI SDK) faz chamadas **POST** para:
|
||||
|
||||
```
|
||||
POST {baseURL}/chat/completions
|
||||
```
|
||||
|
||||
**Headers enviados:**
|
||||
|
||||
- `Authorization: Bearer <sua-api-key>`
|
||||
- `Content-Type: application/json`
|
||||
- Headers customizados que você definir em `options.headers`
|
||||
|
||||
**Payload típico (exemplo real):**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "meu-modelo-principal",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Você é um assistente de código experiente..."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Crie uma função em Python que..."
|
||||
}
|
||||
],
|
||||
"stream": true,
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 8192,
|
||||
"tools": [
|
||||
/* array de tools se o agente estiver usando ferramentas */
|
||||
],
|
||||
"tool_choice": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
**Parâmetros comuns que o OpenCode envia:**
|
||||
|
||||
- `messages` (array completo do histórico)
|
||||
- `stream: true` (quase sempre, para UX em tempo real)
|
||||
- `temperature`, `max_tokens`, `top_p`
|
||||
- `tools` + `tool_choice` (quando usa agentes com ferramentas/MCP)
|
||||
- `response_format` (às vezes)
|
||||
- `providerOptions` (opções específicas do provedor – raras no openai-compatible)
|
||||
|
||||
---
|
||||
|
||||
### 3. Como o OpenCode Recebe e Processa Respostas
|
||||
|
||||
#### A. Streaming (recomendado – usado na maioria das interações)
|
||||
|
||||
Sua API deve responder com `Content-Type: text/event-stream` e eventos SSE:
|
||||
|
||||
```
|
||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1712345678,"model":"meu-modelo-principal","choices":[{"index":0,"delta":{"content":"Olá"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{"content":"! Como"},"finish_reason":null}]}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
O OpenCode processa cada chunk em tempo real.
|
||||
|
||||
#### B. Resposta não-streaming (completa)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"created": 1712345678,
|
||||
"model": "meu-modelo-principal",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Resposta completa aqui..."
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 150,
|
||||
"completion_tokens": 320,
|
||||
"total_tokens": 470
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Suporte a Tool Calling (essencial para agentes)
|
||||
|
||||
Se sua API suportar `tools`, o OpenCode pode:
|
||||
|
||||
1. Enviar `tools` na requisição.
|
||||
2. Receber `tool_calls` na resposta.
|
||||
3. Executar a ferramenta (via MCP ou built-in).
|
||||
4. Enviar o resultado de volta como mensagem `tool`.
|
||||
|
||||
**Exemplo de resposta com tool call:**
|
||||
|
||||
```json
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": "{\"path\": \"src/app.ts\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Como é Exibido no Chat do OpenCode (TUI)
|
||||
|
||||
- **Streaming**: Texto aparece caractere por caractere com efeito de digitação.
|
||||
- **Markdown**: Suporte completo a código (syntax highlight), listas, tabelas, etc.
|
||||
- **Tool Calls**: Mostra “Executando ferramenta: read_file...” + resultado.
|
||||
- **Multi-turn**: Mantém histórico completo na conversa.
|
||||
- **Erros**: Mostra mensagens de erro claras no terminal.
|
||||
- **Contexto**: Respeita os limites de `context` e `output` que você definiu no config.
|
||||
|
||||
O OpenCode usa o TUI (Terminal User Interface) baseado em React/Ink para renderizar tudo de forma bonita.
|
||||
|
||||
---
|
||||
|
||||
### 5. Requisitos Mínimos da Sua API para Funcionar Perfeitamente
|
||||
|
||||
| Recurso | Obrigatório? | Recomendado? | Observação |
|
||||
| ---------------------- | ------------ | ------------ | ---------------------------------- |
|
||||
| `/v1/chat/completions` | Sim | - | Endpoint principal |
|
||||
| Streaming (SSE) | Sim | - | Essencial para UX |
|
||||
| Tool Calling | Não | Sim | Para agentes completos |
|
||||
| System messages | Sim | - | - |
|
||||
| Multi-turn history | Sim | - | - |
|
||||
| Usage stats | Não | Sim | Mostra tokens gastos |
|
||||
| Error handling | Sim | - | Retorne JSON de erro padrão OpenAI |
|
||||
|
||||
**Endpoint exato esperado:**
|
||||
`POST https://sua-api.com/v1/chat/completions`
|
||||
|
||||
---
|
||||
|
||||
### 6. Exemplo Completo de Configuração + Teste
|
||||
|
||||
1. Configure o `opencode.json` como acima.
|
||||
2. Rode:
|
||||
```bash
|
||||
opencode
|
||||
```
|
||||
3. Digite algo ou use `/models` para selecionar seu provedor.
|
||||
4. Teste com:
|
||||
```bash
|
||||
curl -X POST https://sua-api.com/v1/chat/completions \
|
||||
-H "Authorization: Bearer sk-..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"meu-modelo-principal","messages":[{"role":"user","content":"Olá"}],"stream":true}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Troubleshooting Comum
|
||||
|
||||
- **“Route not found” ou requests indo para lugar errado** → Verifique se `baseURL` termina com `/v1` e não com `/v1/chat/completions`.
|
||||
- **Custom provider não aparece** → Certifique-se que o ID do provider no `/connect` bate com a chave no `opencode.json`.
|
||||
- **Sem streaming / resposta lenta** → Implemente SSE corretamente.
|
||||
- **Tool calls não funcionam** → Sua API precisa retornar `tool_calls` no formato OpenAI exato.
|
||||
- **Erro de autenticação** → Use `apiKey` ou `headers` corretamente.
|
||||
|
||||
---
|
||||
|
||||
### Referências Oficiais
|
||||
|
||||
- **Docs Principais**: https://opencode.ai/docs/providers/
|
||||
- **Models**: https://opencode.ai/docs/models/
|
||||
- **Config**: https://opencode.ai/docs/config/
|
||||
- **Schema de Config**: https://opencode.ai/config.json
|
||||
- **GitHub**: https://github.com/anomalyco/opencode
|
||||
- **AI SDK OpenAI-Compatible**: https://ai-sdk.dev/providers/openai-compatible-providers
|
||||
|
||||
---
|
||||
|
||||
**Resumo Final para Você (que está construindo a API):**
|
||||
|
||||
Sua API **precisa ser 100% compatível** com o endpoint OpenAI `/v1/chat/completions` (streaming + tool calling recomendado).
|
||||
O OpenCode vai:
|
||||
|
||||
1. Configurar via `opencode.json` (baseURL + apiKey)
|
||||
2. Enviar requisições padrão OpenAI
|
||||
3. Receber streaming ou resposta completa
|
||||
4. Renderizar tudo lindamente no TUI com suporte a ferramentas
|
||||
|
||||
Se você implementar exatamente o padrão OpenAI Chat Completions, vai funcionar perfeitamente com o OpenCode.
|
||||
|
||||
Quer que eu monte um **exemplo de servidor mínimo** (em Node.js/Fastify ou Python/FastAPI) que já funcione com o OpenCode? Posso criar um template completo para você.
|
||||
@@ -1,146 +0,0 @@
|
||||
# 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**
|
||||
+4
-3
@@ -449,7 +449,8 @@ 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/Draculabo/AntigravityManager/main/images/icon.ico',
|
||||
}),
|
||||
...(process.platform === 'win32' && process.arch === 'x64'
|
||||
? [
|
||||
@@ -483,8 +484,8 @@ const config: ForgeConfig = {
|
||||
name: '@electron-forge/publisher-github',
|
||||
config: {
|
||||
repository: {
|
||||
owner: 'evandrodevbr',
|
||||
name: 'GeminiNexus',
|
||||
owner: 'Draculabo',
|
||||
name: 'AntigravityManager',
|
||||
},
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Gemini Nexus</title>
|
||||
<link rel="icon" type="image/png" href="/src/assets/icon.png" />
|
||||
<meta http-equiv="Content-Security-Policy" content="script-src 'self';" />
|
||||
<link rel="stylesheet" href="/src/styles/global.css" />
|
||||
</head>
|
||||
|
||||
Generated
+1
-671
@@ -17,9 +17,6 @@
|
||||
"@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",
|
||||
@@ -54,7 +51,6 @@
|
||||
"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",
|
||||
@@ -143,7 +139,6 @@
|
||||
"typescript-eslint": "^8.46.3",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vite": "^5.4.21",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
},
|
||||
@@ -4133,341 +4128,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -7753,78 +7413,6 @@
|
||||
"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",
|
||||
@@ -11068,69 +10656,6 @@
|
||||
"@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",
|
||||
@@ -14931,122 +14456,6 @@
|
||||
"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",
|
||||
@@ -18827,13 +18236,6 @@
|
||||
"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",
|
||||
@@ -18881,12 +18283,6 @@
|
||||
"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",
|
||||
@@ -20013,15 +19409,6 @@
|
||||
"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",
|
||||
@@ -21702,6 +21089,7 @@
|
||||
"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": {
|
||||
@@ -26841,16 +26229,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -30311,27 +29689,6 @@
|
||||
"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",
|
||||
@@ -30894,18 +30251,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -31099,21 +30444,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
+8
-25
@@ -10,7 +10,6 @@
|
||||
"dev": "cross-env DEBUG=electron-forge:* electron-forge start",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"make:win": "node scripts/build-windows.js",
|
||||
"publish": "electron-forge publish",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --check .",
|
||||
@@ -22,7 +21,7 @@
|
||||
"test:all": "vitest run && playwright test",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"author": "evandrodevbr",
|
||||
"author": "Draculabo",
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.11.1",
|
||||
@@ -48,7 +47,7 @@
|
||||
"@semantic-release/github": "^12.0.6",
|
||||
"@sentry/vite-plugin": "^4.8.0",
|
||||
"@swc/core": "^1.15.8",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/router-plugin": "^1.135.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
@@ -65,7 +64,7 @@
|
||||
"code-inspector-plugin": "^1.3.3",
|
||||
"conventional-changelog-conventionalcommits": "^9.1.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^39.8.5",
|
||||
"electron": "^37.3.1",
|
||||
"electron-devtools-installer": "^4.0.0",
|
||||
"electron-packager-languages": "^0.6.0",
|
||||
"electron-playwright-helpers": "^1.8.2",
|
||||
@@ -90,8 +89,7 @@
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.3",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vite": "^6.4.2",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vite": "^5.4.21",
|
||||
"vitest": "^4.0.8"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -103,9 +101,6 @@
|
||||
"@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",
|
||||
@@ -128,6 +123,7 @@
|
||||
"@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",
|
||||
@@ -137,9 +133,8 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"fastify": "^5.8.5",
|
||||
"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",
|
||||
@@ -155,7 +150,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"undici": "^7.16.0",
|
||||
"update-electron-app": "^3.1.1",
|
||||
"uuid": "^14.0.0",
|
||||
"uuid": "^13.0.0",
|
||||
"winston": "^3.19.0",
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
"yaml": "^2.8.2",
|
||||
@@ -167,17 +162,5 @@
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
-17902
File diff suppressed because it is too large.
Load diff
@@ -1,12 +0,0 @@
|
||||
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
|
||||
@@ -1,50 +0,0 @@
|
||||
const { execSync, spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WIX_ZIP = path.join(process.cwd(), 'wix314-binaries.zip');
|
||||
const WIX_DIR = path.join(process.cwd(), 'tools', 'wix314');
|
||||
|
||||
function extractWix() {
|
||||
if (!fs.existsSync(WIX_ZIP)) {
|
||||
console.error(`WiX zip not found: ${WIX_ZIP}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (fs.existsSync(WIX_DIR)) {
|
||||
console.log(`WiX already extracted at ${WIX_DIR}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Extracting WiX binaries to ${WIX_DIR}...`);
|
||||
fs.mkdirSync(WIX_DIR, { recursive: true });
|
||||
|
||||
// Use PowerShell Expand-Archive (built into Windows)
|
||||
const psCommand = `Expand-Archive -Path "${WIX_ZIP}" -DestinationPath "${WIX_DIR}" -Force`;
|
||||
execSync(psCommand, { shell: 'powershell.exe', stdio: 'inherit' });
|
||||
|
||||
console.log('WiX extraction complete.');
|
||||
}
|
||||
|
||||
function setupWixPath() {
|
||||
// electron-wix-msi's detect-wix.js runs execSync('candle -?') which relies on PATH
|
||||
process.env.PATH = `${WIX_DIR};${process.env.PATH}`;
|
||||
console.log(`Added ${WIX_DIR} to PATH`);
|
||||
}
|
||||
|
||||
function runMake() {
|
||||
console.log('Running electron-forge make...');
|
||||
const make = spawn('npx', ['electron-forge', 'make'], {
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
make.on('exit', (code) => {
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
extractWix();
|
||||
setupWixPath();
|
||||
runMake();
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 390 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 659 B |
@@ -18,7 +18,7 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
|
||||
const { data: isRunning, isLoading } = useQuery({
|
||||
queryKey: ['process', 'status'],
|
||||
queryFn: isProcessRunning,
|
||||
refetchInterval: 30000,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const startMutation = useMutation({
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,119 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,209 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,173 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,135 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,190 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
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 };
|
||||
@@ -1,67 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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.
|
||||
*/
|
||||
export function ensureDatabaseInitialized(dbPath: string): void {
|
||||
function ensureDatabaseInitialized(dbPath: string): void {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
@@ -68,7 +68,6 @@ export 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
|
||||
@@ -127,40 +126,6 @@ export 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;
|
||||
@@ -172,7 +137,7 @@ export function ensureDatabaseInitialized(dbPath: string): void {
|
||||
/**
|
||||
* Gets a connection to the cloud accounts database.
|
||||
*/
|
||||
export function getCloudDb(): {
|
||||
function getCloudDb(): {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof drizzleSchema>;
|
||||
} {
|
||||
@@ -556,18 +521,12 @@ export class CloudAccountRepo {
|
||||
try {
|
||||
const rows = orm.select().from(accounts).orderBy(desc(accounts.lastUsed)).all();
|
||||
|
||||
// DEBUG LOGS
|
||||
const activeRows = rows.filter((r) => r.isActive);
|
||||
logger.debug(`getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`);
|
||||
|
||||
// Auto-activate the single account if none are active
|
||||
if (rows.length > 0 && activeRows.length === 0) {
|
||||
const targetRow = rows[0];
|
||||
logger.info(
|
||||
`No active account found. Auto-activating account: ${targetRow.email} (${targetRow.id})`,
|
||||
);
|
||||
orm.update(accounts).set({ isActive: 1 }).where(eq(accounts.id, targetRow.id)).run();
|
||||
targetRow.isActive = 1;
|
||||
}
|
||||
logger.info(
|
||||
`[DEBUG] getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`,
|
||||
);
|
||||
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`));
|
||||
|
||||
const cloudAccounts: CloudAccount[] = [];
|
||||
for (const normalizedRow of rows) {
|
||||
|
||||
@@ -1,452 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,51 +27,3 @@ 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'),
|
||||
});
|
||||
@@ -1,274 +0,0 @@
|
||||
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
@@ -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 'gemininexus' (main process)
|
||||
if (name === 'gemininexus' && !isHelperProcess(name, cmd)) {
|
||||
// Also check if the process name is exactly 'Gemini Nexus' (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 gemininexus.exe
|
||||
if (name === 'gemininexus.exe' || name === 'gemininexus') {
|
||||
// Windows: Check for Gemini Nexus.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(
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
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) };
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
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();
|
||||
}),
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -6,8 +6,6 @@ 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';
|
||||
@@ -55,7 +53,5 @@ export const router = os.use(logMiddleware).router({
|
||||
cloud: cloudRouter,
|
||||
config: configRouter,
|
||||
gateway: gatewayRouter,
|
||||
usage: usageRouter,
|
||||
proxyAdvanced: proxyAdvancedRouter,
|
||||
system: systemHandler,
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
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);
|
||||
}),
|
||||
});
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
BarChart3,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -41,11 +40,6 @@ export const MainLayout: React.FC = () => {
|
||||
icon: LayoutDashboard,
|
||||
label: t('nav.accounts'),
|
||||
},
|
||||
{
|
||||
to: '/usage',
|
||||
icon: BarChart3,
|
||||
label: t('nav.usage'),
|
||||
},
|
||||
{
|
||||
to: '/proxy',
|
||||
icon: Network,
|
||||
|
||||
@@ -694,13 +694,7 @@ 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 {
|
||||
@@ -711,10 +705,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -254,10 +254,7 @@ 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);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -58,7 +58,6 @@ i18n
|
||||
},
|
||||
nav: {
|
||||
accounts: 'Accounts',
|
||||
usage: 'Usage',
|
||||
proxy: 'API Proxy',
|
||||
settings: 'Settings',
|
||||
},
|
||||
@@ -489,138 +488,10 @@ 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -669,7 +540,6 @@ i18n
|
||||
},
|
||||
nav: {
|
||||
accounts: '账号',
|
||||
usage: '用量',
|
||||
proxy: 'API 反代',
|
||||
settings: '设置',
|
||||
},
|
||||
@@ -1093,137 +963,10 @@ 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: '请求数',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1269,7 +1012,6 @@ i18n
|
||||
},
|
||||
nav: {
|
||||
accounts: 'Аккаунты',
|
||||
usage: 'Использование',
|
||||
proxy: 'API Прокси',
|
||||
settings: 'Настройки',
|
||||
},
|
||||
@@ -1702,138 +1444,10 @@ 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: 'Запросы',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -39,7 +39,6 @@ const vi = {
|
||||
},
|
||||
nav: {
|
||||
accounts: 'Tài khoản',
|
||||
usage: 'Sử dụng',
|
||||
proxy: 'API Proxy',
|
||||
settings: 'Cài đặt',
|
||||
},
|
||||
@@ -472,79 +471,6 @@ 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',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+3
-21
@@ -9,16 +9,10 @@
|
||||
// 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',
|
||||
@@ -39,45 +33,34 @@ 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' | '/usage'
|
||||
fullPaths: '/' | '/proxy' | '/settings'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/proxy' | '/settings' | '/usage'
|
||||
id: '__root__' | '/' | '/proxy' | '/settings' | '/usage'
|
||||
to: '/' | '/proxy' | '/settings'
|
||||
id: '__root__' | '/' | '/proxy' | '/settings'
|
||||
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'
|
||||
@@ -106,7 +89,6 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ProxyRoute: ProxyRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
UsageRoute: UsageRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
+421
-629
File diff suppressed because it is too large.
Load diff
@@ -1,497 +0,0 @@
|
||||
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,
|
||||
});
|
||||
@@ -82,17 +82,12 @@ 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;
|
||||
|
||||
+7
-3
@@ -22,9 +22,13 @@ export async function bootstrapNestServer(config: ProxyConfig): Promise<boolean>
|
||||
setServerConfig(config);
|
||||
|
||||
try {
|
||||
app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter({ bodyLimit: 10 * 1024 * 1024 }),
|
||||
{
|
||||
logger: ['error', 'warn', 'log'],
|
||||
},
|
||||
);
|
||||
|
||||
// Enable CORS
|
||||
app.enableCors();
|
||||
|
||||
@@ -184,8 +184,6 @@ export interface OpenAIChatResponse {
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
/** Optional identifier for the model's configuration snapshot. Included for OpenAI-spec compliance. */
|
||||
system_fingerprint?: string;
|
||||
}
|
||||
|
||||
export interface OpenAIChoice {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { isString } from 'lodash-es';
|
||||
|
||||
@Injectable()
|
||||
export class ProxyTransformInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const http = context.switchToHttp();
|
||||
const request = http.getRequest<FastifyRequest>();
|
||||
const body = request.body as Record<string, unknown>;
|
||||
|
||||
if (body && typeof body === 'object') {
|
||||
// Normalize model field to string
|
||||
if (body.model !== undefined && !isString(body.model)) {
|
||||
body.model = String(body.model);
|
||||
}
|
||||
|
||||
// Ensure messages array exists and elements have required fields
|
||||
if (Array.isArray(body.messages)) {
|
||||
body.messages = body.messages.map((msg: Record<string, unknown>) => ({
|
||||
role: isString(msg.role) ? msg.role : 'user',
|
||||
content: msg.content !== undefined ? msg.content : '',
|
||||
...(msg.tool_calls !== undefined ? { tool_calls: msg.tool_calls } : {}),
|
||||
...(msg.tool_call_id !== undefined ? { tool_call_id: msg.tool_call_id } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
// Normalize temperature to number if present
|
||||
if (body.temperature !== undefined && typeof body.temperature !== 'number') {
|
||||
const parsed = parseFloat(String(body.temperature));
|
||||
if (!isNaN(parsed)) {
|
||||
body.temperature = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize top_p to number if present
|
||||
if (body.top_p !== undefined && typeof body.top_p !== 'number') {
|
||||
const parsed = parseFloat(String(body.top_p));
|
||||
if (!isNaN(parsed)) {
|
||||
body.top_p = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize max_tokens to number if present
|
||||
if (body.max_tokens !== undefined && typeof body.max_tokens !== 'number') {
|
||||
const parsed = parseInt(String(body.max_tokens), 10);
|
||||
if (!isNaN(parsed)) {
|
||||
body.max_tokens = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,28 +5,11 @@ 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,
|
||||
TokenUsageService,
|
||||
ProxyReplayService,
|
||||
ProxyMetricsService,
|
||||
ProxyIdeConfigService,
|
||||
ProxyEventBus,
|
||||
ProxyTransformInterceptor,
|
||||
],
|
||||
providers: [ProxyService, TokenManagerService, GeminiClient, ProxyGuard],
|
||||
exports: [TokenManagerService],
|
||||
})
|
||||
export class ProxyModule {}
|
||||
@@ -2,7 +2,6 @@ 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,90 +33,20 @@ import {
|
||||
import { getMaxOutputTokens, getThinkingBudget } from '../../../lib/geminiNexus/ModelSpecs';
|
||||
import { resolveRequestUserAgent } from './request-user-agent';
|
||||
import { UpstreamRequestError } from './clients/upstream-error';
|
||||
import { trafficLogger } from '../../../utils/traffic-logger';
|
||||
import { encode } from 'gpt-tokenizer';
|
||||
|
||||
export interface ProxyRequestContext {
|
||||
requestId?: string;
|
||||
accountId?: string;
|
||||
retryCount?: number;
|
||||
model?: string;
|
||||
cacheStatus?: 'HIT' | 'MISS';
|
||||
}
|
||||
|
||||
export interface ProxyConfigOverride {
|
||||
accountId?: string;
|
||||
schedulingMode?: 'cache-first' | 'balance' | 'performance-first';
|
||||
maxRetries?: number;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProxyService {
|
||||
private readonly logger = new Logger(ProxyService.name);
|
||||
private readonly responseCache = new Map<string, { response: OpenAIChatResponse; expiry: number }>();
|
||||
private readonly CACHE_TTL_MS = 60000; // 1 minute
|
||||
|
||||
constructor(
|
||||
@Inject(TokenManagerService) private readonly tokenManager: TokenManagerService,
|
||||
@Inject(GeminiClient) private readonly geminiClient: GeminiClient,
|
||||
@Inject(TokenUsageService) private readonly tokenUsageService: TokenUsageService,
|
||||
) {}
|
||||
|
||||
private recordUsage(
|
||||
accountId: string,
|
||||
model: string,
|
||||
usage:
|
||||
| { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number }
|
||||
| undefined
|
||||
| null,
|
||||
requestType: string,
|
||||
isEstimated?: boolean,
|
||||
): void {
|
||||
if (!usage) {
|
||||
return;
|
||||
}
|
||||
this.tokenUsageService.recordUsage({
|
||||
accountId,
|
||||
model,
|
||||
promptTokens: usage.promptTokenCount ?? 0,
|
||||
completionTokens: usage.candidatesTokenCount ?? 0,
|
||||
totalTokens: usage.totalTokenCount ?? 0,
|
||||
timestamp: Date.now(),
|
||||
requestType,
|
||||
isEstimated,
|
||||
});
|
||||
}
|
||||
|
||||
private estimateTokens(text: string): number {
|
||||
try {
|
||||
return encode(text).length;
|
||||
} catch {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
}
|
||||
|
||||
private extractUsageFromMetadata(
|
||||
meta: unknown,
|
||||
):
|
||||
| { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number }
|
||||
| undefined {
|
||||
if (!meta || typeof meta !== 'object') return undefined;
|
||||
const m = meta as Record<string, unknown>;
|
||||
return {
|
||||
promptTokenCount: typeof m.promptTokenCount === 'number' ? m.promptTokenCount : undefined,
|
||||
candidatesTokenCount:
|
||||
typeof m.candidatesTokenCount === 'number' ? m.candidatesTokenCount : undefined,
|
||||
totalTokenCount: typeof m.totalTokenCount === 'number' ? m.totalTokenCount : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Anthropic Handlers ---
|
||||
|
||||
async handleAnthropicMessages(
|
||||
request: AnthropicChatRequest,
|
||||
context?: ProxyRequestContext,
|
||||
configOverride?: ProxyConfigOverride,
|
||||
): Promise<AnthropicChatResponse | Observable<string>> {
|
||||
const sessionKey = this.extractAnthropicSessionKey(request);
|
||||
|
||||
@@ -143,17 +72,11 @@ 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,
|
||||
@@ -177,7 +100,7 @@ export class ProxyService {
|
||||
token.token.upstream_proxy_url,
|
||||
extraHeaders,
|
||||
);
|
||||
return this.processAnthropicInternalStream(stream, geminiBody.model, token.id);
|
||||
return this.processAnthropicInternalStream(stream, geminiBody.model);
|
||||
} else {
|
||||
const response = await this.generateInternalWithStreamFallback(
|
||||
geminiBody,
|
||||
@@ -185,7 +108,6 @@ export class ProxyService {
|
||||
token.token.upstream_proxy_url,
|
||||
extraHeaders,
|
||||
);
|
||||
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'anthropic');
|
||||
return this.toAnthropicChatResponse(transformResponse(response));
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -209,7 +131,7 @@ export class ProxyService {
|
||||
token.token.upstream_proxy_url,
|
||||
extraHeaders,
|
||||
);
|
||||
return this.processAnthropicInternalStream(stream, fallbackBody.model, token.id);
|
||||
return this.processAnthropicInternalStream(stream, fallbackBody.model);
|
||||
} else {
|
||||
const response = await this.generateInternalWithStreamFallback(
|
||||
fallbackBody,
|
||||
@@ -217,7 +139,6 @@ export class ProxyService {
|
||||
token.token.upstream_proxy_url,
|
||||
extraHeaders,
|
||||
);
|
||||
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'anthropic');
|
||||
return this.toAnthropicChatResponse(transformResponse(response));
|
||||
}
|
||||
} catch (fallbackErr) {
|
||||
@@ -248,7 +169,7 @@ export class ProxyService {
|
||||
token.token.upstream_proxy_url,
|
||||
extraHeaders,
|
||||
);
|
||||
return this.processAnthropicInternalStream(stream, downgradedBody.model, token.id);
|
||||
return this.processAnthropicInternalStream(stream, downgradedBody.model);
|
||||
} else {
|
||||
const response = await this.generateInternalWithStreamFallback(
|
||||
downgradedBody,
|
||||
@@ -256,7 +177,6 @@ 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,
|
||||
@@ -284,7 +204,6 @@ export class ProxyService {
|
||||
private processAnthropicInternalStream(
|
||||
upstreamStream: NodeJS.ReadableStream,
|
||||
_model: string,
|
||||
accountId: string,
|
||||
): Observable<string> {
|
||||
return new Observable<string>((subscriber) => {
|
||||
const decoder = new TextDecoder();
|
||||
@@ -295,7 +214,6 @@ export class ProxyService {
|
||||
|
||||
let lastFinishReason: string | undefined;
|
||||
let lastUsageMetadata: Record<string, unknown> | undefined;
|
||||
let accumulatedText = '';
|
||||
|
||||
let receivedData = false;
|
||||
|
||||
@@ -332,9 +250,6 @@ 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
|
||||
@@ -356,23 +271,6 @@ 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();
|
||||
});
|
||||
|
||||
@@ -440,7 +338,6 @@ 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)) {
|
||||
@@ -463,7 +360,6 @@ 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;
|
||||
@@ -537,7 +433,7 @@ export class ProxyService {
|
||||
token.token.upstream_proxy_url,
|
||||
extraHeaders,
|
||||
);
|
||||
return this.passthroughSseStream(stream, token.id, effectiveTargetModel);
|
||||
return this.passthroughSseStream(stream);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && this.isProjectContextError(err.message)) {
|
||||
this.logger.warn(
|
||||
@@ -559,7 +455,7 @@ export class ProxyService {
|
||||
token.token.upstream_proxy_url,
|
||||
extraHeaders,
|
||||
);
|
||||
return this.passthroughSseStream(stream, token.id, effectiveTargetModel);
|
||||
return this.passthroughSseStream(stream);
|
||||
} catch (fallbackErr) {
|
||||
lastError = fallbackErr;
|
||||
}
|
||||
@@ -580,44 +476,14 @@ export class ProxyService {
|
||||
throw lastError || new Error('Gemini stream request failed after retries');
|
||||
}
|
||||
|
||||
private passthroughSseStream(
|
||||
upstreamStream: NodeJS.ReadableStream,
|
||||
accountId: string,
|
||||
model: string,
|
||||
): Observable<string> {
|
||||
private passthroughSseStream(upstreamStream: NodeJS.ReadableStream): 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;
|
||||
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());
|
||||
subscriber.next(decoder.decode(chunk, { stream: true }));
|
||||
});
|
||||
|
||||
upstreamStream.on('end', () => {
|
||||
@@ -625,23 +491,6 @@ 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();
|
||||
});
|
||||
|
||||
@@ -842,35 +691,19 @@ export class ProxyService {
|
||||
|
||||
async handleChatCompletions(
|
||||
request: OpenAIChatRequest,
|
||||
context?: ProxyRequestContext,
|
||||
configOverride?: ProxyConfigOverride,
|
||||
_requestId?: string,
|
||||
): Promise<OpenAIChatResponse | Observable<string>> {
|
||||
const sessionKey = this.extractOpenAISessionKey(request);
|
||||
|
||||
const targetModel = this.resolveTargetModel(request.model);
|
||||
const extraHeaders = this.createModelSpecificHeaders(request.model);
|
||||
this.logger.log(
|
||||
`Anthropic request received: model=${request.model}, mappedModel=${targetModel}, stream=${request.stream}`,
|
||||
`OpenAI-compatible request received: model=${request.model}, mappedModel=${targetModel}, stream=${request.stream}`,
|
||||
);
|
||||
const sessionKey = this.extractOpenAISessionKey(request);
|
||||
const requestId = uuidv4();
|
||||
|
||||
// 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
|
||||
// Retry loop for account selection
|
||||
let lastError: unknown = null;
|
||||
const maxRetries = configOverride?.maxRetries ?? 3;
|
||||
const maxRetries = 3;
|
||||
const attemptedAccountIds = new Set<string>();
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
@@ -896,12 +729,6 @@ 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);
|
||||
@@ -914,26 +741,13 @@ export class ProxyService {
|
||||
// Use v1internal API (same as Anthropic handler)
|
||||
if (request.stream) {
|
||||
try {
|
||||
trafficLogger.logUpstream(
|
||||
requestId || 'unknown',
|
||||
'/v1/chat/completions',
|
||||
geminiBody,
|
||||
extraHeaders,
|
||||
{ model: effectiveTargetModel },
|
||||
);
|
||||
const stream = await this.geminiClient.streamGenerateInternal(
|
||||
geminiBody,
|
||||
token.token.access_token,
|
||||
token.token.upstream_proxy_url,
|
||||
extraHeaders,
|
||||
);
|
||||
return this.processStreamResponse(
|
||||
stream,
|
||||
request.model,
|
||||
requestId,
|
||||
token.id,
|
||||
effectiveTargetModel,
|
||||
);
|
||||
return this.processStreamResponse(stream, request.model, request.stream_options);
|
||||
} catch (streamError) {
|
||||
this.logger.warn(
|
||||
`Stream path failed for model=${request.model}; falling back to non-stream generation: ${
|
||||
@@ -941,27 +755,12 @@ export class ProxyService {
|
||||
}`,
|
||||
);
|
||||
|
||||
trafficLogger.logUpstream(
|
||||
requestId || 'unknown',
|
||||
'/v1/chat/completions',
|
||||
geminiBody,
|
||||
extraHeaders,
|
||||
{ model: effectiveTargetModel },
|
||||
);
|
||||
const response = await this.generateInternalWithStreamFallback(
|
||||
geminiBody,
|
||||
token.token.access_token,
|
||||
token.token.upstream_proxy_url,
|
||||
extraHeaders,
|
||||
);
|
||||
trafficLogger.logUpstreamResponse(
|
||||
requestId || 'unknown',
|
||||
'/v1/chat/completions',
|
||||
200,
|
||||
response,
|
||||
0,
|
||||
);
|
||||
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai');
|
||||
this.logger.log(
|
||||
`Upstream response snippet after stream fallback: ${JSON.stringify(response).substring(0, 500)}`,
|
||||
);
|
||||
@@ -982,19 +781,12 @@ 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)}`,
|
||||
);
|
||||
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;
|
||||
return this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && this.isProjectContextError(err.message)) {
|
||||
@@ -1008,57 +800,23 @@ export class ProxyService {
|
||||
fallbackBody.model = effectiveTargetModel;
|
||||
this.applyInternalGenerationConstraints(fallbackBody, effectiveTargetModel, token.id);
|
||||
if (request.stream) {
|
||||
trafficLogger.logUpstream(
|
||||
requestId || 'unknown',
|
||||
'/v1/chat/completions',
|
||||
fallbackBody,
|
||||
extraHeaders,
|
||||
{ model: effectiveTargetModel },
|
||||
);
|
||||
const stream = await this.geminiClient.streamGenerateInternal(
|
||||
fallbackBody,
|
||||
token.token.access_token,
|
||||
token.token.upstream_proxy_url,
|
||||
extraHeaders,
|
||||
);
|
||||
return this.processStreamResponse(
|
||||
stream,
|
||||
request.model,
|
||||
requestId,
|
||||
token.id,
|
||||
effectiveTargetModel,
|
||||
);
|
||||
return this.processStreamResponse(stream, request.model, request.stream_options);
|
||||
}
|
||||
|
||||
trafficLogger.logUpstream(
|
||||
requestId || 'unknown',
|
||||
'/v1/chat/completions',
|
||||
fallbackBody,
|
||||
extraHeaders,
|
||||
{ model: effectiveTargetModel },
|
||||
);
|
||||
const response = await this.generateInternalWithStreamFallback(
|
||||
fallbackBody,
|
||||
token.token.access_token,
|
||||
token.token.upstream_proxy_url,
|
||||
extraHeaders,
|
||||
);
|
||||
trafficLogger.logUpstreamResponse(
|
||||
requestId || 'unknown',
|
||||
'/v1/chat/completions',
|
||||
200,
|
||||
response,
|
||||
0,
|
||||
);
|
||||
this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai');
|
||||
const claudeResponse = transformResponse(response);
|
||||
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;
|
||||
return this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
|
||||
} catch (fallbackErr) {
|
||||
lastError = fallbackErr;
|
||||
}
|
||||
@@ -1195,44 +953,38 @@ export class ProxyService {
|
||||
private processStreamResponse(
|
||||
upstreamStream: NodeJS.ReadableStream,
|
||||
model: string,
|
||||
requestId: string | undefined,
|
||||
accountId: string,
|
||||
effectiveTargetModel: string,
|
||||
streamOptions?: { include_usage?: boolean },
|
||||
): Observable<string> {
|
||||
return new Observable<string>((subscriber) => {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let hasEmittedChunk = false;
|
||||
let hasSentRoleChunk = false;
|
||||
let hasSentDone = false;
|
||||
let isThinking = false;
|
||||
let toolCallIndex = 0;
|
||||
let lastUsageMetadata: Record<string, unknown> | undefined;
|
||||
let accumulatedText = '';
|
||||
let lastUsageMetadata: GeminiResponse['usageMetadata'];
|
||||
|
||||
const streamId = `chatcmpl-${uuidv4()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
|
||||
const pushRoleChunk = (): void => {
|
||||
if (hasSentRoleChunk) return;
|
||||
hasSentRoleChunk = true;
|
||||
const roleChunk = {
|
||||
id: streamId,
|
||||
object: 'chat.completion.chunk',
|
||||
created,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { role: 'assistant', content: '' },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
subscriber.next(`data: ${JSON.stringify(roleChunk)}\n\n`);
|
||||
// Emit initial role chunk immediately to prevent strict clients (like Hermes) from timing out during long TTFT
|
||||
const initialChunk = {
|
||||
id: streamId,
|
||||
object: 'chat.completion.chunk',
|
||||
created: created,
|
||||
model: model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { role: 'assistant', content: '' },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
subscriber.next(`data: ${JSON.stringify(initialChunk)}\n\n`);
|
||||
hasEmittedChunk = true;
|
||||
|
||||
const pushChunk = (payload: Record<string, unknown>): void => {
|
||||
hasEmittedChunk = true;
|
||||
subscriber.next(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
};
|
||||
|
||||
@@ -1250,27 +1002,20 @@ export class ProxyService {
|
||||
|
||||
try {
|
||||
const json = JSON.parse(dataStr);
|
||||
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 candidate = json.candidates?.[0];
|
||||
const parts = candidate?.content?.parts || [];
|
||||
|
||||
if (json.usageMetadata) {
|
||||
lastUsageMetadata = json.usageMetadata;
|
||||
}
|
||||
|
||||
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.
|
||||
let textToEmit = part.text;
|
||||
if (!isThinking) {
|
||||
isThinking = true;
|
||||
textToEmit = `<think>\n${textToEmit}`;
|
||||
}
|
||||
const reasoningChunk = {
|
||||
id: streamId,
|
||||
object: 'chat.completion.chunk',
|
||||
@@ -1279,20 +1024,22 @@ export class ProxyService {
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { reasoning_content: part.text },
|
||||
delta: { content: textToEmit },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
pushRoleChunk();
|
||||
pushChunk(reasoningChunk);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.functionCall) {
|
||||
pushRoleChunk();
|
||||
const currentToolIndex = toolCallIndex++;
|
||||
const toolCallChunk = {
|
||||
const toolCallId = part.functionCall.id || `${part.functionCall.name}-${uuidv4()}`;
|
||||
const argsJson = JSON.stringify(part.functionCall.args || {});
|
||||
|
||||
// First delta: id, type, name, empty arguments
|
||||
pushChunk({
|
||||
id: streamId,
|
||||
object: 'chat.completion.chunk',
|
||||
created: created,
|
||||
@@ -1304,11 +1051,11 @@ export class ProxyService {
|
||||
tool_calls: [
|
||||
{
|
||||
index: currentToolIndex,
|
||||
id: part.functionCall.id || `${part.functionCall.name}-${uuidv4()}`,
|
||||
id: toolCallId,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: part.functionCall.name,
|
||||
arguments: JSON.stringify(part.functionCall.args || {}),
|
||||
arguments: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1316,8 +1063,35 @@ export class ProxyService {
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
pushChunk(toolCallChunk);
|
||||
});
|
||||
|
||||
// Subsequent deltas: only arguments (split if large to respect SSE framing)
|
||||
const ARG_CHUNK_SIZE = 1000;
|
||||
for (let i = 0; i < argsJson.length; i += ARG_CHUNK_SIZE) {
|
||||
const slice = argsJson.slice(i, i + ARG_CHUNK_SIZE);
|
||||
pushChunk({
|
||||
id: streamId,
|
||||
object: 'chat.completion.chunk',
|
||||
created: created,
|
||||
model: model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: currentToolIndex,
|
||||
function: {
|
||||
arguments: slice,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1338,14 +1112,16 @@ export class ProxyService {
|
||||
},
|
||||
],
|
||||
};
|
||||
pushRoleChunk();
|
||||
pushChunk(imageChunk);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.text) {
|
||||
accumulatedText += part.text;
|
||||
pushRoleChunk();
|
||||
let textToEmit = part.text;
|
||||
if (isThinking && !part.thought) {
|
||||
isThinking = false;
|
||||
textToEmit = `\n</think>\n\n${textToEmit}`;
|
||||
}
|
||||
const contentChunk = {
|
||||
id: streamId,
|
||||
object: 'chat.completion.chunk',
|
||||
@@ -1354,7 +1130,7 @@ export class ProxyService {
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { content: part.text },
|
||||
delta: { content: textToEmit },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
@@ -1364,13 +1140,23 @@ export class ProxyService {
|
||||
}
|
||||
|
||||
if (candidate?.finishReason) {
|
||||
// Per the OpenAI spec, finish_reason must be "tool_calls" when the
|
||||
// response contains function calls. Gemini always sends "STOP" even
|
||||
// for tool call responses, so we infer from toolCallIndex.
|
||||
const mappedFinishReason =
|
||||
toolCallIndex > 0
|
||||
? 'tool_calls'
|
||||
: this.mapGeminiFinishReasonToOpenAIFinishReason(candidate.finishReason);
|
||||
if (isThinking) {
|
||||
isThinking = false;
|
||||
const closingChunk = {
|
||||
id: streamId,
|
||||
object: 'chat.completion.chunk',
|
||||
created: created,
|
||||
model: model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { content: '\n</think>\n\n' },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
pushChunk(closingChunk);
|
||||
}
|
||||
|
||||
const finishChunk = {
|
||||
id: streamId,
|
||||
@@ -1381,16 +1167,19 @@ export class ProxyService {
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: mappedFinishReason,
|
||||
finish_reason: this.mapGeminiFinishReasonToOpenAIFinishReason(
|
||||
candidate.finishReason,
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
pushChunk(finishChunk);
|
||||
subscriber.next('data: [DONE]\n\n');
|
||||
hasSentDone = true;
|
||||
subscriber.complete();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors on individual SSE lines
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1411,36 +1200,41 @@ export class ProxyService {
|
||||
],
|
||||
});
|
||||
}
|
||||
if (streamOptions?.include_usage && lastUsageMetadata) {
|
||||
const usageChunk = {
|
||||
id: streamId,
|
||||
object: 'chat.completion.chunk',
|
||||
created,
|
||||
model,
|
||||
choices: [],
|
||||
usage: {
|
||||
prompt_tokens: lastUsageMetadata.promptTokenCount || 0,
|
||||
completion_tokens: lastUsageMetadata.candidatesTokenCount || 0,
|
||||
total_tokens:
|
||||
(lastUsageMetadata.promptTokenCount || 0) +
|
||||
(lastUsageMetadata.candidatesTokenCount || 0),
|
||||
},
|
||||
};
|
||||
pushChunk(usageChunk);
|
||||
}
|
||||
if (!hasSentDone) {
|
||||
subscriber.next('data: [DONE]\n\n');
|
||||
hasSentDone = true;
|
||||
}
|
||||
const usage = this.extractUsageFromMetadata(lastUsageMetadata);
|
||||
if (usage) {
|
||||
this.recordUsage(accountId, effectiveTargetModel, usage, 'openai');
|
||||
} else if (accumulatedText) {
|
||||
const estimated = this.estimateTokens(accumulatedText);
|
||||
this.recordUsage(
|
||||
accountId,
|
||||
effectiveTargetModel,
|
||||
{
|
||||
promptTokenCount: 0,
|
||||
candidatesTokenCount: estimated,
|
||||
totalTokenCount: estimated,
|
||||
},
|
||||
'openai',
|
||||
true,
|
||||
);
|
||||
}
|
||||
subscriber.complete();
|
||||
});
|
||||
|
||||
upstreamStream.on('error', (err: unknown) => {
|
||||
// Convert to clean Error to avoid circular reference issues (socket objects)
|
||||
upstreamStream.on('error', (err) => {
|
||||
this.logger.error('OpenAI-compatible stream error:', err);
|
||||
const cleanError = err instanceof Error ? new Error(err.message) : new Error(String(err));
|
||||
this.logger.error(`OpenAI-compatible stream error: ${cleanError.message}`);
|
||||
subscriber.error(cleanError);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (typeof (upstreamStream as any).destroy === 'function') {
|
||||
(upstreamStream as any).destroy();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1451,42 +1245,10 @@ export class ProxyService {
|
||||
const model = response.model;
|
||||
const choice = response.choices?.[0];
|
||||
const finishReason = choice?.finish_reason ?? 'stop';
|
||||
const toolCalls = choice?.message?.tool_calls;
|
||||
const content =
|
||||
choice?.message && isString(choice.message.content) ? choice.message.content : '';
|
||||
const chunkSize = 80;
|
||||
|
||||
// Emit tool_call deltas first when the fallback non-streaming response contains tool calls.
|
||||
// Without this, tool calls are silently dropped when the primary stream path fails.
|
||||
if (toolCalls && toolCalls.length > 0) {
|
||||
for (let tcIndex = 0; tcIndex < toolCalls.length; tcIndex++) {
|
||||
const tc = toolCalls[tcIndex];
|
||||
const tcChunk = {
|
||||
id: streamId,
|
||||
object: 'chat.completion.chunk',
|
||||
created,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: tcIndex,
|
||||
id: tc.id,
|
||||
type: tc.type,
|
||||
function: tc.function,
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
subscriber.next(`data: ${JSON.stringify(tcChunk)}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (content.length === 0) {
|
||||
const finishChunk = {
|
||||
id: streamId,
|
||||
@@ -1642,10 +1404,6 @@ 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 {
|
||||
@@ -1897,16 +1655,13 @@ export class ProxyService {
|
||||
object: 'chat.completion',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: model,
|
||||
system_fingerprint: 'fp_gemininexus',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
// When tool_calls are present, content must be null per OpenAI spec.
|
||||
content: toolCalls.length > 0 ? null : finalContent || '',
|
||||
content: finalContent ? finalContent : toolCalls.length > 0 ? null : '',
|
||||
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||
reasoning_content: reasoningContent || undefined,
|
||||
},
|
||||
finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason(
|
||||
claudeResponse.stop_reason,
|
||||
@@ -2127,75 +1882,6 @@ 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,7 +6,6 @@ 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;
|
||||
@@ -33,19 +32,10 @@ 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;
|
||||
@@ -99,11 +89,6 @@ 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;
|
||||
@@ -111,10 +96,6 @@ export class TokenManagerService implements OnModuleInit {
|
||||
private parityErrorCount = 0;
|
||||
private noGoBlocked = false;
|
||||
|
||||
constructor() {
|
||||
registerProxyAdvancedService('tokenManager', this);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.loadAccounts();
|
||||
}
|
||||
@@ -134,13 +115,6 @@ 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) {
|
||||
@@ -340,26 +314,10 @@ 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),
|
||||
@@ -373,24 +331,18 @@ export class TokenManagerService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (candidateAccountPool.length === 0) {
|
||||
this.logger.warn('No eligible account found after exclusion filtering');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.shouldExecuteShadowComparison()) {
|
||||
this.executeShadowComparison(finalCandidatePool, sessionKey, model);
|
||||
this.executeShadowComparison(candidateAccountPool, sessionKey, model);
|
||||
}
|
||||
|
||||
const selectedTokenEntry = this.isParitySchedulingEnabled()
|
||||
? await this.selectParityTokenCandidate(finalCandidatePool, sessionKey, model, now)
|
||||
: this.selectLegacyTokenCandidate(finalCandidatePool, sessionKey, now);
|
||||
? await this.selectParityTokenCandidate(candidateAccountPool, sessionKey, model, now)
|
||||
: this.selectLegacyTokenCandidate(candidateAccountPool, sessionKey, now);
|
||||
|
||||
if (!selectedTokenEntry) {
|
||||
return null;
|
||||
@@ -962,42 +914,6 @@ 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;
|
||||
@@ -1062,95 +978,6 @@ 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,
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -848,34 +848,6 @@ export class GoogleAPIService {
|
||||
): Promise<{ credits: number; expiryDate: string } | null> {
|
||||
try {
|
||||
const fetchOptions = this.getFetchOptions(proxyUrl);
|
||||
|
||||
// Try loadCodeAssist first — fetchCredits returns 404 for many accounts
|
||||
// and loadCodeAssist provides the same paid tier credit data.
|
||||
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
|
||||
const loadAssistResponse = await fetch(URLS.DAILY_LOAD_PROJECT, {
|
||||
method: 'POST',
|
||||
headers: buildInternalApiHeaders(accessToken),
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ide_type: 'ANTIGRAVITY',
|
||||
ide_version: discoveryVersion,
|
||||
ide_name: 'geminiNexus',
|
||||
},
|
||||
}),
|
||||
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||
...fetchOptions,
|
||||
});
|
||||
|
||||
if (loadAssistResponse.ok) {
|
||||
const loadAssistData = (await loadAssistResponse.json()) as LoadProjectResponse;
|
||||
const credits = extractAiCreditsFromProjectContext(loadAssistData);
|
||||
if (credits) {
|
||||
return credits;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to the legacy fetchCredits endpoint if loadCodeAssist
|
||||
// did not return usable credit data.
|
||||
const response = await fetch(URLS.FETCH_CREDITS, {
|
||||
method: 'POST',
|
||||
headers: buildInternalApiHeaders(accessToken),
|
||||
@@ -895,7 +867,34 @@ export class GoogleAPIService {
|
||||
return toAiCredits(data);
|
||||
}
|
||||
|
||||
return null;
|
||||
if (response.status !== 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `fetchCredits` has started returning 404 for some accounts. Reuse the
|
||||
// internal `loadCodeAssist` payload, which also exposes paid tier credits.
|
||||
logger.warn('[GoogleAPIService] fetchCredits returned 404, falling back to loadCodeAssist');
|
||||
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
|
||||
const fallbackResponse = await fetch(URLS.DAILY_LOAD_PROJECT, {
|
||||
method: 'POST',
|
||||
headers: buildInternalApiHeaders(accessToken),
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ide_type: 'ANTIGRAVITY',
|
||||
ide_version: discoveryVersion,
|
||||
ide_name: 'geminiNexus',
|
||||
},
|
||||
}),
|
||||
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||
...fetchOptions,
|
||||
});
|
||||
|
||||
if (!fallbackResponse.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackData = (await fallbackResponse.json()) as LoadProjectResponse;
|
||||
return extractAiCreditsFromProjectContext(fallbackData);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"contains": [
|
||||
"chat.completion.chunk",
|
||||
"Hello world",
|
||||
"finish_reason",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"model": "gemini-pro",
|
||||
"upstream": {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Hello world"
|
||||
}
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -19,13 +19,9 @@ const mockGeminiClient: any = {
|
||||
generateInternal: async () => undefined,
|
||||
};
|
||||
|
||||
const mockTokenUsageService: any = {
|
||||
recordUsage: () => undefined,
|
||||
};
|
||||
|
||||
class TestableProxyService extends ProxyService {
|
||||
constructor() {
|
||||
super(mockTokenManager, mockGeminiClient, mockTokenUsageService);
|
||||
super(mockTokenManager, mockGeminiClient);
|
||||
}
|
||||
|
||||
public createGeminiInternal(
|
||||
@@ -259,11 +255,7 @@ async function validateRuntimeAnthropicRequestFromRealTokenManager(): Promise<vo
|
||||
};
|
||||
|
||||
try {
|
||||
const service = new ProxyService(
|
||||
tokenManagerProxy as any,
|
||||
mockGeminiClient as any,
|
||||
mockTokenUsageService as any,
|
||||
);
|
||||
const service = new ProxyService(tokenManagerProxy as any, mockGeminiClient as any);
|
||||
await service.handleAnthropicMessages({
|
||||
model: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
|
||||
@@ -779,12 +779,7 @@ describe('cloud oauth client key backfill', () => {
|
||||
const { listCloudAccounts } = await import('../../ipc/cloud/handler');
|
||||
await listCloudAccounts();
|
||||
|
||||
expect(updateTokenMock).toHaveBeenCalledWith(
|
||||
'acc-legacy',
|
||||
expect.objectContaining({
|
||||
oauth_client_key: 'gemininexus_enterprise',
|
||||
}),
|
||||
);
|
||||
expect(updateTokenMock).not.toHaveBeenCalled();
|
||||
expect(setSettingMock).toHaveBeenCalledWith('oauth_client_key_backfill_v1_done', true);
|
||||
});
|
||||
});
|
||||
@@ -1,242 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,6 @@
|
||||
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(() => {
|
||||
@@ -121,7 +114,7 @@ describe('GoogleAPIService OAuth clients', () => {
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
const clients = GoogleAPIService.listOAuthClients();
|
||||
|
||||
expect(clients.some((client) => client.is_builtin)).toBe(true);
|
||||
expect(clients.find((client) => client.key === 'geminiNexus_enterprise')).toBeDefined();
|
||||
expect(clients.find((client) => client.key === 'custom_a')?.label).toBe('Custom A');
|
||||
expect(clients.find((client) => client.key === 'custom_b')?.is_active).toBe(true);
|
||||
});
|
||||
@@ -180,9 +173,7 @@ describe('GoogleAPIService user info parsing', () => {
|
||||
id: 'google-user-1',
|
||||
email: '[email protected]',
|
||||
name: 'Example User',
|
||||
given_name: 'Example',
|
||||
picture: 'https://example.com/avatar.png',
|
||||
verified_email: true,
|
||||
family_name: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -222,23 +213,27 @@ describe('GoogleAPIService fetchQuota fallback policy', () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('falls back to fetchCredits when loadCodeAssist returns no usable credits', async () => {
|
||||
it('falls back to loadCodeAssist credits when fetchCredits returns 404', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
// No paidTier.availableCredits — no usable credits
|
||||
freeTier: {},
|
||||
}),
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: vi.fn().mockResolvedValue('NOT_FOUND'),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
credits: 500,
|
||||
expiryDate: '2025-12-31',
|
||||
paidTier: {
|
||||
availableCredits: [
|
||||
{
|
||||
creditType: 'GOOGLE_ONE_AI',
|
||||
creditAmount: '1000',
|
||||
minimumCreditAmountForUsage: '50',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -254,18 +249,31 @@ 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: 500,
|
||||
expiryDate: '2025-12-31',
|
||||
credits: 1000,
|
||||
expiryDate: '',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:fetchCredits',
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:fetchCredits',
|
||||
'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',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -305,19 +313,9 @@ describe('GoogleAPIService fetchQuota fallback policy', () => {
|
||||
process.env.NO_PROXY = 'localhost,127.0.0.1,::1';
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
paidTier: {
|
||||
availableCredits: [
|
||||
{
|
||||
creditType: 'GOOGLE_ONE_AI',
|
||||
creditAmount: '1000',
|
||||
minimumCreditAmountForUsage: '50',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
ok: false,
|
||||
status: 502,
|
||||
text: vi.fn().mockResolvedValue('BAD_GATEWAY'),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
@@ -333,10 +331,7 @@ describe('GoogleAPIService fetchQuota fallback policy', () => {
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 1000,
|
||||
expiryDate: '',
|
||||
});
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toBeNull();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
|
||||
@@ -350,19 +345,9 @@ describe('GoogleAPIService fetchQuota fallback policy', () => {
|
||||
process.env.ELECTRON_PROXY_SERVER = 'http://127.0.0.1:9090';
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
paidTier: {
|
||||
availableCredits: [
|
||||
{
|
||||
creditType: 'GOOGLE_ONE_AI',
|
||||
creditAmount: '1000',
|
||||
minimumCreditAmountForUsage: '50',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
ok: false,
|
||||
status: 502,
|
||||
text: vi.fn().mockResolvedValue('BAD_GATEWAY'),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
@@ -378,10 +363,7 @@ describe('GoogleAPIService fetchQuota fallback policy', () => {
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 1000,
|
||||
expiryDate: '',
|
||||
});
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toBeNull();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,230 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,337 +0,0 @@
|
||||
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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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([]);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -48,7 +47,7 @@ describe('Process Handler', () => {
|
||||
|
||||
const result = await isProcessRunning();
|
||||
expect(result).toBe(true);
|
||||
expect(mockFindProcess).toHaveBeenCalledWith('name', 'Gemini Nexus', false);
|
||||
expect(mockFindProcess).toHaveBeenCalledWith('name', 'Gemini Nexus', true);
|
||||
});
|
||||
|
||||
it('should return false when only helper processes are found', async () => {
|
||||
|
||||
@@ -11,19 +11,14 @@ function createReplyMock() {
|
||||
return reply;
|
||||
}
|
||||
|
||||
function createReqMock() {
|
||||
return { headers: {} } as any;
|
||||
}
|
||||
|
||||
describe('ProxyController Integration', () => {
|
||||
it('routes Claude OpenAI requests to protocol parity path', async () => {
|
||||
const proxyService = {
|
||||
handleChatCompletions: vi.fn().mockResolvedValue({ ok: true }),
|
||||
handleAnthropicMessages: vi.fn(),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
const req = createReqMock();
|
||||
|
||||
await controller.chatCompletions(
|
||||
{
|
||||
@@ -31,7 +26,6 @@ describe('ProxyController Integration', () => {
|
||||
stream: false,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
} as any,
|
||||
req,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
@@ -46,9 +40,8 @@ describe('ProxyController Integration', () => {
|
||||
handleChatCompletions: vi.fn().mockResolvedValue(stream),
|
||||
handleAnthropicMessages: vi.fn(),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
const req = createReqMock();
|
||||
|
||||
await controller.chatCompletions(
|
||||
{
|
||||
@@ -56,7 +49,6 @@ describe('ProxyController Integration', () => {
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
} as any,
|
||||
req,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
@@ -91,9 +83,8 @@ describe('ProxyController Integration', () => {
|
||||
}),
|
||||
handleAnthropicMessages: vi.fn(),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
const req = createReqMock();
|
||||
|
||||
await controller.completions(
|
||||
{
|
||||
@@ -101,7 +92,6 @@ describe('ProxyController Integration', () => {
|
||||
prompt: 'hello world',
|
||||
stream: false,
|
||||
},
|
||||
req,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
@@ -110,7 +100,6 @@ describe('ProxyController Integration', () => {
|
||||
model: 'gpt-4o',
|
||||
messages: [{ role: 'user', content: 'hello world' }],
|
||||
}),
|
||||
{},
|
||||
);
|
||||
expect(reply.status).toHaveBeenCalledWith(200);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
@@ -151,9 +140,8 @@ describe('ProxyController Integration', () => {
|
||||
}),
|
||||
};
|
||||
|
||||
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
const req = createReqMock();
|
||||
|
||||
await controller.responses(
|
||||
{
|
||||
@@ -178,7 +166,6 @@ describe('ProxyController Integration', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
req,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
@@ -219,9 +206,8 @@ describe('ProxyController Integration', () => {
|
||||
handleChatCompletions: vi.fn().mockResolvedValue(stream),
|
||||
};
|
||||
|
||||
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
const req = createReqMock();
|
||||
|
||||
await controller.responses(
|
||||
{
|
||||
@@ -230,7 +216,6 @@ describe('ProxyController Integration', () => {
|
||||
input: 'hello',
|
||||
stream: true,
|
||||
},
|
||||
req,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
@@ -252,9 +237,8 @@ describe('ProxyController Integration', () => {
|
||||
}),
|
||||
};
|
||||
|
||||
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
const req = createReqMock();
|
||||
|
||||
await controller.responses(
|
||||
{
|
||||
@@ -272,7 +256,6 @@ describe('ProxyController Integration', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
req,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
@@ -300,16 +283,14 @@ describe('ProxyController Integration', () => {
|
||||
],
|
||||
}),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
const req = createReqMock();
|
||||
|
||||
await controller.imageGenerations(
|
||||
{
|
||||
model: 'gemini-3-pro-image',
|
||||
prompt: 'draw a cat',
|
||||
},
|
||||
req,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
@@ -329,16 +310,14 @@ describe('ProxyController Integration', () => {
|
||||
const proxyService = {
|
||||
handleChatCompletions: vi.fn().mockRejectedValue(new Error('429 quota exceeded')),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
const req = createReqMock();
|
||||
|
||||
await controller.imageGenerations(
|
||||
{
|
||||
model: 'gemini-3-pro-image',
|
||||
prompt: 'draw a dog',
|
||||
},
|
||||
req,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
@@ -371,16 +350,14 @@ describe('ProxyController Integration', () => {
|
||||
],
|
||||
}),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
const req = createReqMock();
|
||||
|
||||
await controller.imageGenerations(
|
||||
{
|
||||
model: 'gemini-3-pro-image',
|
||||
prompt: 'draw a fox',
|
||||
},
|
||||
req,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
@@ -410,7 +387,7 @@ describe('ProxyController Integration', () => {
|
||||
],
|
||||
}),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.imageEdits(
|
||||
@@ -437,7 +414,7 @@ describe('ProxyController Integration', () => {
|
||||
const proxyService = {
|
||||
handleChatCompletions: vi.fn(),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.imageEdits(
|
||||
@@ -471,7 +448,7 @@ describe('ProxyController Integration', () => {
|
||||
],
|
||||
}),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.audioTranscriptions(
|
||||
@@ -496,7 +473,7 @@ describe('ProxyController Integration', () => {
|
||||
const proxyService = {
|
||||
handleGeminiGenerateContent: vi.fn(),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.audioTranscriptions(
|
||||
@@ -524,9 +501,8 @@ describe('ProxyController Integration', () => {
|
||||
type: 'message',
|
||||
}),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
const req = createReqMock();
|
||||
|
||||
await controller.anthropicMessages(
|
||||
{
|
||||
@@ -534,7 +510,6 @@ describe('ProxyController Integration', () => {
|
||||
stream: false,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
} as any,
|
||||
req,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
|
||||
@@ -15,11 +15,10 @@ 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, mockTokenUsageService as any);
|
||||
super(mockTokenManager as any, mockGeminiClient as any);
|
||||
}
|
||||
|
||||
public toAnthropic(request: any): any {
|
||||
|
||||
@@ -16,17 +16,16 @@ 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, mockTokenUsageService as any);
|
||||
super(mockTokenManager as any, mockGeminiClient as any);
|
||||
}
|
||||
|
||||
public testProcessStream(stream: any, model: string = 'model'): Observable<string> {
|
||||
// Access private method using type assertion
|
||||
return (this as any).processAnthropicInternalStream(stream, model, 'acc-test');
|
||||
return (this as any).processAnthropicInternalStream(stream, model);
|
||||
}
|
||||
|
||||
public testPassthroughStream(stream: any): Observable<string> {
|
||||
@@ -649,7 +648,6 @@ describe('ProxyService Protocol Parity Fixtures', () => {
|
||||
});
|
||||
|
||||
const output = chunks.join('');
|
||||
expect(output).toContain('"role":"assistant"');
|
||||
expect(output).toContain('"reasoning_content":"reasoning text"');
|
||||
expect(output).toContain('"tool_calls"');
|
||||
expect(output).toContain('"content":"final answer"');
|
||||
@@ -691,7 +689,6 @@ describe('ProxyService Protocol Parity Fixtures', () => {
|
||||
|
||||
expect(streamResult.completed).not.toBe(true);
|
||||
expect(streamResult.error?.message).toContain('socket hang up');
|
||||
expect(chunks.join('')).toContain('"role":"assistant"');
|
||||
expect(chunks.join('')).toContain('"content":"partial output"');
|
||||
expect(chunks.join('')).not.toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import { ProxyService } from '../../server/modules/proxy/proxy.service';
|
||||
|
||||
// Minimal mocks so ProxyService can instantiate
|
||||
const mockTokenManager = {
|
||||
getNextToken: vi.fn(),
|
||||
markAsRateLimited: vi.fn(),
|
||||
markAsForbidden: vi.fn(),
|
||||
forceRefreshToken: vi.fn(),
|
||||
markFromUpstreamError: vi.fn(),
|
||||
recordParityError: vi.fn(),
|
||||
getModelOutputLimitForAccount: vi.fn(),
|
||||
getModelThinkingBudgetForAccount: vi.fn(),
|
||||
resolveDynamicModelForAccount: vi.fn((_token: unknown, model: string) => model),
|
||||
};
|
||||
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
|
||||
const mockTokenUsageService = { recordUsage: vi.fn() };
|
||||
|
||||
class TestableProxyService extends ProxyService {
|
||||
constructor() {
|
||||
super(mockTokenManager as any, mockGeminiClient as any, mockTokenUsageService as any);
|
||||
}
|
||||
|
||||
public testProcessStreamResponse(
|
||||
stream: any,
|
||||
model: string = 'gemini-3.1-pro-low',
|
||||
requestId?: string,
|
||||
) {
|
||||
return (this as any).processStreamResponse(stream, model, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
function collectChunks(observable: any): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: string[] = [];
|
||||
observable.subscribe({
|
||||
next: (chunk: string) => chunks.push(chunk),
|
||||
error: reject,
|
||||
complete: () => resolve(chunks),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function emitSseLine(stream: EventEmitter, payload: object): void {
|
||||
stream.emit('data', Buffer.from(`data: ${JSON.stringify(payload)}\n`));
|
||||
}
|
||||
|
||||
describe('ProxyService Stream Debug', () => {
|
||||
it('emits role chunk followed by content for a simple text response', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
|
||||
|
||||
const promise = collectChunks(observable);
|
||||
|
||||
emitSseLine(stream, {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Hello world' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
emitSseLine(stream, {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: '!' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
stream.emit('end');
|
||||
|
||||
const chunks = await promise;
|
||||
console.log('=== Simple text response chunks ===');
|
||||
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
|
||||
|
||||
// Should have at least role + content + finish + DONE
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(4);
|
||||
expect(chunks[0]).toContain('"role":"assistant"');
|
||||
expect(chunks.some((c) => c.includes('"content":"Hello world"'))).toBe(true);
|
||||
expect(chunks.some((c) => c.includes('"finish_reason":"stop"'))).toBe(true);
|
||||
expect(chunks[chunks.length - 1]).toContain('[DONE]');
|
||||
});
|
||||
|
||||
it('handles empty parts array gracefully', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
|
||||
|
||||
const promise = collectChunks(observable);
|
||||
|
||||
emitSseLine(stream, {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
stream.emit('end');
|
||||
|
||||
const chunks = await promise;
|
||||
console.log('=== Empty parts response chunks ===');
|
||||
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
|
||||
|
||||
// With empty parts, only finish + DONE should be emitted
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(2);
|
||||
expect(chunks.some((c) => c.includes('"finish_reason":"stop"'))).toBe(true);
|
||||
expect(chunks[chunks.length - 1]).toContain('[DONE]');
|
||||
});
|
||||
|
||||
it('handles reasoning content (thought parts)', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
|
||||
|
||||
const promise = collectChunks(observable);
|
||||
|
||||
emitSseLine(stream, {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ thought: true, text: 'Let me think...' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
emitSseLine(stream, {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Final answer' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
stream.emit('end');
|
||||
|
||||
const chunks = await promise;
|
||||
console.log('=== Reasoning response chunks ===');
|
||||
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
|
||||
|
||||
expect(chunks.some((c) => c.includes('"reasoning_content":"Let me think..."'))).toBe(true);
|
||||
expect(chunks.some((c) => c.includes('"content":"Final answer"'))).toBe(true);
|
||||
});
|
||||
|
||||
it('handles function call parts', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
|
||||
|
||||
const promise = collectChunks(observable);
|
||||
|
||||
emitSseLine(stream, {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'fc-1',
|
||||
name: 'get_weather',
|
||||
args: { city: 'London' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
stream.emit('end');
|
||||
|
||||
const chunks = await promise;
|
||||
console.log('=== Function call response chunks ===');
|
||||
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
|
||||
|
||||
expect(chunks.some((c) => c.includes('"tool_calls"'))).toBe(true);
|
||||
expect(chunks.some((c) => c.includes('"finish_reason":"tool_calls"'))).toBe(true);
|
||||
});
|
||||
|
||||
it('handles multiple SSE lines in a single buffer', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
|
||||
|
||||
const promise = collectChunks(observable);
|
||||
|
||||
// Simulate two complete SSE lines in one data chunk
|
||||
const payload1 = JSON.stringify({
|
||||
candidates: [{ content: { parts: [{ text: 'First' }] } }],
|
||||
});
|
||||
const payload2 = JSON.stringify({
|
||||
candidates: [{ content: { parts: [{ text: 'Second' }] }, finishReason: 'STOP' }],
|
||||
});
|
||||
stream.emit('data', Buffer.from(`data: ${payload1}\ndata: ${payload2}\n`));
|
||||
stream.emit('end');
|
||||
|
||||
const chunks = await promise;
|
||||
console.log('=== Multi-line buffer chunks ===');
|
||||
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
|
||||
|
||||
expect(chunks.some((c) => c.includes('"content":"First"'))).toBe(true);
|
||||
expect(chunks.some((c) => c.includes('"content":"Second"'))).toBe(true);
|
||||
expect(chunks[chunks.length - 1]).toContain('[DONE]');
|
||||
});
|
||||
|
||||
it('handles partial SSE lines across multiple data events', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
|
||||
|
||||
const promise = collectChunks(observable);
|
||||
|
||||
const payload = JSON.stringify({
|
||||
candidates: [{ content: { parts: [{ text: 'Split' }] }, finishReason: 'STOP' }],
|
||||
});
|
||||
const line = `data: ${payload}\n`;
|
||||
|
||||
// Split the line across two data events
|
||||
const mid = Math.floor(line.length / 2);
|
||||
stream.emit('data', Buffer.from(line.slice(0, mid)));
|
||||
stream.emit('data', Buffer.from(line.slice(mid)));
|
||||
stream.emit('end');
|
||||
|
||||
const chunks = await promise;
|
||||
console.log('=== Split-line buffer chunks ===');
|
||||
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
|
||||
|
||||
expect(chunks.some((c) => c.includes('"content":"Split"'))).toBe(true);
|
||||
expect(chunks[chunks.length - 1]).toContain('[DONE]');
|
||||
});
|
||||
|
||||
it('handles Gemini v1internal wrapped response format (response.candidates)', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
|
||||
|
||||
const promise = collectChunks(observable);
|
||||
|
||||
// v1internal API wraps the payload in {"response": {"candidates": [...]}}
|
||||
stream.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
`data: ${JSON.stringify({
|
||||
response: {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Hello from wrapped' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
traceId: 'abc123',
|
||||
metadata: {},
|
||||
})}\n`,
|
||||
),
|
||||
);
|
||||
|
||||
stream.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
`data: ${JSON.stringify({
|
||||
response: {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: ' response' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
},
|
||||
traceId: 'abc123',
|
||||
metadata: {},
|
||||
})}\n`,
|
||||
),
|
||||
);
|
||||
|
||||
stream.emit('end');
|
||||
|
||||
const chunks = await promise;
|
||||
console.log('=== Wrapped response format chunks ===');
|
||||
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
|
||||
|
||||
expect(chunks.some((c) => c.includes('"content":"Hello from wrapped"'))).toBe(true);
|
||||
expect(chunks.some((c) => c.includes('"content":" response"'))).toBe(true);
|
||||
expect(chunks[chunks.length - 1]).toContain('[DONE]');
|
||||
});
|
||||
|
||||
it('handles Gemini streaming chunks with no content field', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
|
||||
|
||||
const promise = collectChunks(observable);
|
||||
|
||||
// Sometimes Gemini sends candidates without content at all
|
||||
emitSseLine(stream, {
|
||||
candidates: [{ index: 0 }],
|
||||
});
|
||||
|
||||
emitSseLine(stream, {
|
||||
candidates: [{ content: { parts: [{ text: 'Late content' }] }, finishReason: 'STOP' }],
|
||||
});
|
||||
|
||||
stream.emit('end');
|
||||
|
||||
const chunks = await promise;
|
||||
console.log('=== No-content-then-late-content chunks ===');
|
||||
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
|
||||
|
||||
expect(chunks.some((c) => c.includes('"content":"Late content"'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import { ProxyService } from '../../server/modules/proxy/proxy.service';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
// Subclass to access private method
|
||||
class TestableProxyService extends ProxyService {
|
||||
constructor() {
|
||||
super({} as any, {} as any);
|
||||
}
|
||||
|
||||
public testProcessStream(
|
||||
stream: any,
|
||||
model: string = 'model',
|
||||
streamOptions?: { include_usage?: boolean },
|
||||
): Observable<string> {
|
||||
return (this as any).processStreamResponse(stream, model, streamOptions);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSseChunk(raw: string): Record<string, unknown> {
|
||||
const lines = raw.split('\n').filter((l) => l.trim().startsWith('data: '));
|
||||
if (lines.length === 0) return {};
|
||||
const payload = lines[0].slice(6).trim();
|
||||
try {
|
||||
return JSON.parse(payload) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProxyService Tool Streaming (OpenAI format)', () => {
|
||||
it('should emit tool call deltas with id+name first, then arguments separately', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
const observable = service.testProcessStream(stream, 'gpt-4o');
|
||||
|
||||
const chunks: string[] = [];
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
observable.subscribe({
|
||||
next: (chunk) => chunks.push(chunk),
|
||||
error: reject,
|
||||
complete: resolve,
|
||||
});
|
||||
});
|
||||
|
||||
const payload = JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{ functionCall: { id: 'call_123', name: 'get_weather', args: { location: 'NYC' } } },
|
||||
],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
stream.emit('data', Buffer.from(`data: ${payload}\n`));
|
||||
stream.emit('end');
|
||||
|
||||
await done;
|
||||
|
||||
const toolCallChunks = chunks.filter((c) => c.includes('"tool_calls"'));
|
||||
expect(toolCallChunks.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const first = parseSseChunk(toolCallChunks[0]);
|
||||
const firstDeltas = (first.choices as any)?.[0]?.delta?.tool_calls as any[];
|
||||
expect(firstDeltas).toBeDefined();
|
||||
expect(firstDeltas[0].index).toBe(0);
|
||||
expect(firstDeltas[0].id).toBe('call_123');
|
||||
expect(firstDeltas[0].type).toBe('function');
|
||||
expect(firstDeltas[0].function.name).toBe('get_weather');
|
||||
expect(firstDeltas[0].function.arguments).toBe('');
|
||||
|
||||
const second = parseSseChunk(toolCallChunks[1]);
|
||||
const secondDeltas = (second.choices as any)?.[0]?.delta?.tool_calls as any[];
|
||||
expect(secondDeltas).toBeDefined();
|
||||
expect(secondDeltas[0].index).toBe(0);
|
||||
expect(secondDeltas[0].function.arguments).toBe('{"location":"NYC"}');
|
||||
// Subsequent deltas must NOT repeat id/type/name to keep strict clients happy
|
||||
expect(secondDeltas[0].id).toBeUndefined();
|
||||
expect(secondDeltas[0].type).toBeUndefined();
|
||||
expect(secondDeltas[0].function.name).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should split large tool arguments into multiple argument deltas', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
const observable = service.testProcessStream(stream, 'gpt-4o');
|
||||
|
||||
const chunks: string[] = [];
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
observable.subscribe({
|
||||
next: (chunk) => chunks.push(chunk),
|
||||
error: reject,
|
||||
complete: resolve,
|
||||
});
|
||||
});
|
||||
|
||||
const bigArgs = { code: 'a'.repeat(5000) };
|
||||
const payload = JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ functionCall: { id: 'call_456', name: 'write_file', args: bigArgs } }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
stream.emit('data', Buffer.from(`data: ${payload}\n`));
|
||||
stream.emit('end');
|
||||
|
||||
await done;
|
||||
|
||||
const toolCallChunks = chunks.filter((c) => c.includes('"tool_calls"'));
|
||||
// At least: 1st (id+name) + one or more argument deltas
|
||||
expect(toolCallChunks.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const first = parseSseChunk(toolCallChunks[0]);
|
||||
expect((first.choices as any)[0].delta.tool_calls[0].function.arguments).toBe('');
|
||||
|
||||
// Concatenate all argument deltas
|
||||
let argsConcat = '';
|
||||
for (let i = 1; i < toolCallChunks.length; i++) {
|
||||
const parsed = parseSseChunk(toolCallChunks[i]);
|
||||
const argDelta = (parsed.choices as any)?.[0]?.delta?.tool_calls?.[0]?.function?.arguments;
|
||||
if (typeof argDelta === 'string') {
|
||||
argsConcat += argDelta;
|
||||
}
|
||||
}
|
||||
expect(argsConcat).toBe(JSON.stringify(bigArgs));
|
||||
});
|
||||
|
||||
it('should not break normal text streaming when no tools are present', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
const observable = service.testProcessStream(stream, 'gpt-4o');
|
||||
|
||||
const chunks: string[] = [];
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
observable.subscribe({
|
||||
next: (chunk) => chunks.push(chunk),
|
||||
error: reject,
|
||||
complete: resolve,
|
||||
});
|
||||
});
|
||||
|
||||
const payload = JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'hello world' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
stream.emit('data', Buffer.from(`data: ${payload}\n`));
|
||||
stream.emit('end');
|
||||
|
||||
await done;
|
||||
|
||||
const output = chunks.join('');
|
||||
expect(output).toContain('"content":"hello world"');
|
||||
expect(output).toContain('data: [DONE]');
|
||||
expect(output).not.toContain('"tool_calls"');
|
||||
});
|
||||
});
|
||||
Loaded 100 of 339 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user