Compare commits

..
Author SHA1 Message Date
Gemini Nexus Bot a5e98b02b1 fix(proxy): ensure OpenAI tool call streaming emits proper delta chunks and increase body limit
- Emit tool call id+name in first SSE delta and arguments in subsequent deltas
- Split large tool arguments into 1000-char chunks to prevent framing issues
- Set FastifyAdapter bodyLimit to 10MB to handle large payloads
- Add TDD tests for tool call streaming verification
2026-04-22 01:37:26 -03:00
339 changed files with 925 additions and 78992 deletions

No files matched your search

+3 -15
View File
@@ -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
+2 -14
View File
@@ -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
+3 -34
View File
@@ -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
-29
View File
@@ -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`
-276
View File
@@ -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ê.
-146
View File
@@ -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
View File
@@ -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,
-1
View File
@@ -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>
+1 -671
View File
@@ -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
View File
@@ -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"
}
}
]
}
-17902
View File
File diff suppressed because it is too large. Load diff
-12
View File
@@ -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
-50
View File
@@ -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

+1 -1
View File
@@ -18,7 +18,7 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
const { data: isRunning, isLoading } = useQuery({
queryKey: ['process', 'status'],
queryFn: isProcessRunning,
refetchInterval: 30000,
refetchInterval: 10000,
});
const startMutation = useMutation({
-39
View File
@@ -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>
);
};
-173
View File
@@ -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>
);
};
-135
View File
@@ -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>
);
};
-7
View File
@@ -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 };
-67
View File
@@ -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>
);
};
-60
View File
@@ -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>
);
}
-71
View File
@@ -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>
);
};
+7 -48
View File
@@ -59,7 +59,7 @@ function sleepSync(ms: number): void {
* Ensures that the cloud database file and schema exist.
* @param dbPath {string} The path to the database file.
*/
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) {
-452
View File
@@ -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;
}
}
}
-48
View File
@@ -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'),
});
-274
View File
@@ -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
View File
@@ -132,20 +132,20 @@ export async function isProcessRunning(): Promise<boolean> {
if (platform === 'darwin') {
// macOS: Check for Gemini Nexus.app in path
if (cmd.includes('gemininexus.app')) {
if (cmd.includes('geminiNexus.app')) {
logger.debug(
`Found Gemini Nexus process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
);
return true;
}
// Also check if the process name is exactly '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(
-380
View File
@@ -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) };
}
}
-217
View File
@@ -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;
}
-4
View File
@@ -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,
});
-70
View File
@@ -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);
}),
});
-6
View File
@@ -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,
+2 -11
View File
@@ -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);
}
+1 -4
View File
@@ -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);
}
-61
View File
@@ -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',
},
},
};
-386
View File
@@ -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: 'Запросы',
},
},
},
-74
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
-497
View File
@@ -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,
});
+2 -7
View File
@@ -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
View File
@@ -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
+1 -18
View File
@@ -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 {}
+136 -450
View File
@@ -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);
}
}
+28 -29
View File
@@ -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,
+1 -6
View File
@@ -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');
});
});
-107
View File
@@ -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');
});
});
+40 -58
View File
@@ -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);
});
});
});
-230
View File
@@ -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();
});
});
});
-123
View File
@@ -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);
});
});
});
-256
View File
@@ -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);
});
});
});
-70
View File
@@ -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();
}
-88
View File
@@ -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),
};
}
-19
View File
@@ -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([]);
}
-28
View File
@@ -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);
}
+1 -2
View File
@@ -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,
);
+1 -2
View File
@@ -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 {
+2 -5
View File
@@ -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]');
});
-330
View File
@@ -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);
});
});
+173
View File
@@ -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