Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a5d482c52 | ||
|
|
9d1ce44ea3 |
No files matched your search
@@ -10,4 +10,3 @@ openspec/
|
|||||||
.agent/
|
.agent/
|
||||||
.claude/
|
.claude/
|
||||||
.codex
|
.codex
|
||||||
pnpm-lock.yaml
|
|
||||||
@@ -2,32 +2,6 @@
|
|||||||
|
|
||||||
# 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
|
||||||
|
|||||||
+1
-2
@@ -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,7 +274,6 @@ 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>
|
||||||
|
|||||||
@@ -1,258 +1,190 @@
|
|||||||
<p align="center">
|
<div align="center">
|
||||||
<img src="docs/assets/logo.png" alt="Gemini Nexus" width="128" height="128" />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h1 align="center">Gemini Nexus</h1>
|
<img src="docs/assets/logo.png" width="110" alt="Gemini Nexus logo">
|
||||||
|
|
||||||
<p align="center">
|
<h1>Gemini Nexus</h1>
|
||||||
<strong>Desktop gateway that pools several Google Gemini and Claude accounts behind a single local OpenAI- and Anthropic-compatible API.</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<p><strong>Desktop gateway that pools Google accounts behind a local OpenAI/Anthropic-compatible API, with encrypted credentials and auto-switching.</strong></p>
|
||||||
English | <a href="README.pt-BR.md">Português</a> | <a href="README.zh-CN.md">中文</a> | <a href="README.es.md">Español</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<a href="#-quickstart">Quickstart</a> ·
|
||||||
<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>
|
<a href="#-architecture">Architecture</a> ·
|
||||||
<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 href="#-security-model">Security</a> ·
|
||||||
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-informational?style=flat-square" alt="Platform" />
|
<a href="https://git.evandro.dev.br/evandro/gemini-nexus/issues">Issues</a>
|
||||||
<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>
|
|
||||||
|
|
||||||
> **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.
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
## About
|
<sub>English · <a href="README.pt-BR.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.es.md">Español</a></sub>
|
||||||
|
|
||||||
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.
|
</div>
|
||||||
|
|
||||||
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.
|
<hr />
|
||||||
|
|
||||||
What it does:
|
## 📌 The problem
|
||||||
|
|
||||||
- Manages a pool of Google/Claude accounts with per-account status (active, rate limited, expired) and quota snapshots.
|
Working with AI-powered IDEs and coding tools on a single Google account means quota that runs out, manual account switching, no idea how many tokens you spent, and no view of what the proxy actually sends upstream. Gemini Nexus keeps all accounts in one pool, switches between them automatically and shows the traffic.
|
||||||
- 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.
|
|
||||||
|
|
||||||
It is a personal desktop tool, not a hosted service: everything runs on your machine.
|
## ✨ What it does
|
||||||
|
|
||||||
## How it works
|
- **Pools** Google accounts added through OAuth, with status (Active, Rate Limited, Expired), last-used time and an optional per-account proxy URL.
|
||||||
|
- **Switches** accounts automatically when quota drops below 5 % or an account is rate-limited; `CloudMonitorService` polls quota every 5 minutes.
|
||||||
|
- **Schedules** requests across the pool in `cache-first`, `balance` (default) or `performance-first` mode, with a per-account circuit breaker (default backoff 60 s → 5 min → 30 min → 2 h).
|
||||||
|
- **Serves** `POST /v1/chat/completions` (OpenAI), `POST /v1/messages` (Anthropic), `GET /v1/models` and `GET /v1/models/capabilities`, so Cursor, Windsurf, OpenCode, Claude Code and similar clients can connect.
|
||||||
|
- **Maps** requested model names to upstream models through the `custom_mapping` and `anthropic_mapping` settings; request timeout defaults to 120 s.
|
||||||
|
- **Tracks** token usage per account and model, charts prompt and completion tokens, and estimates cost with editable per-model pricing.
|
||||||
|
- **Inspects** traffic (latency, status, model), replays logged requests and generates cURL and Python snippets plus an IDE Quick Setup.
|
||||||
|
- **Protects** stored credentials with AES-256-GCM and an OS-keychain master key (see [Security model](#-security-model)).
|
||||||
|
|
||||||
|
## 🖼️ Demo
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Screenshots</summary>
|
||||||
|
|
||||||
|
<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" width="80%"></p>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 🚀 Quickstart
|
||||||
|
|
||||||
|
**Use a release.** Download the build for your platform from [Releases](https://git.evandro.dev.br/evandro/gemini-nexus/releases), open the app, add at least one account, enable the proxy (it is disabled by default) and point your IDE at it:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
IDE / CLI (Cursor, Windsurf, OpenCode, Claude Code, custom scripts)
|
API Base URL: http://localhost:8045/v1 (default, configurable on the Proxy page; development builds use 8046)
|
||||||
| OpenAI /v1/chat/completions Anthropic /v1/messages Gemini /v1beta
|
API Key: (copy it from the Proxy page in the app)
|
||||||
v
|
Model: gemini-3-flash (or any mapped model)
|
||||||
Gemini Nexus (Electron app)
|
|
||||||
+-- Renderer: React 19 UI (accounts, usage, proxy, settings)
|
|
||||||
+-- Main process: IPC, SQLite storage, encryption, tray
|
|
||||||
+-- Gateway: NestJS + Fastify, bound to port 8045 (8046 in development)
|
|
||||||
| 1. authenticate the request (Bearer / x-api-key / x-goog-api-key)
|
|
||||||
| 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
|
|
||||||
Google Gemini API / Claude API (per-account OAuth credentials)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- 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.
|
> [!WARNING]
|
||||||
- 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.
|
> Builds are not code-signed. If Windows shows "Windows protected your PC", click **More info → Run anyway**.
|
||||||
- Requests are recorded locally (SQLite) and can be replayed from the Proxy page for debugging.
|
|
||||||
|
|
||||||
## Stack
|
**Build from source.**
|
||||||
|
|
||||||
| Layer | Choice |
|
| Tool | Minimum version |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Desktop shell | Electron 41, Electron Forge 7 (Vite plugin) |
|
| Node.js | 20+ (recommended in `AGENTS.md`; `.nvmrc` pins 22.17.1) |
|
||||||
| UI | React 19, TypeScript 5.9, TanStack Router + Query, Tailwind CSS v4, Radix UI, Nivo charts |
|
| pnpm | 10.11.0 (`packageManager`; lockfile is `pnpm-lock.yaml`) |
|
||||||
| Gateway | NestJS 11 on Fastify 5, RxJS, SSE streaming |
|
| Native toolchain | needed by `better-sqlite3` and `keytar`: Visual Studio 2022 Build Tools (C++) on Windows, Xcode Command Line Tools on macOS, `build-essential` + `python3` + `libsecret-1-dev` on Ubuntu (`base-devel` + `python` + `libsecret` on Arch) |
|
||||||
| 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) |
|
|
||||||
|
|
||||||
## 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
|
||||||
git clone https://github.com/evandrodevbr/GeminiNexus.git
|
git clone https://git.evandro.dev.br/evandro/gemini-nexus.git
|
||||||
cd GeminiNexus
|
cd gemini-nexus
|
||||||
|
pnpm install # postinstall runs scripts/setup-dev.js --check-only to verify the toolchain
|
||||||
# 1. install dependencies (lockfile is pnpm; do not use npm install)
|
pnpm start # dev mode (Electron + Vite HMR)
|
||||||
corepack enable
|
pnpm run make # installers/packages for the current platform
|
||||||
pnpm install --frozen-lockfile
|
pnpm run make:win # Windows: extracts WiX from wix314-binaries.zip, then runs make
|
||||||
|
|
||||||
# 2. run the desktop app (Electron + Vite HMR); the gateway listens on 8046 in dev
|
|
||||||
pnpm start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, in the app:
|
There is no `.env.example` and no variable is required. Optional variables read by the code or described in the docs:
|
||||||
|
|
||||||
1. Add at least one Google or Claude account on the Accounts page (OAuth flow).
|
| Variable | Required | Description |
|
||||||
2. Open the Proxy page, enable the gateway and copy the generated API key.
|
|---|---|---|
|
||||||
3. Point your IDE or CLI at the local endpoint.
|
| `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | No | Route the app's outbound requests through a local proxy (see [TLS inspection](#-security-model)). |
|
||||||
|
| `NODE_EXTRA_CA_CERTS` | No | Extra CA certificate for Node-side TLS when inspecting traffic. |
|
||||||
|
| `SENTRY_DSN` | No | Sentry DSN used when error reporting is enabled. |
|
||||||
|
|
||||||
```text
|
## 🧱 Architecture
|
||||||
API base URL: http://localhost:8045/v1
|
|
||||||
API key: copied from the Proxy page
|
```mermaid
|
||||||
Model: gemini-3-flash (or any model id returned by GET /v1/models)
|
flowchart LR
|
||||||
|
C["Client: Cursor, Windsurf, OpenCode, Claude Code"] -->|"/v1/chat/completions, /v1/messages"| P["NestJS proxy, port 8045"]
|
||||||
|
P --> S["Account scheduler and circuit breaker"]
|
||||||
|
S -->|"Google OAuth account"| G["Google upstream APIs"]
|
||||||
|
M["CloudMonitor and AutoSwitch, quota every 5 min"] -->|"quota polling"| G
|
||||||
|
S -->|"reads accounts"| DB[("SQLite: better-sqlite3 and Drizzle")]
|
||||||
|
M --> DB
|
||||||
|
K["AES-256-GCM, master key in OS keychain"] -->|"encrypts tokens"| DB
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
1. A client sends OpenAI- or Anthropic-format requests to the NestJS proxy (Fastify, bound to `0.0.0.0`).
|
||||||
|
2. The proxy guard checks the API key when one is configured; with an empty key the proxy runs in open mode.
|
||||||
|
3. The token manager picks an account according to the scheduling mode and each account's circuit-breaker state, then calls Google's upstream APIs with that account's OAuth token.
|
||||||
|
4. `CloudMonitorService` polls quota every 5 minutes and `AutoSwitchService` switches the active account when quota is below 5 % or the account is rate-limited.
|
||||||
|
5. The React 19 renderer (TanStack Router and Query) talks to the main process over oRPC on IPC; accounts, usage and settings live in SQLite (better-sqlite3 + Drizzle). Design notes: [`docs/cloud_features.md`](docs/cloud_features.md).
|
||||||
|
|
||||||
Daily commands, all driven by `pnpm`:
|
## 🔒 Security model
|
||||||
|
|
||||||
```bash
|
Gemini Nexus holds OAuth tokens for several accounts and forwards traffic upstream, so credential handling is the core of the design. Everything below is implemented in this repository; limits are stated where they exist.
|
||||||
pnpm start # dev app + gateway (port 8046)
|
|
||||||
pnpm run package # unpack the app for the current platform (out/)
|
|
||||||
pnpm run make # build installers (needs the extra tooling listed above)
|
|
||||||
pnpm test # Vitest unit/integration suite
|
|
||||||
pnpm run type-check # tsc --noEmit
|
|
||||||
pnpm run lint # ESLint
|
|
||||||
pnpm run format # Prettier check (pnpm run format:write to fix)
|
|
||||||
pnpm run test:e2e # Playwright E2E, needs a graphical session
|
|
||||||
```
|
|
||||||
|
|
||||||
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`.
|
- **Credential storage and isolation.** `token_json` and `quota_json` are encrypted with AES-256-GCM (unique IV per operation, authenticated) before they reach SQLite. The master key lives in the OS credential store through `keytar` (Windows Credential Manager, macOS Keychain, Linux Secret Service/libsecret), never in a plain file. Legacy plaintext rows are migrated at startup. Accounts are kept in their own database (`cloud_accounts.db`), independent from the IDE's local database.
|
||||||
|
- **Log masking.** Keys such as `token`, `refresh_token`, `api_key`, `authorization`, `cookie` and `client_secret` are replaced by `[REDACTED]` before logging ([`src/utils/sensitiveDataMasking.ts`](src/utils/sensitiveDataMasking.ts)); the traffic logger also redacts the `authorization`, `x-api-key` and `cookie` headers.
|
||||||
|
- **Request logging, not an audit trail.** Winston writes API, traffic and app logs to rotating files (API log: 30 days, 50 MB per file). These are operational logs, not a tamper-evident audit trail; the `*-audit.json` files are rotation bookkeeping from `winston-daily-rotate-file`.
|
||||||
|
- **Proxy access.** The proxy is off by default. Once enabled it listens on `0.0.0.0`, and requests are authenticated only when `api_key` is non-empty (empty means open mode). Set a key and keep it private.
|
||||||
|
- **TLS inspection (debugging only).** [`docs/proxyman-install.md`](docs/proxyman-install.md) and [`docs/proxyman-debugging.md`](docs/proxyman-debugging.md) explain how to read the app's HTTPS traffic with Proxyman: trust its root CA, set the proxy variables and `NODE_EXTRA_CA_CERTS`, then start the app. `NODE_TLS_REJECT_UNAUTHORIZED=0` (and the script's `-AllowInsecureTls` flag) is a temporary override; never leave it on. Do not commit proxy addresses, certificate paths, cookies or OAuth codes, and redact logs before sharing them. `scripts/start-antigravity-with-proxyman.ps1` targets the installed official Antigravity client, not this app.
|
||||||
|
- **Error reporting.** Sentry reports (they need `SENTRY_DSN`) are gated by the `error_reporting_enabled` setting, which `DEFAULT_APP_CONFIG` ships as `true`; turn it off in Settings to opt out. The Windows user-name segment is masked in reported exception messages.
|
||||||
|
|
||||||
## Screenshots
|
Supported versions and how to report a vulnerability: [`SECURITY.md`](SECURITY.md).
|
||||||
|
|
||||||
| | |
|
## 🗂️ Structure
|
||||||
|---|---|
|
|
||||||
|  |  |
|
|
||||||
|  |  |
|
|
||||||
|  |  |
|
|
||||||
|  | |
|
|
||||||
|
|
||||||
## 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
|
```text
|
||||||
src/
|
src/
|
||||||
├── main.ts, preload.ts, renderer.ts Electron entry points
|
├── components/ # UI (proxy/, usage/, ui/ primitives)
|
||||||
├── routes/ TanStack Router pages: index (accounts), usage, proxy, settings
|
├── ipc/ # oRPC routers (account, cloud, config, gateway, usage...)
|
||||||
├── components/ React UI (accounts, proxy/advanced, usage, ui primitives)
|
├── server/ # NestJS proxy gateway (modules: proxy, usage)
|
||||||
├── server/ Embedded gateway
|
├── services/ # AutoSwitchService, CloudMonitorService, GoogleAPIService
|
||||||
│ ├── main.ts NestJS bootstrap (port and process lifecycle)
|
├── routes/ # index, usage, proxy, settings
|
||||||
│ ├── modules/proxy/ controllers, proxy service, token manager, SSE, replay, metrics
|
├── utils/ # paths, logger, sensitiveDataMasking
|
||||||
│ └── modules/usage/ token usage service
|
└── tests/ # unit tests (Vitest) and E2E (Playwright)
|
||||||
├── ipc/ Electron IPC handlers (accounts, config, database, proxy, tray, ...)
|
cli/ # separate Python CLI for account switching and quota checks
|
||||||
├── lib/geminiNexus/ protocol mappers, model mapping, retry and streaming helpers
|
docs/ # design notes and Proxyman guides
|
||||||
├── localization/ i18n resources
|
scripts/ # setup-dev, build-windows, generate-assets
|
||||||
├── utils/ paths, logging, encryption, traffic logger
|
|
||||||
└── tests/ unit, integration and e2e suites
|
|
||||||
docs/ OpenCode integration, cloud features, Proxyman guides, brand notes
|
|
||||||
scripts/ dev environment setup, Windows build helper, asset generation
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Verification
|
## 🧪 Scripts
|
||||||
|
|
||||||
Previously recorded Linux x64 checks (Node 24.20.0, pnpm 10.11.0), before the review below:
|
| Command | What it does |
|
||||||
|
|
||||||
| Command | Result |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pnpm install --frozen-lockfile` | exit 0 |
|
| `pnpm start` | Run the app in dev mode (`electron-forge start`, with `DEBUG=electron-forge:*`). |
|
||||||
| `pnpm test` | 54 test files, 499 tests, all passing |
|
| `pnpm run dev` | Same as `start`. |
|
||||||
| `pnpm run type-check` | exit 0 |
|
| `pnpm run package` | Package the app with Electron Forge, without building installers. |
|
||||||
| `pnpm run lint` | exit 0 (327 warnings, no errors) |
|
| `pnpm run make` | Build installers/packages for the current platform. |
|
||||||
| `pnpm run format` | all files formatted |
|
| `pnpm run make:win` | Windows build helper: extracts WiX from `wix314-binaries.zip`, adds it to `PATH`, runs `electron-forge make`. |
|
||||||
| `pnpm run package` | exit 0, `out/Gemini Nexus-linux-x64` |
|
| `pnpm run publish` | Publish through Electron Forge. |
|
||||||
| `pnpm run test:e2e` | not run: Playwright drives Electron and needs a graphical session |
|
| `pnpm run setup` | Run `scripts/setup-dev.js`: detect the native toolchain and install or guide the setup. |
|
||||||
|
| `pnpm run postinstall` | `scripts/setup-dev.js --check-only`; runs automatically after `pnpm install`. |
|
||||||
|
| `pnpm run lint` | ESLint over the repo. |
|
||||||
|
| `pnpm run format` | Prettier check. |
|
||||||
|
| `pnpm run format:write` | Prettier write. |
|
||||||
|
| `pnpm test` | Vitest unit tests (`vitest run`). |
|
||||||
|
| `pnpm run test:watch` | Vitest in watch mode. |
|
||||||
|
| `pnpm run test:unit` | Vitest (default mode). |
|
||||||
|
| `pnpm run test:e2e` | Playwright E2E tests. |
|
||||||
|
| `pnpm run test:all` | Unit tests, then Playwright. |
|
||||||
|
| `pnpm run type-check` | `tsc --noEmit`. |
|
||||||
|
|
||||||
The E2E suite is also excluded from CI for the same reason.
|
## 📚 Documentation
|
||||||
|
|
||||||
## Current state and limitations
|
- [`docs/cloud_features.md`](docs/cloud_features.md): account pool, quota monitoring and auto-switch design.
|
||||||
|
- [`docs/proxyman-install.md`](docs/proxyman-install.md) and [`docs/proxyman-debugging.md`](docs/proxyman-debugging.md): TLS inspection setup (Windows).
|
||||||
|
- [`docs/OpencodeAPI.md`](docs/OpencodeAPI.md): OpenCode custom-endpoint integration (in Portuguese).
|
||||||
|
- [`cli/README.md`](cli/README.md): the Python command-line tool.
|
||||||
|
- [`CONTRIBUTING.md`](CONTRIBUTING.md), [`SECURITY.md`](SECURITY.md), [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md), [`CHANGELOG.md`](CHANGELOG.md), [`LICENSE`](LICENSE), [`LICENSE-MIT`](LICENSE-MIT).
|
||||||
|
- Translations: [Português](README.pt-BR.md), [中文](README.zh-CN.md), [Español](README.es.md).
|
||||||
|
|
||||||
- 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.
|
## 🛣️ Limitations and next steps
|
||||||
- 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.
|
|
||||||
- Accounts are added through the app UI and the OAuth flow; there is no CLI to import credentials.
|
|
||||||
- UI languages are en, zh-CN, ru and vi. The default language in `DEFAULT_APP_CONFIG` is `zh-CN`.
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Documentation
|
- Upstream access goes through Google's `v1internal` Cloud Code endpoints and OAuth scopes ([`src/services/GoogleAPIService.ts`](src/services/GoogleAPIService.ts)). These are internal, undocumented APIs and may change without notice. The project is unofficial and not affiliated with Google or Anthropic.
|
||||||
|
- Accounts are added through Google OAuth; Claude models are reached through model mapping, not through separate Claude accounts.
|
||||||
|
- End-to-end tests are not run in CI (Electron needs a graphical environment); run `pnpm run test:e2e` locally.
|
||||||
|
- The Windows MSI is only produced when the WiX Toolset is available on the build machine; otherwise the Windows artifact is the ZIP.
|
||||||
|
- The workflows in `.github/workflows/` are written for GitHub (they call the `gh` CLI and the GitHub release publisher), so this README shows no CI badge.
|
||||||
|
- Next step stated by the project: migrate the remaining original code to MIT (see [License](#-license)).
|
||||||
|
|
||||||
| Document | Content |
|
## 🤝 Contributing
|
||||||
|---|---|
|
|
||||||
| [`docs/OpencodeAPI.md`](docs/OpencodeAPI.md) | Wiring OpenCode to the local custom endpoint |
|
|
||||||
| [`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 |
|
|
||||||
|
|
||||||
## License
|
Read [`CONTRIBUTING.md`](CONTRIBUTING.md) and [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md). Releases are versioned by semantic-release from Conventional Commits. Before opening a PR, these must pass:
|
||||||
|
|
||||||
Dual license:
|
```bash
|
||||||
|
pnpm run lint && pnpm run format:write && pnpm run type-check && pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
- Original code from [AntigravityManager](https://github.com/Draculabo/AntigravityManager) by [Draculabo](https://github.com/Draculabo): [CC BY-NC-SA 4.0](LICENSE).
|
## 📄 License
|
||||||
- All new code, features and architecture by [evandrodevbr](https://github.com/evandrodevbr): [MIT](LICENSE-MIT).
|
|
||||||
|
|
||||||
The project is being migrated to MIT as the remaining original code is rewritten.
|
Mixed licensing: code inherited from [AntigravityManager](https://github.com/Draculabo/AntigravityManager) (by Draculabo) is [CC BY-NC-SA 4.0](LICENSE) (non-commercial, share-alike); all new code is [MIT](LICENSE-MIT). The project is migrating to MIT as the remaining original code is rewritten, so check both files before any commercial use.
|
||||||
|
|
||||||
## Credits
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
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 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.
|
|
||||||
+1
-2
@@ -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,7 +295,6 @@ 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>
|
||||||
|
|||||||
+1
-2
@@ -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,7 +274,6 @@ 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>
|
||||||
|
|||||||
@@ -50,11 +50,4 @@ 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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "gemini-nexus",
|
"name": "gemini-nexus",
|
||||||
"productName": "Gemini Nexus",
|
"productName": "Gemini Nexus",
|
||||||
"version": "0.15.5",
|
"version": "0.15.3",
|
||||||
"description": "Gemini Nexus - Electron App",
|
"description": "Gemini Nexus - Electron App",
|
||||||
"main": ".vite/build/main.js",
|
"main": ".vite/build/main.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -417,11 +417,7 @@ 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);
|
||||||
if (selected) {
|
selected ? next.add(id) : next.delete(id);
|
||||||
next.add(id);
|
|
||||||
} else {
|
|
||||||
next.delete(id);
|
|
||||||
}
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
const toggleSelectAllAccounts = () =>
|
const toggleSelectAllAccounts = () =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -10,6 +10,7 @@ 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';
|
||||||
|
|
||||||
@@ -34,16 +35,8 @@ 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 });
|
|
||||||
|
|
||||||
// Re-seed the editable copy when the dialog opens or the source pricing changes.
|
useEffect(() => {
|
||||||
// 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 }> = {};
|
||||||
@@ -56,7 +49,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);
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ 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) || undefined,
|
((log.metadata?.tokensCompletion as number) || 0)) ||
|
||||||
|
undefined,
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
const stats = statsData?.success ? statsData.data : undefined;
|
const stats = statsData?.success ? statsData.data : undefined;
|
||||||
|
|||||||
@@ -26,16 +26,7 @@ interface TrafficLogTableProps {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortKey =
|
type SortKey = 'timestamp' | 'direction' | 'endpoint' | 'model' | 'method' | 'status' | 'tokens' | 'cost' | 'duration';
|
||||||
| 'timestamp'
|
|
||||||
| 'direction'
|
|
||||||
| 'endpoint'
|
|
||||||
| 'model'
|
|
||||||
| 'method'
|
|
||||||
| 'status'
|
|
||||||
| 'tokens'
|
|
||||||
| 'cost'
|
|
||||||
| 'duration';
|
|
||||||
type SortDir = 'asc' | 'desc';
|
type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
function SortIcon({
|
function SortIcon({
|
||||||
@@ -88,10 +79,13 @@ 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((m: Record<string, unknown>) => m.role === 'user');
|
const userMsg = obj.messages.find(
|
||||||
|
(m: Record<string, unknown>) => m.role === 'user',
|
||||||
|
);
|
||||||
if (userMsg) {
|
if (userMsg) {
|
||||||
const content =
|
const content = typeof userMsg.content === 'string'
|
||||||
typeof userMsg.content === 'string' ? userMsg.content : JSON.stringify(userMsg.content);
|
? userMsg.content
|
||||||
|
: JSON.stringify(userMsg.content);
|
||||||
return content.length > 120 ? content.slice(0, 120) + '…' : content;
|
return content.length > 120 ? content.slice(0, 120) + '…' : content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,7 +95,9 @@ 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 ? message.content.slice(0, 120) + '…' : message.content;
|
return message.content.length > 120
|
||||||
|
? message.content.slice(0, 120) + '…'
|
||||||
|
: message.content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,16 +254,18 @@ 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 ? 'bg-white/[0.03]' : 'hover:bg-white/[0.02]'
|
isExpanded
|
||||||
|
? '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="text-muted-foreground h-3.5 w-3.5" />
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="text-muted-foreground/50 h-3.5 w-3.5" />
|
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||||
)}
|
)}
|
||||||
</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">
|
||||||
@@ -302,9 +300,11 @@ 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="text-muted-foreground mb-3 rounded-lg bg-white/[0.03] px-3 py-2 text-xs break-words italic">
|
<div className="mb-3 rounded-lg bg-white/[0.03] px-3 py-2 text-xs text-muted-foreground italic break-words">
|
||||||
"{preview}"
|
"{preview}"
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -339,32 +339,18 @@ export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoadin
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{log.tokensPrompt != null && (
|
{log.tokensPrompt != null && (
|
||||||
<Badge
|
<Badge variant="outline" className="border-blue-500/20 text-blue-400 text-[10px]">
|
||||||
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
|
<Badge variant="outline" className="border-emerald-500/20 text-emerald-400 text-[10px]">
|
||||||
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
|
<Badge variant="outline" className="border-amber-500/20 text-amber-400 text-[10px]">
|
||||||
variant="outline"
|
Cost: ${calculateCost(log.model, log.tokensPrompt || 0, log.tokensCompletion || 0).toFixed(6)}
|
||||||
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 && (
|
||||||
@@ -377,15 +363,11 @@ 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 tracking-wider uppercase">
|
<span className="text-muted-foreground text-[10px] font-medium uppercase tracking-wider">
|
||||||
{log.direction === 'inbound'
|
{log.direction === 'inbound' ? 'Request Body' : log.direction === 'outbound' ? 'Response Body' : 'Payload'}
|
||||||
? 'Request Body'
|
|
||||||
: log.direction === 'outbound'
|
|
||||||
? 'Response Body'
|
|
||||||
: 'Payload'}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<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">
|
||||||
{formatBody(log.body)}
|
{formatBody(log.body)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { getServiceOrThrow, getServiceOptional } from './service-registry';
|
import { getServiceOrThrow, getServiceOptional } from './service-registry';
|
||||||
import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service';
|
import type {
|
||||||
|
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';
|
||||||
@@ -254,7 +257,9 @@ export async function getRecentRequests(): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function replayRequest(requestId: string): Promise<{
|
export async function replayRequest(
|
||||||
|
requestId: string,
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: { original: unknown; newResponse: unknown };
|
data?: { original: unknown; newResponse: unknown };
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -453,7 +458,6 @@ 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';
|
||||||
try {
|
|
||||||
const interfaces = networkInterfaces();
|
const interfaces = networkInterfaces();
|
||||||
for (const iface of Object.values(interfaces)) {
|
for (const iface of Object.values(interfaces)) {
|
||||||
if (iface) {
|
if (iface) {
|
||||||
@@ -465,10 +469,6 @@ export async function generateIdeConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} 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
|
||||||
const baseUrl = `http://${localIp}:${port}/v1`;
|
const baseUrl = `http://${localIp}:${port}/v1`;
|
||||||
|
|||||||
@@ -406,7 +406,7 @@ function UsagePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="analytics" className="flex flex-1 flex-col">
|
<Tabs defaultValue="analytics" className="flex-1 flex 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>
|
||||||
@@ -574,9 +574,7 @@ function UsagePage() {
|
|||||||
style={{ backgroundColor: COLOR_PROMPT }}
|
style={{ backgroundColor: COLOR_PROMPT }}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground">Input</span>
|
<span className="text-muted-foreground">Input</span>
|
||||||
<span className="text-foreground font-mono font-semibold">
|
<span className="text-foreground font-mono font-semibold">{promptRatio}%</span>
|
||||||
{promptRatio}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -44,9 +44,7 @@ function toLowerText(value: string | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseDurationToSeconds(text: string): number | null {
|
function parseDurationToSeconds(text: string): number | null {
|
||||||
const match = text
|
const match = text.match(/(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?/i);
|
||||||
.trim()
|
|
||||||
.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?$/i);
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -56,8 +54,9 @@ 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 = Math.ceil(hours * 3600 + minutes * 60 + seconds + milliseconds / 1000);
|
const totalSeconds =
|
||||||
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
|
hours * 3600 + minutes * 60 + Math.ceil(seconds) + Math.ceil(milliseconds / 1000);
|
||||||
|
if (totalSeconds <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return totalSeconds;
|
return totalSeconds;
|
||||||
@@ -109,16 +108,19 @@ export class RateLimitTracker {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const globalLock = this.lockoutByKey.get(accountId);
|
const globalLock = this.lockoutByKey.get(accountId);
|
||||||
let resetTimeMs = globalLock?.resetTimeMs ?? now;
|
if (globalLock && 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);
|
||||||
resetTimeMs = Math.max(resetTimeMs, modelLock?.resetTimeMs ?? now);
|
if (modelLock && modelLock.resetTimeMs > now) {
|
||||||
|
return Math.max(0, Math.ceil((modelLock.resetTimeMs - now) / 1000));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both scopes apply; retrying before either lock expires still fails upstream.
|
return 0;
|
||||||
return Math.max(0, Math.ceil((resetTimeMs - now) / 1000));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getRemainingWaitSec(accountId: string, model?: string): number {
|
getRemainingWaitSec(accountId: string, model?: string): number {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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,
|
||||||
@@ -15,16 +14,6 @@ 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(),
|
||||||
@@ -78,7 +67,6 @@ 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', () => {
|
||||||
@@ -427,38 +415,9 @@ 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, result.error).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.data?.name).toBe('VS Code');
|
expect(result.data?.name).toBe('VS Code');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it } 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({
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ vi.mock('electron', () => {
|
|||||||
session: {
|
session: {
|
||||||
webRequest: {
|
webRequest: {
|
||||||
onBeforeSendHeaders: vi.fn(),
|
onBeforeSendHeaders: vi.fn(),
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
})),
|
})),
|
||||||
|
|||||||
Reference in new issue
Block a user