Compare commits

...
Author SHA1 Message Date
semantic-release-bot cf5b669133 chore(release): 0.15.5
## [0.15.5](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.4...v0.15.5) (2026-10-01)

### 🐛 Bug Fixes

* parse complete retry durations and preserve local IDE configuration ([b193bee](https://github.com/evandrodevbr/GeminiNexus/commit/b193bee03f1aaea5f7173e53ec6cdb787717067c))

### ✅ Tests

* mock OS exports consistently and verify LAN discovery ([fa39fb3](https://github.com/evandrodevbr/GeminiNexus/commit/fa39fb34fef662c11cd8780b562fa8008b982823))
2026-10-01 02:03:18 +00:00
Evandro Fonseca Junior 4b3f193bd0 Merge pull request #11 from evandrodevbr/codex/review-2026-09-30-049051
fix: correct retry timing and preserve local IDE configuration
2026-09-30 23:02:43 -03:00
Evandro Fonseca Junior fa39fb34fe test: mock OS exports consistently and verify LAN discovery 2026-09-30 17:43:09 -03:00
Evandro Fonseca Junior b193bee03f fix: parse complete retry durations and preserve local IDE configuration 2026-09-30 17:36:01 -03:00
semantic-release-bot e8eaa049d8 chore(release): 0.15.4
## [0.15.4](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.3...v0.15.4) (2026-09-14)

### 📝 Documentation

* establish dual license (CC-BY-NC-SA + MIT) and correct attribution ([42466c2](https://github.com/evandrodevbr/GeminiNexus/commit/42466c274b36b5dde2d7d33c9016b2fab05cf551))
* refine READMEs and highlight Windows SmartScreen warning ([0dbae81](https://github.com/evandrodevbr/GeminiNexus/commit/0dbae81353c162b0b95d566493164ea6c02e2fa2))
* rewrite the README with verified behavior and honest limitations ([acb8508](https://github.com/evandrodevbr/GeminiNexus/commit/acb8508bff2f6e32691f394d21d7c8193608cd5e))

### 💄 Styles

* clear the lint and prettier findings so the gates pass ([8ad3eef](https://github.com/evandrodevbr/GeminiNexus/commit/8ad3eef53d88d049a3a25cb54df590073a67b75b))

### 🔧 Continuous Integration

* upload release assets sequentially with retry ([b13f06c](https://github.com/evandrodevbr/GeminiNexus/commit/b13f06c0a3538ff962eae2f6fc07cfc81281bf02))
2026-09-14 11:52:31 +00:00
Evandro Fonseca Junior acb8508bff docs: rewrite the README with verified behavior and honest limitations
- README.md rebuilt from the code: account pool, proxy endpoints, settings paths, verified commands, build/make steps and limitations
- translations get a pointer to the canonical README
2026-09-14 08:51:55 -03:00
Evandro Fonseca Junior 43aa538932 chore(ci): drop trailing whitespace in the publish workflow 2026-09-14 08:51:55 -03:00
Evandro Fonseca Junior 8ad3eef53d style: clear the lint and prettier findings so the gates pass
- prettier formatting across routes, components and tests (no behavior change)
- unused imports and ternary-as-statement replaced with explicit branches
- eslint: scripts/**/*.js are CommonJS, allow require() there; prettier ignores pnpm-lock.yaml
2026-09-14 08:51:55 -03:00
30 changed files with 704 additions and 657 deletions

No files matched your search

+1 -1
View File
@@ -225,7 +225,7 @@ jobs:
fi fi
printf 'Uploading %s assets to %s\n' "${#files[@]}" "$RELEASE_TAG" printf 'Uploading %s assets to %s\n' "${#files[@]}" "$RELEASE_TAG"
# Upload files sequentially with retries to prevent GitHub API rate limits and 404/502 errors # Upload files sequentially with retries to prevent GitHub API rate limits and 404/502 errors
for file in "${files[@]}"; do for file in "${files[@]}"; do
echo "Uploading $file..." echo "Uploading $file..."
+1
View File
@@ -10,3 +10,4 @@ openspec/
.agent/ .agent/
.claude/ .claude/
.codex .codex
pnpm-lock.yaml
+26
View File
@@ -2,6 +2,32 @@
# Changelog # Changelog
## [0.15.5](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.4...v0.15.5) (2026-10-01)
### 🐛 Bug Fixes
* parse complete retry durations and preserve local IDE configuration ([b193bee](https://github.com/evandrodevbr/GeminiNexus/commit/b193bee03f1aaea5f7173e53ec6cdb787717067c))
### ✅ Tests
* mock OS exports consistently and verify LAN discovery ([fa39fb3](https://github.com/evandrodevbr/GeminiNexus/commit/fa39fb34fef662c11cd8780b562fa8008b982823))
## [0.15.4](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.3...v0.15.4) (2026-09-14)
### 📝 Documentation
* establish dual license (CC-BY-NC-SA + MIT) and correct attribution ([42466c2](https://github.com/evandrodevbr/GeminiNexus/commit/42466c274b36b5dde2d7d33c9016b2fab05cf551))
* refine READMEs and highlight Windows SmartScreen warning ([0dbae81](https://github.com/evandrodevbr/GeminiNexus/commit/0dbae81353c162b0b95d566493164ea6c02e2fa2))
* rewrite the README with verified behavior and honest limitations ([acb8508](https://github.com/evandrodevbr/GeminiNexus/commit/acb8508bff2f6e32691f394d21d7c8193608cd5e))
### 💄 Styles
* clear the lint and prettier findings so the gates pass ([8ad3eef](https://github.com/evandrodevbr/GeminiNexus/commit/8ad3eef53d88d049a3a25cb54df590073a67b75b))
### 🔧 Continuous Integration
* upload release assets sequentially with retry ([b13f06c](https://github.com/evandrodevbr/GeminiNexus/commit/b13f06c0a3538ff962eae2f6fc07cfc81281bf02))
## [0.15.3](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.2...v0.15.3) (2026-05-02) ## [0.15.3](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.2...v0.15.3) (2026-05-02)
### 👷 Build System ### 👷 Build System
+2 -1
View File
@@ -194,7 +194,7 @@ Al usar IDEs y herramientas de programación con IA, ¿te has encontrado con est
Puedes descargar los binarios precompilados más recientes para Windows, macOS y Linux desde nuestra [página de Releases](https://github.com/evandrodevbr/GeminiNexus/releases). Puedes descargar los binarios precompilados más recientes para Windows, macOS y Linux desde nuestra [página de Releases](https://github.com/evandrodevbr/GeminiNexus/releases).
*(Consulta la advertencia en la parte superior de esta página si usas Windows y te encuentras con el aviso de SmartScreen).* _(Consulta la advertencia en la parte superior de esta página si usas Windows y te encuentras con el aviso de SmartScreen)._
### 🔌 Uso con IDEs de IA ### 🔌 Uso con IDEs de IA
@@ -274,6 +274,7 @@ npm test
<summary><b>P: ¿Aviso de Windows SmartScreen ("Windows protegió su PC") durante la instalación?</b></summary> <summary><b>P: ¿Aviso de Windows SmartScreen ("Windows protegió su PC") durante la instalación?</b></summary>
Sí, este es un aviso común para nuevas aplicaciones no firmadas. Haz clic en **"Más información"** y luego en **"Ejecutar de todas formas"**. Consulta la advertencia en la parte superior del README para más detalles. Sí, este es un aviso común para nuevas aplicaciones no firmadas. Haz clic en **"Más información"** y luego en **"Ejecutar de todas formas"**. Consulta la advertencia en la parte superior del README para más detalles.
</details> </details>
<details> <details>
+191 -299
View File
@@ -5,7 +5,7 @@
<h1 align="center">Gemini Nexus</h1> <h1 align="center">Gemini Nexus</h1>
<p align="center"> <p align="center">
<strong>🚀 Professional multi-account AI gateway for Google Gemini & Claude</strong> <strong>Desktop gateway that pools several Google Gemini and Claude accounts behind a single local OpenAI- and Anthropic-compatible API.</strong>
</p> </p>
<p align="center"> <p align="center">
@@ -13,354 +13,246 @@
</p> </p>
<p align="center"> <p align="center">
<a href="https://github.com/evandrodevbr/GeminiNexus/releases"> <a href="https://github.com/evandrodevbr/GeminiNexus/releases"><img src="https://img.shields.io/github/v/release/evandrodevbr/GeminiNexus?style=flat-square" alt="Release" /></a>
<img src="https://img.shields.io/github/v/release/evandrodevbr/GeminiNexus?style=flat-square" alt="Release" /> <a href="https://github.com/evandrodevbr/GeminiNexus/actions/workflows/testing.yaml"><img src="https://github.com/evandrodevbr/GeminiNexus/actions/workflows/testing.yaml/badge.svg" alt="Tests" /></a>
</a>
<a href="https://github.com/evandrodevbr/GeminiNexus/releases">
<img src="https://img.shields.io/github/downloads/evandrodevbr/GeminiNexus/total?style=flat-square&color=blue" alt="Downloads" />
</a>
<a href="https://github.com/evandrodevbr/GeminiNexus/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/evandrodevbr/GeminiNexus?style=flat-square" alt="License" />
</a>
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-informational?style=flat-square" alt="Platform" /> <img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-informational?style=flat-square" alt="Platform" />
<img src="https://img.shields.io/badge/electron-latest-47848F?style=flat-square&logo=electron&logoColor=white" alt="Electron" /> <img src="https://img.shields.io/badge/electron-41-47848F?style=flat-square&logo=electron&logoColor=white" alt="Electron 41" />
<img src="https://img.shields.io/badge/react-19-61DAFB?style=flat-square&logo=react&logoColor=white" alt="React 19" />
<img src="https://img.shields.io/badge/node-%3E%3D20.19-5FA04E?style=flat-square&logo=nodedotjs&logoColor=white" alt="Node 20.19+" />
<img src="https://img.shields.io/badge/license-MIT%20%2B%20CC%20BY--NC--SA%204.0-blue?style=flat-square" alt="License" />
</p> </p>
> [!IMPORTANT] > **Windows users:** the installers are not code-signed, so SmartScreen may show "Windows protected your PC". Choose "More info" and then "Run anyway"; this is the expected prompt for an unsigned application.
> **Windows Users:** If you see a **"Windows protected your PC"** (SmartScreen) warning during installation, click **"More info"** and then **"Run anyway"**. This is an expected security prompt for new, unsigned applications.
--- ## About
## 📖 Table of Contents AI coding tools assume a single account with a single quota. When that quota runs out, you have to switch accounts by hand, and you usually have no idea how many tokens you burned.
- [✨ Why Gemini Nexus?](#-why-gemini-nexus) Gemini Nexus is an Electron desktop application that runs a local gateway (an embedded NestJS server) in front of a pool of Google Gemini and Claude accounts. Your IDE points at the local gateway once; the gateway picks an account, translates the protocol, retries on quota or rate-limit errors, and records usage.
- [🎯 Features](#-features)
- [📸 Screenshots](#-screenshots)
- [⚡ Installation & Quick Start](#-installation--quick-start)
- [🛠️ Tech Stack](#️-tech-stack)
- [💻 Development](#-development)
- [❓ FAQ](#-faq)
- [🤝 Contributing](#-contributing)
- [📄 License](#-license)
--- What it does:
## ✨ Why Gemini Nexus? - Manages a pool of Google/Claude accounts with per-account status (active, rate limited, expired) and quota snapshots.
- Serves an OpenAI-compatible surface (`/v1/chat/completions`, `/v1/models`, `/v1/completions`, `/v1/responses`, images and audio routes), an Anthropic-compatible surface (`/v1/messages`) and the Gemini-native surface (`/v1beta/models`, `countTokens`).
- Streams responses over SSE and maps model names in both directions (for example `claude-sonnet-4-5` to the configured Gemini/Claude target).
- Stores tokens and keys encrypted at rest (AES-256-GCM) with an OS keychain wrapper, falling back to a local key file.
- Shows local analytics: token usage, cost estimates, model distribution, request traffic and request replay.
When using AI-powered IDEs and coding tools, have you ever run into these problems? It is a personal desktop tool, not a hosted service: everything runs on your machine.
- 😫 **Quota limits:** Single account quotas run out quickly, requiring frequent manual switching. ## How it works
- 🔄 **Account management:** Managing multiple Google/Claude accounts is cumbersome.
- 📊 **Blind usage:** You don't know how much quota or how many tokens you've actually consumed.
- 🔌 **Integration issues:** You need a reliable local API proxy that speaks OpenAI/Anthropic protocols natively.
- 🔍 **Lack of transparency:** You can't see what's actually being sent or received by the proxy under the hood.
**Gemini Nexus** solves all of these issues. It's a professional Electron desktop application that acts as an intelligent gateway between your development tools and Google Gemini / Claude AI. ```text
IDE / CLI (Cursor, Windsurf, OpenCode, Claude Code, custom scripts)
### Key Value Proposition | OpenAI /v1/chat/completions Anthropic /v1/messages Gemini /v1beta
v
- ✅ **Unlimited Account Pool** — Add any number of Google Gemini & Claude accounts. Gemini Nexus (Electron app)
- ✅ **Smart Auto-Switching** — Automatically rotates to the next available account when quota is low or rate-limited. +-- Renderer: React 19 UI (accounts, usage, proxy, settings)
- ✅ **Real-time Usage Analytics** — SaaS-grade dashboard with area charts, trend indicators, and model distribution. +-- Main process: IPC, SQLite storage, encryption, tray
- ✅ **Full Proxy Observability** — Live traffic monitor, request replay, and model capabilities inspector. +-- Gateway: NestJS + Fastify, bound to port 8045 (8046 in development)
- ✅ **OpenAI & Anthropic Compatible** — Drop-in replacement proxy for Cursor, Windsurf, OpenCode, and any OpenAI-compatible tool. | 1. authenticate the request (Bearer / x-api-key / x-goog-api-key)
- ✅ **Secure by Default** — AES-256-GCM encryption with OS-native credential management. | 2. choose an account from the pool (quota, rate limit, circuit breaker)
| 3. translate the protocol and call the upstream API
--- | 4. stream the answer back and persist usage metrics
v
## 🎯 Features Google Gemini API / Claude API (per-account OAuth credentials)
<table>
<tr>
<td width="50%">
<h3>☁️ Cloud Account Pool</h3>
<ul>
<li>Add unlimited Google Gemini / Claude accounts via OAuth</li>
<li>Display avatar, email, status, and last used time</li>
<li>Real-time status monitoring (Active, Rate Limited, Expired)</li>
<li>Per-account proxy URL configuration</li>
<li>Device identity profile management with history</li>
</ul>
</td>
<td width="50%">
<h3>📊 Usage Analytics Dashboard</h3>
<ul>
<li>Real-time token consumption with 15s auto-refresh</li>
<li>Area charts for daily/hourly prompt & completion tokens</li>
<li>Trend indicators (% change vs previous period)</li>
<li>Inline sparklines on stat cards</li>
<li>Model distribution ranking with horizontal bar charts</li>
<li>Prompt/Completion ratio visualization</li>
</ul>
</td>
</tr>
<tr>
<td width="50%">
<h3>🔌 Local API Proxy (Gateway)</h3>
<ul>
<li>OpenAI <code>/v1/chat/completions</code> compatible endpoint</li>
<li>Anthropic <code>/v1/messages</code> compatible endpoint</li>
<li>Full SSE streaming support (tested with Cursor, Windsurf, OpenCode)</li>
<li>Model mapping (e.g. <code>claude-sonnet-4-6</code> → <code>gemini-3-flash</code>)</li>
<li>Configurable port, timeout, and API key</li>
<li>Model visibility control (show/hide specific models)</li>
</ul>
</td>
<td width="50%">
<h3>🔄 Intelligent Auto-Switching</h3>
<ul>
<li>Unlimited pool mode with smart backup selection</li>
<li>Auto-switch when quota < 5% or rate-limited</li>
<li>Background monitoring every 5 minutes</li>
<li>Graceful fallback with status reason tracking</li>
</ul>
</td>
</tr>
<tr>
<td width="50%">
<h3>🔍 Proxy Observability (Advanced)</h3>
<ul>
<li><strong>Traffic Monitor</strong> — Live request/response log with latency, status, and model info</li>
<li><strong>Request Replay</strong> — Replay any recorded request for debugging</li>
<li><strong>Model Capabilities</strong> — Inspect vision, thinking, streaming, JSON mode support per model</li>
<li><strong>Developer Tools</strong> — cURL & Python code generation, one-click copy</li>
</ul>
</td>
<td width="50%">
<h3>🔐 Security & Encryption</h3>
<ul>
<li>AES-256-GCM encryption for all sensitive data</li>
<li>OS native credential manager integration (Keytar + SafeStorage)</li>
<li>Auto migration of legacy plaintext data</li>
<li>Encrypted token & quota storage per account</li>
</ul>
</td>
</tr>
<tr>
<td width="50%">
<h3>⚙️ Settings & Customization</h3>
<ul>
<li>Dark / Light / System theme support</li>
<li>Multi-language: English & Português (Brasil)</li>
<li>Per-account proxy URL override</li>
<li>Model visibility toggles</li>
<li>Log directory access</li>
</ul>
</td>
<td width="50%">
<h3>🖥️ Desktop Experience</h3>
<ul>
<li>Native Electron app with system tray integration</li>
<li>Collapsible sidebar with persistent state</li>
<li>Responsive layouts across all views</li>
<li>Status bar with live proxy and connection indicators</li>
<li>Error boundaries with user-friendly fallbacks</li>
</ul>
</td>
</tr>
</table>
---
## 📸 Screenshots
<p align="center">
<img src="docs/assets/main.png" alt="Main Dashboard" width="80%" />
</p>
<p align="center">
<img src="docs/assets/2usage.png" alt="Usage Analytics" width="80%" />
</p>
<p align="center">
<img src="docs/assets/3proxy.png" alt="Proxy Monitor" width="80%" />
</p>
<p align="center">
<img src="docs/assets/4routing.png" alt="Routing Config" width="80%" />
</p>
<p align="center">
<img src="docs/assets/5docs.png" alt="Documentation" width="80%" />
</p>
<p align="center">
<img src="docs/assets/6connections.png" alt="Active Connections" width="80%" />
</p>
<p align="center">
<img src="docs/assets/7config.png" alt="Settings Configuration" width="80%" />
</p>
---
## ⚡ Installation & Quick Start
### 📦 Download the App
You can download the latest pre-compiled binaries for Windows, macOS, and Linux from our [Releases page](https://github.com/evandrodevbr/GeminiNexus/releases).
*(Refer to the warning at the top of this page if you are using Windows and encounter the SmartScreen prompt).*
### 🔌 Using with AI IDEs
Once the application is running and you have added at least one account, configure your preferred IDE (Cursor, Windsurf, OpenCode, etc.):
```plaintext
API Base URL: http://localhost:10100/v1
API Key: (copy this from the Proxy page in the app)
Model: gemini-3-flash (or any mapped model of your choice)
``` ```
### 🛠️ Build from Source - The gateway is started by the desktop app (Proxy page, or auto-start if enabled). It is a child process, so closing the app stops it.
- Account selection honours a scheduling mode (`balance`, `cache-first`, `performance-first`), a preferred account, per-account upstream proxy URLs, and a circuit breaker with backoff steps.
- Requests are recorded locally (SQLite) and can be replayed from the Proxy page for debugging.
#### Prerequisites ## Stack
- **Node.js** v20 or higher | Layer | Choice |
- **npm** (this project uses `package-lock.json`) |---|---|
| Desktop shell | Electron 41, Electron Forge 7 (Vite plugin) |
| UI | React 19, TypeScript 5.9, TanStack Router + Query, Tailwind CSS v4, Radix UI, Nivo charts |
| Gateway | NestJS 11 on Fastify 5, RxJS, SSE streaming |
| Storage | better-sqlite3 + Drizzle ORM / raw SQL, JSON config in the app data dir |
| Secrets | Node crypto AES-256-GCM, `safeStorage` / `keytar` with file fallback |
| Validation | Zod schemas, class-validator |
| Internationalization | react-i18next (en, zh-CN, ru, vi) |
| Tests | Vitest (unit), Testing Library, Playwright (E2E, local only) |
| Package manager | pnpm 10.11.0 (`packageManager` field, Corepack) |
#### Steps ## Requirements
- Node.js `^20.19.0 || >=22.12.0` (Vite 8 engine range). The repository pins 22.17.1 in `.nvmrc`; this audit ran on Node 24.20.0.
- pnpm `10.11.0` (declared in `package.json`). With Corepack enabled, `pnpm` resolves to that version automatically.
- Native build toolchain for `better-sqlite3` and `keytar`: on Linux, `base-devel`/`build-essential`, `python3` and `libsecret`; on macOS, Xcode Command Line Tools; on Windows, Visual Studio Build Tools with the C++ workload. `pnpm install` runs `scripts/setup-dev.js --check-only`, which reports what is missing.
- A graphical session to run the desktop app itself (Electron).
- For installers: `dpkg`/`rpmbuild` on Linux, WiX Toolset on Windows.
## Quick start
```bash ```bash
# Clone the repository
git clone https://github.com/evandrodevbr/GeminiNexus.git git clone https://github.com/evandrodevbr/GeminiNexus.git
cd GeminiNexus cd GeminiNexus
# Install dependencies # 1. install dependencies (lockfile is pnpm; do not use npm install)
npm install corepack enable
pnpm install --frozen-lockfile
# Start the application in development mode # 2. run the desktop app (Electron + Vite HMR); the gateway listens on 8046 in dev
npm start pnpm start
# Build for production (e.g., Windows installer)
npm run make
``` ```
--- Then, in the app:
## 🛠️ Tech Stack 1. Add at least one Google or Claude account on the Accounts page (OAuth flow).
2. Open the Proxy page, enable the gateway and copy the generated API key.
3. Point your IDE or CLI at the local endpoint.
| Category | Technologies | ```text
|----------|-------------| API base URL: http://localhost:8045/v1
| **Core** | [Electron](https://www.electronjs.org/), [React 19](https://react.dev/), [TypeScript](https://www.typescriptlang.org/) | API key: copied from the Proxy page
| **Build** | [Vite](https://vitejs.dev/), [Electron Forge](https://www.electronforge.io/) | Model: gemini-3-flash (or any model id returned by GET /v1/models)
| **Styling** | [Tailwind CSS v4](https://tailwindcss.com/), [Radix UI](https://www.radix-ui.com/), [Lucide Icons](https://lucide.dev/) | ```
| **State** | [TanStack Query](https://tanstack.com/query/latest), [TanStack Router](https://tanstack.com/router/latest) |
| **Backend** | [NestJS](https://nestjs.com/) (internal gateway), [ORPC](https://orpc.unnoq.com/) (type-safe RPC) |
| **Database** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3), [Drizzle ORM](https://orm.drizzle.team/) |
| **Charts** | [Nivo](https://nivo.rocks/) (Line, Bar, Pie) |
| **i18n** | [react-i18next](https://react.i18next.com/) |
| **Testing** | [Vitest](https://vitest.dev/), [Playwright](https://playwright.dev/) |
--- ## Usage
## 💻 Development Daily commands, all driven by `pnpm`:
```bash ```bash
# Start dev environment (Electron + Vite HMR) pnpm start # dev app + gateway (port 8046)
npm start pnpm run package # unpack the app for the current platform (out/)
pnpm run make # build installers (needs the extra tooling listed above)
# Run linting pnpm test # Vitest unit/integration suite
npm run lint pnpm run type-check # tsc --noEmit
pnpm run lint # ESLint
# Type check pnpm run format # Prettier check (pnpm run format:write to fix)
npm run type-check pnpm run test:e2e # Playwright E2E, needs a graphical session
# Format code
npm run format:write
# Run tests
npm test
``` ```
### Project Structure Settings live in the app data directory (`%APPDATA%\Gemini Nexus` on Windows, `~/Library/Application Support/Gemini Nexus` on macOS, `~/.config/Gemini Nexus` on Linux; `${app}-dev` names in development). Account metadata is stored in `~/.geminiNexus-agent/geminiNexus_accounts.json`.
```plaintext ## Screenshots
| | |
|---|---|
| ![Main dashboard](docs/assets/main.png) | ![Usage analytics](docs/assets/2usage.png) |
| ![Proxy monitor](docs/assets/3proxy.png) | ![Routing config](docs/assets/4routing.png) |
| ![Documentation](docs/assets/5docs.png) | ![Active connections](docs/assets/6connections.png) |
| ![Settings](docs/assets/7config.png) | |
## API
Routes actually mapped by the gateway (read from the running server):
| Route | Purpose |
|---|---|
| `GET /v1/models` | OpenAI-style model list built from the built-in mapping plus models collected from accounts. |
| `GET /v1/models/capabilities` | Per-model flags: vision, streaming, JSON mode, audio, image generation. |
| `GET /v1/status` | Gateway health, uptime, account counters and request metrics. |
| `POST /v1/chat/completions` | OpenAI Chat Completions, streaming and non-streaming. |
| `POST /v1/completions` | Legacy completions compatibility endpoint. |
| `POST /v1/responses` | OpenAI Responses-style endpoint. |
| `POST /v1/messages` | Anthropic Messages API. |
| `POST /v1/messages/chat/completions` | Anthropic-shaped requests over the chat completions path. |
| `POST /v1/batch/completions` | Batched chat completion requests. |
| `POST /v1/images/generations`, `POST /v1/images/edits` | Image endpoints. |
| `POST /v1/audio/transcriptions` | Audio transcription endpoint. |
| `GET /v1/events` | Server-sent events stream of gateway activity. |
| `GET /v1/replay/requests`, `POST /v1/replay/:requestId` | List recorded requests and replay one of them. |
| `GET /v1beta/models`, `GET /v1beta/models/:model` | Gemini-native model listing. |
| `POST /v1beta/models/:modelAction`, `POST /v1beta/models/:model/countTokens` | Gemini-native generate and token counting. |
Authentication: the gateway accepts the API key as `Authorization: Bearer <key>`, `x-api-key: <key>` or `x-goog-api-key: <key>`. If the configured key is empty, the gateway runs open, which is only sensible while it is bound to localhost.
## Build and release
```bash
pnpm run package # app bundle in out/<product>-<platform>-<arch>
pnpm run make # installers (deb, rpm, AppImage, zip on Linux; dmg/zip on macOS; WiX installer on Windows)
pnpm run make:win # Windows helper script
```
The gateway is statically bundled into the Electron app; there is no separate server deployment.
Release automation in `.github/workflows/`:
- `format.yaml` and `lint.yaml` run Prettier and ESLint on pull requests to `main`.
- `testing.yaml` runs the unit suite and `tsc --noEmit` on pushes and pull requests. E2E is intentionally excluded because it needs a graphical environment.
- `release.yml` runs semantic-release on `main`, which tags the version, updates `CHANGELOG.md` and creates the GitHub release.
- `publish.yaml` builds `win32`, `darwin` and `linux` for x64 and arm64 when the Release workflow succeeds, and uploads the installers to the release.
## Project structure
```text
src/ src/
├── actions/ # App actions and flow orchestration ├── main.ts, preload.ts, renderer.ts Electron entry points
├── components/ # React UI components ├── routes/ TanStack Router pages: index (accounts), usage, proxy, settings
│ ├── proxy/ # Proxy page components (advanced tabs) ├── components/ React UI (accounts, proxy/advanced, usage, ui primitives)
│ ├── usage/ # Usage analytics components (StatCard, ChartCard) ├── server/ Embedded gateway
│ └── ui/ # Base UI primitives (Radix-based) │ ├── main.ts NestJS bootstrap (port and process lifecycle)
├── hooks/ # Custom React hooks │ ├── modules/proxy/ controllers, proxy service, token manager, SSE, replay, metrics
├── ipc/ # Electron IPC + database handlers │ └── modules/usage/ token usage service
├── layouts/ # Layout components (MainLayout + sidebar) ├── ipc/ Electron IPC handlers (accounts, config, database, proxy, tray, ...)
├── localization/ # i18n translation resources ├── lib/geminiNexus/ protocol mappers, model mapping, retry and streaming helpers
├── routes/ # TanStack Router pages (index, usage, proxy, settings) ├── localization/ i18n resources
├── server/ # NestJS backend (proxy gateway service) ├── utils/ paths, logging, encryption, traffic logger
├── services/ # Service layer └── tests/ unit, integration and e2e suites
├── types/ # TypeScript type definitions + Zod schemas docs/ OpenCode integration, cloud features, Proxyman guides, brand notes
└── utils/ # Utility functions scripts/ dev environment setup, Windows build helper, asset generation
``` ```
--- ## Verification
## ❓ FAQ Previously recorded Linux x64 checks (Node 24.20.0, pnpm 10.11.0), before the review below:
<details> | Command | Result |
<summary><b>Q: "Windows protected your PC" (SmartScreen) warning during installation?</b></summary> |---|---|
| `pnpm install --frozen-lockfile` | exit 0 |
| `pnpm test` | 54 test files, 499 tests, all passing |
| `pnpm run type-check` | exit 0 |
| `pnpm run lint` | exit 0 (327 warnings, no errors) |
| `pnpm run format` | all files formatted |
| `pnpm run package` | exit 0, `out/Gemini Nexus-linux-x64` |
| `pnpm run test:e2e` | not run: Playwright drives Electron and needs a graphical session |
Yes, this is a common warning for new unsigned applications. Click **"More info"** and then **"Run anyway"**. See the warning at the top of the README for more details. The E2E suite is also excluded from CI for the same reason.
</details>
<details> ## Current state and limitations
<summary><b>Q: The app won't start when building from source?</b></summary>
1. Make sure all dependencies are installed: `npm install` - Upstream calls cannot be exercised without real Google/Claude accounts. With an empty pool the gateway starts, lists models and answers `429 No available accounts (all exhausted or rate limited)` on inference routes.
2. Check if your Node.js version is >= 20. - The app is desktop-only (Windows, macOS, Linux). There is no headless or container mode, because the gateway is a child process of the Electron app.
3. Try deleting `node_modules` and reinstalling. - Accounts are added through the app UI and the OAuth flow; there is no CLI to import credentials.
4. On Windows, ensure the WiX Toolset is available for `npm run make`. - UI languages are en, zh-CN, ru and vi. The default language in `DEFAULT_APP_CONFIG` is `zh-CN`.
</details> - Windows installers are unsigned (SmartScreen warning). macOS builds are unsigned and unnotarized.
- The E2E suite does not run in CI.
- The project is mid-migration to a single license: the code inherited from AntigravityManager is CC BY-NC-SA 4.0 (non-commercial), while the new code is MIT. See the license section.
- `pnpm install` reports `Ignored build scripts: sharp`. `sharp` is only used by `scripts/generate-assets.ts` for icon generation, and its prebuilt binaries are installed through optional dependencies.
<details> ## Documentation
<summary><b>Q: Account login failed?</b></summary>
1. Ensure your network connection is working. | Document | Content |
2. Try clearing the app data and logging in again. |---|---|
3. Check if the account is restricted by Google/Claude. | [`docs/OpencodeAPI.md`](docs/OpencodeAPI.md) | Wiring OpenCode to the local custom endpoint |
</details> | [`docs/cloud_features.md`](docs/cloud_features.md) | Cloud account management and switching behaviour |
| [`docs/proxyman-debugging.md`](docs/proxyman-debugging.md) | Debugging upstream traffic with Proxyman |
| [`docs/proxyman-install.md`](docs/proxyman-install.md) | Proxyman setup for contributors |
| [`docs/brand-briefing.md`](docs/brand-briefing.md) | Naming, positioning and visual notes |
| [`CONTRIBUTING.md`](CONTRIBUTING.md), [`AGENTS.md`](AGENTS.md) | Contribution rules and repository conventions |
| [`CHANGELOG.md`](CHANGELOG.md) | Release history |
<details> ## License
<summary><b>Q: My IDE can't connect to the proxy?</b></summary>
1. Make sure the proxy is actually running (look for the green indicator in the status bar). Dual license:
2. Verify the port matches your IDE config (default is `10100`).
3. Copy the API key directly from the Proxy page and paste it into your IDE settings.
4. Check that at least one account is active in the Accounts page.
</details>
<details> - Original code from [AntigravityManager](https://github.com/Draculabo/AntigravityManager) by [Draculabo](https://github.com/Draculabo): [CC BY-NC-SA 4.0](LICENSE).
<summary><b>Q: Token counts are showing zero?</b></summary> - All new code, features and architecture by [evandrodevbr](https://github.com/evandrodevbr): [MIT](LICENSE-MIT).
1. This was a known bug in earlier versions — please update to the latest release. The project is being migrated to MIT as the remaining original code is rewritten.
2. Token tracking now correctly handles streaming metadata and estimated counts.
3. Usage data refreshes every 15 seconds on the Usage Analytics page.
</details>
--- ## Credits
## 🤝 Contributing Originally forked from [AntigravityManager](https://github.com/Draculabo/AntigravityManager). Since the fork the codebase has been largely rewritten by [evandrodevbr](https://github.com/evandrodevbr): UI/UX, usage analytics, traffic monitor, environment isolation, CI/CD, test infrastructure and documentation.
Contributions are welcome! Please read `CONTRIBUTING.md` for details. ## Review checks (2026-09-30)
--- Retry durations are parsed as complete values: `500ms` is milliseconds, and fractional components are rounded after summation. Invalid trailing text is rejected. Account and model lockouts honor the later expiration. IDE fallback configuration remains available at loopback when the OS denies network-interface enumeration.
## 📄 License Focused checks: `pnpm exec vitest run src/tests/unit/rate-limit-tracker.test.ts src/tests/unit/ipc/proxy-advanced-handler.test.ts`. Run `pnpm test`, `pnpm type-check`, `pnpm lint` and `pnpm format` for the full non-E2E gates. Native SQLite/keytar are mocked by the unit-test configuration; Electron packaging, real account switching and E2E still require separate validation.
This project uses a dual-license structure: This review passes all 509 tests across 54 files, type checking, lint (327 existing warnings, no errors) and formatting with Node 24.19.0 and pnpm 10.11.0. The original lockfile is retained: its audit still reports two critical findings, in `tar` and `seroval`, requiring a separately validated dependency update.
- **Original code** from [AntigravityManager](https://github.com/Draculabo/AntigravityManager) by [Draculabo](https://github.com/Draculabo): Licensed under [CC BY-NC-SA 4.0](LICENSE).
- **All new code, features, and architecture** by [evandrodevbr](https://github.com/evandrodevbr): Licensed under the [MIT License](LICENSE-MIT).
> 🔄 **Migration Notice:** This project is actively being migrated to a fully open-source license (MIT). As the remaining original code is progressively rewritten, the entire project will transition to MIT. We are committed to making Gemini Nexus 100% open-source.
---
## 🙏 Credits
This project was originally forked from [AntigravityManager](https://github.com/Draculabo/AntigravityManager) created by [Draculabo](https://github.com/Draculabo). The initial proxy concept and early Electron scaffolding were based on his work.
Since the fork, **the vast majority of the codebase has been rewritten, redesigned, and expanded** by [evandrodevbr](https://github.com/evandrodevbr), including but not limited to:
- Complete UI/UX redesign (navigation, accounts, usage analytics, proxy management)
- Usage Analytics dashboard with cost tracking, token averages, and OpenRouter integration
- Traffic monitor with per-request token/cost inspection
- Environment isolation (dev/prod), CI/CD pipeline, and test infrastructure
- IDE Quick Setup (OpenCode, Cursor, VS Code, Claude Code)
- Custom model pricing configuration
- All documentation and internationalization (EN, PT-BR, ES, ZH-CN)
+2 -1
View File
@@ -194,7 +194,7 @@ O **Gemini Nexus** resolve tudo isso. É um aplicativo desktop profissional em E
Você pode baixar os binários pré-compilados mais recentes para Windows, macOS e Linux na nossa [página de Releases](https://github.com/evandrodevbr/GeminiNexus/releases). Você pode baixar os binários pré-compilados mais recentes para Windows, macOS e Linux na nossa [página de Releases](https://github.com/evandrodevbr/GeminiNexus/releases).
*(Consulte o aviso no topo desta página se você estiver usando o Windows e encontrar o prompt do SmartScreen).* _(Consulte o aviso no topo desta página se você estiver usando o Windows e encontrar o prompt do SmartScreen)._
### 🔌 Usando com IDEs de IA ### 🔌 Usando com IDEs de IA
@@ -295,6 +295,7 @@ src/
<summary><b>P: Aviso do Windows SmartScreen ("O Windows protegeu o seu computador") durante a instalação?</b></summary> <summary><b>P: Aviso do Windows SmartScreen ("O Windows protegeu o seu computador") durante a instalação?</b></summary>
Sim, este é um aviso comum para novos aplicativos não assinados. Clique em **"Mais informações"** e depois em **"Executar mesmo assim"**. Veja o aviso no topo do README para mais detalhes. Sim, este é um aviso comum para novos aplicativos não assinados. Clique em **"Mais informações"** e depois em **"Executar mesmo assim"**. Veja o aviso no topo do README para mais detalhes.
</details> </details>
<details> <details>
+2 -1
View File
@@ -194,7 +194,7 @@
您可以从我们的 [Releases 页面](https://github.com/evandrodevbr/GeminiNexus/releases) 下载适用于 Windows、macOS 和 Linux 的最新预编译版本。 您可以从我们的 [Releases 页面](https://github.com/evandrodevbr/GeminiNexus/releases) 下载适用于 Windows、macOS 和 Linux 的最新预编译版本。
*(如果您使用的是 Windows 并在安装时遇到了 SmartScreen 提示,请参阅页面顶部的警告)。* _(如果您使用的是 Windows 并在安装时遇到了 SmartScreen 提示,请参阅页面顶部的警告)。_
### 🔌 配合 AI IDE 使用 ### 🔌 配合 AI IDE 使用
@@ -274,6 +274,7 @@ npm test
<summary><b>问:安装时出现 Windows SmartScreen 警告("Windows 已保护你的电脑")?</b></summary> <summary><b>问:安装时出现 Windows SmartScreen 警告("Windows 已保护你的电脑")?</b></summary>
是的,对于未签名的新应用,这是一个常见的警告。请点击 **"更多信息"** (More info),然后点击 **"仍要运行"** (Run anyway)。详情请参阅 README 顶部的提示。 是的,对于未签名的新应用,这是一个常见的警告。请点击 **"更多信息"** (More info),然后点击 **"仍要运行"** (Run anyway)。详情请参阅 README 顶部的提示。
</details> </details>
<details> <details>
+7
View File
@@ -50,4 +50,11 @@ export default defineConfig([
'prefer-const': 'off', 'prefer-const': 'off',
}, },
}, },
{
// Node maintenance scripts are CommonJS; require() is the native API there.
files: ['scripts/**/*.js'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
},
]); ]);
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "gemini-nexus", "name": "gemini-nexus",
"productName": "Gemini Nexus", "productName": "Gemini Nexus",
"version": "0.15.3", "version": "0.15.5",
"description": "Gemini Nexus - Electron App", "description": "Gemini Nexus - Electron App",
"main": ".vite/build/main.js", "main": ".vite/build/main.js",
"private": true, "private": true,
+5 -1
View File
@@ -417,7 +417,11 @@ export function CloudAccountList() {
const setSelectionState = (id: string, selected: boolean) => const setSelectionState = (id: string, selected: boolean) =>
setSelectedIds((prev) => { setSelectedIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
selected ? next.add(id) : next.delete(id); if (selected) {
next.add(id);
} else {
next.delete(id);
}
return next; return next;
}); });
const toggleSelectAllAccounts = () => const toggleSelectAllAccounts = () =>
-1
View File
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { calculateCost } from '@/constants/pricing';
export interface ModelCostEntry { export interface ModelCostEntry {
model: string; model: string;
+11 -4
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -10,7 +10,6 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Loader2, RefreshCw, RotateCcw } from 'lucide-react'; import { Loader2, RefreshCw, RotateCcw } from 'lucide-react';
import { MODEL_PRICING } from '@/constants/pricing'; import { MODEL_PRICING } from '@/constants/pricing';
@@ -35,8 +34,16 @@ export const ModelPricingDialog: React.FC<ModelPricingDialogProps> = ({
Record<string, { input: number; output: number; source: string }> Record<string, { input: number; output: number; source: string }>
>({}); >({});
const [isFetching, setIsFetching] = useState(false); const [isFetching, setIsFetching] = useState(false);
const [pricingSource, setPricingSource] = useState<{
open: boolean;
customPricing: typeof customPricing;
}>({ open, customPricing });
useEffect(() => { // Re-seed the editable copy when the dialog opens or the source pricing changes.
// Adjusting state during render replaces the previous synchronous setState inside
// an effect (https://react.dev/learn/you-might-not-need-an-effect).
if (pricingSource.open !== open || pricingSource.customPricing !== customPricing) {
setPricingSource({ open, customPricing });
if (open) { if (open) {
// Initialize local state with custom pricing or empty if default // Initialize local state with custom pricing or empty if default
const initial: Record<string, { input: number; output: number; source: string }> = {}; const initial: Record<string, { input: number; output: number; source: string }> = {};
@@ -49,7 +56,7 @@ export const ModelPricingDialog: React.FC<ModelPricingDialogProps> = ({
}); });
setLocalPricing(initial); setLocalPricing(initial);
} }
}, [open, customPricing]); }
const handlePriceChange = (model: string, type: 'input' | 'output', value: string) => { const handlePriceChange = (model: string, type: 'input' | 'output', value: string) => {
const numValue = parseFloat(value); const numValue = parseFloat(value);
+2 -3
View File
@@ -58,9 +58,8 @@ export const MonitorTab: React.FC = () => {
tokensPrompt: log.metadata?.tokensPrompt as number | undefined, tokensPrompt: log.metadata?.tokensPrompt as number | undefined,
tokensCompletion: log.metadata?.tokensCompletion as number | undefined, tokensCompletion: log.metadata?.tokensCompletion as number | undefined,
tokensTotal: tokensTotal:
(((log.metadata?.tokensPrompt as number) || 0) + ((log.metadata?.tokensPrompt as number) || 0) +
((log.metadata?.tokensCompletion as number) || 0)) || ((log.metadata?.tokensCompletion as number) || 0) || undefined,
undefined,
})) }))
: []; : [];
const stats = statsData?.success ? statsData.data : undefined; const stats = statsData?.success ? statsData.data : undefined;
+46 -28
View File
@@ -26,7 +26,16 @@ interface TrafficLogTableProps {
isLoading: boolean; isLoading: boolean;
} }
type SortKey = 'timestamp' | 'direction' | 'endpoint' | 'model' | 'method' | 'status' | 'tokens' | 'cost' | 'duration'; type SortKey =
| 'timestamp'
| 'direction'
| 'endpoint'
| 'model'
| 'method'
| 'status'
| 'tokens'
| 'cost'
| 'duration';
type SortDir = 'asc' | 'desc'; type SortDir = 'asc' | 'desc';
function SortIcon({ function SortIcon({
@@ -79,13 +88,10 @@ function getBodyPreview(body: unknown): string | null {
// Request body: extract first user message // Request body: extract first user message
if (Array.isArray(obj.messages)) { if (Array.isArray(obj.messages)) {
const userMsg = obj.messages.find( const userMsg = obj.messages.find((m: Record<string, unknown>) => m.role === 'user');
(m: Record<string, unknown>) => m.role === 'user',
);
if (userMsg) { if (userMsg) {
const content = typeof userMsg.content === 'string' const content =
? userMsg.content typeof userMsg.content === 'string' ? userMsg.content : JSON.stringify(userMsg.content);
: JSON.stringify(userMsg.content);
return content.length > 120 ? content.slice(0, 120) + '…' : content; return content.length > 120 ? content.slice(0, 120) + '…' : content;
} }
} }
@@ -95,9 +101,7 @@ function getBodyPreview(body: unknown): string | null {
const firstChoice = obj.choices[0] as Record<string, unknown> | undefined; const firstChoice = obj.choices[0] as Record<string, unknown> | undefined;
const message = firstChoice?.message as Record<string, unknown> | undefined; const message = firstChoice?.message as Record<string, unknown> | undefined;
if (message?.content && typeof message.content === 'string') { if (message?.content && typeof message.content === 'string') {
return message.content.length > 120 return message.content.length > 120 ? message.content.slice(0, 120) + '…' : message.content;
? message.content.slice(0, 120) + '…'
: message.content;
} }
} }
@@ -254,18 +258,16 @@ export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoadin
<React.Fragment key={logKey}> <React.Fragment key={logKey}>
<tr <tr
className={`cursor-pointer border-b border-white/[0.04] transition-colors last:border-0 ${ className={`cursor-pointer border-b border-white/[0.04] transition-colors last:border-0 ${
isExpanded isExpanded ? 'bg-white/[0.03]' : 'hover:bg-white/[0.02]'
? 'bg-white/[0.03]'
: 'hover:bg-white/[0.02]'
}`} }`}
onClick={() => toggleExpand(logKey)} onClick={() => toggleExpand(logKey)}
> >
{/* Expand chevron */} {/* Expand chevron */}
<td className="py-2 pr-1"> <td className="py-2 pr-1">
{isExpanded ? ( {isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> <ChevronDown className="text-muted-foreground h-3.5 w-3.5" />
) : ( ) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" /> <ChevronRight className="text-muted-foreground/50 h-3.5 w-3.5" />
)} )}
</td> </td>
<td className="text-muted-foreground py-2 pr-4 font-mono text-xs"> <td className="text-muted-foreground py-2 pr-4 font-mono text-xs">
@@ -300,11 +302,9 @@ export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoadin
)} )}
</td> </td>
<td className="py-2 pr-4 text-xs"> <td className="py-2 pr-4 text-xs">
{log.tokensTotal ? ( {log.tokensTotal
`${formatTokens(log.tokensPrompt)} / ${formatTokens(log.tokensCompletion)}` ? `${formatTokens(log.tokensPrompt)} / ${formatTokens(log.tokensCompletion)}`
) : ( : '—'}
'—'
)}
</td> </td>
<td className="py-2 pr-4 text-xs"> <td className="py-2 pr-4 text-xs">
{log.durationMs != null ? `${log.durationMs}ms` : '—'} {log.durationMs != null ? `${log.durationMs}ms` : '—'}
@@ -323,7 +323,7 @@ export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoadin
<div className="border-t border-white/[0.06] bg-white/[0.01] px-6 py-4"> <div className="border-t border-white/[0.06] bg-white/[0.01] px-6 py-4">
{/* Preview line */} {/* Preview line */}
{preview && ( {preview && (
<div className="mb-3 rounded-lg bg-white/[0.03] px-3 py-2 text-xs text-muted-foreground italic break-words"> <div className="text-muted-foreground mb-3 rounded-lg bg-white/[0.03] px-3 py-2 text-xs break-words italic">
"{preview}" "{preview}"
</div> </div>
)} )}
@@ -339,18 +339,32 @@ export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoadin
</Badge> </Badge>
)} )}
{log.tokensPrompt != null && ( {log.tokensPrompt != null && (
<Badge variant="outline" className="border-blue-500/20 text-blue-400 text-[10px]"> <Badge
variant="outline"
className="border-blue-500/20 text-[10px] text-blue-400"
>
Input: {log.tokensPrompt.toLocaleString()} tokens Input: {log.tokensPrompt.toLocaleString()} tokens
</Badge> </Badge>
)} )}
{log.tokensCompletion != null && ( {log.tokensCompletion != null && (
<Badge variant="outline" className="border-emerald-500/20 text-emerald-400 text-[10px]"> <Badge
variant="outline"
className="border-emerald-500/20 text-[10px] text-emerald-400"
>
Output: {log.tokensCompletion.toLocaleString()} tokens Output: {log.tokensCompletion.toLocaleString()} tokens
</Badge> </Badge>
)} )}
{log.tokensTotal != null && log.model && ( {log.tokensTotal != null && log.model && (
<Badge variant="outline" className="border-amber-500/20 text-amber-400 text-[10px]"> <Badge
Cost: ${calculateCost(log.model, log.tokensPrompt || 0, log.tokensCompletion || 0).toFixed(6)} variant="outline"
className="border-amber-500/20 text-[10px] text-amber-400"
>
Cost: $
{calculateCost(
log.model,
log.tokensPrompt || 0,
log.tokensCompletion || 0,
).toFixed(6)}
</Badge> </Badge>
)} )}
{log.durationMs != null && ( {log.durationMs != null && (
@@ -363,11 +377,15 @@ export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoadin
{/* Body content */} {/* Body content */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground text-[10px] font-medium uppercase tracking-wider"> <span className="text-muted-foreground text-[10px] font-medium tracking-wider uppercase">
{log.direction === 'inbound' ? 'Request Body' : log.direction === 'outbound' ? 'Response Body' : 'Payload'} {log.direction === 'inbound'
? 'Request Body'
: log.direction === 'outbound'
? 'Response Body'
: 'Payload'}
</span> </span>
</div> </div>
<pre className="custom-scrollbar max-h-[400px] overflow-auto rounded-lg bg-black/30 p-3 font-mono text-[11px] leading-relaxed text-foreground/80 whitespace-pre-wrap break-all"> <pre className="custom-scrollbar text-foreground/80 max-h-[400px] overflow-auto rounded-lg bg-black/30 p-3 font-mono text-[11px] leading-relaxed break-all whitespace-pre-wrap">
{formatBody(log.body)} {formatBody(log.body)}
</pre> </pre>
</div> </div>
+14 -14
View File
@@ -1,9 +1,6 @@
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { getServiceOrThrow, getServiceOptional } from './service-registry'; import { getServiceOrThrow, getServiceOptional } from './service-registry';
import type { import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service';
IdeConfigResult,
SupportedIde,
} from '../../server/modules/proxy/proxy-ide-config.service';
import { TrafficLogsRepo } from '../database/proxyMetricsHandler'; import { TrafficLogsRepo } from '../database/proxyMetricsHandler';
import { CloudAccountRepo } from '../database/cloudHandler'; import { CloudAccountRepo } from '../database/cloudHandler';
import { ConfigManager } from '../config/manager'; import { ConfigManager } from '../config/manager';
@@ -257,9 +254,7 @@ export async function getRecentRequests(): Promise<{
} }
} }
export async function replayRequest( export async function replayRequest(requestId: string): Promise<{
requestId: string,
): Promise<{
success: boolean; success: boolean;
data?: { original: unknown; newResponse: unknown }; data?: { original: unknown; newResponse: unknown };
error?: string; error?: string;
@@ -458,16 +453,21 @@ export async function generateIdeConfig(
const apiKey = config?.api_key || 'YOUR_API_KEY'; const apiKey = config?.api_key || 'YOUR_API_KEY';
let localIp = '127.0.0.1'; let localIp = '127.0.0.1';
const interfaces = networkInterfaces(); try {
for (const iface of Object.values(interfaces)) { const interfaces = networkInterfaces();
if (iface) { for (const iface of Object.values(interfaces)) {
for (const entry of iface) { if (iface) {
if (entry.family === 'IPv4' && !entry.internal) { for (const entry of iface) {
localIp = entry.address; if (entry.family === 'IPv4' && !entry.internal) {
break; localIp = entry.address;
break;
}
} }
} }
} }
} catch (error) {
// Local proxy configuration remains usable when LAN enumeration is restricted.
logger.warn('Could not enumerate network interfaces; using loopback', error);
} }
// Fallback generator mimicking the proxyIdeConfig.service // Fallback generator mimicking the proxyIdeConfig.service
+276 -274
View File
@@ -406,7 +406,7 @@ function UsagePage() {
</div> </div>
</div> </div>
<Tabs defaultValue="analytics" className="flex-1 flex flex-col"> <Tabs defaultValue="analytics" className="flex flex-1 flex-col">
<div className="px-6 pt-5"> <div className="px-6 pt-5">
<TabsList className="grid w-full max-w-md grid-cols-2"> <TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="analytics">Analytics</TabsTrigger> <TabsTrigger value="analytics">Analytics</TabsTrigger>
@@ -415,299 +415,301 @@ function UsagePage() {
</div> </div>
<TabsContent value="analytics" className="flex-1 space-y-4 px-6 py-5"> <TabsContent value="analytics" className="flex-1 space-y-4 px-6 py-5">
{hasQuotaWarning && ( {hasQuotaWarning && (
<Alert <Alert
variant="destructive" variant="destructive"
className="border-amber-500/50 bg-amber-500/10 text-amber-500" className="border-amber-500/50 bg-amber-500/10 text-amber-500"
> >
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertTitle>Quota Warning</AlertTitle> <AlertTitle>Quota Warning</AlertTitle>
<AlertDescription> <AlertDescription>
One or more models have exceeded 80% of their allocated usage limit. One or more models have exceeded 80% of their allocated usage limit.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{/* ─── Stat cards ─── */} {/* ─── Stat cards ─── */}
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
<StatCard
label="Total Tokens"
value={formatNumber(totals.totalTokens)}
icon={Zap}
isLoading={dailyLoading}
accent="amber"
trend={
prevDailyData ? calcTrend(totals.totalTokens, prevTotals.totalTokens) : undefined
}
sparkline={sparklineTotal}
/>
<div className="group relative">
<StatCard <StatCard
label={ label="Total Tokens"
<div className="flex cursor-help items-center gap-1.5"> value={formatNumber(totals.totalTokens)}
Input Tokens icon={Zap}
<Tooltip>
<TooltipTrigger asChild>
<Info className="text-muted-foreground/70 h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent>
<p>Tokens sent to the model (prompt, context, files).</p>
</TooltipContent>
</Tooltip>
</div>
}
value={formatNumber(totals.promptTokens)}
icon={MessageSquare}
isLoading={dailyLoading} isLoading={dailyLoading}
accent="blue" accent="amber"
trend={ trend={
prevDailyData prevDailyData ? calcTrend(totals.totalTokens, prevTotals.totalTokens) : undefined
? calcTrend(totals.promptTokens, prevTotals.promptTokens)
: undefined
} }
sparkline={sparklinePrompt} sparkline={sparklineTotal}
/> />
</div> <div className="group relative">
<div className="group relative"> <StatCard
<StatCard label={
label={
<div className="flex cursor-help items-center gap-1.5">
Output Tokens
<Tooltip>
<TooltipTrigger asChild>
<Info className="text-muted-foreground/70 h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent>
<p>Tokens generated by the model in response.</p>
</TooltipContent>
</Tooltip>
</div>
}
value={formatNumber(totals.completionTokens)}
icon={Activity}
isLoading={dailyLoading}
accent="green"
trend={
prevDailyData
? calcTrend(totals.completionTokens, prevTotals.completionTokens)
: undefined
}
sparkline={sparklineCompletion}
/>
</div>
<StatCard
label="Total Requests"
value={totals.requests.toLocaleString()}
icon={Layers}
isLoading={dailyLoading}
accent="slate"
trend={prevDailyData ? calcTrend(totals.requests, prevTotals.requests) : undefined}
sparkline={sparklineRequests}
/>
<div className="group relative">
<StatCard
label={
<div className="flex w-full items-center justify-between">
<div className="flex cursor-help items-center gap-1.5"> <div className="flex cursor-help items-center gap-1.5">
Estimated Cost Input Tokens
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="text-muted-foreground/70 h-3.5 w-3.5" /> <Info className="text-muted-foreground/70 h-3.5 w-3.5" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Approximate cost based on configured pricing.</p> <p>Tokens sent to the model (prompt, context, files).</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Button }
variant="ghost" value={formatNumber(totals.promptTokens)}
size="icon" icon={MessageSquare}
className="ml-2 h-5 w-5 hover:bg-white/10" isLoading={dailyLoading}
onClick={() => setIsPricingDialogOpen(true)} accent="blue"
> trend={
<Settings className="text-muted-foreground h-3 w-3" /> prevDailyData
</Button> ? calcTrend(totals.promptTokens, prevTotals.promptTokens)
: undefined
}
sparkline={sparklinePrompt}
/>
</div>
<div className="group relative">
<StatCard
label={
<div className="flex cursor-help items-center gap-1.5">
Output Tokens
<Tooltip>
<TooltipTrigger asChild>
<Info className="text-muted-foreground/70 h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent>
<p>Tokens generated by the model in response.</p>
</TooltipContent>
</Tooltip>
</div>
}
value={formatNumber(totals.completionTokens)}
icon={Activity}
isLoading={dailyLoading}
accent="green"
trend={
prevDailyData
? calcTrend(totals.completionTokens, prevTotals.completionTokens)
: undefined
}
sparkline={sparklineCompletion}
/>
</div>
<StatCard
label="Total Requests"
value={totals.requests.toLocaleString()}
icon={Layers}
isLoading={dailyLoading}
accent="slate"
trend={prevDailyData ? calcTrend(totals.requests, prevTotals.requests) : undefined}
sparkline={sparklineRequests}
/>
<div className="group relative">
<StatCard
label={
<div className="flex w-full items-center justify-between">
<div className="flex cursor-help items-center gap-1.5">
Estimated Cost
<Tooltip>
<TooltipTrigger asChild>
<Info className="text-muted-foreground/70 h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent>
<p>Approximate cost based on configured pricing.</p>
</TooltipContent>
</Tooltip>
</div>
<Button
variant="ghost"
size="icon"
className="ml-2 h-5 w-5 hover:bg-white/10"
onClick={() => setIsPricingDialogOpen(true)}
>
<Settings className="text-muted-foreground h-3 w-3" />
</Button>
</div>
}
value={`$${totalCost.toFixed(2)}`}
icon={DollarSign}
isLoading={modelLoading}
accent="purple"
/>
</div>
</div>
</TooltipProvider>
{/* ─── Prompt/Completion ratio bar + top models ─── */}
{!dailyLoading && totals.totalTokens > 0 && (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
<div className="bg-card col-span-1 flex flex-col gap-3 rounded-xl border border-white/[0.06] p-4 lg:col-span-2">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
Input / Output
</span>
<span className="text-muted-foreground font-mono text-xs">
{formatNumber(totals.totalTokens)} total
</span>
</div>
<div className="flex h-2.5 w-full overflow-hidden rounded-full bg-white/[0.04]">
<div
className="h-full rounded-l-full transition-all duration-500"
style={{
width: `${promptRatio}%`,
backgroundColor: COLOR_PROMPT,
}}
/>
<div
className="h-full rounded-r-full transition-all duration-500"
style={{
width: `${completionRatio}%`,
backgroundColor: COLOR_COMPLETION,
}}
/>
</div>
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: COLOR_PROMPT }}
/>
<span className="text-muted-foreground">Input</span>
<span className="text-foreground font-mono font-semibold">
{promptRatio}%
</span>
</div>
<div className="flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: COLOR_COMPLETION }}
/>
<span className="text-muted-foreground">Output</span>
<span className="text-foreground font-mono font-semibold">
{completionRatio}%
</span>
</div>
</div> </div>
} <span className="text-muted-foreground">
value={`$${totalCost.toFixed(2)}`} Avg/req:{' '}
icon={DollarSign} <span className="text-foreground font-mono font-medium">
{formatNumber(avgReqSize)}
</span>
</span>
</div>
</div>
<ForecastCard hourlyData={hourlyData} dailyData={dailyData} range={range} />
</div>
)}
{/* ─── Charts — Area + Model ranking ─── */}
<div className="grid grid-cols-1 gap-3 xl:grid-cols-5">
<div className="xl:col-span-3">
<ChartCard
title={range === '24h' ? 'Hourly Usage' : 'Daily Usage'}
isLoading={areaLoading}
error={areaError}
isEmpty={isAreaEmpty}
onRetry={() => (range === '24h' ? refetchHourly() : refetchDaily())}
emptyTitle="No usage data"
emptyDescription="Data will appear here once proxy requests are recorded."
errorMessage="Failed to load usage data"
retryLabel="Retry"
>
<div className="h-[320px]">
<ResponsiveLine
data={areaChartData}
theme={nivoTheme}
colors={[COLOR_PROMPT, COLOR_COMPLETION]}
margin={{ top: 10, right: 10, bottom: 40, left: 50 }}
xScale={{ type: 'point' }}
yScale={{ type: 'linear', min: 0, stacked: false }}
curve="monotoneX"
enableArea={true}
areaOpacity={0.08}
areaBlendMode="normal"
enablePoints={true}
pointSize={4}
pointColor={{ theme: 'background' }}
pointBorderWidth={2}
pointBorderColor={{ from: 'serieColor' }}
enableGridX={false}
enableGridY={true}
gridYValues={5}
axisBottom={{
tickSize: 0,
tickPadding: 8,
tickRotation: 0,
}}
axisLeft={{
tickSize: 0,
tickPadding: 8,
tickRotation: 0,
format: (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`),
}}
tooltip={LineTooltip}
legends={[
{
anchor: 'bottom',
direction: 'row',
justify: false,
translateX: 0,
translateY: 36,
itemsSpacing: 16,
itemWidth: 80,
itemHeight: 16,
itemDirection: 'left-to-right',
symbolSize: 8,
symbolShape: 'circle',
},
]}
animate={true}
motionConfig="gentle"
/>
</div>
</ChartCard>
</div>
<div className="xl:col-span-2">
<ChartCard
title="Model Distribution"
isLoading={modelLoading}
error={modelError}
isEmpty={isModelEmpty}
onRetry={() => refetchModel()}
emptyTitle="No model data"
emptyDescription="Model distribution will appear after proxy usage."
errorMessage="Failed to load model data"
retryLabel="Retry"
>
<div className="custom-scrollbar h-[320px] overflow-y-auto pr-2">
{topModelsData.map((m) => (
<ModelQuotaBar
key={m.model}
modelName={m.model}
usedTokens={m.tokens}
limitTokens={m.limit}
/>
))}
</div>
</ChartCard>
</div>
</div>
{/* ─── Cost Report ─── */}
{!dailyLoading && modelData && modelData.length > 0 && (
<div className="mt-6">
<CostReportCard
models={costReport}
totalInputCost={costReport.reduce((acc, r) => acc + r.inputCost, 0)}
totalOutputCost={costReport.reduce((acc, r) => acc + r.outputCost, 0)}
totalCost={totalCost}
isLoading={modelLoading} isLoading={modelLoading}
accent="purple"
/> />
</div> </div>
</div> )}
</TooltipProvider>
{/* ─── Prompt/Completion ratio bar + top models ─── */} {/* ─── Modals ─── */}
{!dailyLoading && totals.totalTokens > 0 && (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
<div className="bg-card col-span-1 flex flex-col gap-3 rounded-xl border border-white/[0.06] p-4 lg:col-span-2">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
Input / Output
</span>
<span className="text-muted-foreground font-mono text-xs">
{formatNumber(totals.totalTokens)} total
</span>
</div>
<div className="flex h-2.5 w-full overflow-hidden rounded-full bg-white/[0.04]">
<div
className="h-full rounded-l-full transition-all duration-500"
style={{
width: `${promptRatio}%`,
backgroundColor: COLOR_PROMPT,
}}
/>
<div
className="h-full rounded-r-full transition-all duration-500"
style={{
width: `${completionRatio}%`,
backgroundColor: COLOR_COMPLETION,
}}
/>
</div>
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: COLOR_PROMPT }}
/>
<span className="text-muted-foreground">Input</span>
<span className="text-foreground font-mono font-semibold">{promptRatio}%</span>
</div>
<div className="flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: COLOR_COMPLETION }}
/>
<span className="text-muted-foreground">Output</span>
<span className="text-foreground font-mono font-semibold">
{completionRatio}%
</span>
</div>
</div>
<span className="text-muted-foreground">
Avg/req:{' '}
<span className="text-foreground font-mono font-medium">
{formatNumber(avgReqSize)}
</span>
</span>
</div>
</div>
<ForecastCard hourlyData={hourlyData} dailyData={dailyData} range={range} />
</div>
)}
{/* ─── Charts — Area + Model ranking ─── */}
<div className="grid grid-cols-1 gap-3 xl:grid-cols-5">
<div className="xl:col-span-3">
<ChartCard
title={range === '24h' ? 'Hourly Usage' : 'Daily Usage'}
isLoading={areaLoading}
error={areaError}
isEmpty={isAreaEmpty}
onRetry={() => (range === '24h' ? refetchHourly() : refetchDaily())}
emptyTitle="No usage data"
emptyDescription="Data will appear here once proxy requests are recorded."
errorMessage="Failed to load usage data"
retryLabel="Retry"
>
<div className="h-[320px]">
<ResponsiveLine
data={areaChartData}
theme={nivoTheme}
colors={[COLOR_PROMPT, COLOR_COMPLETION]}
margin={{ top: 10, right: 10, bottom: 40, left: 50 }}
xScale={{ type: 'point' }}
yScale={{ type: 'linear', min: 0, stacked: false }}
curve="monotoneX"
enableArea={true}
areaOpacity={0.08}
areaBlendMode="normal"
enablePoints={true}
pointSize={4}
pointColor={{ theme: 'background' }}
pointBorderWidth={2}
pointBorderColor={{ from: 'serieColor' }}
enableGridX={false}
enableGridY={true}
gridYValues={5}
axisBottom={{
tickSize: 0,
tickPadding: 8,
tickRotation: 0,
}}
axisLeft={{
tickSize: 0,
tickPadding: 8,
tickRotation: 0,
format: (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`),
}}
tooltip={LineTooltip}
legends={[
{
anchor: 'bottom',
direction: 'row',
justify: false,
translateX: 0,
translateY: 36,
itemsSpacing: 16,
itemWidth: 80,
itemHeight: 16,
itemDirection: 'left-to-right',
symbolSize: 8,
symbolShape: 'circle',
},
]}
animate={true}
motionConfig="gentle"
/>
</div>
</ChartCard>
</div>
<div className="xl:col-span-2">
<ChartCard
title="Model Distribution"
isLoading={modelLoading}
error={modelError}
isEmpty={isModelEmpty}
onRetry={() => refetchModel()}
emptyTitle="No model data"
emptyDescription="Model distribution will appear after proxy usage."
errorMessage="Failed to load model data"
retryLabel="Retry"
>
<div className="custom-scrollbar h-[320px] overflow-y-auto pr-2">
{topModelsData.map((m) => (
<ModelQuotaBar
key={m.model}
modelName={m.model}
usedTokens={m.tokens}
limitTokens={m.limit}
/>
))}
</div>
</ChartCard>
</div>
</div>
{/* ─── Cost Report ─── */}
{!dailyLoading && modelData && modelData.length > 0 && (
<div className="mt-6">
<CostReportCard
models={costReport}
totalInputCost={costReport.reduce((acc, r) => acc + r.inputCost, 0)}
totalOutputCost={costReport.reduce((acc, r) => acc + r.outputCost, 0)}
totalCost={totalCost}
isLoading={modelLoading}
/>
</div>
)}
{/* ─── Modals ─── */}
<ModelPricingDialog <ModelPricingDialog
open={isPricingDialogOpen} open={isPricingDialogOpen}
onOpenChange={setIsPricingDialogOpen} onOpenChange={setIsPricingDialogOpen}
+9 -11
View File
@@ -44,7 +44,9 @@ function toLowerText(value: string | undefined): string {
} }
function parseDurationToSeconds(text: string): number | null { function parseDurationToSeconds(text: string): number | null {
const match = text.match(/(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?/i); const match = text
.trim()
.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?$/i);
if (!match) { if (!match) {
return null; return null;
} }
@@ -54,9 +56,8 @@ function parseDurationToSeconds(text: string): number | null {
const seconds = Number(match[3] ?? 0); const seconds = Number(match[3] ?? 0);
const milliseconds = Number(match[4] ?? 0); const milliseconds = Number(match[4] ?? 0);
const totalSeconds = const totalSeconds = Math.ceil(hours * 3600 + minutes * 60 + seconds + milliseconds / 1000);
hours * 3600 + minutes * 60 + Math.ceil(seconds) + Math.ceil(milliseconds / 1000); if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
if (totalSeconds <= 0) {
return null; return null;
} }
return totalSeconds; return totalSeconds;
@@ -108,19 +109,16 @@ export class RateLimitTracker {
const now = Date.now(); const now = Date.now();
const globalLock = this.lockoutByKey.get(accountId); const globalLock = this.lockoutByKey.get(accountId);
if (globalLock && globalLock.resetTimeMs > now) { let resetTimeMs = globalLock?.resetTimeMs ?? now;
return Math.max(0, Math.ceil((globalLock.resetTimeMs - now) / 1000));
}
if (model) { if (model) {
const modelKey = this.buildLockoutKey(accountId, model); const modelKey = this.buildLockoutKey(accountId, model);
const modelLock = this.lockoutByKey.get(modelKey); const modelLock = this.lockoutByKey.get(modelKey);
if (modelLock && modelLock.resetTimeMs > now) { resetTimeMs = Math.max(resetTimeMs, modelLock?.resetTimeMs ?? now);
return Math.max(0, Math.ceil((modelLock.resetTimeMs - now) / 1000));
}
} }
return 0; // Both scopes apply; retrying before either lock expires still fails upstream.
return Math.max(0, Math.ceil((resetTimeMs - now) / 1000));
} }
getRemainingWaitSec(accountId: string, model?: string): number { getRemainingWaitSec(accountId: string, model?: string): number {
+2 -2
View File
@@ -16,8 +16,8 @@ import { getSwitchGuardSnapshot } from '../../ipc/switchGuard';
vi.mock('../../utils/paths', async () => { vi.mock('../../utils/paths', async () => {
const path = await import('path'); const path = await import('path');
const agentDir = path.join(process.cwd(), 'temp_test_agent'); const agentDir = path.join(process.cwd(), 'temp_test_agent');
return { return {
IS_DEV_ENVIRONMENT: false, IS_DEV_ENVIRONMENT: false,
getAgentDir: vi.fn(() => agentDir), getAgentDir: vi.fn(() => agentDir),
getAccountsFilePath: vi.fn(() => path.join(agentDir, 'accounts.json')), getAccountsFilePath: vi.fn(() => path.join(agentDir, 'accounts.json')),
getBackupsDir: vi.fn(() => path.join(agentDir, 'backups')), getBackupsDir: vi.fn(() => path.join(agentDir, 'backups')),
+1 -1
View File
@@ -57,7 +57,7 @@ vi.mock('../../ipc/database/dbConnection', () => ({
}), }),
})); }));
vi.mock('../../utils/paths', () => ({ vi.mock('../../utils/paths', () => ({
IS_DEV_ENVIRONMENT: false, IS_DEV_ENVIRONMENT: false,
getGeminiNexusDbPaths: () => ['mock-db'], getGeminiNexusDbPaths: () => ['mock-db'],
getCloudAccountsDbPath: () => 'mock-cloud-db', getCloudAccountsDbPath: () => 'mock-cloud-db',
+1 -1
View File
@@ -21,7 +21,7 @@ vi.mock('../../ipc/database/dbConnection', () => ({
}), }),
})); }));
vi.mock('../../utils/paths', () => ({ vi.mock('../../utils/paths', () => ({
IS_DEV_ENVIRONMENT: false, IS_DEV_ENVIRONMENT: false,
getGeminiNexusDbPaths: () => ['mock-db'], getGeminiNexusDbPaths: () => ['mock-db'],
})); }));
+1 -1
View File
@@ -47,7 +47,7 @@ vi.mock('../../utils/logger', () => ({
}, },
})); }));
vi.mock('../../utils/paths', () => ({ vi.mock('../../utils/paths', () => ({
IS_DEV_ENVIRONMENT: false, IS_DEV_ENVIRONMENT: false,
getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/test.vscdb']), getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/test.vscdb']),
})); }));
+1 -1
View File
@@ -64,7 +64,7 @@ vi.mock('../../../ipc/database/dbConnection', () => ({
configureDatabase: vi.fn(), configureDatabase: vi.fn(),
})); }));
vi.mock('../../../utils/paths', () => ({ vi.mock('../../../utils/paths', () => ({
IS_DEV_ENVIRONMENT: false, IS_DEV_ENVIRONMENT: false,
getCloudAccountsDbPath: vi.fn(() => '/mock/db/path.sqlite'), getCloudAccountsDbPath: vi.fn(() => '/mock/db/path.sqlite'),
})); }));
+1 -1
View File
@@ -48,7 +48,7 @@ vi.mock('../../../utils/logger', () => ({
}, },
})); }));
vi.mock('../../../utils/paths', () => ({ vi.mock('../../../utils/paths', () => ({
IS_DEV_ENVIRONMENT: false, IS_DEV_ENVIRONMENT: false,
getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/test.vscdb']), getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/test.vscdb']),
})); }));
+1 -1
View File
@@ -52,7 +52,7 @@ vi.mock('../../../utils/logger', () => ({
}, },
})); }));
vi.mock('../../../utils/paths', () => ({ vi.mock('../../../utils/paths', () => ({
IS_DEV_ENVIRONMENT: false, IS_DEV_ENVIRONMENT: false,
getGeminiNexusStoragePaths: vi.fn().mockReturnValue(['/tmp/storage.json']), getGeminiNexusStoragePaths: vi.fn().mockReturnValue(['/tmp/storage.json']),
getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/state.vscdb']), getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/state.vscdb']),
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { networkInterfaces } from 'os';
import { import {
getRecentTrafficLogs, getRecentTrafficLogs,
getTrafficLogStats, getTrafficLogStats,
@@ -14,6 +15,16 @@ import {
import { TrafficLogsRepo } from '../../../ipc/database/proxyMetricsHandler'; import { TrafficLogsRepo } from '../../../ipc/database/proxyMetricsHandler';
import { proxyAdvancedRegistry } from '../../../ipc/proxy-advanced/service-registry'; import { proxyAdvancedRegistry } from '../../../ipc/proxy-advanced/service-registry';
vi.mock('os', async (importOriginal) => {
const actual = await importOriginal<typeof import('os')>();
const mockedNetworkInterfaces = vi.fn().mockReturnValue({});
return {
...actual,
default: { ...actual, networkInterfaces: mockedNetworkInterfaces },
networkInterfaces: mockedNetworkInterfaces,
};
});
vi.mock('../../../utils/logger', () => ({ vi.mock('../../../utils/logger', () => ({
logger: { logger: {
info: vi.fn(), info: vi.fn(),
@@ -67,6 +78,7 @@ vi.mock('../../../ipc/proxy-advanced/service-registry', () => {
describe('Proxy Advanced Handler', () => { describe('Proxy Advanced Handler', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(networkInterfaces).mockReset().mockReturnValue({});
}); });
describe('getRecentTrafficLogs', () => { describe('getRecentTrafficLogs', () => {
@@ -415,9 +427,38 @@ describe('Proxy Advanced Handler', () => {
}); });
describe('generateIdeConfig', () => { describe('generateIdeConfig', () => {
it('uses loopback when network enumeration fails', async () => {
vi.mocked(networkInterfaces).mockImplementationOnce(() => {
throw new Error('network enumeration denied');
});
const result = await generateIdeConfig('vscode');
expect(networkInterfaces).toHaveBeenCalledOnce();
expect(result.success, result.error).toBe(true);
expect(result.data?.content).toMatchObject({ 'openai.url': 'http://127.0.0.1:8045/v1' });
});
it('uses the discovered LAN address when enumeration succeeds', async () => {
vi.mocked(networkInterfaces).mockReturnValueOnce({
eth0: [
{
address: '192.0.2.10',
netmask: '255.255.255.0',
family: 'IPv4',
mac: '00:11:22:33:44:55',
internal: false,
cidr: '192.0.2.10/24',
},
],
});
const result = await generateIdeConfig('vscode');
expect(networkInterfaces).toHaveBeenCalledOnce();
expect(result.success, result.error).toBe(true);
expect(result.data?.content).toMatchObject({ 'openai.url': 'http://192.0.2.10:8045/v1' });
});
it('should generate fallback config when proxyIdeConfig is null', async () => { it('should generate fallback config when proxyIdeConfig is null', async () => {
const result = await generateIdeConfig('vscode'); const result = await generateIdeConfig('vscode');
expect(result.success).toBe(true); expect(result.success, result.error).toBe(true);
expect(result.data?.name).toBe('VS Code'); expect(result.data?.name).toBe('VS Code');
}); });
@@ -460,5 +501,5 @@ describe('Proxy Advanced Handler', () => {
expect(result.data?.proxySetting).toBe('OPENAI_BASE_URL'); expect(result.data?.proxySetting).toBe('OPENAI_BASE_URL');
vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = null; vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = null;
}); });
}); });
}); });
@@ -24,7 +24,7 @@ vi.mock('../../../ipc/database/cloudHandler', () => ({
ensureDatabaseInitialized: vi.fn(), ensureDatabaseInitialized: vi.fn(),
})); }));
vi.mock('../../../utils/paths', () => ({ vi.mock('../../../utils/paths', () => ({
IS_DEV_ENVIRONMENT: false, IS_DEV_ENVIRONMENT: false,
getCloudAccountsDbPath: vi.fn(() => '/mock/db/path.sqlite'), getCloudAccountsDbPath: vi.fn(() => '/mock/db/path.sqlite'),
})); }));
+2 -2
View File
@@ -4,8 +4,8 @@ import path from 'path';
vi.mock('../../utils/paths', async () => { vi.mock('../../utils/paths', async () => {
const path = await import('path'); const path = await import('path');
return { return {
IS_DEV_ENVIRONMENT: false, IS_DEV_ENVIRONMENT: false,
getAgentDir: vi.fn(() => path.join(process.cwd(), 'temp_test_logs')), getAgentDir: vi.fn(() => path.join(process.cwd(), 'temp_test_logs')),
}; };
}); });
+51 -1
View File
@@ -1,7 +1,57 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { RateLimitReason, RateLimitTracker } from '../../server/modules/proxy/rate-limit-tracker'; import { RateLimitReason, RateLimitTracker } from '../../server/modules/proxy/rate-limit-tracker';
describe('RateLimitTracker parity replay', () => { describe('RateLimitTracker parity replay', () => {
it.each([
['500ms', 2],
['2500ms', 3],
['2.2s100ms', 3],
['1h2m3s', 3723],
[' 30s ', 30],
['30seconds', 5],
['invalid30s', 5],
])(
'parses complete retry duration %s without confusing minutes and milliseconds',
(duration, expected) => {
const tracker = new RateLimitTracker();
const info = tracker.parseAndMarkFromError({
accountId: 'acc-duration',
status: 503,
body: JSON.stringify({
error: { details: [{ reason: 'MODEL_CAPACITY_EXHAUSTED', retryDelay: duration }] },
}),
backoffSteps: [60],
});
expect(info?.retryAfterSec).toBe(expected);
},
);
it('waits for the longer of the account and model lockouts', () => {
const now = Date.parse('2026-01-01T00:00:00Z');
const clock = vi.spyOn(Date, 'now').mockReturnValue(now);
try {
const tracker = new RateLimitTracker();
tracker.setLockoutUntilIso(
'acc-overlap',
new Date(now + 5000).toISOString(),
RateLimitReason.RateLimitExceeded,
);
tracker.setLockoutUntilIso(
'acc-overlap',
new Date(now + 60000).toISOString(),
RateLimitReason.QuotaExhausted,
'model-a',
);
expect(tracker.getRemainingWaitSeconds('acc-overlap', 'model-a')).toBe(60);
expect(tracker.getRemainingWaitSeconds('acc-overlap', 'model-b')).toBe(5);
clock.mockReturnValue(now + 10000);
expect(tracker.getRemainingWaitSeconds('acc-overlap', 'model-a')).toBe(50);
expect(tracker.getRemainingWaitSeconds('acc-overlap', 'model-b')).toBe(0);
} finally {
clock.mockRestore();
}
});
it('uses Retry-After header before body/default', () => { it('uses Retry-After header before body/default', () => {
const tracker = new RateLimitTracker(); const tracker = new RateLimitTracker();
const info = tracker.parseAndMarkFromError({ const info = tracker.parseAndMarkFromError({
+2 -2
View File
@@ -104,8 +104,8 @@ vi.mock('electron', () => {
session: { session: {
webRequest: { webRequest: {
onBeforeSendHeaders: vi.fn(), onBeforeSendHeaders: vi.fn(),
} },
} },
}, },
on: vi.fn(), on: vi.fn(),
})), })),
+1 -1
View File
@@ -25,7 +25,7 @@ vi.mock('../../ipc/database/cloudHandler', () => ({
ensureDatabaseInitialized: vi.fn(), ensureDatabaseInitialized: vi.fn(),
})); }));
vi.mock('../../utils/paths', () => ({ vi.mock('../../utils/paths', () => ({
IS_DEV_ENVIRONMENT: false, IS_DEV_ENVIRONMENT: false,
getCloudAccountsDbPath: vi.fn(() => '/mock/db/path.sqlite'), getCloudAccountsDbPath: vi.fn(() => '/mock/db/path.sqlite'),
})); }));