Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ed730b23b | ||
|
|
0a6a8ff4bb | ||
|
|
8e20c32117 | ||
|
|
2e8657d7a0 | ||
|
|
1ad15d6e3a | ||
|
|
291b8ac91f | ||
|
|
880b799535 | ||
|
|
cf5b669133 | ||
|
|
4b3f193bd0 | ||
|
|
fa39fb34fe | ||
|
|
b193bee03f | ||
|
|
e8eaa049d8 | ||
|
|
acb8508bff | ||
|
|
43aa538932 | ||
|
|
8ad3eef53d |
No files matched your search
@@ -225,7 +225,7 @@ jobs:
|
||||
fi
|
||||
|
||||
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
|
||||
for file in "${files[@]}"; do
|
||||
echo "Uploading $file..."
|
||||
|
||||
@@ -95,10 +95,6 @@ out/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# Tanstack
|
||||
.tanstack/
|
||||
|
||||
@@ -10,3 +10,4 @@ openspec/
|
||||
.agent/
|
||||
.claude/
|
||||
.codex
|
||||
pnpm-lock.yaml
|
||||
@@ -2,6 +2,32 @@
|
||||
|
||||
# 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)
|
||||
|
||||
### 👷 Build System
|
||||
|
||||
+4
-3
@@ -194,14 +194,14 @@ 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).
|
||||
|
||||
*(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
|
||||
|
||||
Con la aplicación en ejecución y al menos una cuenta agregada, configura tu IDE preferido (Cursor, Windsurf, OpenCode, etc.):
|
||||
|
||||
```plaintext
|
||||
API Base URL: http://localhost:10100/v1
|
||||
API Base URL: http://localhost:8045/v1
|
||||
API Key: (copiar de la página Proxy en la app)
|
||||
Model: gemini-3-flash (o cualquier modelo mapeado)
|
||||
```
|
||||
@@ -274,6 +274,7 @@ npm test
|
||||
<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.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -297,7 +298,7 @@ Sí, este es un aviso común para nuevas aplicaciones no firmadas. Haz clic en *
|
||||
<summary><b>P: ¿El IDE no se puede conectar al proxy?</b></summary>
|
||||
|
||||
1. Asegúrate de que el proxy esté funcionando (indicador verde en la barra de estado).
|
||||
2. Confirma que el puerto coincide con tu configuración del IDE (por defecto `10100`).
|
||||
2. Confirma que el puerto coincide con tu configuración del IDE (por defecto `8045`).
|
||||
3. Copia la API key directamente desde la página Proxy y pégala en la configuración de tu IDE.
|
||||
4. Asegúrate de tener al menos una cuenta activa en la página Cuentas.
|
||||
</details>
|
||||
|
||||
@@ -1,190 +1,251 @@
|
||||
<div align="center">
|
||||
<p align="center">
|
||||
<img src="docs/assets/logo.png" alt="Gemini Nexus" width="128" height="128" />
|
||||
</p>
|
||||
|
||||
<img src="docs/assets/logo.png" width="110" alt="Gemini Nexus logo">
|
||||
<h1 align="center">Gemini Nexus</h1>
|
||||
|
||||
<h1>Gemini Nexus</h1>
|
||||
<p align="center">
|
||||
<strong>Desktop gateway that pools several Google Gemini and Claude accounts behind a single local OpenAI- and Anthropic-compatible API.</strong>
|
||||
</p>
|
||||
|
||||
<p><strong>Desktop gateway that pools Google accounts behind a local OpenAI/Anthropic-compatible API, with encrypted credentials and auto-switching.</strong></p>
|
||||
<p align="center">
|
||||
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>
|
||||
|
||||
<a href="#-quickstart">Quickstart</a> ·
|
||||
<a href="#-architecture">Architecture</a> ·
|
||||
<a href="#-security-model">Security</a> ·
|
||||
<a href="https://git.evandro.dev.br/evandro/gemini-nexus/issues">Issues</a>
|
||||
<p align="center">
|
||||
<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="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>
|
||||
<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-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.
|
||||
|
||||
<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>
|
||||
## About
|
||||
|
||||
</div>
|
||||
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.
|
||||
|
||||
<hr />
|
||||
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.
|
||||
|
||||
## 📌 The problem
|
||||
What it does:
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
## ✨ What it does
|
||||
It is a personal desktop tool, not a hosted service: everything runs on your machine.
|
||||
|
||||
- **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:
|
||||
## How it works
|
||||
|
||||
```text
|
||||
API Base URL: http://localhost:8045/v1 (default, configurable on the Proxy page; development builds use 8046)
|
||||
API Key: (copy it from the Proxy page in the app)
|
||||
Model: gemini-3-flash (or any mapped model)
|
||||
IDE / CLI (Cursor, Windsurf, OpenCode, Claude Code, custom scripts)
|
||||
| OpenAI /v1/chat/completions Anthropic /v1/messages Gemini /v1beta
|
||||
v
|
||||
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)
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Builds are not code-signed. If Windows shows "Windows protected your PC", click **More info → Run anyway**.
|
||||
- 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.
|
||||
|
||||
**Build from source.**
|
||||
## Stack
|
||||
|
||||
| Tool | Minimum version |
|
||||
| Layer | Choice |
|
||||
|---|---|
|
||||
| Node.js | 20+ (recommended in `AGENTS.md`; `.nvmrc` pins 22.17.1) |
|
||||
| pnpm | 10.11.0 (`packageManager`; lockfile is `pnpm-lock.yaml`) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js `^20.19.0 || >=22.12.0` (Vite 8 engine range). The repository pins 22.17.1 in `.nvmrc`.
|
||||
- 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
|
||||
git clone https://git.evandro.dev.br/evandro/gemini-nexus.git
|
||||
cd gemini-nexus
|
||||
pnpm install # postinstall runs scripts/setup-dev.js --check-only to verify the toolchain
|
||||
pnpm start # dev mode (Electron + Vite HMR)
|
||||
pnpm run make # installers/packages for the current platform
|
||||
pnpm run make:win # Windows: extracts WiX from wix314-binaries.zip, then runs make
|
||||
git clone https://github.com/evandrodevbr/GeminiNexus.git
|
||||
cd GeminiNexus
|
||||
|
||||
# 1. install dependencies (lockfile is pnpm; do not use npm install)
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# 2. run the desktop app (Electron + Vite HMR); the gateway listens on 8046 in dev
|
||||
pnpm start
|
||||
```
|
||||
|
||||
There is no `.env.example` and no variable is required. Optional variables read by the code or described in the docs:
|
||||
Then, in the app:
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `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. |
|
||||
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.
|
||||
|
||||
## 🧱 Architecture
|
||||
|
||||
```mermaid
|
||||
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
|
||||
```text
|
||||
API base URL: http://localhost:8045/v1
|
||||
API key: copied from the Proxy page
|
||||
Model: gemini-3-flash (or any model id returned by GET /v1/models)
|
||||
```
|
||||
|
||||
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).
|
||||
## Usage
|
||||
|
||||
## 🔒 Security model
|
||||
Daily commands, all driven by `pnpm`:
|
||||
|
||||
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.
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
- **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.
|
||||
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`.
|
||||
|
||||
Supported versions and how to report a vulnerability: [`SECURITY.md`](SECURITY.md).
|
||||
## Screenshots
|
||||
|
||||
## 🗂️ 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. It listens on all network interfaces (`0.0.0.0`, see `src/server/main.ts`), so always set an API key on a machine that is reachable from a LAN.
|
||||
|
||||
## 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/
|
||||
├── components/ # UI (proxy/, usage/, ui/ primitives)
|
||||
├── ipc/ # oRPC routers (account, cloud, config, gateway, usage...)
|
||||
├── server/ # NestJS proxy gateway (modules: proxy, usage)
|
||||
├── services/ # AutoSwitchService, CloudMonitorService, GoogleAPIService
|
||||
├── routes/ # index, usage, proxy, settings
|
||||
├── utils/ # paths, logger, sensitiveDataMasking
|
||||
└── tests/ # unit tests (Vitest) and E2E (Playwright)
|
||||
cli/ # separate Python CLI for account switching and quota checks
|
||||
docs/ # design notes and Proxyman guides
|
||||
scripts/ # setup-dev, build-windows, generate-assets
|
||||
├── main.ts, preload.ts, renderer.ts Electron entry points
|
||||
├── routes/ TanStack Router pages: index (accounts), usage, proxy, settings
|
||||
├── components/ React UI (accounts, proxy/advanced, usage, ui primitives)
|
||||
├── server/ Embedded gateway
|
||||
│ ├── main.ts NestJS bootstrap (port and process lifecycle)
|
||||
│ ├── modules/proxy/ controllers, proxy service, token manager, SSE, replay, metrics
|
||||
│ └── modules/usage/ token usage service
|
||||
├── ipc/ Electron IPC handlers (accounts, config, database, proxy, tray, ...)
|
||||
├── lib/geminiNexus/ protocol mappers, model mapping, retry and streaming helpers
|
||||
├── localization/ i18n resources
|
||||
├── 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
|
||||
```
|
||||
|
||||
## 🧪 Scripts
|
||||
## Verification
|
||||
|
||||
| Command | What it does |
|
||||
Checks used before merging, all run with `pnpm`:
|
||||
|
||||
| Command | Notes |
|
||||
|---|---|
|
||||
| `pnpm start` | Run the app in dev mode (`electron-forge start`, with `DEBUG=electron-forge:*`). |
|
||||
| `pnpm run dev` | Same as `start`. |
|
||||
| `pnpm run package` | Package the app with Electron Forge, without building installers. |
|
||||
| `pnpm run make` | Build installers/packages for the current platform. |
|
||||
| `pnpm run make:win` | Windows build helper: extracts WiX from `wix314-binaries.zip`, adds it to `PATH`, runs `electron-forge make`. |
|
||||
| `pnpm run publish` | Publish through Electron Forge. |
|
||||
| `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 install --frozen-lockfile` | Run it with a native toolchain, or add `--ignore-scripts` to skip compiling `better-sqlite3`/`keytar` (the unit tests mock both). |
|
||||
| `pnpm test` | Vitest suite under `src/tests/unit` and `src/tests/integration`. |
|
||||
| `pnpm run type-check` | `tsc --noEmit`. |
|
||||
| `pnpm run lint` | ESLint; reports warnings (mostly `no-explicit-any`) but no errors. |
|
||||
| `pnpm run format` | Prettier check. |
|
||||
| `pnpm run test:e2e` | Not part of CI: Playwright drives Electron and needs a graphical session. |
|
||||
|
||||
## 📚 Documentation
|
||||
Electron packaging, real account switching and the E2E suite need separate validation on each platform.
|
||||
|
||||
- [`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).
|
||||
## Current state and limitations
|
||||
|
||||
## 🛣️ Limitations and next steps
|
||||
- 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.
|
||||
- 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.
|
||||
- `src/tests/GeminiNexusCoreFeatures.test.ts` sits outside the Vitest `include` globs, so it never runs, and several of its cases no longer match the current request mapper.
|
||||
- `response_format: { type: 'json_schema' }` sets the JSON mime type but the schema itself is not forwarded upstream (`buildGenerationConfig` in `src/lib/geminiNexus/ClaudeRequestMapper.ts`).
|
||||
- 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.
|
||||
|
||||
- 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)).
|
||||
## Documentation
|
||||
|
||||
## 🤝 Contributing
|
||||
| Document | Content |
|
||||
|---|---|
|
||||
| [`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 |
|
||||
|
||||
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:
|
||||
## License
|
||||
|
||||
```bash
|
||||
pnpm run lint && pnpm run format:write && pnpm run type-check && pnpm test
|
||||
```
|
||||
Dual license:
|
||||
|
||||
## 📄 License
|
||||
- Original code from [AntigravityManager](https://github.com/Draculabo/AntigravityManager) by [Draculabo](https://github.com/Draculabo): [CC BY-NC-SA 4.0](LICENSE).
|
||||
- All new code, features and architecture by [evandrodevbr](https://github.com/evandrodevbr): [MIT](LICENSE-MIT).
|
||||
|
||||
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.
|
||||
The project is being migrated to MIT as the remaining original code is rewritten.
|
||||
|
||||
## 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.
|
||||
+4
-3
@@ -194,14 +194,14 @@ 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).
|
||||
|
||||
*(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
|
||||
|
||||
Com o aplicativo rodando e pelo menos uma conta adicionada, configure sua IDE preferida (Cursor, Windsurf, OpenCode, etc.):
|
||||
|
||||
```plaintext
|
||||
API Base URL: http://localhost:10100/v1
|
||||
API Base URL: http://localhost:8045/v1
|
||||
API Key: (copie da página Proxy no app)
|
||||
Model: gemini-3-flash (ou qualquer modelo mapeado)
|
||||
```
|
||||
@@ -295,6 +295,7 @@ src/
|
||||
<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.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -318,7 +319,7 @@ Sim, este é um aviso comum para novos aplicativos não assinados. Clique em **"
|
||||
<summary><b>P: A IDE não consegue conectar ao proxy?</b></summary>
|
||||
|
||||
1. Certifique-se de que o proxy está rodando (indicador verde na barra de status).
|
||||
2. Verifique se a porta corresponde à configuração da sua IDE (padrão: `10100`).
|
||||
2. Verifique se a porta corresponde à configuração da sua IDE (padrão: `8045`).
|
||||
3. Copie a API key da página Proxy e cole nas configurações da sua IDE.
|
||||
4. Verifique se pelo menos uma conta está ativa na página de Contas.
|
||||
</details>
|
||||
|
||||
+4
-3
@@ -194,14 +194,14 @@
|
||||
|
||||
您可以从我们的 [Releases 页面](https://github.com/evandrodevbr/GeminiNexus/releases) 下载适用于 Windows、macOS 和 Linux 的最新预编译版本。
|
||||
|
||||
*(如果您使用的是 Windows 并在安装时遇到了 SmartScreen 提示,请参阅页面顶部的警告)。*
|
||||
_(如果您使用的是 Windows 并在安装时遇到了 SmartScreen 提示,请参阅页面顶部的警告)。_
|
||||
|
||||
### 🔌 配合 AI IDE 使用
|
||||
|
||||
在代理运行并至少添加一个账户后,配置您首选的 IDE(Cursor、Windsurf、OpenCode 等):
|
||||
|
||||
```plaintext
|
||||
API Base URL: http://localhost:10100/v1
|
||||
API Base URL: http://localhost:8045/v1
|
||||
API Key: (从应用的 Proxy 页面复制)
|
||||
Model: gemini-3-flash (或您选择的任何映射模型)
|
||||
```
|
||||
@@ -274,6 +274,7 @@ npm test
|
||||
<summary><b>问:安装时出现 Windows SmartScreen 警告("Windows 已保护你的电脑")?</b></summary>
|
||||
|
||||
是的,对于未签名的新应用,这是一个常见的警告。请点击 **"更多信息"** (More info),然后点击 **"仍要运行"** (Run anyway)。详情请参阅 README 顶部的提示。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -297,7 +298,7 @@ npm test
|
||||
<summary><b>问:IDE 无法连接到代理?</b></summary>
|
||||
|
||||
1. 确保代理正在运行(查看状态栏的绿色指示器)。
|
||||
2. 确认端口与您 IDE 中的配置一致(默认为 `10100`)。
|
||||
2. 确认端口与您 IDE 中的配置一致(默认为 `8045`)。
|
||||
3. 直接从 Proxy 页面复制 API 密钥并粘贴到 IDE 设置中。
|
||||
4. 确保在 Accounts 页面中至少有一个活跃的账户。
|
||||
</details>
|
||||
|
||||
@@ -50,4 +50,11 @@ export default defineConfig([
|
||||
'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
-7
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gemini-nexus",
|
||||
"productName": "Gemini Nexus",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.5",
|
||||
"description": "Gemini Nexus - Electron App",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
@@ -99,7 +99,6 @@
|
||||
"vitest": "^4.0.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@icons-pack/react-simple-icons": "^13.8.0",
|
||||
"@nestjs/common": "^11.1.18",
|
||||
"@nestjs/core": "^11.1.18",
|
||||
"@nestjs/microservices": "^11.1.18",
|
||||
@@ -116,19 +115,14 @@
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/electron": "^7.7.0",
|
||||
"@sentry/integrations": "^7.114.0",
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"@tanstack/react-router": "^1.139.3",
|
||||
"@tanstack/react-router-devtools": "^1.135.0",
|
||||
|
||||
Generated
+3
-172
@@ -19,9 +19,6 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@icons-pack/react-simple-icons':
|
||||
specifier: ^13.8.0
|
||||
version: 13.13.0([email protected])
|
||||
'@nestjs/common':
|
||||
specifier: ^11.1.18
|
||||
version: 11.1.19([email protected])([email protected])([email protected])([email protected])
|
||||
@@ -70,15 +67,9 @@ importers:
|
||||
'@radix-ui/react-dropdown-menu':
|
||||
specifier: ^2.1.16
|
||||
version: 2.1.16(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-icons':
|
||||
specifier: ^1.3.2
|
||||
version: 1.3.2([email protected])
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.1.8
|
||||
version: 2.1.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-navigation-menu':
|
||||
specifier: ^1.2.14
|
||||
version: 1.2.14(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.2.6
|
||||
version: 2.2.6(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
@@ -94,21 +85,12 @@ importers:
|
||||
'@radix-ui/react-toast':
|
||||
specifier: ^1.2.15
|
||||
version: 1.2.15(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-toggle':
|
||||
specifier: ^1.1.9
|
||||
version: 1.1.10(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-toggle-group':
|
||||
specifier: ^1.1.11
|
||||
version: 1.1.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.8
|
||||
version: 1.2.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@sentry/electron':
|
||||
specifier: ^7.7.0
|
||||
version: 7.12.0
|
||||
'@sentry/integrations':
|
||||
specifier: ^7.114.0
|
||||
version: 7.114.0
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.90.7
|
||||
version: 5.100.5([email protected])
|
||||
@@ -1095,12 +1077,6 @@ packages:
|
||||
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
||||
engines: {node: '>=18.18'}
|
||||
|
||||
'@icons-pack/[email protected]':
|
||||
resolution: {integrity: sha512-B5HhQMIpcSH4z8IZ8HFhD59CboHceKYMpPC9kAwGyKntvPdyJJv26DLu4Z1wAjcCLyrJhf11tMhiQGom9Rxb9g==}
|
||||
engines: {node: '>=24', pnpm: '>=10'}
|
||||
peerDependencies:
|
||||
react: ^16.13 || ^17 || ^18 || ^19
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2276,11 +2252,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==}
|
||||
peerDependencies:
|
||||
react: ^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
|
||||
peerDependencies:
|
||||
@@ -2316,19 +2287,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||
peerDependencies:
|
||||
@@ -2477,32 +2435,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
|
||||
peerDependencies:
|
||||
@@ -3020,10 +2952,6 @@ packages:
|
||||
resolution: {integrity: sha512-UaFeum3LUM1mB0d67jvKnqId1yWQjyqmaDV6kWngG03x+jqXb08tJdGpSoxjXZe13jFBbiBL/wKDDYIK7rCK4g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/[email protected]':
|
||||
resolution: {integrity: sha512-YnanVlmulkjgZiVZ9BfY9k6I082n+C+LbZo52MTvx3FY6RE5iyiPMpaOh67oXEZRWcYQEGm+bKruRxLVP6RlbA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@sentry/[email protected]':
|
||||
resolution: {integrity: sha512-i8WkwxvtioaOKbE957GhoWaAuyiaUdjBNtJeus2VIg5o3RZaM871Q57WcQ3t2TF+U0z+IIlWgwPgGsLcsrPrDw==}
|
||||
peerDependencies:
|
||||
@@ -3032,10 +2960,6 @@ packages:
|
||||
'@sentry/node-native':
|
||||
optional: true
|
||||
|
||||
'@sentry/[email protected]':
|
||||
resolution: {integrity: sha512-BJIBWXGKeIH0ifd7goxOS29fBA8BkEgVVCahs6xIOXBjX1IRS6PmX0zYx/GP23nQTfhJiubv2XPzoYOlZZmDxg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@sentry/[email protected]':
|
||||
resolution: {integrity: sha512-7WO0KuCDPSq3G54TVUSI1CKFJwB67LasG+n/gDMBqbrarzs/Yh/s34OOMU5gfVQpncxQAmQsy4nEboQms8iNqA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3073,14 +2997,6 @@ packages:
|
||||
'@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0
|
||||
'@opentelemetry/semantic-conventions': ^1.39.0
|
||||
|
||||
'@sentry/[email protected]':
|
||||
resolution: {integrity: sha512-tsqkkyL3eJtptmPtT0m9W/bPLkU7ILY7nvwpi1hahA5jrM7ppoU0IMaQWAgTD+U3rzFH40IdXNBFb8Gnqcva4w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@sentry/[email protected]':
|
||||
resolution: {integrity: sha512-319N90McVpupQ6vws4+tfCy/03AdtsU0MurIE4+W5cubHME08HtiEWlfacvAxX+yuKFhvdsO4K4BB/dj54ideg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@sentry/[email protected]':
|
||||
resolution: {integrity: sha512-Tlyg2cyFYp/icX58GWvfpvZr9NLdLs2/xyFVyS8pQ0faZWmoXic3FMzoXYHV1gsdMbL1Yy5WQvGJy8j1rS8LGA==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -3751,6 +3667,7 @@ packages:
|
||||
'@xmldom/[email protected]':
|
||||
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
deprecated: this version has critical issues, please update to the latest version
|
||||
|
||||
'@xtuc/[email protected]':
|
||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
||||
@@ -5090,6 +5007,7 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
jiti: '*'
|
||||
@@ -6180,9 +6098,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-lsmMmGXBxXIK/VMLEj0kL6MtUs1kBGj1nTCzi6zgQoG1DEwqwt2DQyHxcLykceIxAnfE3hya7NuIh6PpC6S3fA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
|
||||
@@ -6286,9 +6201,6 @@ packages:
|
||||
resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==}
|
||||
engines: {node: '>=6.11.5'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -8181,6 +8093,7 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
|
||||
engines: {node: ^18 || >=20}
|
||||
deprecated: unmaintained
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: ^5.0.0
|
||||
@@ -9771,10 +9684,6 @@ snapshots:
|
||||
|
||||
'@humanwhocodes/[email protected]': {}
|
||||
|
||||
'@icons-pack/[email protected]([email protected])':
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
|
||||
'@img/[email protected]': {}
|
||||
|
||||
'@img/[email protected]':
|
||||
@@ -11172,10 +11081,6 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||
|
||||
'@radix-ui/[email protected]([email protected])':
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
|
||||
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected])
|
||||
@@ -11218,28 +11123,6 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||
|
||||
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-direction': 1.1.1(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-id': 1.1.1(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-presence': 1.1.5(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5([email protected])
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||
|
||||
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.8([email protected]([email protected]))([email protected])
|
||||
@@ -11407,32 +11290,6 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||
|
||||
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-direction': 1.1.1(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-toggle': 1.1.10(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5([email protected])
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||
|
||||
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5([email protected])
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||
|
||||
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
@@ -11892,11 +11749,6 @@ snapshots:
|
||||
|
||||
'@sentry/[email protected]': {}
|
||||
|
||||
'@sentry/[email protected]':
|
||||
dependencies:
|
||||
'@sentry/types': 7.114.0
|
||||
'@sentry/utils': 7.114.0
|
||||
|
||||
'@sentry/[email protected]':
|
||||
dependencies:
|
||||
'@sentry/browser': 10.49.0
|
||||
@@ -11906,13 +11758,6 @@ snapshots:
|
||||
- '@opentelemetry/exporter-trace-otlp-http'
|
||||
- supports-color
|
||||
|
||||
'@sentry/[email protected]':
|
||||
dependencies:
|
||||
'@sentry/core': 7.114.0
|
||||
'@sentry/types': 7.114.0
|
||||
'@sentry/utils': 7.114.0
|
||||
localforage: 1.10.0
|
||||
|
||||
'@sentry/[email protected](@opentelemetry/[email protected])(@opentelemetry/[email protected](@opentelemetry/[email protected]))(@opentelemetry/[email protected](@opentelemetry/[email protected]))(@opentelemetry/[email protected](@opentelemetry/[email protected]))(@opentelemetry/[email protected])':
|
||||
dependencies:
|
||||
'@sentry/core': 10.49.0
|
||||
@@ -11971,12 +11816,6 @@ snapshots:
|
||||
'@opentelemetry/semantic-conventions': 1.40.0
|
||||
'@sentry/core': 10.49.0
|
||||
|
||||
'@sentry/[email protected]': {}
|
||||
|
||||
'@sentry/[email protected]':
|
||||
dependencies:
|
||||
'@sentry/types': 7.114.0
|
||||
|
||||
'@sentry/[email protected]([email protected])':
|
||||
dependencies:
|
||||
'@sentry/bundler-plugin-core': 4.9.1([email protected])
|
||||
@@ -15482,10 +15321,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
@@ -15576,10 +15411,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
lie: 3.1.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
p-locate: 2.0.0
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import type { DeviceProfile } from '@/types/account';
|
||||
|
||||
export function listAccounts() {
|
||||
return ipc.client.account.listAccounts();
|
||||
}
|
||||
|
||||
export function addAccountSnapshot() {
|
||||
return ipc.client.account.addAccountSnapshot();
|
||||
}
|
||||
|
||||
export function switchAccount(accountId: string) {
|
||||
return ipc.client.account.switchAccount({ accountId });
|
||||
}
|
||||
|
||||
export function deleteAccount(accountId: string) {
|
||||
return ipc.client.account.deleteAccount({ accountId });
|
||||
}
|
||||
|
||||
export function previewGenerateIdentityProfile() {
|
||||
return ipc.client.account.previewGenerateIdentityProfile();
|
||||
}
|
||||
|
||||
export function getIdentityProfiles(accountId: string) {
|
||||
return ipc.client.account.getIdentityProfiles({ accountId });
|
||||
}
|
||||
|
||||
export function bindIdentityProfile(accountId: string, mode: 'capture' | 'generate') {
|
||||
return ipc.client.account.bindIdentityProfile({ accountId, mode });
|
||||
}
|
||||
|
||||
export function bindIdentityProfileWithPayload(accountId: string, profile: DeviceProfile) {
|
||||
return ipc.client.account.bindIdentityProfileWithPayload({ accountId, profile });
|
||||
}
|
||||
|
||||
export function applyBoundIdentityProfile(accountId: string) {
|
||||
return ipc.client.account.applyBoundIdentityProfile({ accountId });
|
||||
}
|
||||
|
||||
export function restoreIdentityProfileRevision(accountId: string, versionId: string) {
|
||||
return ipc.client.account.restoreIdentityProfileRevision({ accountId, versionId });
|
||||
}
|
||||
|
||||
export function deleteIdentityProfileRevision(accountId: string, versionId: string) {
|
||||
return ipc.client.account.deleteIdentityProfileRevision({ accountId, versionId });
|
||||
}
|
||||
|
||||
export function restoreBaselineProfile(accountId: string) {
|
||||
return ipc.client.account.restoreBaselineProfile({ accountId });
|
||||
}
|
||||
|
||||
export function openIdentityStorageFolder() {
|
||||
return ipc.client.account.openIdentityStorageFolder();
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { AccountBackupData, Account } from '@/types/account';
|
||||
|
||||
export function backupAccount(account: Account) {
|
||||
return ipc.client.database.backupAccount(account);
|
||||
}
|
||||
|
||||
export function restoreAccount(backup: AccountBackupData) {
|
||||
return ipc.client.database.restoreAccount(backup);
|
||||
}
|
||||
|
||||
export function getCurrentAccountInfo() {
|
||||
return ipc.client.database.getCurrentAccountInfo();
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ipc } from '../ipc/manager';
|
||||
|
||||
export async function minimizeWindow() {
|
||||
await ipc.client.window.minimizeWindow();
|
||||
}
|
||||
export async function maximizeWindow() {
|
||||
await ipc.client.window.maximizeWindow();
|
||||
}
|
||||
export async function closeWindow() {
|
||||
await ipc.client.window.closeWindow();
|
||||
}
|
||||
+866
-866
File diff suppressed because it is too large.
Load diff
@@ -417,7 +417,11 @@ export function CloudAccountList() {
|
||||
const setSelectionState = (id: string, selected: boolean) =>
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
selected ? next.add(id) : next.delete(id);
|
||||
if (selected) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const toggleSelectAllAccounts = () =>
|
||||
|
||||
@@ -112,6 +112,8 @@ export function AccountsToolbar(props: AccountsToolbarProps) {
|
||||
size="icon"
|
||||
className="h-7 w-7 cursor-pointer rounded-md"
|
||||
onClick={() => onLayoutChange('auto')}
|
||||
aria-label={t('cloud.layout.auto')}
|
||||
aria-pressed={gridLayout === 'auto'}
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -125,6 +127,8 @@ export function AccountsToolbar(props: AccountsToolbarProps) {
|
||||
size="icon"
|
||||
className="h-7 w-7 cursor-pointer rounded-md"
|
||||
onClick={() => onLayoutChange('2-col')}
|
||||
aria-label={t('cloud.layout.twoCol')}
|
||||
aria-pressed={gridLayout === '2-col'}
|
||||
>
|
||||
<Columns2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -138,6 +142,8 @@ export function AccountsToolbar(props: AccountsToolbarProps) {
|
||||
size="icon"
|
||||
className="h-7 w-7 cursor-pointer rounded-md"
|
||||
onClick={() => onLayoutChange('3-col')}
|
||||
aria-label={t('cloud.layout.threeCol')}
|
||||
aria-pressed={gridLayout === '3-col'}
|
||||
>
|
||||
<Columns3 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -151,6 +157,8 @@ export function AccountsToolbar(props: AccountsToolbarProps) {
|
||||
size="icon"
|
||||
className="h-7 w-7 cursor-pointer rounded-md"
|
||||
onClick={() => onLayoutChange('list')}
|
||||
aria-label={t('cloud.layout.list')}
|
||||
aria-pressed={gridLayout === 'list'}
|
||||
>
|
||||
<List className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -164,6 +172,8 @@ export function AccountsToolbar(props: AccountsToolbarProps) {
|
||||
size="icon"
|
||||
className="h-7 w-7 cursor-pointer rounded-md"
|
||||
onClick={() => onLayoutChange('compact')}
|
||||
aria-label={t('cloud.layout.compact')}
|
||||
aria-pressed={gridLayout === 'compact'}
|
||||
>
|
||||
<LayoutList className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -206,7 +216,12 @@ export function AccountsToolbar(props: AccountsToolbarProps) {
|
||||
{/* More Options Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="cursor-pointer bg-white/[0.02]">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="cursor-pointer bg-white/[0.02]"
|
||||
aria-label={t('common.openMenu')}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -29,6 +29,7 @@ export function BatchActionBar({
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-full"
|
||||
onClick={onClearSelection}
|
||||
aria-label={t('cloud.batch.clear')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { getPlatform } from '@/actions/app';
|
||||
import { closeWindow, maximizeWindow, minimizeWindow } from '@/actions/window';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
interface DragWindowRegionProps {
|
||||
title?: ReactNode;
|
||||
}
|
||||
|
||||
export default function DragWindowRegion({ title }: DragWindowRegionProps) {
|
||||
const [platform, setPlatform] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
getPlatform()
|
||||
.then((value: any) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setPlatform(value as string);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
logger.error('Failed to detect platform', error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isMacOS = platform === 'darwin';
|
||||
|
||||
return (
|
||||
<div className="flex w-screen items-stretch justify-between">
|
||||
<div className="draglayer w-full">
|
||||
{title && !isMacOS && (
|
||||
<div className="text-muted-foreground flex flex-1 p-2 text-xs whitespace-nowrap select-none">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{isMacOS && (
|
||||
<div className="flex flex-1 p-2">
|
||||
{/* Maintain the same height but do not display content */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isMacOS && <WindowButtons />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowButtons() {
|
||||
return (
|
||||
<div className="flex">
|
||||
<button
|
||||
title="Minimize"
|
||||
type="button"
|
||||
className="p-2 hover:bg-slate-300"
|
||||
onClick={minimizeWindow}
|
||||
>
|
||||
<svg aria-hidden="true" role="img" width="12" height="12" viewBox="0 0 12 12">
|
||||
<rect fill="currentColor" width="10" height="1" x="1" y="6"></rect>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
title="Maximize"
|
||||
type="button"
|
||||
className="p-2 hover:bg-slate-300"
|
||||
onClick={maximizeWindow}
|
||||
>
|
||||
<svg aria-hidden="true" role="img" width="12" height="12" viewBox="0 0 12 12">
|
||||
<rect width="9" height="9" x="1.5" y="1.5" fill="none" stroke="currentColor"></rect>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" title="Close" className="p-2 hover:bg-red-300" onClick={closeWindow}>
|
||||
<svg aria-hidden="true" role="img" width="12" height="12" viewBox="0 0 12 12">
|
||||
<polygon
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
points="11 1.576 6.583 6 11 10.424 10.424 11 6 6.583 1.576 11 1 10.424 5.417 6 1 1.576 1.576 1 6 5.417 10.424 1"
|
||||
></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import {
|
||||
NavigationMenu as NavigationMenuBase,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
navigationMenuTriggerStyle,
|
||||
} from '@/components/ui/navigation-menu';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function NavigationMenu() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<NavigationMenuBase className="text-muted-foreground px-2">
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||
<Link to="/">{t('titleHomePage')}</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenuBase>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn('group flex flex-1 list-none items-center justify-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn('relative', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1',
|
||||
);
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), 'group', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{' '}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
|
||||
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div className={cn('absolute top-full left-0 isolate z-50 flex justify-center')}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
|
||||
import { type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toggleVariants } from '@/components/ui/toggle';
|
||||
|
||||
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
});
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(
|
||||
'group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
@@ -1,44 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import * as TogglePrimitive from '@radix-ui/react-toggle';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline:
|
||||
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-2 min-w-9',
|
||||
sm: 'h-8 px-1.5 min-w-8',
|
||||
lg: 'h-10 px-2.5 min-w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { calculateCost } from '@/constants/pricing';
|
||||
|
||||
export interface ModelCostEntry {
|
||||
model: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, RefreshCw, RotateCcw } from 'lucide-react';
|
||||
import { MODEL_PRICING } from '@/constants/pricing';
|
||||
|
||||
@@ -35,8 +34,16 @@ export const ModelPricingDialog: React.FC<ModelPricingDialogProps> = ({
|
||||
Record<string, { input: number; output: number; source: string }>
|
||||
>({});
|
||||
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) {
|
||||
// Initialize local state with custom pricing or empty if default
|
||||
const initial: Record<string, { input: number; output: number; source: string }> = {};
|
||||
@@ -49,7 +56,7 @@ export const ModelPricingDialog: React.FC<ModelPricingDialogProps> = ({
|
||||
});
|
||||
setLocalPricing(initial);
|
||||
}
|
||||
}, [open, customPricing]);
|
||||
}
|
||||
|
||||
const handlePriceChange = (model: string, type: 'input' | 'output', value: string) => {
|
||||
const numValue = parseFloat(value);
|
||||
@@ -251,6 +258,7 @@ export const ModelPricingDialog: React.FC<ModelPricingDialogProps> = ({
|
||||
className="text-muted-foreground hover:text-foreground h-6 w-6"
|
||||
onClick={() => handleReset(model)}
|
||||
title="Reset to default"
|
||||
aria-label={`Reset ${model} pricing to default`}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
@@ -58,9 +58,8 @@ export const MonitorTab: React.FC = () => {
|
||||
tokensPrompt: log.metadata?.tokensPrompt as number | undefined,
|
||||
tokensCompletion: log.metadata?.tokensCompletion as number | undefined,
|
||||
tokensTotal:
|
||||
(((log.metadata?.tokensPrompt as number) || 0) +
|
||||
((log.metadata?.tokensCompletion as number) || 0)) ||
|
||||
undefined,
|
||||
((log.metadata?.tokensPrompt as number) || 0) +
|
||||
((log.metadata?.tokensCompletion as number) || 0) || undefined,
|
||||
}))
|
||||
: [];
|
||||
const stats = statsData?.success ? statsData.data : undefined;
|
||||
|
||||
@@ -26,7 +26,16 @@ interface TrafficLogTableProps {
|
||||
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';
|
||||
|
||||
function SortIcon({
|
||||
@@ -79,13 +88,10 @@ function getBodyPreview(body: unknown): string | null {
|
||||
|
||||
// Request body: extract first user message
|
||||
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) {
|
||||
const content = typeof userMsg.content === 'string'
|
||||
? userMsg.content
|
||||
: JSON.stringify(userMsg.content);
|
||||
const content =
|
||||
typeof userMsg.content === 'string' ? userMsg.content : JSON.stringify(userMsg.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 message = firstChoice?.message as Record<string, unknown> | undefined;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,18 +258,16 @@ export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoadin
|
||||
<React.Fragment key={logKey}>
|
||||
<tr
|
||||
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)}
|
||||
>
|
||||
{/* Expand chevron */}
|
||||
<td className="py-2 pr-1">
|
||||
{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 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 className="py-2 pr-4 text-xs">
|
||||
{log.tokensTotal ? (
|
||||
`${formatTokens(log.tokensPrompt)} / ${formatTokens(log.tokensCompletion)}`
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
{log.tokensTotal
|
||||
? `${formatTokens(log.tokensPrompt)} / ${formatTokens(log.tokensCompletion)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs">
|
||||
{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">
|
||||
{/* Preview line */}
|
||||
{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}"
|
||||
</div>
|
||||
)}
|
||||
@@ -339,18 +339,32 @@ export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoadin
|
||||
</Badge>
|
||||
)}
|
||||
{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
|
||||
</Badge>
|
||||
)}
|
||||
{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
|
||||
</Badge>
|
||||
)}
|
||||
{log.tokensTotal != null && log.model && (
|
||||
<Badge variant="outline" className="border-amber-500/20 text-amber-400 text-[10px]">
|
||||
Cost: ${calculateCost(log.model, log.tokensPrompt || 0, log.tokensCompletion || 0).toFixed(6)}
|
||||
<Badge
|
||||
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>
|
||||
)}
|
||||
{log.durationMs != null && (
|
||||
@@ -363,11 +377,15 @@ export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoadin
|
||||
{/* Body content */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-[10px] font-medium uppercase tracking-wider">
|
||||
{log.direction === 'inbound' ? 'Request Body' : log.direction === 'outbound' ? 'Response Body' : 'Payload'}
|
||||
<span className="text-muted-foreground text-[10px] font-medium tracking-wider uppercase">
|
||||
{log.direction === 'inbound'
|
||||
? 'Request Body'
|
||||
: log.direction === 'outbound'
|
||||
? 'Response Body'
|
||||
: 'Payload'}
|
||||
</span>
|
||||
</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)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
import { CloudAccountSchema } from '../../types/cloudAccount';
|
||||
import { DeviceProfileSchema, DeviceProfilesSnapshotSchema } from '../../types/account';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { redactCloudAccountTokens } from '../../utils/cloud-account-redaction';
|
||||
import { getSwitchMetricsSnapshot } from '../switchMetrics';
|
||||
import { getSwitchGuardSnapshot } from '../switchGuard';
|
||||
import { getDeviceHardeningSnapshot } from '../device/handler';
|
||||
@@ -107,11 +108,11 @@ export const cloudRouter = os.router({
|
||||
.input(z.object({ authCode: z.string(), oauthClientKey: z.string().optional() }))
|
||||
.output(CloudAccountSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return addGoogleAccount(input.authCode, input.oauthClientKey);
|
||||
return redactCloudAccountTokens(await addGoogleAccount(input.authCode, input.oauthClientKey));
|
||||
}),
|
||||
|
||||
listCloudAccounts: os.output(z.array(CloudAccountSchema)).handler(async () => {
|
||||
return listCloudAccounts();
|
||||
return (await listCloudAccounts()).map(redactCloudAccountTokens);
|
||||
}),
|
||||
|
||||
deleteCloudAccount: os
|
||||
@@ -125,7 +126,7 @@ export const cloudRouter = os.router({
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(CloudAccountSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return refreshAccountQuota(input.accountId);
|
||||
return redactCloudAccountTokens(await refreshAccountQuota(input.accountId));
|
||||
}),
|
||||
|
||||
switchCloudAccount: os
|
||||
@@ -206,7 +207,7 @@ export const cloudRouter = os.router({
|
||||
try {
|
||||
const result = await CloudAccountRepo.syncFromIDE();
|
||||
|
||||
return result;
|
||||
return result ? redactCloudAccountTokens(result) : result;
|
||||
} catch (error: unknown) {
|
||||
logger.error(
|
||||
'[ORPC] syncLocalAccount error:',
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getServiceOrThrow, getServiceOptional } from './service-registry';
|
||||
import type {
|
||||
IdeConfigResult,
|
||||
SupportedIde,
|
||||
} from '../../server/modules/proxy/proxy-ide-config.service';
|
||||
import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service';
|
||||
import { TrafficLogsRepo } from '../database/proxyMetricsHandler';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
import { ConfigManager } from '../config/manager';
|
||||
@@ -257,9 +254,7 @@ export async function getRecentRequests(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
export async function replayRequest(
|
||||
requestId: string,
|
||||
): Promise<{
|
||||
export async function replayRequest(requestId: string): Promise<{
|
||||
success: boolean;
|
||||
data?: { original: unknown; newResponse: unknown };
|
||||
error?: string;
|
||||
@@ -458,16 +453,21 @@ export async function generateIdeConfig(
|
||||
const apiKey = config?.api_key || 'YOUR_API_KEY';
|
||||
|
||||
let localIp = '127.0.0.1';
|
||||
const interfaces = networkInterfaces();
|
||||
for (const iface of Object.values(interfaces)) {
|
||||
if (iface) {
|
||||
for (const entry of iface) {
|
||||
if (entry.family === 'IPv4' && !entry.internal) {
|
||||
localIp = entry.address;
|
||||
break;
|
||||
try {
|
||||
const interfaces = networkInterfaces();
|
||||
for (const iface of Object.values(interfaces)) {
|
||||
if (iface) {
|
||||
for (const entry of iface) {
|
||||
if (entry.family === 'IPv4' && !entry.internal) {
|
||||
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
|
||||
|
||||
@@ -42,6 +42,139 @@ interface RequestConfig {
|
||||
|
||||
// --- Main Logic ---
|
||||
|
||||
type ToolConfig = { functionCallingConfig: { mode: string; allowedFunctionNames?: string[] } };
|
||||
|
||||
/**
|
||||
* Maps the request tool_choice to a Gemini toolConfig.
|
||||
* Without declared tools only an explicit 'none' produces a config.
|
||||
*/
|
||||
function resolveToolConfig(
|
||||
toolChoice: ClaudeRequest['tool_choice'],
|
||||
hasTools: boolean,
|
||||
): ToolConfig | undefined {
|
||||
if (toolChoice === 'none') {
|
||||
return { functionCallingConfig: { mode: 'NONE' } };
|
||||
}
|
||||
if (!hasTools) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
typeof toolChoice === 'object' &&
|
||||
toolChoice.type === 'function' &&
|
||||
toolChoice.function?.name
|
||||
) {
|
||||
return {
|
||||
functionCallingConfig: { mode: 'ANY', allowedFunctionNames: [toolChoice.function.name] },
|
||||
};
|
||||
}
|
||||
return { functionCallingConfig: { mode: 'AUTO' } };
|
||||
}
|
||||
|
||||
function resolveThinkingEnabled(claudeReq: ClaudeRequest, finalModel: string): boolean {
|
||||
const thinkingType = (claudeReq.thinking?.type ?? '').toLowerCase();
|
||||
if (thinkingType === 'enabled' || thinkingType === 'adaptive') {
|
||||
return true;
|
||||
}
|
||||
return !claudeReq.thinking && shouldEnableThinkingByDefault(finalModel, claudeReq.model);
|
||||
}
|
||||
|
||||
function buildSafetySettings(): SafetySetting[] {
|
||||
return [
|
||||
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'OFF' },
|
||||
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'OFF' },
|
||||
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'OFF' },
|
||||
{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'OFF' },
|
||||
{ category: 'HARM_CATEGORY_CIVIC_INTEGRITY', threshold: 'OFF' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Image generation endpoints reject tools, system prompts and thinking/JSON output settings,
|
||||
* so they are stripped before the image config is attached.
|
||||
*/
|
||||
function applyImageGenerationOverrides(
|
||||
innerRequest: {
|
||||
tools?: unknown;
|
||||
systemInstruction?: unknown;
|
||||
generationConfig?: GenerationConfig;
|
||||
},
|
||||
imageConfig: ImageConfig,
|
||||
): void {
|
||||
delete innerRequest.tools;
|
||||
delete innerRequest.systemInstruction;
|
||||
|
||||
const genConfig = innerRequest.generationConfig || {};
|
||||
delete genConfig.thinkingConfig;
|
||||
delete genConfig.responseMimeType;
|
||||
delete genConfig.responseModalities;
|
||||
genConfig.imageConfig = imageConfig;
|
||||
innerRequest.generationConfig = genConfig;
|
||||
}
|
||||
|
||||
interface InnerRequest {
|
||||
contents: GeminiContent[];
|
||||
safetySettings: SafetySetting[];
|
||||
systemInstruction?: { parts: { text: string }[] };
|
||||
generationConfig?: GenerationConfig;
|
||||
tools?: GeminiToolDeclaration[];
|
||||
toolConfig?: ToolConfig;
|
||||
}
|
||||
|
||||
/** Copies only the sections that are present, so empty ones never reach the upstream payload. */
|
||||
function attachOptionalSections(
|
||||
innerRequest: InnerRequest,
|
||||
sections: {
|
||||
systemInstruction: InnerRequest['systemInstruction'] | null;
|
||||
generationConfig: GenerationConfig;
|
||||
tools: InnerRequest['tools'] | null;
|
||||
toolConfig: ToolConfig | undefined;
|
||||
},
|
||||
): void {
|
||||
if (sections.systemInstruction) {
|
||||
innerRequest.systemInstruction = sections.systemInstruction;
|
||||
}
|
||||
if (Object.keys(sections.generationConfig).length > 0) {
|
||||
innerRequest.generationConfig = sections.generationConfig;
|
||||
}
|
||||
if (sections.tools) {
|
||||
innerRequest.tools = sections.tools;
|
||||
}
|
||||
if (sections.toolConfig) {
|
||||
innerRequest.toolConfig = sections.toolConfig;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequestEnvelope(
|
||||
innerRequest: object,
|
||||
config: RequestConfig,
|
||||
claudeReq: ClaudeRequest,
|
||||
projectId: string | undefined,
|
||||
userAgent: string | undefined,
|
||||
): GeminiInternalRequest {
|
||||
const requestId = `agent-${uuidv4()}`;
|
||||
|
||||
const normalizedProjectId = projectId?.trim();
|
||||
|
||||
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
|
||||
const body: GeminiInternalRequest = {
|
||||
requestId: requestId,
|
||||
request: innerRequest as GeminiInternalRequest['request'],
|
||||
model: config.finalModel,
|
||||
userAgent: userAgent?.trim() || buildUserAgent(discoveryVersion),
|
||||
requestType: config.requestType,
|
||||
};
|
||||
|
||||
if (normalizedProjectId) {
|
||||
body.project = normalizedProjectId;
|
||||
}
|
||||
|
||||
if (claudeReq.metadata?.user_id) {
|
||||
body.sessionId = claudeReq.metadata.user_id;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms Claude request into Gemini internal request format
|
||||
* @param claudeReq Claude API request
|
||||
@@ -76,15 +209,10 @@ export function transformClaudeRequestIn(
|
||||
const allowDummyThought = config.finalModel.startsWith('gemini-');
|
||||
|
||||
// 4. Generation Config & Thinking
|
||||
const thinkingType = (claudeReq.thinking?.type ?? '').toLowerCase();
|
||||
const autoThinkingEnabled =
|
||||
!claudeReq.thinking && shouldEnableThinkingByDefault(config.finalModel, claudeReq.model);
|
||||
let isThinkingEnabled =
|
||||
thinkingType === 'enabled' || thinkingType === 'adaptive' || autoThinkingEnabled;
|
||||
|
||||
// Thinking is always kept enabled for thinking-capable models.
|
||||
// All functionCall/functionResponse/thinking parts get unconditional
|
||||
// thoughtSignature injection in buildContents(), so no guard is needed here.
|
||||
const isThinkingEnabled = resolveThinkingEnabled(claudeReq, config.finalModel);
|
||||
|
||||
const generationConfig = buildGenerationConfig(
|
||||
claudeReq,
|
||||
@@ -110,61 +238,16 @@ export function transformClaudeRequestIn(
|
||||
const tools = buildTools(claudeReq.tools, hasWebSearchTool, config.finalModel);
|
||||
|
||||
// 5. Safety Settings
|
||||
const safetySettings = [
|
||||
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'OFF' },
|
||||
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'OFF' },
|
||||
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'OFF' },
|
||||
{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'OFF' },
|
||||
{ category: 'HARM_CATEGORY_CIVIC_INTEGRITY', threshold: 'OFF' },
|
||||
];
|
||||
|
||||
// Build inner request
|
||||
const innerRequest: {
|
||||
contents: GeminiContent[];
|
||||
safetySettings: SafetySetting[];
|
||||
systemInstruction?: { parts: { text: string }[] };
|
||||
generationConfig?: GenerationConfig;
|
||||
tools?: GeminiToolDeclaration[];
|
||||
toolConfig?: { functionCallingConfig: { mode: string; allowedFunctionNames?: string[] } };
|
||||
} = {
|
||||
contents,
|
||||
safetySettings,
|
||||
};
|
||||
const safetySettings = buildSafetySettings();
|
||||
|
||||
const innerRequest: InnerRequest = { contents, safetySettings };
|
||||
deepCleanUndefined(innerRequest);
|
||||
|
||||
if (systemInstruction) {
|
||||
innerRequest.systemInstruction = systemInstruction;
|
||||
}
|
||||
|
||||
if (generationConfig && Object.keys(generationConfig).length > 0) {
|
||||
innerRequest.generationConfig = generationConfig;
|
||||
}
|
||||
|
||||
if (tools) {
|
||||
innerRequest.tools = tools;
|
||||
const toolChoice = claudeReq.tool_choice;
|
||||
if (toolChoice === 'none') {
|
||||
innerRequest.toolConfig = { functionCallingConfig: { mode: 'NONE' } };
|
||||
} else if (toolChoice === 'auto' || toolChoice === undefined) {
|
||||
innerRequest.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
|
||||
} else if (
|
||||
typeof toolChoice === 'object' &&
|
||||
toolChoice.type === 'function' &&
|
||||
toolChoice.function?.name
|
||||
) {
|
||||
innerRequest.toolConfig = {
|
||||
functionCallingConfig: {
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames: [toolChoice.function.name],
|
||||
},
|
||||
};
|
||||
} else {
|
||||
innerRequest.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
|
||||
}
|
||||
} else if (claudeReq.tool_choice === 'none') {
|
||||
innerRequest.toolConfig = { functionCallingConfig: { mode: 'NONE' } };
|
||||
}
|
||||
attachOptionalSections(innerRequest, {
|
||||
systemInstruction,
|
||||
generationConfig,
|
||||
tools,
|
||||
toolConfig: resolveToolConfig(claudeReq.tool_choice, !!tools),
|
||||
});
|
||||
|
||||
// Inject googleSearch tool if needed (and not already done by buildTools)
|
||||
if (config.injectGoogleSearch && !hasWebSearchTool) {
|
||||
@@ -173,42 +256,10 @@ export function transformClaudeRequestIn(
|
||||
|
||||
// Inject imageConfig if present (for image generation models)
|
||||
if (config.imageConfig) {
|
||||
// 1. Remove tools (image generation does not support tools)
|
||||
delete innerRequest.tools;
|
||||
// 2. Remove systemInstruction (image generation does not support system prompts)
|
||||
delete innerRequest.systemInstruction;
|
||||
|
||||
// 3. Clean generationConfig
|
||||
const genConfig = innerRequest.generationConfig || {};
|
||||
delete genConfig.thinkingConfig;
|
||||
delete genConfig.responseMimeType;
|
||||
delete genConfig.responseModalities;
|
||||
genConfig.imageConfig = config.imageConfig;
|
||||
innerRequest.generationConfig = genConfig;
|
||||
applyImageGenerationOverrides(innerRequest, config.imageConfig);
|
||||
}
|
||||
|
||||
const requestId = `agent-${uuidv4()}`;
|
||||
|
||||
const normalizedProjectId = projectId?.trim();
|
||||
|
||||
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
|
||||
const body: GeminiInternalRequest = {
|
||||
requestId: requestId,
|
||||
request: innerRequest as GeminiInternalRequest['request'],
|
||||
model: config.finalModel,
|
||||
userAgent: userAgent?.trim() || buildUserAgent(discoveryVersion),
|
||||
requestType: config.requestType,
|
||||
};
|
||||
|
||||
if (normalizedProjectId) {
|
||||
body.project = normalizedProjectId;
|
||||
}
|
||||
|
||||
if (claudeReq.metadata?.user_id) {
|
||||
body.sessionId = claudeReq.metadata.user_id;
|
||||
}
|
||||
|
||||
return body;
|
||||
return buildRequestEnvelope(innerRequest, config, claudeReq, projectId, userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -464,6 +515,142 @@ function hasValidSignatureForFunctionCalls(
|
||||
return false;
|
||||
}
|
||||
|
||||
type ContentPart = GeminiContent['parts'][number];
|
||||
|
||||
interface ContentBuildState {
|
||||
/** Most recent thought signature seen while walking the messages */
|
||||
lastThoughtSignature: string | null;
|
||||
toolIdToName: Map<string, string>;
|
||||
}
|
||||
|
||||
type ContentBlock = Exclude<Message['content'], string | undefined>[number];
|
||||
|
||||
function buildTextPart(block: any, msg: Message): ContentPart | null {
|
||||
if (!block.text || block.text === '(no content)' || isEmpty(block.text.trim())) {
|
||||
return null;
|
||||
}
|
||||
let text = block.text.trim();
|
||||
if (msg.role === 'function' && msg.name) {
|
||||
text = `Function result (${msg.name}):\n${text}`;
|
||||
} else if (msg.name) {
|
||||
text = `[${msg.name}]: ${text}`;
|
||||
}
|
||||
return { text };
|
||||
}
|
||||
|
||||
function buildThinkingPart(block: any, state: ContentBuildState): ContentPart {
|
||||
const part: any = { text: block.thinking, thought: true };
|
||||
cleanJsonSchema(part);
|
||||
if (block.signature) {
|
||||
state.lastThoughtSignature = block.signature;
|
||||
part.thoughtSignature = block.signature;
|
||||
} else {
|
||||
// Inject fallback signature for thinking blocks without one
|
||||
part.thoughtSignature = SignatureStore.get() || 'skip_thought_signature_validator';
|
||||
}
|
||||
return part;
|
||||
}
|
||||
|
||||
function buildToolUsePart(block: any, state: ContentBuildState): ContentPart {
|
||||
const part: any = { functionCall: { name: block.name, args: block.input, id: block.id } };
|
||||
cleanJsonSchema(part);
|
||||
state.toolIdToName.set(block.id, block.name);
|
||||
// Always inject thoughtSignature on functionCall parts.
|
||||
// The v1internal API rejects functionCalls without signatures when thinking is enabled.
|
||||
const finalSig = block.signature || state.lastThoughtSignature || SignatureStore.get();
|
||||
part.thoughtSignature = finalSig || 'skip_thought_signature_validator';
|
||||
return part;
|
||||
}
|
||||
|
||||
function mergeToolResultContent(content: any): string {
|
||||
if (isString(content)) {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter((b: any) => b.type === 'text')
|
||||
.map((b: any) => b.text)
|
||||
.join('\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildToolResultPart(block: any, state: ContentBuildState): ContentPart {
|
||||
const funcName = state.toolIdToName.get(block.tool_use_id) || block.tool_use_id;
|
||||
let mergedContent = mergeToolResultContent(block.content);
|
||||
if (isEmpty(mergedContent.trim())) {
|
||||
mergedContent = block.is_error
|
||||
? 'Tool execution failed with no output.'
|
||||
: 'Command executed successfully.';
|
||||
}
|
||||
const part: any = {
|
||||
functionResponse: {
|
||||
name: funcName,
|
||||
response: block.is_error ? { error: mergedContent } : { result: mergedContent },
|
||||
id: block.tool_use_id,
|
||||
},
|
||||
};
|
||||
// Always inject thoughtSignature on functionResponse parts for v1internal compatibility
|
||||
part.thoughtSignature =
|
||||
state.lastThoughtSignature || SignatureStore.get() || 'skip_thought_signature_validator';
|
||||
return part;
|
||||
}
|
||||
|
||||
function buildPartFromBlock(
|
||||
block: ContentBlock,
|
||||
msg: Message,
|
||||
state: ContentBuildState,
|
||||
): ContentPart | null {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return buildTextPart(block, msg);
|
||||
case 'thinking':
|
||||
return buildThinkingPart(block, state);
|
||||
case 'image':
|
||||
return block.source.type === 'base64'
|
||||
? { inlineData: { mimeType: block.source.media_type, data: block.source.data } }
|
||||
: null;
|
||||
case 'tool_use':
|
||||
return buildToolUsePart(block, state);
|
||||
case 'tool_result':
|
||||
return buildToolResultPart(block, state);
|
||||
case 'redacted_thinking':
|
||||
return { text: `[Redacted Thinking: ${block.data}]`, thought: true };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getContentBlocks(msg: Message): ContentBlock[] {
|
||||
if (Array.isArray(msg.content)) {
|
||||
return msg.content;
|
||||
}
|
||||
return msg.content ? [{ type: 'text' as const, text: msg.content }] : [];
|
||||
}
|
||||
|
||||
function toGeminiRole(role: string): string {
|
||||
if (role === 'assistant') return 'model';
|
||||
return role === 'function' ? 'user' : role;
|
||||
}
|
||||
|
||||
function buildMessageParts(msg: Message, state: ContentBuildState): ContentPart[] {
|
||||
const parts: ContentPart[] = [];
|
||||
for (const block of getContentBlocks(msg)) {
|
||||
const part = buildPartFromBlock(block, msg, state);
|
||||
if (part) {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Prepends a placeholder thought when the final model turn carries none. */
|
||||
function ensureThoughtPart(parts: ContentPart[]): void {
|
||||
if (!parts.some((p) => p.thought === true)) {
|
||||
parts.unshift({ text: 'Thinking...', thought: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds message contents
|
||||
* Converts Claude message list to Gemini content format
|
||||
@@ -476,96 +663,16 @@ function buildContents(
|
||||
mappedModel: string,
|
||||
): GeminiContent[] {
|
||||
const contents: GeminiContent[] = [];
|
||||
let lastThoughtSignature: string | null = null;
|
||||
const state: ContentBuildState = { lastThoughtSignature: null, toolIdToName };
|
||||
|
||||
const lastIndex = messages.length - 1;
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
let role = msg.role === 'assistant' ? 'model' : msg.role;
|
||||
if (role === 'function') {
|
||||
role = 'user';
|
||||
}
|
||||
const parts: {
|
||||
text?: string;
|
||||
inlineData?: {
|
||||
mimeType: string;
|
||||
data: string;
|
||||
};
|
||||
thought?: boolean;
|
||||
}[] = [];
|
||||
const contentBlocks = Array.isArray(msg.content)
|
||||
? msg.content
|
||||
: msg.content
|
||||
? [{ type: 'text' as const, text: msg.content }]
|
||||
: [];
|
||||
|
||||
for (const block of contentBlocks) {
|
||||
if (block.type === 'text') {
|
||||
if (block.text && block.text !== '(no content)' && !isEmpty(block.text.trim())) {
|
||||
let text = block.text.trim();
|
||||
if (msg.role === 'function' && msg.name) {
|
||||
text = `Function result (${msg.name}):\n${text}`;
|
||||
} else if (msg.name) {
|
||||
text = `[${msg.name}]: ${text}`;
|
||||
}
|
||||
parts.push({ text });
|
||||
}
|
||||
} else if (block.type === 'thinking') {
|
||||
const part: any = { text: block.thinking, thought: true };
|
||||
cleanJsonSchema(part);
|
||||
if (block.signature) {
|
||||
lastThoughtSignature = block.signature;
|
||||
part.thoughtSignature = block.signature;
|
||||
} else {
|
||||
// Inject fallback signature for thinking blocks without one
|
||||
const fallbackSig = SignatureStore.get() || 'skip_thought_signature_validator';
|
||||
part.thoughtSignature = fallbackSig;
|
||||
}
|
||||
parts.push(part);
|
||||
} else if (block.type === 'image') {
|
||||
if (block.source.type === 'base64')
|
||||
parts.push({
|
||||
inlineData: { mimeType: block.source.media_type, data: block.source.data },
|
||||
});
|
||||
} else if (block.type === 'tool_use') {
|
||||
const part: any = { functionCall: { name: block.name, args: block.input, id: block.id } };
|
||||
cleanJsonSchema(part);
|
||||
toolIdToName.set(block.id, block.name);
|
||||
// Always inject thoughtSignature on functionCall parts.
|
||||
// The v1internal API rejects functionCalls without signatures when thinking is enabled.
|
||||
const finalSig = block.signature || lastThoughtSignature || SignatureStore.get();
|
||||
part.thoughtSignature = finalSig || 'skip_thought_signature_validator';
|
||||
parts.push(part);
|
||||
} else if (block.type === 'tool_result') {
|
||||
const funcName = toolIdToName.get(block.tool_use_id) || block.tool_use_id;
|
||||
let mergedContent = '';
|
||||
if (isString(block.content)) mergedContent = block.content;
|
||||
else if (Array.isArray(block.content))
|
||||
mergedContent = block.content
|
||||
.filter((b: any) => b.type === 'text')
|
||||
.map((b: any) => b.text)
|
||||
.join('\n');
|
||||
if (isEmpty(mergedContent.trim()))
|
||||
mergedContent = block.is_error
|
||||
? 'Tool execution failed with no output.'
|
||||
: 'Command executed successfully.';
|
||||
const part: any = {
|
||||
functionResponse: {
|
||||
name: funcName,
|
||||
response: block.is_error ? { error: mergedContent } : { result: mergedContent },
|
||||
id: block.tool_use_id,
|
||||
},
|
||||
};
|
||||
// Always inject thoughtSignature on functionResponse parts for v1internal compatibility
|
||||
part.thoughtSignature =
|
||||
lastThoughtSignature || SignatureStore.get() || 'skip_thought_signature_validator';
|
||||
parts.push(part);
|
||||
} else if (block.type === 'redacted_thinking') {
|
||||
parts.push({ text: `[Redacted Thinking: ${block.data}]`, thought: true });
|
||||
}
|
||||
}
|
||||
if (allowDummyThought && role === 'model' && isThinkingEnabled && i === messages.length - 1) {
|
||||
const hasThought = parts.some((p) => p.thought === true);
|
||||
if (!hasThought) parts.unshift({ text: 'Thinking...', thought: true });
|
||||
const role = toGeminiRole(msg.role);
|
||||
const parts = buildMessageParts(msg, state);
|
||||
if (allowDummyThought && isThinkingEnabled && role === 'model' && i === lastIndex) {
|
||||
ensureThoughtPart(parts);
|
||||
}
|
||||
if (parts.length > 0) contents.push({ role, parts });
|
||||
}
|
||||
@@ -626,6 +733,91 @@ function buildTools(
|
||||
return null;
|
||||
}
|
||||
|
||||
const DEFAULT_STOP_SEQUENCES = [
|
||||
'<|user|>',
|
||||
'<|endoftext|>',
|
||||
'<|end_of_turn|>',
|
||||
'[DONE]',
|
||||
'\n\nHuman:',
|
||||
];
|
||||
|
||||
function buildThinkingConfig(
|
||||
claudeReq: ClaudeRequest,
|
||||
hasWebSearch: boolean,
|
||||
mappedModel: string,
|
||||
): GenerationConfig['thinkingConfig'] {
|
||||
const thinkingType = String(claudeReq.thinking?.type ?? '').toLowerCase();
|
||||
const thinkingConfig: GenerationConfig['thinkingConfig'] = { includeThoughts: true };
|
||||
if (thinkingType === 'adaptive') {
|
||||
if (isClaudeModel(mappedModel)) {
|
||||
thinkingConfig.thinkingLevel = resolveAdaptiveThinkingLevel(claudeReq);
|
||||
} else {
|
||||
thinkingConfig.thinkingBudget = 24576;
|
||||
}
|
||||
} else if (claudeReq.thinking?.budget_tokens) {
|
||||
let budget = claudeReq.thinking.budget_tokens;
|
||||
const isFlash = hasWebSearch || isGeminiFlashModel(mappedModel);
|
||||
if (isFlash) {
|
||||
budget = Math.min(budget, 24576);
|
||||
}
|
||||
thinkingConfig.thinkingBudget = budget;
|
||||
} else {
|
||||
thinkingConfig.thinkingBudget = getThinkingBudget(mappedModel);
|
||||
}
|
||||
return thinkingConfig;
|
||||
}
|
||||
|
||||
function applyOpenAISampling(
|
||||
config: GenerationConfig,
|
||||
claudeReq: ClaudeRequest,
|
||||
mappedModel: string,
|
||||
maxTokens: number | undefined,
|
||||
): void {
|
||||
config.temperature = claudeReq.temperature ?? 1.0;
|
||||
config.topP = claudeReq.top_p ?? 0.95;
|
||||
config.maxOutputTokens = maxTokens ?? getMaxOutputTokens(mappedModel);
|
||||
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
|
||||
config.stopSequences = claudeReq.stop_sequences;
|
||||
}
|
||||
}
|
||||
|
||||
function applyNativeSampling(
|
||||
config: GenerationConfig,
|
||||
claudeReq: ClaudeRequest,
|
||||
maxTokens: number | undefined,
|
||||
): void {
|
||||
if (claudeReq.temperature !== undefined) {
|
||||
config.temperature = claudeReq.temperature;
|
||||
}
|
||||
if (claudeReq.top_p !== undefined) {
|
||||
config.topP = claudeReq.top_p;
|
||||
}
|
||||
if (claudeReq.top_k !== undefined) {
|
||||
config.topK = claudeReq.top_k;
|
||||
}
|
||||
if (maxTokens !== undefined) {
|
||||
config.maxOutputTokens = maxTokens;
|
||||
}
|
||||
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
|
||||
config.stopSequences = [...new Set([...claudeReq.stop_sequences, ...DEFAULT_STOP_SEQUENCES])];
|
||||
} else {
|
||||
config.stopSequences = [...DEFAULT_STOP_SEQUENCES];
|
||||
}
|
||||
}
|
||||
|
||||
function applyResponseFormat(config: GenerationConfig, claudeReq: ClaudeRequest): void {
|
||||
const format = claudeReq.response_format;
|
||||
if (!format) {
|
||||
return;
|
||||
}
|
||||
if (format.type === 'json_object') {
|
||||
config.responseMimeType = 'application/json';
|
||||
} else if (format.type === 'json_schema' && format.json_schema) {
|
||||
config.responseMimeType = 'application/json';
|
||||
config.responseSchema = cleanJsonSchema(format.json_schema);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* build generation config
|
||||
* convert claude request parameters to gemini generation config
|
||||
@@ -637,89 +829,22 @@ function buildGenerationConfig(
|
||||
isThinkingEnabled: boolean,
|
||||
): GenerationConfig {
|
||||
const source = String(claudeReq.metadata?.source || '').toLowerCase();
|
||||
const isOpenAIPath = source === 'openai';
|
||||
const config: GenerationConfig = {};
|
||||
const thinkingType = String(claudeReq.thinking?.type ?? '').toLowerCase();
|
||||
|
||||
const buildThinkingConfig = (): GenerationConfig['thinkingConfig'] => {
|
||||
const thinkingConfig: GenerationConfig['thinkingConfig'] = { includeThoughts: true };
|
||||
if (thinkingType === 'adaptive') {
|
||||
if (isClaudeModel(mappedModel)) {
|
||||
thinkingConfig.thinkingLevel = resolveAdaptiveThinkingLevel(claudeReq);
|
||||
} else {
|
||||
thinkingConfig.thinkingBudget = 24576;
|
||||
}
|
||||
} else if (claudeReq.thinking?.budget_tokens) {
|
||||
let budget = claudeReq.thinking.budget_tokens;
|
||||
const isFlash = hasWebSearch || isGeminiFlashModel(mappedModel);
|
||||
if (isFlash) {
|
||||
budget = Math.min(budget, 24576);
|
||||
}
|
||||
thinkingConfig.thinkingBudget = budget;
|
||||
} else {
|
||||
thinkingConfig.thinkingBudget = getThinkingBudget(mappedModel);
|
||||
}
|
||||
return thinkingConfig;
|
||||
};
|
||||
|
||||
const maxTokens = claudeReq.max_completion_tokens ?? claudeReq.max_tokens;
|
||||
|
||||
if (isOpenAIPath) {
|
||||
config.temperature = claudeReq.temperature ?? 1.0;
|
||||
config.topP = claudeReq.top_p ?? 0.95;
|
||||
if (maxTokens !== undefined) {
|
||||
config.maxOutputTokens = maxTokens;
|
||||
} else {
|
||||
config.maxOutputTokens = getMaxOutputTokens(mappedModel);
|
||||
}
|
||||
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
|
||||
config.stopSequences = claudeReq.stop_sequences;
|
||||
}
|
||||
if (source === 'openai') {
|
||||
applyOpenAISampling(config, claudeReq, mappedModel, maxTokens);
|
||||
if (isThinkingEnabled) {
|
||||
config.thinkingConfig = buildThinkingConfig();
|
||||
config.thinkingConfig = buildThinkingConfig(claudeReq, hasWebSearch, mappedModel);
|
||||
}
|
||||
} else {
|
||||
if (isThinkingEnabled) {
|
||||
config.thinkingConfig = buildThinkingConfig();
|
||||
}
|
||||
if (claudeReq.temperature !== undefined) {
|
||||
config.temperature = claudeReq.temperature;
|
||||
}
|
||||
if (claudeReq.top_p !== undefined) {
|
||||
config.topP = claudeReq.top_p;
|
||||
}
|
||||
if (claudeReq.top_k !== undefined) {
|
||||
config.topK = claudeReq.top_k;
|
||||
}
|
||||
if (maxTokens !== undefined) {
|
||||
config.maxOutputTokens = maxTokens;
|
||||
}
|
||||
const defaultStopSequences = [
|
||||
'<|user|>',
|
||||
'<|endoftext|>',
|
||||
'<|end_of_turn|>',
|
||||
'[DONE]',
|
||||
'\n\nHuman:',
|
||||
];
|
||||
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
|
||||
config.stopSequences = [...new Set([...claudeReq.stop_sequences, ...defaultStopSequences])];
|
||||
} else {
|
||||
config.stopSequences = defaultStopSequences;
|
||||
}
|
||||
}
|
||||
|
||||
if (claudeReq.response_format) {
|
||||
if (claudeReq.response_format.type === 'json_object') {
|
||||
config.responseMimeType = 'application/json';
|
||||
} else if (
|
||||
claudeReq.response_format.type === 'json_schema' &&
|
||||
claudeReq.response_format.json_schema
|
||||
) {
|
||||
config.responseMimeType = 'application/json';
|
||||
config.responseSchema = cleanJsonSchema(claudeReq.response_format.json_schema);
|
||||
config.thinkingConfig = buildThinkingConfig(claudeReq, hasWebSearch, mappedModel);
|
||||
}
|
||||
applyNativeSampling(config, claudeReq, maxTokens);
|
||||
}
|
||||
|
||||
applyResponseFormat(config, claudeReq);
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,92 @@ export function normalizeGeminiModelAlias(modelId: string): string {
|
||||
return GEMINI_MODEL_ALIASES[normalizedModelId] ?? modelId;
|
||||
}
|
||||
|
||||
interface OpenAIRouteRule {
|
||||
matches: (lowerModel: string) => boolean;
|
||||
/** Mapping keys tried in order; the first one configured by the user wins */
|
||||
targets: { key: string; label: string }[];
|
||||
}
|
||||
|
||||
/** Evaluated in order; a rule whose mapping keys are all unset falls through to the next one. */
|
||||
const OPENAI_ROUTE_RULES: OpenAIRouteRule[] = [
|
||||
{
|
||||
// GPT-4 Series (includes GPT-4 classic, o1, o3, etc., excludes 4o/mini/turbo)
|
||||
matches: (lowerModel) =>
|
||||
(lowerModel.startsWith('gpt-4') &&
|
||||
!lowerModel.includes('o') &&
|
||||
!lowerModel.includes('mini') &&
|
||||
!lowerModel.includes('turbo')) ||
|
||||
lowerModel.startsWith('o1-') ||
|
||||
lowerModel.startsWith('o3-') ||
|
||||
lowerModel === 'gpt-4',
|
||||
targets: [{ key: 'gpt-4-series', label: 'GPT-4 series' }],
|
||||
},
|
||||
{
|
||||
// GPT-4o / 3.5 Series (Balanced & Lightweight, includes 4o, mini, turbo)
|
||||
matches: (lowerModel) =>
|
||||
lowerModel.includes('4o') ||
|
||||
lowerModel.startsWith('gpt-3.5') ||
|
||||
(lowerModel.includes('mini') && !lowerModel.includes('gemini')) ||
|
||||
lowerModel.includes('turbo'),
|
||||
targets: [{ key: 'gpt-4o-series', label: 'GPT-4o/3.5 series' }],
|
||||
},
|
||||
{
|
||||
// GPT-5 Series (gpt-5, gpt-5.1, gpt-5.2, etc.); falls back to the GPT-4 series mapping
|
||||
matches: (lowerModel) => lowerModel.startsWith('gpt-5'),
|
||||
targets: [
|
||||
{ key: 'gpt-5-series', label: 'GPT-5 series' },
|
||||
{ key: 'gpt-4-series', label: 'GPT-4 series (GPT-5 fallback)' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function resolveOpenAIFamilyRoute(
|
||||
originalModel: string,
|
||||
lowerModel: string,
|
||||
openaiMapping: Record<string, string>,
|
||||
): string | undefined {
|
||||
for (const rule of OPENAI_ROUTE_RULES) {
|
||||
if (!rule.matches(lowerModel)) {
|
||||
continue;
|
||||
}
|
||||
for (const target of rule.targets) {
|
||||
const mappedModel = openaiMapping[target.key];
|
||||
if (mappedModel) {
|
||||
logger.info(`[Router] Using ${target.label} mapping: ${originalModel} -> ${mappedModel}`);
|
||||
return mappedModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveAnthropicFamilyRoute(
|
||||
originalModel: string,
|
||||
lowerModel: string,
|
||||
anthropicMapping: Record<string, string>,
|
||||
): string | undefined {
|
||||
if (!lowerModel.startsWith('claude-')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let familyKey = 'claude-default';
|
||||
if (lowerModel.includes('4-5') || lowerModel.includes('4.5')) {
|
||||
familyKey = 'claude-4.5-series';
|
||||
} else if (lowerModel.includes('3-5') || lowerModel.includes('3.5')) {
|
||||
familyKey = 'claude-3.5-series';
|
||||
}
|
||||
|
||||
if (anthropicMapping[familyKey]) {
|
||||
logger.warn(
|
||||
`[Router] Using Anthropic series mapping: ${originalModel} -> ${anthropicMapping[familyKey]}`,
|
||||
);
|
||||
return anthropicMapping[familyKey];
|
||||
}
|
||||
|
||||
// Fallback to legacy exact mapping
|
||||
return anthropicMapping[originalModel] || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core Model Routing Engine
|
||||
* Priority: Custom Mapping (Exact) > Group Mapping (Family) > System Mapping (Built-in Plugin)
|
||||
@@ -199,80 +285,15 @@ export function resolveModelRoute(
|
||||
|
||||
const lowerModel = originalModel.toLowerCase();
|
||||
|
||||
// 2. Check family group mapping (OpenAI Series)
|
||||
// GPT-4 Series (includes GPT-4 classic, o1, o3, etc., excludes 4o/mini/turbo)
|
||||
if (
|
||||
(lowerModel.startsWith('gpt-4') &&
|
||||
!lowerModel.includes('o') &&
|
||||
!lowerModel.includes('mini') &&
|
||||
!lowerModel.includes('turbo')) ||
|
||||
lowerModel.startsWith('o1-') ||
|
||||
lowerModel.startsWith('o3-') ||
|
||||
lowerModel === 'gpt-4'
|
||||
) {
|
||||
if (openaiMapping['gpt-4-series']) {
|
||||
logger.info(
|
||||
`[Router] Using GPT-4 series mapping: ${originalModel} -> ${openaiMapping['gpt-4-series']}`,
|
||||
);
|
||||
return openaiMapping['gpt-4-series'];
|
||||
}
|
||||
// 2. Check family group mapping (OpenAI Series, then Anthropic Series)
|
||||
const familyRoute =
|
||||
resolveOpenAIFamilyRoute(originalModel, lowerModel, openaiMapping) ??
|
||||
resolveAnthropicFamilyRoute(originalModel, lowerModel, anthropicMapping);
|
||||
if (familyRoute !== undefined) {
|
||||
return familyRoute;
|
||||
}
|
||||
|
||||
// GPT-4o / 3.5 Series (Balanced & Lightweight, includes 4o, mini, turbo)
|
||||
if (
|
||||
lowerModel.includes('4o') ||
|
||||
lowerModel.startsWith('gpt-3.5') ||
|
||||
(lowerModel.includes('mini') && !lowerModel.includes('gemini')) ||
|
||||
lowerModel.includes('turbo')
|
||||
) {
|
||||
if (openaiMapping['gpt-4o-series']) {
|
||||
logger.info(
|
||||
`[Router] Using GPT-4o/3.5 series mapping: ${originalModel} -> ${openaiMapping['gpt-4o-series']}`,
|
||||
);
|
||||
return openaiMapping['gpt-4o-series'];
|
||||
}
|
||||
}
|
||||
|
||||
// GPT-5 Series (gpt-5, gpt-5.1, gpt-5.2, etc.)
|
||||
if (lowerModel.startsWith('gpt-5')) {
|
||||
// Prefer gpt-5-series mapping, fallback to gpt-4-series if missing
|
||||
if (openaiMapping['gpt-5-series']) {
|
||||
logger.info(
|
||||
`[Router] Using GPT-5 series mapping: ${originalModel} -> ${openaiMapping['gpt-5-series']}`,
|
||||
);
|
||||
return openaiMapping['gpt-5-series'];
|
||||
}
|
||||
if (openaiMapping['gpt-4-series']) {
|
||||
logger.info(
|
||||
`[Router] Using GPT-4 series mapping (GPT-5 fallback): ${originalModel} -> ${openaiMapping['gpt-4-series']}`,
|
||||
);
|
||||
return openaiMapping['gpt-4-series'];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check family group mapping (Anthropic Series)
|
||||
if (lowerModel.startsWith('claude-')) {
|
||||
let familyKey = 'claude-default';
|
||||
if (lowerModel.includes('4-5') || lowerModel.includes('4.5')) {
|
||||
familyKey = 'claude-4.5-series';
|
||||
} else if (lowerModel.includes('3-5') || lowerModel.includes('3.5')) {
|
||||
familyKey = 'claude-3.5-series';
|
||||
}
|
||||
|
||||
if (anthropicMapping[familyKey]) {
|
||||
logger.warn(
|
||||
`[Router] Using Anthropic series mapping: ${originalModel} -> ${anthropicMapping[familyKey]}`,
|
||||
);
|
||||
return anthropicMapping[familyKey];
|
||||
}
|
||||
|
||||
// Fallback to legacy exact mapping
|
||||
if (anthropicMapping[originalModel]) {
|
||||
return anthropicMapping[originalModel];
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fall through to system default mapping logic
|
||||
// 3. Fall through to system default mapping logic
|
||||
return mapClaudeModelToGemini(originalModel);
|
||||
}
|
||||
|
||||
|
||||
@@ -275,6 +275,8 @@ export interface GeminiRequest {
|
||||
systemInstruction?: { parts: { text: string }[] };
|
||||
/** Generation config */
|
||||
generationConfig?: GenerationConfig;
|
||||
/** Function calling mode, emitted by the request mapper */
|
||||
toolConfig?: { functionCallingConfig: { mode: string; allowedFunctionNames?: string[] } };
|
||||
}
|
||||
|
||||
export interface GeminiInternalRequest {
|
||||
|
||||
@@ -405,6 +405,8 @@ print(response.choices[0].message.content)`;
|
||||
className="absolute top-0 right-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
title={showKey ? t('proxy.config.hide_key') : t('proxy.config.show_key')}
|
||||
aria-label={showKey ? t('proxy.config.hide_key') : t('proxy.config.show_key')}
|
||||
aria-pressed={showKey}
|
||||
>
|
||||
{showKey ? (
|
||||
<EyeOff className="text-muted-foreground h-4 w-4" />
|
||||
@@ -537,6 +539,7 @@ print(response.choices[0].message.content)`;
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-blue-300 hover:bg-blue-500/10"
|
||||
aria-label={t('proxy.copy')}
|
||||
onClick={() =>
|
||||
copyToClipboard(`http://${selectedIp || 'localhost'}:${proxyConfig.port}/v1`)
|
||||
}
|
||||
@@ -906,6 +909,7 @@ print(response.choices[0].message.content)`;
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={t('proxy.copy')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(getCurlExample(activeModelTab));
|
||||
@@ -938,6 +942,7 @@ print(response.choices[0].message.content)`;
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={t('proxy.copy')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(getPythonExample(activeModelTab));
|
||||
|
||||
+277
-274
@@ -406,7 +406,7 @@ function UsagePage() {
|
||||
</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">
|
||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||
<TabsTrigger value="analytics">Analytics</TabsTrigger>
|
||||
@@ -415,299 +415,302 @@ function UsagePage() {
|
||||
</div>
|
||||
<TabsContent value="analytics" className="flex-1 space-y-4 px-6 py-5">
|
||||
{hasQuotaWarning && (
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="border-amber-500/50 bg-amber-500/10 text-amber-500"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Quota Warning</AlertTitle>
|
||||
<AlertDescription>
|
||||
One or more models have exceeded 80% of their allocated usage limit.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="border-amber-500/50 bg-amber-500/10 text-amber-500"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Quota Warning</AlertTitle>
|
||||
<AlertDescription>
|
||||
One or more models have exceeded 80% of their allocated usage limit.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* ─── Stat cards ─── */}
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<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">
|
||||
{/* ─── Stat cards ─── */}
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<StatCard
|
||||
label={
|
||||
<div className="flex cursor-help items-center gap-1.5">
|
||||
Input Tokens
|
||||
<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}
|
||||
label="Total Tokens"
|
||||
value={formatNumber(totals.totalTokens)}
|
||||
icon={Zap}
|
||||
isLoading={dailyLoading}
|
||||
accent="blue"
|
||||
accent="amber"
|
||||
trend={
|
||||
prevDailyData
|
||||
? calcTrend(totals.promptTokens, prevTotals.promptTokens)
|
||||
: undefined
|
||||
prevDailyData ? calcTrend(totals.totalTokens, prevTotals.totalTokens) : undefined
|
||||
}
|
||||
sparkline={sparklinePrompt}
|
||||
sparkline={sparklineTotal}
|
||||
/>
|
||||
</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="group relative">
|
||||
<StatCard
|
||||
label={
|
||||
<div className="flex cursor-help items-center gap-1.5">
|
||||
Estimated Cost
|
||||
Input Tokens
|
||||
<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>
|
||||
<p>Tokens sent to the model (prompt, context, files).</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>
|
||||
}
|
||||
value={formatNumber(totals.promptTokens)}
|
||||
icon={MessageSquare}
|
||||
isLoading={dailyLoading}
|
||||
accent="blue"
|
||||
trend={
|
||||
prevDailyData
|
||||
? 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)}
|
||||
aria-label="Model pricing settings"
|
||||
>
|
||||
<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>
|
||||
}
|
||||
value={`$${totalCost.toFixed(2)}`}
|
||||
icon={DollarSign}
|
||||
<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}
|
||||
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>
|
||||
<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 ─── */}
|
||||
{/* ─── Modals ─── */}
|
||||
<ModelPricingDialog
|
||||
open={isPricingDialogOpen}
|
||||
onOpenChange={setIsPricingDialogOpen}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { getServerConfig } from '../server-config';
|
||||
import { extractApiKeyToken, hasConfiguredApiKey, RequestHeaders } from './api-key-auth.util';
|
||||
import {
|
||||
apiKeyMatches,
|
||||
extractApiKeyToken,
|
||||
hasConfiguredApiKey,
|
||||
RequestHeaders,
|
||||
} from './api-key-auth.util';
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
@@ -16,7 +21,7 @@ export class AdminGuard implements CanActivate {
|
||||
const headers = request.headers as RequestHeaders;
|
||||
const clientToken = extractApiKeyToken(headers);
|
||||
|
||||
if (clientToken && clientToken === apiKey) {
|
||||
if (apiKeyMatches(clientToken, apiKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash, timingSafeEqual } from 'crypto';
|
||||
import { isEmpty, isString } from 'lodash-es';
|
||||
|
||||
export type RequestHeaderValue = string | string[] | undefined;
|
||||
@@ -8,6 +9,20 @@ export function hasConfiguredApiKey(apiKey: string | undefined): apiKey is strin
|
||||
return isString(apiKey) && !isEmpty(apiKey.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the presented key with the configured one without leaking, through response time,
|
||||
* how many leading characters matched. Both values are hashed so the buffers always have the
|
||||
* same length, which timingSafeEqual requires.
|
||||
*/
|
||||
export function apiKeyMatches(clientToken: string | null, apiKey: string): boolean {
|
||||
if (clientToken === null) {
|
||||
return false;
|
||||
}
|
||||
const presented = createHash('sha256').update(clientToken).digest();
|
||||
const expected = createHash('sha256').update(apiKey).digest();
|
||||
return timingSafeEqual(presented, expected);
|
||||
}
|
||||
|
||||
export function extractApiKeyToken(headers: RequestHeaders): string | null {
|
||||
const authorizationHeader = readHeaderValue(headers['authorization']);
|
||||
if (authorizationHeader) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { getServerConfig } from '../../server-config';
|
||||
import {
|
||||
apiKeyMatches,
|
||||
extractApiKeyToken,
|
||||
hasConfiguredApiKey,
|
||||
RequestHeaders,
|
||||
@@ -32,7 +33,7 @@ export class ProxyGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (clientToken === apiKey) {
|
||||
if (apiKeyMatches(clientToken, apiKey)) {
|
||||
return true;
|
||||
}
|
||||
this.logger.warn(`Rejected unauthorized request from ${request.ip}`);
|
||||
|
||||
@@ -35,6 +35,7 @@ import { getMaxOutputTokens, getThinkingBudget } from '../../../lib/geminiNexus/
|
||||
import { resolveRequestUserAgent } from './request-user-agent';
|
||||
import { UpstreamRequestError } from './clients/upstream-error';
|
||||
import { trafficLogger } from '../../../utils/traffic-logger';
|
||||
import { TtlCache } from '../../../utils/ttl-cache';
|
||||
import { encode } from 'gpt-tokenizer';
|
||||
|
||||
export interface ProxyRequestContext {
|
||||
@@ -52,14 +53,17 @@ export interface ProxyConfigOverride {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
const RESPONSE_CACHE_TTL_MS = 60_000;
|
||||
const RESPONSE_CACHE_MAX_ENTRIES = 100;
|
||||
|
||||
@Injectable()
|
||||
export class ProxyService {
|
||||
private readonly logger = new Logger(ProxyService.name);
|
||||
private readonly responseCache = new Map<
|
||||
string,
|
||||
{ response: OpenAIChatResponse; expiry: number }
|
||||
>();
|
||||
private readonly CACHE_TTL_MS = 60000; // 1 minute
|
||||
/** Bounded: each key is a full request body, so an unlimited cache grows with every unique prompt. */
|
||||
private readonly responseCache = new TtlCache<OpenAIChatResponse>(
|
||||
RESPONSE_CACHE_TTL_MS,
|
||||
RESPONSE_CACHE_MAX_ENTRIES,
|
||||
);
|
||||
|
||||
constructor(
|
||||
@Inject(TokenManagerService) private readonly tokenManager: TokenManagerService,
|
||||
@@ -2268,19 +2272,11 @@ export class ProxyService {
|
||||
}
|
||||
|
||||
private getCachedResponse(key: string): OpenAIChatResponse | undefined {
|
||||
const entry = this.responseCache.get(key);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
if (Date.now() > entry.expiry) {
|
||||
this.responseCache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.response;
|
||||
return this.responseCache.get(key);
|
||||
}
|
||||
|
||||
private setCachedResponse(key: string, response: OpenAIChatResponse): void {
|
||||
this.responseCache.set(key, { response, expiry: Date.now() + this.CACHE_TTL_MS });
|
||||
this.responseCache.set(key, response);
|
||||
}
|
||||
|
||||
private extractAnthropicSessionKey(request: AnthropicChatRequest): string | undefined {
|
||||
|
||||
@@ -44,7 +44,9 @@ function toLowerText(value: string | undefined): string {
|
||||
}
|
||||
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
@@ -54,9 +56,8 @@ function parseDurationToSeconds(text: string): number | null {
|
||||
const seconds = Number(match[3] ?? 0);
|
||||
const milliseconds = Number(match[4] ?? 0);
|
||||
|
||||
const totalSeconds =
|
||||
hours * 3600 + minutes * 60 + Math.ceil(seconds) + Math.ceil(milliseconds / 1000);
|
||||
if (totalSeconds <= 0) {
|
||||
const totalSeconds = Math.ceil(hours * 3600 + minutes * 60 + seconds + milliseconds / 1000);
|
||||
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
|
||||
return null;
|
||||
}
|
||||
return totalSeconds;
|
||||
@@ -93,6 +94,153 @@ function mapGoogleReasonToTrackerReason(reason: string): RateLimitReason | null
|
||||
return null;
|
||||
}
|
||||
|
||||
function getErrorDetails(parsedBody: ParsedGoogleErrorBody | null): GoogleErrorDetail[] {
|
||||
const details = parsedBody?.error?.details;
|
||||
return Array.isArray(details) ? details : [];
|
||||
}
|
||||
|
||||
function detectReasonFromDetails(parsedBody: ParsedGoogleErrorBody | null): RateLimitReason | null {
|
||||
for (const detail of getErrorDetails(parsedBody)) {
|
||||
if (!isString(detail.reason) || isEmpty(detail.reason.trim())) {
|
||||
continue;
|
||||
}
|
||||
const mappedReason = mapGoogleReasonToTrackerReason(detail.reason);
|
||||
if (mappedReason) {
|
||||
return mappedReason;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectReasonFromStatusField(
|
||||
parsedBody: ParsedGoogleErrorBody | null,
|
||||
body: string | undefined,
|
||||
): RateLimitReason | null {
|
||||
const statusFromBody = parsedBody?.error?.status?.trim().toUpperCase();
|
||||
if (statusFromBody === 'RESOURCE_EXHAUSTED') {
|
||||
return RateLimitReason.QuotaExhausted;
|
||||
}
|
||||
if (statusFromBody === 'UNAVAILABLE') {
|
||||
const loweredBody = toLowerText(body);
|
||||
if (loweredBody.includes('no capacity available') || loweredBody.includes('model capacity')) {
|
||||
return RateLimitReason.ModelCapacityExhausted;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Last resort for 429s: guess from free-form error text. */
|
||||
function detectReasonFromText(body: string | undefined): RateLimitReason {
|
||||
const lowerBody = toLowerText(body);
|
||||
if (
|
||||
lowerBody.includes('per minute') ||
|
||||
lowerBody.includes('rate limit') ||
|
||||
lowerBody.includes('rate_limit') ||
|
||||
lowerBody.includes('too many requests')
|
||||
) {
|
||||
return RateLimitReason.RateLimitExceeded;
|
||||
}
|
||||
if (
|
||||
lowerBody.includes('model capacity exhausted') ||
|
||||
lowerBody.includes('no capacity available')
|
||||
) {
|
||||
return RateLimitReason.ModelCapacityExhausted;
|
||||
}
|
||||
if (lowerBody.includes('quota') || lowerBody.includes('exhausted')) {
|
||||
return RateLimitReason.QuotaExhausted;
|
||||
}
|
||||
return RateLimitReason.Unknown;
|
||||
}
|
||||
|
||||
/** Seconds from a Retry-After header (delta-seconds or HTTP-date), or null when unusable. */
|
||||
function parseRetryAfterHeader(rawHeader: string | undefined): number | null {
|
||||
const headerRetryRaw = rawHeader?.trim() ?? '';
|
||||
if (headerRetryRaw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const headerRetry = Number(headerRetryRaw);
|
||||
if (Number.isFinite(headerRetry) && headerRetry > 0) {
|
||||
return Math.max(2, Math.ceil(headerRetry));
|
||||
}
|
||||
|
||||
const headerRetryAt = Date.parse(headerRetryRaw);
|
||||
if (Number.isFinite(headerRetryAt)) {
|
||||
const retryAfterSec = Math.ceil((headerRetryAt - Date.now()) / 1000);
|
||||
if (retryAfterSec > 0) {
|
||||
return Math.max(2, retryAfterSec);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDefaultRetrySeconds(params: {
|
||||
reason: RateLimitReason;
|
||||
status: number;
|
||||
failureCount: number;
|
||||
backoffSteps: number[];
|
||||
}): number {
|
||||
switch (params.reason) {
|
||||
case RateLimitReason.QuotaExhausted: {
|
||||
const steps = params.backoffSteps.length > 0 ? params.backoffSteps : [60, 300, 1800, 7200];
|
||||
const index = Math.max(0, params.failureCount - 1);
|
||||
return steps[Math.min(index, steps.length - 1)];
|
||||
}
|
||||
case RateLimitReason.RateLimitExceeded:
|
||||
return 5;
|
||||
case RateLimitReason.ModelCapacityExhausted:
|
||||
if (params.failureCount <= 1) {
|
||||
return 5;
|
||||
}
|
||||
return params.failureCount === 2 ? 10 : 15;
|
||||
case RateLimitReason.ServerError:
|
||||
return params.status === 404 ? 5 : 8;
|
||||
default:
|
||||
return 60;
|
||||
}
|
||||
}
|
||||
|
||||
function nonBlankString(value: unknown): string | null {
|
||||
return isString(value) && !isEmpty(value.trim()) ? value : null;
|
||||
}
|
||||
|
||||
function parseRetryDelayFromDetails(parsedBody: ParsedGoogleErrorBody | null): number | null {
|
||||
for (const detail of getErrorDetails(parsedBody)) {
|
||||
const candidates = [
|
||||
detail.retryDelay,
|
||||
detail.metadata?.retryDelay,
|
||||
detail.metadata?.quotaResetDelay,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const delayText = nonBlankString(candidate);
|
||||
const parsedDelay = delayText === null ? null : parseDurationToSeconds(delayText);
|
||||
if (parsedDelay !== null) {
|
||||
return parsedDelay;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseRetryDelayFromText(body: string | undefined): number | null {
|
||||
const lowerBody = toLowerText(body);
|
||||
const minSecMatch = lowerBody.match(/try again in\s+(\d+)m\s*(\d+)s/);
|
||||
if (minSecMatch) {
|
||||
return Number(minSecMatch[1]) * 60 + Number(minSecMatch[2]);
|
||||
}
|
||||
|
||||
const secMatch = lowerBody.match(/(?:try again in|backoff for|wait)\s*(\d+)s/);
|
||||
if (secMatch) {
|
||||
return Number(secMatch[1]);
|
||||
}
|
||||
|
||||
const resetMatch = lowerBody.match(/quota will reset in\s*(\d+)\s*second/);
|
||||
if (resetMatch) {
|
||||
return Number(resetMatch[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class RateLimitTracker {
|
||||
private readonly lockoutByKey = new Map<string, RateLimitInfo>();
|
||||
private readonly failureCounts = new Map<string, FailureCountEntry>();
|
||||
@@ -108,19 +256,16 @@ export class RateLimitTracker {
|
||||
const now = Date.now();
|
||||
|
||||
const globalLock = this.lockoutByKey.get(accountId);
|
||||
if (globalLock && globalLock.resetTimeMs > now) {
|
||||
return Math.max(0, Math.ceil((globalLock.resetTimeMs - now) / 1000));
|
||||
}
|
||||
let resetTimeMs = globalLock?.resetTimeMs ?? now;
|
||||
|
||||
if (model) {
|
||||
const modelKey = this.buildLockoutKey(accountId, model);
|
||||
const modelLock = this.lockoutByKey.get(modelKey);
|
||||
if (modelLock && modelLock.resetTimeMs > now) {
|
||||
return Math.max(0, Math.ceil((modelLock.resetTimeMs - now) / 1000));
|
||||
}
|
||||
resetTimeMs = Math.max(resetTimeMs, modelLock?.resetTimeMs ?? now);
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -246,52 +391,15 @@ export class RateLimitTracker {
|
||||
|
||||
private detectRateLimitReason(status: number, body: string | undefined): RateLimitReason {
|
||||
const parsedBody = tryParseGoogleErrorBody(body);
|
||||
const details = Array.isArray(parsedBody?.error?.details) ? parsedBody?.error?.details : [];
|
||||
for (const detail of details) {
|
||||
if (!isString(detail.reason) || isEmpty(detail.reason.trim())) {
|
||||
continue;
|
||||
}
|
||||
const mappedReason = mapGoogleReasonToTrackerReason(detail.reason);
|
||||
if (mappedReason) {
|
||||
return mappedReason;
|
||||
}
|
||||
const structuredReason =
|
||||
detectReasonFromDetails(parsedBody) ?? detectReasonFromStatusField(parsedBody, body);
|
||||
if (structuredReason) {
|
||||
return structuredReason;
|
||||
}
|
||||
|
||||
const statusFromBody = parsedBody?.error?.status?.trim().toUpperCase();
|
||||
if (statusFromBody === 'RESOURCE_EXHAUSTED') {
|
||||
return RateLimitReason.QuotaExhausted;
|
||||
}
|
||||
if (statusFromBody === 'UNAVAILABLE') {
|
||||
const loweredBody = toLowerText(body);
|
||||
if (loweredBody.includes('no capacity available') || loweredBody.includes('model capacity')) {
|
||||
return RateLimitReason.ModelCapacityExhausted;
|
||||
}
|
||||
}
|
||||
|
||||
if (status !== 429) {
|
||||
return RateLimitReason.ServerError;
|
||||
}
|
||||
|
||||
const lowerBody = toLowerText(body);
|
||||
if (
|
||||
lowerBody.includes('per minute') ||
|
||||
lowerBody.includes('rate limit') ||
|
||||
lowerBody.includes('rate_limit') ||
|
||||
lowerBody.includes('too many requests')
|
||||
) {
|
||||
return RateLimitReason.RateLimitExceeded;
|
||||
}
|
||||
if (
|
||||
lowerBody.includes('model capacity exhausted') ||
|
||||
lowerBody.includes('no capacity available')
|
||||
) {
|
||||
return RateLimitReason.ModelCapacityExhausted;
|
||||
}
|
||||
if (lowerBody.includes('quota') || lowerBody.includes('exhausted')) {
|
||||
return RateLimitReason.QuotaExhausted;
|
||||
}
|
||||
|
||||
return RateLimitReason.Unknown;
|
||||
return detectReasonFromText(body);
|
||||
}
|
||||
|
||||
private computeRetryAfterSeconds(params: {
|
||||
@@ -302,20 +410,9 @@ export class RateLimitTracker {
|
||||
accountId: string;
|
||||
backoffSteps: number[];
|
||||
}): number {
|
||||
const headerRetryRaw = params.retryAfter?.trim() ?? '';
|
||||
if (headerRetryRaw !== '') {
|
||||
const headerRetry = Number(headerRetryRaw);
|
||||
if (!Number.isNaN(headerRetry) && Number.isFinite(headerRetry) && headerRetry > 0) {
|
||||
return Math.max(2, Math.ceil(headerRetry));
|
||||
}
|
||||
|
||||
const headerRetryAt = Date.parse(headerRetryRaw);
|
||||
if (Number.isFinite(headerRetryAt)) {
|
||||
const retryAfterSec = Math.ceil((headerRetryAt - Date.now()) / 1000);
|
||||
if (retryAfterSec > 0) {
|
||||
return Math.max(2, retryAfterSec);
|
||||
}
|
||||
}
|
||||
const headerRetry = parseRetryAfterHeader(params.retryAfter);
|
||||
if (headerRetry !== null) {
|
||||
return headerRetry;
|
||||
}
|
||||
|
||||
const bodyRetry = this.parseRetryAfterSecondsFromBody(params.body);
|
||||
@@ -327,32 +424,12 @@ export class RateLimitTracker {
|
||||
params.reason !== RateLimitReason.ServerError
|
||||
? this.incrementFailureCount(params.accountId)
|
||||
: 1;
|
||||
|
||||
if (params.reason === RateLimitReason.QuotaExhausted) {
|
||||
const steps = params.backoffSteps.length > 0 ? params.backoffSteps : [60, 300, 1800, 7200];
|
||||
const index = Math.max(0, failureCount - 1);
|
||||
return steps[Math.min(index, steps.length - 1)];
|
||||
}
|
||||
|
||||
if (params.reason === RateLimitReason.RateLimitExceeded) {
|
||||
return 5;
|
||||
}
|
||||
|
||||
if (params.reason === RateLimitReason.ModelCapacityExhausted) {
|
||||
if (failureCount <= 1) {
|
||||
return 5;
|
||||
}
|
||||
if (failureCount === 2) {
|
||||
return 10;
|
||||
}
|
||||
return 15;
|
||||
}
|
||||
|
||||
if (params.reason === RateLimitReason.ServerError) {
|
||||
return params.status === 404 ? 5 : 8;
|
||||
}
|
||||
|
||||
return 60;
|
||||
return resolveDefaultRetrySeconds({
|
||||
reason: params.reason,
|
||||
status: params.status,
|
||||
failureCount,
|
||||
backoffSteps: params.backoffSteps,
|
||||
});
|
||||
}
|
||||
|
||||
private parseRetryAfterSecondsFromBody(body: string | undefined): number | null {
|
||||
@@ -360,31 +437,9 @@ export class RateLimitTracker {
|
||||
return null;
|
||||
}
|
||||
const parsedBody = tryParseGoogleErrorBody(body);
|
||||
const details = Array.isArray(parsedBody?.error?.details) ? parsedBody.error.details : [];
|
||||
for (const detail of details) {
|
||||
if (isString(detail.retryDelay) && !isEmpty(detail.retryDelay.trim())) {
|
||||
const parsedDelay = parseDurationToSeconds(detail.retryDelay);
|
||||
if (parsedDelay !== null) {
|
||||
return parsedDelay;
|
||||
}
|
||||
}
|
||||
|
||||
if (isString(detail.metadata?.retryDelay) && !isEmpty(detail.metadata.retryDelay.trim())) {
|
||||
const parsedDelay = parseDurationToSeconds(detail.metadata.retryDelay);
|
||||
if (parsedDelay !== null) {
|
||||
return parsedDelay;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isString(detail.metadata?.quotaResetDelay) &&
|
||||
!isEmpty(detail.metadata.quotaResetDelay.trim())
|
||||
) {
|
||||
const parsedDelay = parseDurationToSeconds(detail.metadata.quotaResetDelay);
|
||||
if (parsedDelay !== null) {
|
||||
return parsedDelay;
|
||||
}
|
||||
}
|
||||
const detailDelay = parseRetryDelayFromDetails(parsedBody);
|
||||
if (detailDelay !== null) {
|
||||
return detailDelay;
|
||||
}
|
||||
|
||||
const retryAfter = parsedBody?.error?.retry_after;
|
||||
@@ -392,23 +447,7 @@ export class RateLimitTracker {
|
||||
return Math.ceil(retryAfter);
|
||||
}
|
||||
|
||||
const lowerBody = toLowerText(body);
|
||||
const minSecMatch = lowerBody.match(/try again in\s+(\d+)m\s*(\d+)s/);
|
||||
if (minSecMatch) {
|
||||
return Number(minSecMatch[1]) * 60 + Number(minSecMatch[2]);
|
||||
}
|
||||
|
||||
const secMatch = lowerBody.match(/(?:try again in|backoff for|wait)\s*(\d+)s/);
|
||||
if (secMatch) {
|
||||
return Number(secMatch[1]);
|
||||
}
|
||||
|
||||
const resetMatch = lowerBody.match(/quota will reset in\s*(\d+)\s*second/);
|
||||
if (resetMatch) {
|
||||
return Number(resetMatch[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
return parseRetryDelayFromText(body);
|
||||
}
|
||||
|
||||
private incrementFailureCount(accountId: string): number {
|
||||
|
||||
@@ -16,8 +16,8 @@ import { getSwitchGuardSnapshot } from '../../ipc/switchGuard';
|
||||
vi.mock('../../utils/paths', async () => {
|
||||
const path = await import('path');
|
||||
const agentDir = path.join(process.cwd(), 'temp_test_agent');
|
||||
return {
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
return {
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getAgentDir: vi.fn(() => agentDir),
|
||||
getAccountsFilePath: vi.fn(() => path.join(agentDir, 'accounts.json')),
|
||||
getBackupsDir: vi.fn(() => path.join(agentDir, 'backups')),
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { transformClaudeRequestIn } from '../../lib/geminiNexus/ClaudeRequestMapper';
|
||||
import { SignatureStore } from '../../lib/geminiNexus/SignatureStore';
|
||||
import type { ClaudeRequest, Message } from '../../lib/geminiNexus/types';
|
||||
|
||||
const FALLBACK_SIGNATURE = 'skip_thought_signature_validator';
|
||||
|
||||
function makeRequest(overrides: Record<string, unknown> = {}): ClaudeRequest {
|
||||
return {
|
||||
model: 'gemini-3-flash',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
max_tokens: 100,
|
||||
...overrides,
|
||||
} as unknown as ClaudeRequest;
|
||||
}
|
||||
|
||||
function asMessages(messages: unknown[]): Message[] {
|
||||
return messages as Message[];
|
||||
}
|
||||
|
||||
function contentsOf(messages: unknown[], overrides: Record<string, unknown> = {}) {
|
||||
return transformClaudeRequestIn(
|
||||
makeRequest({ messages: asMessages(messages), thinking: { type: 'disabled' }, ...overrides }),
|
||||
'project',
|
||||
).request.contents;
|
||||
}
|
||||
|
||||
function generationConfigOf(overrides: Record<string, unknown> = {}) {
|
||||
return transformClaudeRequestIn(makeRequest(overrides), 'project').request.generationConfig;
|
||||
}
|
||||
|
||||
describe('transformClaudeRequestIn - contents', () => {
|
||||
beforeEach(() => {
|
||||
SignatureStore.clear();
|
||||
});
|
||||
|
||||
it('maps a plain string message to a user text part', () => {
|
||||
expect(contentsOf([{ role: 'user', content: 'Hello' }])).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps assistant to model and drops empty-string content', () => {
|
||||
const contents = contentsOf([
|
||||
{ role: 'user', content: 'Hi' },
|
||||
{ role: 'assistant', content: '' },
|
||||
{ role: 'assistant', content: 'Answer' },
|
||||
]);
|
||||
expect(contents.map((c) => c.role)).toEqual(['user', 'model']);
|
||||
expect(contents[1].parts).toEqual([{ text: 'Answer' }]);
|
||||
});
|
||||
|
||||
it('skips blank text blocks and the "(no content)" placeholder', () => {
|
||||
const contents = contentsOf([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: ' ' },
|
||||
{ type: 'text', text: '(no content)' },
|
||||
{ type: 'text', text: ' kept ' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(contents).toEqual([{ role: 'user', parts: [{ text: 'kept' }] }]);
|
||||
});
|
||||
|
||||
it('prefixes function results and named messages, treating function role as user', () => {
|
||||
const contents = contentsOf([
|
||||
{ role: 'function', name: 'lookup', content: 'result' },
|
||||
{ role: 'user', name: 'bob', content: 'hey' },
|
||||
]);
|
||||
expect(contents[0]).toEqual({
|
||||
role: 'user',
|
||||
parts: [{ text: 'Function result (lookup):\nresult' }],
|
||||
});
|
||||
expect(contents[1].parts).toEqual([{ text: '[bob]: hey' }]);
|
||||
});
|
||||
|
||||
it('keeps base64 images and ignores non-base64 image sources', () => {
|
||||
const contents = contentsOf([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } },
|
||||
{ type: 'image', source: { type: 'url', url: 'https://example.com/a.png' } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(contents).toEqual([
|
||||
{ role: 'user', parts: [{ inlineData: { mimeType: 'image/png', data: 'AAAA' } }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the block signature for thinking blocks and remembers it for tool calls', () => {
|
||||
const contents = contentsOf([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'hmm', signature: 'sig-from-block' },
|
||||
{ type: 'tool_use', id: 'call-1', name: 'search', input: { q: 'x' } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const parts = contents[0].parts as any[];
|
||||
expect(parts[0]).toMatchObject({
|
||||
text: 'hmm',
|
||||
thought: true,
|
||||
thoughtSignature: 'sig-from-block',
|
||||
});
|
||||
expect(parts[1].functionCall).toEqual({ name: 'search', args: { q: 'x' }, id: 'call-1' });
|
||||
expect(parts[1].thoughtSignature).toBe('sig-from-block');
|
||||
});
|
||||
|
||||
it('falls back to the global signature, then to the validator bypass, for thinking blocks', () => {
|
||||
const messages = [{ role: 'assistant', content: [{ type: 'thinking', thinking: 'hmm' }] }];
|
||||
expect((contentsOf(messages)[0].parts[0] as any).thoughtSignature).toBe(FALLBACK_SIGNATURE);
|
||||
|
||||
SignatureStore.store('global-signature');
|
||||
expect((contentsOf(messages)[0].parts[0] as any).thoughtSignature).toBe('global-signature');
|
||||
});
|
||||
|
||||
it('prefers the tool_use own signature over the global one', () => {
|
||||
SignatureStore.store('global-signature-longer');
|
||||
const contents = contentsOf([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 't', name: 'n', input: {}, signature: 'own' }],
|
||||
},
|
||||
]);
|
||||
expect((contents[0].parts[0] as any).thoughtSignature).toBe('own');
|
||||
});
|
||||
|
||||
it('uses the validator bypass for tool_use when no signature is available', () => {
|
||||
const contents = contentsOf([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 't', name: 'n', input: {} }],
|
||||
},
|
||||
]);
|
||||
expect((contents[0].parts[0] as any).thoughtSignature).toBe(FALLBACK_SIGNATURE);
|
||||
});
|
||||
|
||||
it('resolves tool_result names from earlier tool_use ids, falling back to the id', () => {
|
||||
const contents = contentsOf([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'call-1', name: 'search', input: {} }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'call-1', content: 'found' },
|
||||
{ type: 'tool_result', tool_use_id: 'unknown-id', content: 'orphan' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const parts = contents[1].parts as any[];
|
||||
expect(parts[0].functionResponse).toEqual({
|
||||
name: 'search',
|
||||
response: { result: 'found' },
|
||||
id: 'call-1',
|
||||
});
|
||||
expect(parts[1].functionResponse.name).toBe('unknown-id');
|
||||
});
|
||||
|
||||
it('joins text items of array tool_result content and ignores other item types', () => {
|
||||
const contents = contentsOf([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'x',
|
||||
content: [
|
||||
{ type: 'text', text: 'a' },
|
||||
{ type: 'image', source: {} },
|
||||
{ type: 'text', text: 'b' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect((contents[0].parts[0] as any).functionResponse.response).toEqual({ result: 'a\nb' });
|
||||
});
|
||||
|
||||
it('uses default messages for empty tool results, distinguishing errors', () => {
|
||||
const contents = contentsOf([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'ok', content: ' ' },
|
||||
{ type: 'tool_result', tool_use_id: 'bad', content: '', is_error: true },
|
||||
{ type: 'tool_result', tool_use_id: 'boom', content: 'exploded', is_error: true },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const parts = contents[0].parts as any[];
|
||||
expect(parts[0].functionResponse.response).toEqual({
|
||||
result: 'Command executed successfully.',
|
||||
});
|
||||
expect(parts[1].functionResponse.response).toEqual({
|
||||
error: 'Tool execution failed with no output.',
|
||||
});
|
||||
expect(parts[2].functionResponse.response).toEqual({ error: 'exploded' });
|
||||
expect(parts[0].thoughtSignature).toBe(FALLBACK_SIGNATURE);
|
||||
});
|
||||
|
||||
it('keeps redacted thinking as a marked thought part', () => {
|
||||
const contents = contentsOf([
|
||||
{ role: 'assistant', content: [{ type: 'redacted_thinking', data: 'abc' }] },
|
||||
]);
|
||||
expect(contents[0].parts).toEqual([{ text: '[Redacted Thinking: abc]', thought: true }]);
|
||||
});
|
||||
|
||||
it('drops messages that produce no parts', () => {
|
||||
const contents = contentsOf([
|
||||
{ role: 'user', content: [{ type: 'text', text: ' ' }] },
|
||||
{ role: 'user', content: 'real' },
|
||||
]);
|
||||
expect(contents).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('prepends a dummy thought to a trailing model turn without thinking when thinking is on', () => {
|
||||
const contents = contentsOf(
|
||||
[
|
||||
{ role: 'user', content: 'Hi' },
|
||||
{ role: 'assistant', content: 'Partial' },
|
||||
],
|
||||
{ thinking: { type: 'enabled' } },
|
||||
);
|
||||
expect(contents[1].parts[0]).toEqual({ text: 'Thinking...', thought: true });
|
||||
expect(contents[1].parts[1]).toEqual({ text: 'Partial' });
|
||||
});
|
||||
|
||||
it('does not add a dummy thought when the model turn already has a thought', () => {
|
||||
const contents = contentsOf(
|
||||
[
|
||||
{ role: 'user', content: 'Hi' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'real', signature: 's' },
|
||||
{ type: 'text', text: 'Partial' },
|
||||
],
|
||||
},
|
||||
],
|
||||
{ thinking: { type: 'enabled' } },
|
||||
);
|
||||
expect(contents[1].parts).toHaveLength(2);
|
||||
expect((contents[1].parts[0] as any).text).toBe('real');
|
||||
});
|
||||
|
||||
it('does not add a dummy thought when thinking is off or the model turn is not last', () => {
|
||||
const off = contentsOf(
|
||||
[
|
||||
{ role: 'user', content: 'Hi' },
|
||||
{ role: 'assistant', content: 'Partial' },
|
||||
],
|
||||
{ thinking: { type: 'disabled' } },
|
||||
);
|
||||
expect(off[1].parts).toEqual([{ text: 'Partial' }]);
|
||||
|
||||
const notLast = contentsOf(
|
||||
[
|
||||
{ role: 'assistant', content: 'First' },
|
||||
{ role: 'user', content: 'Hi' },
|
||||
],
|
||||
{ thinking: { type: 'enabled' } },
|
||||
);
|
||||
expect(notLast[0].parts).toEqual([{ text: 'First' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformClaudeRequestIn - generationConfig', () => {
|
||||
beforeEach(() => {
|
||||
SignatureStore.clear();
|
||||
});
|
||||
|
||||
const DEFAULT_STOP_SEQUENCES = [
|
||||
'<|user|>',
|
||||
'<|endoftext|>',
|
||||
'<|end_of_turn|>',
|
||||
'[DONE]',
|
||||
'\n\nHuman:',
|
||||
];
|
||||
|
||||
it('native path: forwards sampling options and merges default stop sequences', () => {
|
||||
const config = generationConfigOf({
|
||||
temperature: 0.2,
|
||||
top_p: 0.5,
|
||||
top_k: 10,
|
||||
max_tokens: 321,
|
||||
stop_sequences: ['STOP', '[DONE]'],
|
||||
thinking: { type: 'disabled' },
|
||||
});
|
||||
expect(config).toMatchObject({ temperature: 0.2, topP: 0.5, topK: 10, maxOutputTokens: 321 });
|
||||
expect(config?.stopSequences).toEqual([
|
||||
'STOP',
|
||||
'[DONE]',
|
||||
'<|user|>',
|
||||
'<|endoftext|>',
|
||||
'<|end_of_turn|>',
|
||||
'\n\nHuman:',
|
||||
]);
|
||||
expect(config?.thinkingConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it('native path: uses only the default stop sequences and omits unset options', () => {
|
||||
const config = generationConfigOf({ max_tokens: undefined, thinking: { type: 'disabled' } });
|
||||
expect(config?.stopSequences).toEqual(DEFAULT_STOP_SEQUENCES);
|
||||
expect(config).not.toHaveProperty('temperature');
|
||||
expect(config).not.toHaveProperty('topP');
|
||||
expect(config).not.toHaveProperty('topK');
|
||||
expect(config).not.toHaveProperty('maxOutputTokens');
|
||||
});
|
||||
|
||||
it('max_completion_tokens takes precedence over max_tokens', () => {
|
||||
const config = generationConfigOf({
|
||||
max_completion_tokens: 55,
|
||||
max_tokens: 99,
|
||||
thinking: { type: 'disabled' },
|
||||
});
|
||||
expect(config?.maxOutputTokens).toBe(55);
|
||||
});
|
||||
|
||||
it('openai path: applies temperature/topP defaults and model max output tokens', () => {
|
||||
const config = generationConfigOf({
|
||||
metadata: { source: 'openai' },
|
||||
max_tokens: undefined,
|
||||
thinking: { type: 'disabled' },
|
||||
});
|
||||
expect(config?.temperature).toBe(1.0);
|
||||
expect(config?.topP).toBe(0.95);
|
||||
expect(config?.maxOutputTokens).toBeGreaterThan(0);
|
||||
expect(config).not.toHaveProperty('stopSequences');
|
||||
expect(config).not.toHaveProperty('topK');
|
||||
});
|
||||
|
||||
it('openai path: keeps explicit values and only custom stop sequences', () => {
|
||||
const config = generationConfigOf({
|
||||
metadata: { source: 'OpenAI' },
|
||||
temperature: 0,
|
||||
top_p: 0.1,
|
||||
max_tokens: 42,
|
||||
stop_sequences: ['END'],
|
||||
thinking: { type: 'disabled' },
|
||||
});
|
||||
expect(config).toMatchObject({
|
||||
temperature: 0,
|
||||
topP: 0.1,
|
||||
maxOutputTokens: 42,
|
||||
stopSequences: ['END'],
|
||||
});
|
||||
});
|
||||
|
||||
it('thinking: explicit budget is capped for flash models and kept otherwise', () => {
|
||||
const flash = generationConfigOf({ thinking: { type: 'enabled', budget_tokens: 50000 } });
|
||||
expect(flash?.thinkingConfig).toEqual({ includeThoughts: true, thinkingBudget: 24576 });
|
||||
|
||||
const flashSmall = generationConfigOf({ thinking: { type: 'enabled', budget_tokens: 1000 } });
|
||||
expect(flashSmall?.thinkingConfig?.thinkingBudget).toBe(1000);
|
||||
|
||||
const pro = generationConfigOf({
|
||||
model: 'gemini-3.1-pro-high',
|
||||
thinking: { type: 'enabled', budget_tokens: 50000 },
|
||||
});
|
||||
expect(pro?.thinkingConfig?.thinkingBudget).toBe(50000);
|
||||
});
|
||||
|
||||
it('thinking: without explicit budget uses the model default budget', () => {
|
||||
const config = generationConfigOf({ thinking: { type: 'enabled' } });
|
||||
expect(config?.thinkingConfig?.includeThoughts).toBe(true);
|
||||
expect(typeof config?.thinkingConfig?.thinkingBudget).toBe('number');
|
||||
});
|
||||
|
||||
it('thinking: adaptive uses a fixed budget on non-Claude models', () => {
|
||||
const config = generationConfigOf({ thinking: { type: 'adaptive', effort: 'low' } });
|
||||
expect(config?.thinkingConfig).toEqual({ includeThoughts: true, thinkingBudget: 24576 });
|
||||
});
|
||||
|
||||
it('thinking: enabled by default for gemini-3-flash and off when explicitly disabled', () => {
|
||||
expect(generationConfigOf()?.thinkingConfig?.includeThoughts).toBe(true);
|
||||
expect(generationConfigOf({ thinking: { type: 'disabled' } })?.thinkingConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it('response_format: json_object sets the mime type only', () => {
|
||||
const config = generationConfigOf({ response_format: { type: 'json_object' } });
|
||||
expect(config?.responseMimeType).toBe('application/json');
|
||||
expect(config).not.toHaveProperty('responseSchema');
|
||||
});
|
||||
|
||||
it('response_format: json_schema sets the mime type; incomplete or text sets nothing', () => {
|
||||
const withSchema = generationConfigOf({
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: { type: 'object', properties: { a: { type: 'string' } } },
|
||||
},
|
||||
});
|
||||
expect(withSchema?.responseMimeType).toBe('application/json');
|
||||
// Known limitation: cleanJsonSchema() mutates in place and returns void, so the schema
|
||||
// assigned to responseSchema is undefined and dropped from the request.
|
||||
expect(withSchema?.responseSchema).toBeUndefined();
|
||||
|
||||
const missingSchema = generationConfigOf({ response_format: { type: 'json_schema' } });
|
||||
expect(missingSchema).not.toHaveProperty('responseMimeType');
|
||||
|
||||
const text = generationConfigOf({ response_format: { type: 'text' } });
|
||||
expect(text).not.toHaveProperty('responseMimeType');
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformClaudeRequestIn - request assembly', () => {
|
||||
beforeEach(() => {
|
||||
SignatureStore.clear();
|
||||
});
|
||||
|
||||
const FUNCTION_TOOL = {
|
||||
name: 'lookup',
|
||||
description: 'Look something up',
|
||||
input_schema: { type: 'object', properties: { q: { type: 'string' } } },
|
||||
};
|
||||
|
||||
function transform(overrides: Record<string, unknown> = {}, projectId?: string) {
|
||||
return transformClaudeRequestIn(makeRequest(overrides), projectId);
|
||||
}
|
||||
|
||||
it('defaults to an agent request with safety settings and a trimmed project id', () => {
|
||||
const body = transform({}, ' my-project ');
|
||||
expect(body.requestType).toBe('agent');
|
||||
expect(body.project).toBe('my-project');
|
||||
expect(body.model).toBe('gemini-3-flash');
|
||||
expect(body.requestId).toMatch(/^agent-/);
|
||||
expect(body.request.safetySettings).toHaveLength(5);
|
||||
expect(body.request.safetySettings?.every((s) => s.threshold === 'OFF')).toBe(true);
|
||||
expect(body.request).not.toHaveProperty('tools');
|
||||
expect(body.request).not.toHaveProperty('toolConfig');
|
||||
});
|
||||
|
||||
it('omits project and sessionId when absent and forwards metadata.user_id as sessionId', () => {
|
||||
const bare = transform();
|
||||
expect(bare).not.toHaveProperty('project');
|
||||
expect(bare).not.toHaveProperty('sessionId');
|
||||
|
||||
const blankProject = transform({}, ' ');
|
||||
expect(blankProject).not.toHaveProperty('project');
|
||||
|
||||
const withSession = transform({ metadata: { user_id: 'session-1' } });
|
||||
expect(withSession.sessionId).toBe('session-1');
|
||||
});
|
||||
|
||||
it('uses a caller supplied user agent after trimming', () => {
|
||||
const body = transformClaudeRequestIn(makeRequest(), 'p', ' custom-agent/1.0 ');
|
||||
expect(body.userAgent).toBe('custom-agent/1.0');
|
||||
expect(transformClaudeRequestIn(makeRequest(), 'p', ' ').userAgent).toBeTruthy();
|
||||
});
|
||||
|
||||
it('system prompt: string and text blocks become parts; empty system is omitted', () => {
|
||||
expect(transform({ system: 'Be brief' }).request.systemInstruction?.parts).toEqual([
|
||||
{ text: 'Be brief' },
|
||||
]);
|
||||
expect(
|
||||
transform({
|
||||
system: [
|
||||
{ type: 'text', text: 'one' },
|
||||
{ type: 'other', text: 'skipped' },
|
||||
{ type: 'text', text: 'two' },
|
||||
],
|
||||
}).request.systemInstruction?.parts,
|
||||
).toEqual([{ text: 'one' }, { text: 'two' }]);
|
||||
expect(transform().request).not.toHaveProperty('systemInstruction');
|
||||
});
|
||||
|
||||
it('declares function tools and defaults tool_choice to AUTO', () => {
|
||||
const body = transform({ tools: [FUNCTION_TOOL] });
|
||||
expect(body.request.tools?.[0].functionDeclarations?.[0].name).toBe('lookup');
|
||||
expect(body.request.toolConfig).toEqual({ functionCallingConfig: { mode: 'AUTO' } });
|
||||
});
|
||||
|
||||
it('tool_choice: auto stays AUTO, none disables, named function forces ANY', () => {
|
||||
expect(transform({ tools: [FUNCTION_TOOL], tool_choice: 'auto' }).request.toolConfig).toEqual({
|
||||
functionCallingConfig: { mode: 'AUTO' },
|
||||
});
|
||||
expect(transform({ tools: [FUNCTION_TOOL], tool_choice: 'none' }).request.toolConfig).toEqual({
|
||||
functionCallingConfig: { mode: 'NONE' },
|
||||
});
|
||||
expect(
|
||||
transform({
|
||||
tools: [FUNCTION_TOOL],
|
||||
tool_choice: { type: 'function', function: { name: 'lookup' } },
|
||||
}).request.toolConfig,
|
||||
).toEqual({ functionCallingConfig: { mode: 'ANY', allowedFunctionNames: ['lookup'] } });
|
||||
});
|
||||
|
||||
it('tool_choice: a function choice without a name falls back to AUTO', () => {
|
||||
const body = transform({
|
||||
tools: [FUNCTION_TOOL],
|
||||
tool_choice: { type: 'function', function: { name: '' } },
|
||||
});
|
||||
expect(body.request.toolConfig).toEqual({ functionCallingConfig: { mode: 'AUTO' } });
|
||||
});
|
||||
|
||||
it('tool_choice without tools: only none produces a toolConfig', () => {
|
||||
expect(transform({ tool_choice: 'none' }).request.toolConfig).toEqual({
|
||||
functionCallingConfig: { mode: 'NONE' },
|
||||
});
|
||||
expect(transform({ tool_choice: 'auto' }).request).not.toHaveProperty('toolConfig');
|
||||
expect(
|
||||
transform({ tool_choice: { type: 'function', function: { name: 'lookup' } } }).request,
|
||||
).not.toHaveProperty('toolConfig');
|
||||
});
|
||||
|
||||
it('web search tool switches to a web_search request on gemini-3-flash with googleSearch', () => {
|
||||
const body = transform({
|
||||
model: 'gemini-3.1-pro-high',
|
||||
tools: [{ type: 'web_search_20250305', name: 'web_search' }],
|
||||
});
|
||||
expect(body.requestType).toBe('web_search');
|
||||
expect(body.model).toBe('gemini-3-flash');
|
||||
expect(body.request.tools).toEqual([{ googleSearch: {} }]);
|
||||
});
|
||||
|
||||
it('-online suffix enables networking; function declarations suppress googleSearch', () => {
|
||||
const online = transform({ model: 'gemini-3-flash-online' });
|
||||
expect(online.requestType).toBe('web_search');
|
||||
expect(online.model).toBe('gemini-3-flash');
|
||||
expect(online.request.tools).toEqual([{ googleSearch: {} }]);
|
||||
|
||||
const withFunctions = transform({ model: 'gemini-3-flash-online', tools: [FUNCTION_TOOL] });
|
||||
expect(withFunctions.requestType).toBe('web_search');
|
||||
expect(withFunctions.request.tools?.every((t) => !t.googleSearch)).toBe(true);
|
||||
});
|
||||
|
||||
it('image models: strip tools, system prompt and thinking, and attach imageConfig', () => {
|
||||
const body = transform({
|
||||
model: 'gemini-3-pro-image-16x9-4k',
|
||||
system: 'ignored',
|
||||
tools: [FUNCTION_TOOL],
|
||||
response_format: { type: 'json_object' },
|
||||
});
|
||||
expect(body.requestType).toBe('image_gen');
|
||||
expect(body.request).not.toHaveProperty('tools');
|
||||
expect(body.request).not.toHaveProperty('systemInstruction');
|
||||
expect(body.request.generationConfig?.imageConfig).toEqual({
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '4K',
|
||||
});
|
||||
expect(body.request.generationConfig).not.toHaveProperty('thinkingConfig');
|
||||
expect(body.request.generationConfig).not.toHaveProperty('responseMimeType');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { redactCloudAccountTokens } from '../../utils/cloud-account-redaction';
|
||||
import type { CloudAccount } from '../../types/cloudAccount';
|
||||
|
||||
function makeAccount(): CloudAccount {
|
||||
return {
|
||||
id: 'acc-1',
|
||||
provider: 'google',
|
||||
email: '[email protected]',
|
||||
name: 'User',
|
||||
token: {
|
||||
access_token: 'ya29.secret-access',
|
||||
refresh_token: '1//secret-refresh',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: 1_900_000_000,
|
||||
token_type: 'Bearer',
|
||||
project_id: 'project-1',
|
||||
is_gcp_tos: true,
|
||||
},
|
||||
created_at: 1,
|
||||
last_used: 2,
|
||||
};
|
||||
}
|
||||
|
||||
describe('redactCloudAccountTokens', () => {
|
||||
it('blanks the OAuth credentials and keeps the non-secret token metadata', () => {
|
||||
const redacted = redactCloudAccountTokens(makeAccount());
|
||||
expect(redacted.token.access_token).toBe('');
|
||||
expect(redacted.token.refresh_token).toBe('');
|
||||
expect(redacted.token).toMatchObject({
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: 1_900_000_000,
|
||||
token_type: 'Bearer',
|
||||
project_id: 'project-1',
|
||||
is_gcp_tos: true,
|
||||
});
|
||||
expect(redacted.email).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('does not mutate the original account', () => {
|
||||
const account = makeAccount();
|
||||
redactCloudAccountTokens(account);
|
||||
expect(account.token.refresh_token).toBe('1//secret-refresh');
|
||||
expect(account.token.access_token).toBe('ya29.secret-access');
|
||||
});
|
||||
|
||||
it('never leaves a credential in the serialized payload', () => {
|
||||
const payload = JSON.stringify(redactCloudAccountTokens(makeAccount()));
|
||||
expect(payload).not.toContain('secret-access');
|
||||
expect(payload).not.toContain('secret-refresh');
|
||||
});
|
||||
});
|
||||
@@ -57,7 +57,7 @@ vi.mock('../../ipc/database/dbConnection', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/paths', () => ({
|
||||
vi.mock('../../utils/paths', () => ({
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getGeminiNexusDbPaths: () => ['mock-db'],
|
||||
getCloudAccountsDbPath: () => 'mock-cloud-db',
|
||||
|
||||
@@ -21,7 +21,7 @@ vi.mock('../../ipc/database/dbConnection', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/paths', () => ({
|
||||
vi.mock('../../utils/paths', () => ({
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getGeminiNexusDbPaths: () => ['mock-db'],
|
||||
}));
|
||||
|
||||
@@ -47,7 +47,7 @@ vi.mock('../../utils/logger', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/paths', () => ({
|
||||
vi.mock('../../utils/paths', () => ({
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/test.vscdb']),
|
||||
}));
|
||||
|
||||
@@ -1,437 +1,439 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { isString } from 'lodash-es';
|
||||
|
||||
vi.mock('@/server/modules/proxy/request-user-agent', () => ({
|
||||
resolveRequestUserAgent: vi.fn().mockResolvedValue('test-agent/1.0.0'),
|
||||
buildUserAgent: vi.fn().mockReturnValue('test-agent/1.0.0'),
|
||||
resolveLocalInstalledVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
FALLBACK_VERSION: '1.22.2',
|
||||
}));
|
||||
|
||||
// Mock module to test timeout behavior
|
||||
describe('GoogleAPIService Timeout', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should abort request after timeout', async () => {
|
||||
const TIMEOUT_MS = 100;
|
||||
|
||||
// Recreate the same logic used in GoogleAPIService
|
||||
const createTimeoutSignal = (ms: number): AbortSignal => {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller.signal;
|
||||
};
|
||||
|
||||
const signal = createTimeoutSignal(TIMEOUT_MS);
|
||||
|
||||
// Verify signal is not aborted initially
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
// Fast-forward time
|
||||
vi.advanceTimersByTime(TIMEOUT_MS + 10);
|
||||
|
||||
// Signal should now be aborted
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('should set aborted state when controller.abort() is called', () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
// Initially not aborted
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
|
||||
// Abort the controller
|
||||
controller.abort();
|
||||
|
||||
// Signal should now be aborted
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('should transform AbortError to user-friendly message', () => {
|
||||
// This tests our error transformation logic from GoogleAPIService
|
||||
const handleAbortError = (err: Error) => {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
};
|
||||
|
||||
const abortError = new Error('The operation was aborted');
|
||||
abortError.name = 'AbortError';
|
||||
|
||||
expect(() => handleAbortError(abortError)).toThrow(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not transform non-AbortError', () => {
|
||||
const handleAbortError = (err: Error) => {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
};
|
||||
|
||||
const networkError = new Error('Network failure');
|
||||
networkError.name = 'NetworkError';
|
||||
|
||||
expect(() => handleAbortError(networkError)).toThrow('Network failure');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GoogleAPIService OAuth clients', () => {
|
||||
const originalOauthClientsEnv = process.env.ANTIGRAVITY_OAUTH_CLIENTS;
|
||||
const originalActiveOauthClientEnv = process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENTS;
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (isString(originalOauthClientsEnv)) {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENTS = originalOauthClientsEnv;
|
||||
} else {
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENTS;
|
||||
}
|
||||
|
||||
if (isString(originalActiveOauthClientEnv)) {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY = originalActiveOauthClientEnv;
|
||||
} else {
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('loads builtin and custom oauth clients with active marker', async () => {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENTS =
|
||||
'custom_a|id-a|secret-a|Custom A;custom_b|id-b|secret-b|Custom B';
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY = 'custom_b';
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
const clients = GoogleAPIService.listOAuthClients();
|
||||
|
||||
expect(clients.some((client) => client.is_builtin)).toBe(true);
|
||||
expect(clients.find((client) => client.key === 'custom_a')?.label).toBe('Custom A');
|
||||
expect(clients.find((client) => client.key === 'custom_b')?.is_active).toBe(true);
|
||||
});
|
||||
|
||||
it('switches active oauth client key', async () => {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENTS = 'custom_a|id-a|secret-a|Custom A';
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
GoogleAPIService.setActiveOAuthClientKey('custom_a');
|
||||
|
||||
expect(GoogleAPIService.getActiveOAuthClientKey()).toBe('custom_a');
|
||||
});
|
||||
|
||||
it('throws when switching to unknown oauth client key', async () => {
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
expect(() => GoogleAPIService.setActiveOAuthClientKey('missing_client')).toThrow(
|
||||
'Unknown OAuth client key',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GoogleAPIService user info parsing', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('accepts Google user info responses without family_name', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
id: 'google-user-1',
|
||||
email: '[email protected]',
|
||||
verified_email: true,
|
||||
name: 'Example User',
|
||||
given_name: 'Example',
|
||||
picture: 'https://example.com/avatar.png',
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.getUserInfo('access-token')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'google-user-1',
|
||||
email: '[email protected]',
|
||||
name: 'Example User',
|
||||
given_name: 'Example',
|
||||
picture: 'https://example.com/avatar.png',
|
||||
verified_email: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CloudAccountList auth code auto-submit guard', () => {
|
||||
it('returns true for a fresh auth code while the dialog is open', async () => {
|
||||
const { shouldAutoSubmitGoogleAuthCode } = await import('../../utils/googleAuthSubmission');
|
||||
|
||||
expect(
|
||||
shouldAutoSubmitGoogleAuthCode({
|
||||
authCode: 'fresh-code',
|
||||
isAddDialogOpen: true,
|
||||
isPending: false,
|
||||
lastSubmittedAuthCode: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false after the same auth code was already auto-submitted', async () => {
|
||||
const { shouldAutoSubmitGoogleAuthCode } = await import('../../utils/googleAuthSubmission');
|
||||
|
||||
expect(
|
||||
shouldAutoSubmitGoogleAuthCode({
|
||||
authCode: 'same-code',
|
||||
isAddDialogOpen: true,
|
||||
isPending: false,
|
||||
lastSubmittedAuthCode: 'same-code',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GoogleAPIService fetchQuota fallback policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('falls back to fetchCredits when loadCodeAssist returns no usable credits', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
// No paidTier.availableCredits — no usable credits
|
||||
freeTier: {},
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
credits: 500,
|
||||
expiryDate: '2025-12-31',
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 500,
|
||||
expiryDate: '2025-12-31',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:fetchCredits',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not fall through to the next endpoint on permanent 400 errors', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: vi.fn().mockResolvedValue('INVALID_ARGUMENT'),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
vi.spyOn(GoogleAPIService, 'fetchProjectContext').mockResolvedValue({
|
||||
projectId: 'project-1',
|
||||
subscriptionTier: 'free',
|
||||
});
|
||||
|
||||
await expect(GoogleAPIService.fetchQuota('access-token')).rejects.toThrow(
|
||||
'HTTP 400 - INVALID_ARGUMENT',
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses an explicit dispatcher from env proxy settings when no account proxy is provided', async () => {
|
||||
process.env.HTTP_PROXY = 'http://127.0.0.1:9090';
|
||||
process.env.HTTPS_PROXY = 'http://127.0.0.1:9090';
|
||||
process.env.NO_PROXY = 'localhost,127.0.0.1,::1';
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
paidTier: {
|
||||
availableCredits: [
|
||||
{
|
||||
creditType: 'GOOGLE_ONE_AI',
|
||||
creditAmount: '1000',
|
||||
minimumCreditAmountForUsage: '50',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 1000,
|
||||
expiryDate: '',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
|
||||
|
||||
delete process.env.HTTP_PROXY;
|
||||
delete process.env.HTTPS_PROXY;
|
||||
delete process.env.NO_PROXY;
|
||||
});
|
||||
|
||||
it('uses electron proxy env as an explicit dispatcher fallback', async () => {
|
||||
process.env.ELECTRON_PROXY_SERVER = 'http://127.0.0.1:9090';
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
paidTier: {
|
||||
availableCredits: [
|
||||
{
|
||||
creditType: 'GOOGLE_ONE_AI',
|
||||
creditAmount: '1000',
|
||||
minimumCreditAmountForUsage: '50',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 1000,
|
||||
expiryDate: '',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
|
||||
|
||||
delete process.env.ELECTRON_PROXY_SERVER;
|
||||
});
|
||||
});
|
||||
|
||||
describe('QuotaService fallback policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('does not fall through to the next endpoint on permanent 400 errors', async () => {
|
||||
const postMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
cloudaicompanionProject: 'project-1',
|
||||
currentTier: { id: 'free' },
|
||||
},
|
||||
})
|
||||
.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 400,
|
||||
data: { error: 'INVALID_ARGUMENT' },
|
||||
},
|
||||
message: 'Request failed with status code 400',
|
||||
});
|
||||
|
||||
vi.doMock('axios', () => ({
|
||||
default: {
|
||||
create: vi.fn(() => ({
|
||||
post: postMock,
|
||||
})),
|
||||
isAxiosError: (error: unknown) =>
|
||||
Boolean((error as { isAxiosError?: boolean })?.isAxiosError),
|
||||
},
|
||||
isAxiosError: (error: unknown) =>
|
||||
Boolean((error as { isAxiosError?: boolean })?.isAxiosError),
|
||||
}));
|
||||
|
||||
const { QuotaService } = await import('../../lib/geminiNexus/QuotaService');
|
||||
|
||||
await expect(QuotaService.fetchQuota('access-token', '[email protected]')).rejects.toThrow(
|
||||
'HTTP 400 - {"error":"INVALID_ARGUMENT"}',
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { isString } from 'lodash-es';
|
||||
|
||||
vi.mock('@/server/modules/proxy/request-user-agent', () => ({
|
||||
resolveRequestUserAgent: vi.fn().mockResolvedValue('test-agent/1.0.0'),
|
||||
buildUserAgent: vi.fn().mockReturnValue('test-agent/1.0.0'),
|
||||
resolveLocalInstalledVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
FALLBACK_VERSION: '1.22.2',
|
||||
}));
|
||||
|
||||
// Mock module to test timeout behavior
|
||||
describe('GoogleAPIService Timeout', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should abort request after timeout', async () => {
|
||||
const TIMEOUT_MS = 100;
|
||||
|
||||
// Recreate the same logic used in GoogleAPIService
|
||||
const createTimeoutSignal = (ms: number): AbortSignal => {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller.signal;
|
||||
};
|
||||
|
||||
const signal = createTimeoutSignal(TIMEOUT_MS);
|
||||
|
||||
// Verify signal is not aborted initially
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
// Fast-forward time
|
||||
vi.advanceTimersByTime(TIMEOUT_MS + 10);
|
||||
|
||||
// Signal should now be aborted
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('should set aborted state when controller.abort() is called', () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
// Initially not aborted
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
|
||||
// Abort the controller
|
||||
controller.abort();
|
||||
|
||||
// Signal should now be aborted
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('should transform AbortError to user-friendly message', () => {
|
||||
// This tests our error transformation logic from GoogleAPIService
|
||||
const handleAbortError = (err: Error) => {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
};
|
||||
|
||||
const abortError = new Error('The operation was aborted');
|
||||
abortError.name = 'AbortError';
|
||||
|
||||
expect(() => handleAbortError(abortError)).toThrow(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not transform non-AbortError', () => {
|
||||
const handleAbortError = (err: Error) => {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
};
|
||||
|
||||
const networkError = new Error('Network failure');
|
||||
networkError.name = 'NetworkError';
|
||||
|
||||
expect(() => handleAbortError(networkError)).toThrow('Network failure');
|
||||
});
|
||||
});
|
||||
|
||||
// Each test resets the module registry and re-imports GoogleAPIService, which can exceed the default
|
||||
// 5s timeout while the whole suite runs in parallel.
|
||||
describe('GoogleAPIService OAuth clients', { timeout: 30_000 }, () => {
|
||||
const originalOauthClientsEnv = process.env.ANTIGRAVITY_OAUTH_CLIENTS;
|
||||
const originalActiveOauthClientEnv = process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENTS;
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (isString(originalOauthClientsEnv)) {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENTS = originalOauthClientsEnv;
|
||||
} else {
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENTS;
|
||||
}
|
||||
|
||||
if (isString(originalActiveOauthClientEnv)) {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY = originalActiveOauthClientEnv;
|
||||
} else {
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('loads builtin and custom oauth clients with active marker', async () => {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENTS =
|
||||
'custom_a|id-a|secret-a|Custom A;custom_b|id-b|secret-b|Custom B';
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY = 'custom_b';
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
const clients = GoogleAPIService.listOAuthClients();
|
||||
|
||||
expect(clients.some((client) => client.is_builtin)).toBe(true);
|
||||
expect(clients.find((client) => client.key === 'custom_a')?.label).toBe('Custom A');
|
||||
expect(clients.find((client) => client.key === 'custom_b')?.is_active).toBe(true);
|
||||
});
|
||||
|
||||
it('switches active oauth client key', async () => {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENTS = 'custom_a|id-a|secret-a|Custom A';
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
GoogleAPIService.setActiveOAuthClientKey('custom_a');
|
||||
|
||||
expect(GoogleAPIService.getActiveOAuthClientKey()).toBe('custom_a');
|
||||
});
|
||||
|
||||
it('throws when switching to unknown oauth client key', async () => {
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
expect(() => GoogleAPIService.setActiveOAuthClientKey('missing_client')).toThrow(
|
||||
'Unknown OAuth client key',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GoogleAPIService user info parsing', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('accepts Google user info responses without family_name', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
id: 'google-user-1',
|
||||
email: 'user@example.com',
|
||||
verified_email: true,
|
||||
name: 'Example User',
|
||||
given_name: 'Example',
|
||||
picture: 'https://example.com/avatar.png',
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.getUserInfo('access-token')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'google-user-1',
|
||||
email: 'user@example.com',
|
||||
name: 'Example User',
|
||||
given_name: 'Example',
|
||||
picture: 'https://example.com/avatar.png',
|
||||
verified_email: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CloudAccountList auth code auto-submit guard', () => {
|
||||
it('returns true for a fresh auth code while the dialog is open', async () => {
|
||||
const { shouldAutoSubmitGoogleAuthCode } = await import('../../utils/googleAuthSubmission');
|
||||
|
||||
expect(
|
||||
shouldAutoSubmitGoogleAuthCode({
|
||||
authCode: 'fresh-code',
|
||||
isAddDialogOpen: true,
|
||||
isPending: false,
|
||||
lastSubmittedAuthCode: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false after the same auth code was already auto-submitted', async () => {
|
||||
const { shouldAutoSubmitGoogleAuthCode } = await import('../../utils/googleAuthSubmission');
|
||||
|
||||
expect(
|
||||
shouldAutoSubmitGoogleAuthCode({
|
||||
authCode: 'same-code',
|
||||
isAddDialogOpen: true,
|
||||
isPending: false,
|
||||
lastSubmittedAuthCode: 'same-code',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GoogleAPIService fetchQuota fallback policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('falls back to fetchCredits when loadCodeAssist returns no usable credits', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
// No paidTier.availableCredits — no usable credits
|
||||
freeTier: {},
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
credits: 500,
|
||||
expiryDate: '2025-12-31',
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 500,
|
||||
expiryDate: '2025-12-31',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:fetchCredits',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not fall through to the next endpoint on permanent 400 errors', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: vi.fn().mockResolvedValue('INVALID_ARGUMENT'),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
vi.spyOn(GoogleAPIService, 'fetchProjectContext').mockResolvedValue({
|
||||
projectId: 'project-1',
|
||||
subscriptionTier: 'free',
|
||||
});
|
||||
|
||||
await expect(GoogleAPIService.fetchQuota('access-token')).rejects.toThrow(
|
||||
'HTTP 400 - INVALID_ARGUMENT',
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses an explicit dispatcher from env proxy settings when no account proxy is provided', async () => {
|
||||
process.env.HTTP_PROXY = 'http://127.0.0.1:9090';
|
||||
process.env.HTTPS_PROXY = 'http://127.0.0.1:9090';
|
||||
process.env.NO_PROXY = 'localhost,127.0.0.1,::1';
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
paidTier: {
|
||||
availableCredits: [
|
||||
{
|
||||
creditType: 'GOOGLE_ONE_AI',
|
||||
creditAmount: '1000',
|
||||
minimumCreditAmountForUsage: '50',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 1000,
|
||||
expiryDate: '',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
|
||||
|
||||
delete process.env.HTTP_PROXY;
|
||||
delete process.env.HTTPS_PROXY;
|
||||
delete process.env.NO_PROXY;
|
||||
});
|
||||
|
||||
it('uses electron proxy env as an explicit dispatcher fallback', async () => {
|
||||
process.env.ELECTRON_PROXY_SERVER = 'http://127.0.0.1:9090';
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
paidTier: {
|
||||
availableCredits: [
|
||||
{
|
||||
creditType: 'GOOGLE_ONE_AI',
|
||||
creditAmount: '1000',
|
||||
minimumCreditAmountForUsage: '50',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 1000,
|
||||
expiryDate: '',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
|
||||
|
||||
delete process.env.ELECTRON_PROXY_SERVER;
|
||||
});
|
||||
});
|
||||
|
||||
describe('QuotaService fallback policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('does not fall through to the next endpoint on permanent 400 errors', async () => {
|
||||
const postMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
cloudaicompanionProject: 'project-1',
|
||||
currentTier: { id: 'free' },
|
||||
},
|
||||
})
|
||||
.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 400,
|
||||
data: { error: 'INVALID_ARGUMENT' },
|
||||
},
|
||||
message: 'Request failed with status code 400',
|
||||
});
|
||||
|
||||
vi.doMock('axios', () => ({
|
||||
default: {
|
||||
create: vi.fn(() => ({
|
||||
post: postMock,
|
||||
})),
|
||||
isAxiosError: (error: unknown) =>
|
||||
Boolean((error as { isAxiosError?: boolean })?.isAxiosError),
|
||||
},
|
||||
isAxiosError: (error: unknown) =>
|
||||
Boolean((error as { isAxiosError?: boolean })?.isAxiosError),
|
||||
}));
|
||||
|
||||
const { QuotaService } = await import('../../lib/geminiNexus/QuotaService');
|
||||
|
||||
await expect(QuotaService.fetchQuota('access-token', '[email protected]')).rejects.toThrow(
|
||||
'HTTP 400 - {"error":"INVALID_ARGUMENT"}',
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,7 @@ vi.mock('../../../ipc/database/dbConnection', () => ({
|
||||
configureDatabase: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/paths', () => ({
|
||||
vi.mock('../../../utils/paths', () => ({
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getCloudAccountsDbPath: vi.fn(() => '/mock/db/path.sqlite'),
|
||||
}));
|
||||
|
||||
@@ -48,7 +48,7 @@ vi.mock('../../../utils/logger', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/paths', () => ({
|
||||
vi.mock('../../../utils/paths', () => ({
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/test.vscdb']),
|
||||
}));
|
||||
|
||||
@@ -52,7 +52,7 @@ vi.mock('../../../utils/logger', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/paths', () => ({
|
||||
vi.mock('../../../utils/paths', () => ({
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getGeminiNexusStoragePaths: vi.fn().mockReturnValue(['/tmp/storage.json']),
|
||||
getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/state.vscdb']),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { networkInterfaces } from 'os';
|
||||
import {
|
||||
getRecentTrafficLogs,
|
||||
getTrafficLogStats,
|
||||
@@ -14,6 +15,16 @@ import {
|
||||
import { TrafficLogsRepo } from '../../../ipc/database/proxyMetricsHandler';
|
||||
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', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
@@ -67,6 +78,7 @@ vi.mock('../../../ipc/proxy-advanced/service-registry', () => {
|
||||
describe('Proxy Advanced Handler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(networkInterfaces).mockReset().mockReturnValue({});
|
||||
});
|
||||
|
||||
describe('getRecentTrafficLogs', () => {
|
||||
@@ -415,9 +427,38 @@ describe('Proxy Advanced Handler', () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const result = await generateIdeConfig('vscode');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.success, result.error).toBe(true);
|
||||
expect(result.data?.name).toBe('VS Code');
|
||||
});
|
||||
|
||||
@@ -460,5 +501,5 @@ describe('Proxy Advanced Handler', () => {
|
||||
expect(result.data?.proxySetting).toBe('OPENAI_BASE_URL');
|
||||
vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = null;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ vi.mock('../../../ipc/database/cloudHandler', () => ({
|
||||
ensureDatabaseInitialized: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/paths', () => ({
|
||||
vi.mock('../../../utils/paths', () => ({
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getCloudAccountsDbPath: vi.fn(() => '/mock/db/path.sqlite'),
|
||||
}));
|
||||
|
||||
@@ -4,8 +4,8 @@ import path from 'path';
|
||||
|
||||
vi.mock('../../utils/paths', async () => {
|
||||
const path = await import('path');
|
||||
return {
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
return {
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getAgentDir: vi.fn(() => path.join(process.cwd(), 'temp_test_logs')),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getDynamicForwardingTarget,
|
||||
resolveModelRoute,
|
||||
updateDynamicForwardingRules,
|
||||
} from '../../lib/geminiNexus/ModelMapping';
|
||||
|
||||
describe('resolveModelRoute', () => {
|
||||
const NO_MAPPING: Record<string, string> = {};
|
||||
|
||||
function route(
|
||||
model: string,
|
||||
options: {
|
||||
custom?: Record<string, string>;
|
||||
openai?: Record<string, string>;
|
||||
anthropic?: Record<string, string>;
|
||||
} = {},
|
||||
) {
|
||||
return resolveModelRoute(
|
||||
model,
|
||||
options.custom ?? NO_MAPPING,
|
||||
options.openai ?? NO_MAPPING,
|
||||
options.anthropic ?? NO_MAPPING,
|
||||
);
|
||||
}
|
||||
|
||||
describe('dynamic forwarding', () => {
|
||||
it('forwards a deprecated model to a supported replacement', () => {
|
||||
updateDynamicForwardingRules('route-test-old-model', 'gemini-3-flash');
|
||||
expect(getDynamicForwardingTarget('route-test-old-model')).toBe('gemini-3-flash');
|
||||
expect(route('route-test-old-model', { custom: { 'route-test-old-model': 'custom' } })).toBe(
|
||||
'gemini-3-flash',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a forwarding rule whose target is not a supported model', () => {
|
||||
updateDynamicForwardingRules('route-test-unsupported', 'not-a-supported-model');
|
||||
expect(route('route-test-unsupported')).toBe('route-test-unsupported');
|
||||
});
|
||||
|
||||
it('ignores empty or non-string rules', () => {
|
||||
updateDynamicForwardingRules(' ', 'gemini-3-flash');
|
||||
updateDynamicForwardingRules('route-test-empty-target', ' ');
|
||||
expect(getDynamicForwardingTarget('route-test-empty-target')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom exact mapping', () => {
|
||||
it('wins over family mappings and the built-in table', () => {
|
||||
expect(
|
||||
route('gpt-4o', {
|
||||
custom: { 'gpt-4o': 'my-model' },
|
||||
openai: { 'gpt-4o-series': 'other' },
|
||||
}),
|
||||
).toBe('my-model');
|
||||
});
|
||||
|
||||
it('is case sensitive and does not apply to other names', () => {
|
||||
expect(route('GPT-4O', { custom: { 'gpt-4o': 'my-model' } })).toBe('GPT-4O');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAI families', () => {
|
||||
it('maps the GPT-4 series (classic, o1-, o3-) when configured', () => {
|
||||
const openai = { 'gpt-4-series': 'gpt4-target' };
|
||||
expect(route('gpt-4', { openai })).toBe('gpt4-target');
|
||||
expect(route('gpt-4-0613', { openai })).toBe('gpt4-target');
|
||||
expect(route('o1-preview', { openai })).toBe('gpt4-target');
|
||||
expect(route('O3-mini', { openai })).toBe('gpt4-target');
|
||||
});
|
||||
|
||||
it('does not treat gpt-4o, mini or turbo variants as the GPT-4 series', () => {
|
||||
const openai = { 'gpt-4-series': 'gpt4-target' };
|
||||
expect(route('gpt-4o', { openai })).toBe('gemini-3-flash');
|
||||
expect(route('gpt-4-turbo', { openai })).toBe('gemini-3-flash');
|
||||
});
|
||||
|
||||
it('maps the GPT-4o / 3.5 / mini / turbo series when configured', () => {
|
||||
const openai = { 'gpt-4o-series': 'mini-target' };
|
||||
expect(route('gpt-4o', { openai })).toBe('mini-target');
|
||||
expect(route('gpt-3.5-turbo', { openai })).toBe('mini-target');
|
||||
expect(route('gpt-4-turbo', { openai })).toBe('mini-target');
|
||||
expect(route('gpt-4o-mini', { openai })).toBe('mini-target');
|
||||
expect(route('some-mini-model', { openai })).toBe('mini-target');
|
||||
});
|
||||
|
||||
it('does not treat gemini names containing "mini" as the GPT-4o series', () => {
|
||||
const openai = { 'gpt-4o-series': 'mini-target' };
|
||||
expect(route('gemini-3-flash', { openai })).toBe('gemini-3-flash');
|
||||
});
|
||||
|
||||
it('maps GPT-5 to gpt-5-series, then falls back to gpt-4-series, then to the default', () => {
|
||||
expect(route('gpt-5.2', { openai: { 'gpt-5-series': 'g5', 'gpt-4-series': 'g4' } })).toBe(
|
||||
'g5',
|
||||
);
|
||||
expect(route('gpt-5', { openai: { 'gpt-4-series': 'g4' } })).toBe('g4');
|
||||
expect(route('gpt-5', { openai: { 'gpt-4o-series': 'mini' } })).toBe('gpt-5');
|
||||
});
|
||||
|
||||
it('falls through to the built-in table when the family key is not configured', () => {
|
||||
expect(route('gpt-4')).toBe('gemini-3-flash');
|
||||
expect(route('gpt-4', { openai: { 'gpt-4o-series': 'mini' } })).toBe('gemini-3-flash');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Anthropic families', () => {
|
||||
it('picks the 4.5, 3.5 or default family key from the model name', () => {
|
||||
const anthropic = {
|
||||
'claude-4.5-series': 'family-45',
|
||||
'claude-3.5-series': 'family-35',
|
||||
'claude-default': 'family-default',
|
||||
};
|
||||
expect(route('claude-sonnet-4-5-20250929', { anthropic })).toBe('family-45');
|
||||
expect(route('claude-sonnet-4.5', { anthropic })).toBe('family-45');
|
||||
expect(route('claude-3-5-sonnet-20241022', { anthropic })).toBe('family-35');
|
||||
expect(route('claude-3.5-haiku', { anthropic })).toBe('family-35');
|
||||
expect(route('claude-opus-4-6', { anthropic })).toBe('family-default');
|
||||
});
|
||||
|
||||
it('falls back to a legacy exact mapping when the family key is missing', () => {
|
||||
expect(route('claude-opus-4-6', { anthropic: { 'claude-opus-4-6': 'legacy-target' } })).toBe(
|
||||
'legacy-target',
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers the family key over the legacy exact mapping', () => {
|
||||
expect(
|
||||
route('claude-opus-4-6', {
|
||||
anthropic: { 'claude-default': 'family-default', 'claude-opus-4-6': 'legacy-target' },
|
||||
}),
|
||||
).toBe('family-default');
|
||||
});
|
||||
|
||||
it('is not applied to names that do not start with claude-', () => {
|
||||
expect(route('my-claude-model', { anthropic: { 'claude-default': 'family-default' } })).toBe(
|
||||
'my-claude-model',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls through to the built-in table when nothing is configured', () => {
|
||||
expect(route('claude-sonnet-4-5')).toBe('claude-sonnet-4-6-thinking');
|
||||
});
|
||||
});
|
||||
|
||||
describe('system default mapping', () => {
|
||||
it('maps known aliases and passes unknown names through', () => {
|
||||
expect(route('gemini-2.5-pro')).toBe('gemini-3.1-pro-high');
|
||||
expect(route('totally-unknown-model')).toBe('totally-unknown-model');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { RateLimitReason, RateLimitTracker } from '../../server/modules/proxy/rate-limit-tracker';
|
||||
|
||||
function track(params: { status: number; body?: string; retryAfter?: string }) {
|
||||
return new RateLimitTracker().trackFromUpstreamError({
|
||||
accountId: 'acc',
|
||||
backoffSteps: [],
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
function reasonFor(status: number, body?: string): RateLimitReason | undefined {
|
||||
return track({ status, body })?.reason;
|
||||
}
|
||||
|
||||
function googleBody(error: Record<string, unknown>): string {
|
||||
return JSON.stringify({ error });
|
||||
}
|
||||
|
||||
describe('RateLimitTracker.trackFromUpstreamError - reason detection', () => {
|
||||
it('ignores statuses that are not rate-limit related', () => {
|
||||
expect(track({ status: 200 })).toBeNull();
|
||||
expect(track({ status: 400 })).toBeNull();
|
||||
expect(track({ status: 401 })).toBeNull();
|
||||
});
|
||||
|
||||
describe('structured Google error details', () => {
|
||||
it.each([
|
||||
['QUOTA_EXHAUSTED', RateLimitReason.QuotaExhausted],
|
||||
['RATE_LIMIT_EXCEEDED', RateLimitReason.RateLimitExceeded],
|
||||
['MODEL_CAPACITY_EXHAUSTED', RateLimitReason.ModelCapacityExhausted],
|
||||
[' quota_exhausted ', RateLimitReason.QuotaExhausted],
|
||||
])('maps detail reason %s', (reason, expected) => {
|
||||
const body = googleBody({ details: [{ reason }] });
|
||||
expect(reasonFor(429, body)).toBe(expected);
|
||||
expect(reasonFor(503, body)).toBe(expected);
|
||||
});
|
||||
|
||||
it('skips blank, non-string and unknown reasons and uses the first mapped one', () => {
|
||||
const body = googleBody({
|
||||
details: [
|
||||
{ reason: ' ' },
|
||||
{ reason: 42 },
|
||||
{},
|
||||
{ reason: 'SOMETHING_ELSE' },
|
||||
{ reason: 'RATE_LIMIT_EXCEEDED' },
|
||||
{ reason: 'QUOTA_EXHAUSTED' },
|
||||
],
|
||||
});
|
||||
expect(reasonFor(429, body)).toBe(RateLimitReason.RateLimitExceeded);
|
||||
});
|
||||
|
||||
it('prefers detail reasons over the status field and the raw text', () => {
|
||||
const body = googleBody({
|
||||
status: 'RESOURCE_EXHAUSTED',
|
||||
message: 'quota exceeded',
|
||||
details: [{ reason: 'RATE_LIMIT_EXCEEDED' }],
|
||||
});
|
||||
expect(reasonFor(429, body)).toBe(RateLimitReason.RateLimitExceeded);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error status field', () => {
|
||||
it('treats RESOURCE_EXHAUSTED as quota exhaustion for any tracked status', () => {
|
||||
const body = googleBody({ status: ' resource_exhausted ' });
|
||||
expect(reasonFor(429, body)).toBe(RateLimitReason.QuotaExhausted);
|
||||
expect(reasonFor(503, body)).toBe(RateLimitReason.QuotaExhausted);
|
||||
});
|
||||
|
||||
it('treats UNAVAILABLE with capacity wording as model capacity exhaustion', () => {
|
||||
expect(
|
||||
reasonFor(503, googleBody({ status: 'UNAVAILABLE', message: 'No capacity available' })),
|
||||
).toBe(RateLimitReason.ModelCapacityExhausted);
|
||||
expect(
|
||||
reasonFor(503, googleBody({ status: 'UNAVAILABLE', message: 'model capacity reached' })),
|
||||
).toBe(RateLimitReason.ModelCapacityExhausted);
|
||||
});
|
||||
|
||||
it('treats UNAVAILABLE without capacity wording as a server error (non-429)', () => {
|
||||
expect(reasonFor(503, googleBody({ status: 'UNAVAILABLE', message: 'try later' }))).toBe(
|
||||
RateLimitReason.ServerError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('status fallback', () => {
|
||||
it.each([404, 500, 503, 529])('classifies a plain %i as a server error', (status) => {
|
||||
expect(reasonFor(status, 'boom')).toBe(RateLimitReason.ServerError);
|
||||
expect(reasonFor(status, undefined)).toBe(RateLimitReason.ServerError);
|
||||
});
|
||||
|
||||
it('does not apply 429 text heuristics to other statuses', () => {
|
||||
expect(reasonFor(500, 'rate limit exceeded, quota exhausted')).toBe(
|
||||
RateLimitReason.ServerError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('429 text heuristics', () => {
|
||||
it.each(['Requests per minute exceeded', 'RATE LIMIT hit', 'rate_limit', 'Too Many Requests'])(
|
||||
'classifies %s as a rate limit',
|
||||
(body) => {
|
||||
expect(reasonFor(429, body)).toBe(RateLimitReason.RateLimitExceeded);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['Model capacity exhausted', 'No capacity available right now'])(
|
||||
'classifies %s as model capacity',
|
||||
(body) => {
|
||||
expect(reasonFor(429, body)).toBe(RateLimitReason.ModelCapacityExhausted);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['Quota reached', 'resources exhausted'])('classifies %s as quota', (body) => {
|
||||
expect(reasonFor(429, body)).toBe(RateLimitReason.QuotaExhausted);
|
||||
});
|
||||
|
||||
it('checks rate-limit wording before capacity and quota wording', () => {
|
||||
expect(reasonFor(429, 'too many requests: quota exhausted, no capacity available')).toBe(
|
||||
RateLimitReason.RateLimitExceeded,
|
||||
);
|
||||
expect(reasonFor(429, 'no capacity available, quota exhausted')).toBe(
|
||||
RateLimitReason.ModelCapacityExhausted,
|
||||
);
|
||||
});
|
||||
|
||||
it('is unknown when nothing matches or the body is missing', () => {
|
||||
expect(reasonFor(429, 'something odd')).toBe(RateLimitReason.Unknown);
|
||||
expect(reasonFor(429, undefined)).toBe(RateLimitReason.Unknown);
|
||||
expect(reasonFor(429, '{not json')).toBe(RateLimitReason.Unknown);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RateLimitTracker.trackFromUpstreamError - default retry delays', () => {
|
||||
const STEPS = [10, 20, 30];
|
||||
|
||||
it('uses configured backoff steps for quota exhaustion and clamps at the last one', () => {
|
||||
const tracker = new RateLimitTracker();
|
||||
const delays = [1, 2, 3, 4].map(
|
||||
() =>
|
||||
tracker.trackFromUpstreamError({
|
||||
accountId: 'acc',
|
||||
status: 429,
|
||||
body: 'quota exhausted',
|
||||
backoffSteps: STEPS,
|
||||
})?.retryAfterSec,
|
||||
);
|
||||
expect(delays).toEqual([10, 20, 30, 30]);
|
||||
});
|
||||
|
||||
it('falls back to built-in quota steps when none are configured', () => {
|
||||
const tracker = new RateLimitTracker();
|
||||
const first = tracker.trackFromUpstreamError({
|
||||
accountId: 'acc',
|
||||
status: 429,
|
||||
body: 'quota exhausted',
|
||||
backoffSteps: [],
|
||||
});
|
||||
expect(first?.retryAfterSec).toBe(60);
|
||||
});
|
||||
|
||||
it('uses a fixed 5 seconds for rate limits', () => {
|
||||
expect(track({ status: 429, body: 'rate limit' })?.retryAfterSec).toBe(5);
|
||||
});
|
||||
|
||||
it('escalates model capacity delays 5, 10, 15 per account', () => {
|
||||
const tracker = new RateLimitTracker();
|
||||
const delays = [1, 2, 3, 4].map(
|
||||
() =>
|
||||
tracker.trackFromUpstreamError({
|
||||
accountId: 'acc',
|
||||
status: 429,
|
||||
body: 'no capacity available',
|
||||
backoffSteps: [],
|
||||
})?.retryAfterSec,
|
||||
);
|
||||
expect(delays).toEqual([5, 10, 15, 15]);
|
||||
});
|
||||
|
||||
it('uses 5 seconds for 404 and 8 seconds for other server errors without escalating', () => {
|
||||
const tracker = new RateLimitTracker();
|
||||
const first = tracker.trackFromUpstreamError({ accountId: 'a', status: 404, backoffSteps: [] });
|
||||
const second = tracker.trackFromUpstreamError({
|
||||
accountId: 'a',
|
||||
status: 500,
|
||||
backoffSteps: [],
|
||||
});
|
||||
const third = tracker.trackFromUpstreamError({ accountId: 'a', status: 500, backoffSteps: [] });
|
||||
expect([first?.retryAfterSec, second?.retryAfterSec, third?.retryAfterSec]).toEqual([5, 8, 8]);
|
||||
});
|
||||
|
||||
it('uses 60 seconds for unknown reasons', () => {
|
||||
expect(track({ status: 429, body: 'something odd' })?.retryAfterSec).toBe(60);
|
||||
});
|
||||
|
||||
it('prefers a numeric Retry-After header, with a 2 second floor and rounding up', () => {
|
||||
expect(track({ status: 429, retryAfter: '30' })?.retryAfterSec).toBe(30);
|
||||
expect(track({ status: 429, retryAfter: '1' })?.retryAfterSec).toBe(2);
|
||||
expect(track({ status: 429, retryAfter: '2.1' })?.retryAfterSec).toBe(3);
|
||||
});
|
||||
|
||||
it('parses an HTTP-date Retry-After header and ignores dates in the past', () => {
|
||||
const future = new Date(Date.now() + 45_000).toUTCString();
|
||||
const delay = track({ status: 429, retryAfter: future, body: 'rate limit' })?.retryAfterSec;
|
||||
expect(delay).toBeGreaterThanOrEqual(43);
|
||||
expect(delay).toBeLessThanOrEqual(46);
|
||||
|
||||
const past = new Date(Date.now() - 60_000).toUTCString();
|
||||
expect(track({ status: 429, retryAfter: past, body: 'rate limit' })?.retryAfterSec).toBe(5);
|
||||
});
|
||||
|
||||
it('ignores zero, negative, blank and garbage Retry-After headers', () => {
|
||||
expect(track({ status: 429, retryAfter: '0', body: 'rate limit' })?.retryAfterSec).toBe(5);
|
||||
expect(track({ status: 429, retryAfter: '-5', body: 'rate limit' })?.retryAfterSec).toBe(5);
|
||||
expect(track({ status: 429, retryAfter: ' ', body: 'rate limit' })?.retryAfterSec).toBe(5);
|
||||
expect(track({ status: 429, retryAfter: 'soon', body: 'rate limit' })?.retryAfterSec).toBe(5);
|
||||
});
|
||||
|
||||
it('prefers the header over delays found in the body', () => {
|
||||
const body = googleBody({ details: [{ retryDelay: '99s' }] });
|
||||
expect(track({ status: 429, retryAfter: '7', body })?.retryAfterSec).toBe(7);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['retryDelay detail', googleBody({ details: [{ retryDelay: '12s' }] }), 12],
|
||||
[
|
||||
'metadata.retryDelay detail',
|
||||
googleBody({ details: [{ metadata: { retryDelay: '13s' } }] }),
|
||||
13,
|
||||
],
|
||||
[
|
||||
'metadata.quotaResetDelay detail',
|
||||
googleBody({ details: [{ metadata: { quotaResetDelay: '1m1s' } }] }),
|
||||
61,
|
||||
],
|
||||
['retry_after number', googleBody({ retry_after: 14.2 }), 15],
|
||||
['try again in minutes and seconds', 'Please try again in 2m 5s', 125],
|
||||
['try again in seconds', 'Please try again in 9s.', 9],
|
||||
['backoff for', 'backoff for 11s', 11],
|
||||
['quota reset message', 'Your quota will reset in 17 seconds', 17],
|
||||
])('uses the body hint (%s)', (_label, body, expected) => {
|
||||
expect(track({ status: 429, body })?.retryAfterSec).toBe(expected);
|
||||
});
|
||||
|
||||
it('skips unparseable body delays and falls through to the default', () => {
|
||||
const body = googleBody({ details: [{ retryDelay: 'later' }], retry_after: 0 });
|
||||
expect(track({ status: 429, body })?.retryAfterSec).toBe(60);
|
||||
});
|
||||
|
||||
it('does not escalate failure counters when a header or body hint is used', () => {
|
||||
const tracker = new RateLimitTracker();
|
||||
tracker.trackFromUpstreamError({
|
||||
accountId: 'acc',
|
||||
status: 429,
|
||||
body: 'quota exhausted',
|
||||
retryAfter: '30',
|
||||
backoffSteps: STEPS,
|
||||
});
|
||||
const next = tracker.trackFromUpstreamError({
|
||||
accountId: 'acc',
|
||||
status: 429,
|
||||
body: 'quota exhausted',
|
||||
backoffSteps: STEPS,
|
||||
});
|
||||
expect(next?.retryAfterSec).toBe(10);
|
||||
});
|
||||
|
||||
it('locks quota exhaustion per model and other reasons per account', () => {
|
||||
const tracker = new RateLimitTracker();
|
||||
tracker.trackFromUpstreamError({
|
||||
accountId: 'acc',
|
||||
status: 429,
|
||||
body: 'quota exhausted',
|
||||
model: 'm1',
|
||||
backoffSteps: [],
|
||||
});
|
||||
expect(tracker.isRateLimited('acc', 'm1')).toBe(true);
|
||||
expect(tracker.isRateLimited('acc', 'm2')).toBe(false);
|
||||
expect(tracker.isRateLimited('acc')).toBe(false);
|
||||
|
||||
tracker.trackFromUpstreamError({
|
||||
accountId: 'acc',
|
||||
status: 429,
|
||||
body: 'rate limit',
|
||||
model: 'm1',
|
||||
backoffSteps: [],
|
||||
});
|
||||
expect(tracker.isRateLimited('acc', 'm2')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
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', () => {
|
||||
const tracker = new RateLimitTracker();
|
||||
const info = tracker.parseAndMarkFromError({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
apiKeyMatches,
|
||||
hasConfiguredApiKey,
|
||||
extractApiKeyToken,
|
||||
type RequestHeaders,
|
||||
@@ -81,4 +82,19 @@ describe('api-key-auth.util', () => {
|
||||
expect(extractApiKeyToken(headers)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiKeyMatches', () => {
|
||||
it('accepts only the exact configured key', () => {
|
||||
expect(apiKeyMatches('secret-key', 'secret-key')).toBe(true);
|
||||
expect(apiKeyMatches('secret-key ', 'secret-key')).toBe(false);
|
||||
expect(apiKeyMatches('Secret-Key', 'secret-key')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing, empty and different-length tokens without throwing', () => {
|
||||
expect(apiKeyMatches(null, 'secret-key')).toBe(false);
|
||||
expect(apiKeyMatches('', 'secret-key')).toBe(false);
|
||||
expect(apiKeyMatches('secret', 'secret-key')).toBe(false);
|
||||
expect(apiKeyMatches('secret-key-and-more', 'secret-key')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -104,8 +104,8 @@ vi.mock('electron', () => {
|
||||
session: {
|
||||
webRequest: {
|
||||
onBeforeSendHeaders: vi.fn(),
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
on: vi.fn(),
|
||||
})),
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TtlCache } from '../../utils/ttl-cache';
|
||||
|
||||
describe('TtlCache', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns stored values until the ttl elapses', () => {
|
||||
const cache = new TtlCache<string>(1000, 10);
|
||||
cache.set('a', 'value');
|
||||
expect(cache.get('a')).toBe('value');
|
||||
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(cache.get('a')).toBe('value');
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(cache.get('a')).toBeUndefined();
|
||||
expect(cache.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns undefined for unknown keys', () => {
|
||||
expect(new TtlCache<string>(1000, 10).get('missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops expired entries on write even if they are never read again', () => {
|
||||
const cache = new TtlCache<number>(1000, 10);
|
||||
cache.set('old-1', 1);
|
||||
cache.set('old-2', 2);
|
||||
vi.advanceTimersByTime(1500);
|
||||
|
||||
cache.set('fresh', 3);
|
||||
expect(cache.size).toBe(1);
|
||||
expect(cache.get('fresh')).toBe(3);
|
||||
});
|
||||
|
||||
it('evicts the oldest entries when the limit is exceeded', () => {
|
||||
const cache = new TtlCache<number>(60_000, 2);
|
||||
cache.set('a', 1);
|
||||
cache.set('b', 2);
|
||||
cache.set('c', 3);
|
||||
|
||||
expect(cache.size).toBe(2);
|
||||
expect(cache.get('a')).toBeUndefined();
|
||||
expect(cache.get('b')).toBe(2);
|
||||
expect(cache.get('c')).toBe(3);
|
||||
});
|
||||
|
||||
it('refreshes ttl and recency when a key is overwritten', () => {
|
||||
const cache = new TtlCache<number>(1000, 2);
|
||||
cache.set('a', 1);
|
||||
cache.set('b', 2);
|
||||
vi.advanceTimersByTime(600);
|
||||
cache.set('a', 10);
|
||||
cache.set('c', 3);
|
||||
|
||||
expect(cache.get('b')).toBeUndefined();
|
||||
vi.advanceTimersByTime(600);
|
||||
expect(cache.get('a')).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ vi.mock('../../ipc/database/cloudHandler', () => ({
|
||||
ensureDatabaseInitialized: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/paths', () => ({
|
||||
vi.mock('../../utils/paths', () => ({
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getCloudAccountsDbPath: vi.fn(() => '/mock/db/path.sqlite'),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CloudAccount } from '../types/cloudAccount';
|
||||
|
||||
/**
|
||||
* Returns a copy of the account that is safe to hand to the renderer.
|
||||
*
|
||||
* The renderer never needs the OAuth credentials: they are only used by the main process
|
||||
* (quota refresh, IDE switch, proxy). Keeping them out of IPC payloads means an XSS or a
|
||||
* compromised renderer cannot read refresh tokens. The non-secret token metadata
|
||||
* (expiry, project id, tier flags) is preserved so the output schema stays valid.
|
||||
*/
|
||||
export function redactCloudAccountTokens<T extends CloudAccount>(account: T): T {
|
||||
return {
|
||||
...account,
|
||||
token: {
|
||||
...account.token,
|
||||
access_token: '',
|
||||
refresh_token: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Small in-memory cache with a fixed time-to-live and a hard entry limit.
|
||||
*
|
||||
* Entries live in insertion order and share one TTL, so expiry order matches insertion order:
|
||||
* pruning can stop at the first live entry. The size cap evicts the oldest entries, which keeps
|
||||
* memory bounded even when callers never read a key again (a plain Map only drops expired
|
||||
* entries on read).
|
||||
*/
|
||||
export class TtlCache<V> {
|
||||
private readonly entries = new Map<string, { value: V; expiresAt: number }>();
|
||||
|
||||
constructor(
|
||||
private readonly ttlMs: number,
|
||||
private readonly maxEntries: number,
|
||||
) {}
|
||||
|
||||
get(key: string): V | undefined {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
if (Date.now() >= entry.expiresAt) {
|
||||
this.entries.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key: string, value: V): void {
|
||||
const now = Date.now();
|
||||
// Re-inserting moves the key to the newest position so insertion order stays expiry order.
|
||||
this.entries.delete(key);
|
||||
this.pruneExpired(now);
|
||||
this.entries.set(key, { value, expiresAt: now + this.ttlMs });
|
||||
this.evictOverflow();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
private pruneExpired(now: number): void {
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (entry.expiresAt > now) {
|
||||
return;
|
||||
}
|
||||
this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private evictOverflow(): void {
|
||||
while (this.entries.size > this.maxEntries) {
|
||||
const oldestKey = this.entries.keys().next().value;
|
||||
if (oldestKey === undefined) {
|
||||
return;
|
||||
}
|
||||
this.entries.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user