Compare commits
15
Commits
main
...
refine/2026-10
| 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>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h1 align="center">Gemini Nexus</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>🚀 Professional multi-account AI gateway for Google Gemini & Claude</strong>
|
||||
<strong>Desktop gateway that pools several Google Gemini and Claude accounts behind a single local OpenAI- and Anthropic-compatible API.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -13,354 +13,239 @@
|
||||
</p>
|
||||
|
||||
<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/releases">
|
||||
<img src="https://img.shields.io/github/downloads/evandrodevbr/GeminiNexus/total?style=flat-square&color=blue" alt="Downloads" />
|
||||
</a>
|
||||
<a href="https://github.com/evandrodevbr/GeminiNexus/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/evandrodevbr/GeminiNexus?style=flat-square" alt="License" />
|
||||
</a>
|
||||
<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-latest-47848F?style=flat-square&logo=electron&logoColor=white" alt="Electron" />
|
||||
<img src="https://img.shields.io/badge/electron-41-47848F?style=flat-square&logo=electron&logoColor=white" alt="Electron 41" />
|
||||
<img src="https://img.shields.io/badge/react-19-61DAFB?style=flat-square&logo=react&logoColor=white" alt="React 19" />
|
||||
<img src="https://img.shields.io/badge/node-%3E%3D20.19-5FA04E?style=flat-square&logo=nodedotjs&logoColor=white" alt="Node 20.19+" />
|
||||
<img src="https://img.shields.io/badge/license-MIT%20%2B%20CC%20BY--NC--SA%204.0-blue?style=flat-square" alt="License" />
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Windows Users:** If you see a **"Windows protected your PC"** (SmartScreen) warning during installation, click **"More info"** and then **"Run anyway"**. This is an expected security prompt for new, unsigned applications.
|
||||
> **Windows users:** the installers are not code-signed, so SmartScreen may show "Windows protected your PC". Choose "More info" and then "Run anyway"; this is the expected prompt for an unsigned application.
|
||||
|
||||
---
|
||||
## About
|
||||
|
||||
## 📖 Table of Contents
|
||||
AI coding tools assume a single account with a single quota. When that quota runs out, you have to switch accounts by hand, and you usually have no idea how many tokens you burned.
|
||||
|
||||
- [✨ Why Gemini Nexus?](#-why-gemini-nexus)
|
||||
- [🎯 Features](#-features)
|
||||
- [📸 Screenshots](#-screenshots)
|
||||
- [⚡ Installation & Quick Start](#-installation--quick-start)
|
||||
- [🛠️ Tech Stack](#️-tech-stack)
|
||||
- [💻 Development](#-development)
|
||||
- [❓ FAQ](#-faq)
|
||||
- [🤝 Contributing](#-contributing)
|
||||
- [📄 License](#-license)
|
||||
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.
|
||||
|
||||
---
|
||||
What it does:
|
||||
|
||||
## ✨ Why Gemini Nexus?
|
||||
- Manages a pool of Google/Claude accounts with per-account status (active, rate limited, expired) and quota snapshots.
|
||||
- Serves an OpenAI-compatible surface (`/v1/chat/completions`, `/v1/models`, `/v1/completions`, `/v1/responses`, images and audio routes), an Anthropic-compatible surface (`/v1/messages`) and the Gemini-native surface (`/v1beta/models`, `countTokens`).
|
||||
- Streams responses over SSE and maps model names in both directions (for example `claude-sonnet-4-5` to the configured Gemini/Claude target).
|
||||
- Stores tokens and keys encrypted at rest (AES-256-GCM) with an OS keychain wrapper, falling back to a local key file.
|
||||
- Shows local analytics: token usage, cost estimates, model distribution, request traffic and request replay.
|
||||
|
||||
When using AI-powered IDEs and coding tools, have you ever run into these problems?
|
||||
It is a personal desktop tool, not a hosted service: everything runs on your machine.
|
||||
|
||||
- 😫 **Quota limits:** Single account quotas run out quickly, requiring frequent manual switching.
|
||||
- 🔄 **Account management:** Managing multiple Google/Claude accounts is cumbersome.
|
||||
- 📊 **Blind usage:** You don't know how much quota or how many tokens you've actually consumed.
|
||||
- 🔌 **Integration issues:** You need a reliable local API proxy that speaks OpenAI/Anthropic protocols natively.
|
||||
- 🔍 **Lack of transparency:** You can't see what's actually being sent or received by the proxy under the hood.
|
||||
## How it works
|
||||
|
||||
**Gemini Nexus** solves all of these issues. It's a professional Electron desktop application that acts as an intelligent gateway between your development tools and Google Gemini / Claude AI.
|
||||
|
||||
### Key Value Proposition
|
||||
|
||||
- ✅ **Unlimited Account Pool** — Add any number of Google Gemini & Claude accounts.
|
||||
- ✅ **Smart Auto-Switching** — Automatically rotates to the next available account when quota is low or rate-limited.
|
||||
- ✅ **Real-time Usage Analytics** — SaaS-grade dashboard with area charts, trend indicators, and model distribution.
|
||||
- ✅ **Full Proxy Observability** — Live traffic monitor, request replay, and model capabilities inspector.
|
||||
- ✅ **OpenAI & Anthropic Compatible** — Drop-in replacement proxy for Cursor, Windsurf, OpenCode, and any OpenAI-compatible tool.
|
||||
- ✅ **Secure by Default** — AES-256-GCM encryption with OS-native credential management.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Features
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>☁️ Cloud Account Pool</h3>
|
||||
<ul>
|
||||
<li>Add unlimited Google Gemini / Claude accounts via OAuth</li>
|
||||
<li>Display avatar, email, status, and last used time</li>
|
||||
<li>Real-time status monitoring (Active, Rate Limited, Expired)</li>
|
||||
<li>Per-account proxy URL configuration</li>
|
||||
<li>Device identity profile management with history</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>📊 Usage Analytics Dashboard</h3>
|
||||
<ul>
|
||||
<li>Real-time token consumption with 15s auto-refresh</li>
|
||||
<li>Area charts for daily/hourly prompt & completion tokens</li>
|
||||
<li>Trend indicators (% change vs previous period)</li>
|
||||
<li>Inline sparklines on stat cards</li>
|
||||
<li>Model distribution ranking with horizontal bar charts</li>
|
||||
<li>Prompt/Completion ratio visualization</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>🔌 Local API Proxy (Gateway)</h3>
|
||||
<ul>
|
||||
<li>OpenAI <code>/v1/chat/completions</code> compatible endpoint</li>
|
||||
<li>Anthropic <code>/v1/messages</code> compatible endpoint</li>
|
||||
<li>Full SSE streaming support (tested with Cursor, Windsurf, OpenCode)</li>
|
||||
<li>Model mapping (e.g. <code>claude-sonnet-4-6</code> → <code>gemini-3-flash</code>)</li>
|
||||
<li>Configurable port, timeout, and API key</li>
|
||||
<li>Model visibility control (show/hide specific models)</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>🔄 Intelligent Auto-Switching</h3>
|
||||
<ul>
|
||||
<li>Unlimited pool mode with smart backup selection</li>
|
||||
<li>Auto-switch when quota < 5% or rate-limited</li>
|
||||
<li>Background monitoring every 5 minutes</li>
|
||||
<li>Graceful fallback with status reason tracking</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>🔍 Proxy Observability (Advanced)</h3>
|
||||
<ul>
|
||||
<li><strong>Traffic Monitor</strong> — Live request/response log with latency, status, and model info</li>
|
||||
<li><strong>Request Replay</strong> — Replay any recorded request for debugging</li>
|
||||
<li><strong>Model Capabilities</strong> — Inspect vision, thinking, streaming, JSON mode support per model</li>
|
||||
<li><strong>Developer Tools</strong> — cURL & Python code generation, one-click copy</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>🔐 Security & Encryption</h3>
|
||||
<ul>
|
||||
<li>AES-256-GCM encryption for all sensitive data</li>
|
||||
<li>OS native credential manager integration (Keytar + SafeStorage)</li>
|
||||
<li>Auto migration of legacy plaintext data</li>
|
||||
<li>Encrypted token & quota storage per account</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>⚙️ Settings & Customization</h3>
|
||||
<ul>
|
||||
<li>Dark / Light / System theme support</li>
|
||||
<li>Multi-language: English & Português (Brasil)</li>
|
||||
<li>Per-account proxy URL override</li>
|
||||
<li>Model visibility toggles</li>
|
||||
<li>Log directory access</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>🖥️ Desktop Experience</h3>
|
||||
<ul>
|
||||
<li>Native Electron app with system tray integration</li>
|
||||
<li>Collapsible sidebar with persistent state</li>
|
||||
<li>Responsive layouts across all views</li>
|
||||
<li>Status bar with live proxy and connection indicators</li>
|
||||
<li>Error boundaries with user-friendly fallbacks</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/main.png" alt="Main Dashboard" width="80%" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/assets/2usage.png" alt="Usage Analytics" width="80%" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/assets/3proxy.png" alt="Proxy Monitor" width="80%" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/assets/4routing.png" alt="Routing Config" width="80%" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/assets/5docs.png" alt="Documentation" width="80%" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/assets/6connections.png" alt="Active Connections" width="80%" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/assets/7config.png" alt="Settings Configuration" width="80%" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Installation & Quick Start
|
||||
|
||||
### 📦 Download the App
|
||||
|
||||
You can download the latest pre-compiled binaries for Windows, macOS, and Linux from our [Releases page](https://github.com/evandrodevbr/GeminiNexus/releases).
|
||||
|
||||
*(Refer to the warning at the top of this page if you are using Windows and encounter the SmartScreen prompt).*
|
||||
|
||||
### 🔌 Using with AI IDEs
|
||||
|
||||
Once the application is running and you have added at least one account, configure your preferred IDE (Cursor, Windsurf, OpenCode, etc.):
|
||||
|
||||
```plaintext
|
||||
API Base URL: http://localhost:10100/v1
|
||||
API Key: (copy this from the Proxy page in the app)
|
||||
Model: gemini-3-flash (or any mapped model of your choice)
|
||||
```text
|
||||
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)
|
||||
```
|
||||
|
||||
### 🛠️ Build from Source
|
||||
- The gateway is started by the desktop app (Proxy page, or auto-start if enabled). It is a child process, so closing the app stops it.
|
||||
- Account selection honours a scheduling mode (`balance`, `cache-first`, `performance-first`), a preferred account, per-account upstream proxy URLs, and a circuit breaker with backoff steps.
|
||||
- Requests are recorded locally (SQLite) and can be replayed from the Proxy page for debugging.
|
||||
|
||||
#### Prerequisites
|
||||
## Stack
|
||||
|
||||
- **Node.js** v20 or higher
|
||||
- **npm** (this project uses `package-lock.json`)
|
||||
| Layer | Choice |
|
||||
|---|---|
|
||||
| Desktop shell | Electron 41, Electron Forge 7 (Vite plugin) |
|
||||
| UI | React 19, TypeScript 5.9, TanStack Router + Query, Tailwind CSS v4, Radix UI, Nivo charts |
|
||||
| Gateway | NestJS 11 on Fastify 5, RxJS, SSE streaming |
|
||||
| Storage | better-sqlite3 + Drizzle ORM / raw SQL, JSON config in the app data dir |
|
||||
| Secrets | Node crypto AES-256-GCM, `safeStorage` / `keytar` with file fallback |
|
||||
| Validation | Zod schemas, class-validator |
|
||||
| Internationalization | react-i18next (en, zh-CN, ru, vi) |
|
||||
| Tests | Vitest (unit), Testing Library, Playwright (E2E, local only) |
|
||||
| Package manager | pnpm 10.11.0 (`packageManager` field, Corepack) |
|
||||
|
||||
#### Steps
|
||||
## Requirements
|
||||
|
||||
- Node.js `^20.19.0 || >=22.12.0` (Vite 8 engine range). The repository pins 22.17.1 in `.nvmrc`.
|
||||
- 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
|
||||
# Clone the repository
|
||||
git clone https://github.com/evandrodevbr/GeminiNexus.git
|
||||
cd GeminiNexus
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
# 1. install dependencies (lockfile is pnpm; do not use npm install)
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Start the application in development mode
|
||||
npm start
|
||||
|
||||
# Build for production (e.g., Windows installer)
|
||||
npm run make
|
||||
# 2. run the desktop app (Electron + Vite HMR); the gateway listens on 8046 in dev
|
||||
pnpm start
|
||||
```
|
||||
|
||||
---
|
||||
Then, in the app:
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
1. Add at least one Google or Claude account on the Accounts page (OAuth flow).
|
||||
2. Open the Proxy page, enable the gateway and copy the generated API key.
|
||||
3. Point your IDE or CLI at the local endpoint.
|
||||
|
||||
| Category | Technologies |
|
||||
|----------|-------------|
|
||||
| **Core** | [Electron](https://www.electronjs.org/), [React 19](https://react.dev/), [TypeScript](https://www.typescriptlang.org/) |
|
||||
| **Build** | [Vite](https://vitejs.dev/), [Electron Forge](https://www.electronforge.io/) |
|
||||
| **Styling** | [Tailwind CSS v4](https://tailwindcss.com/), [Radix UI](https://www.radix-ui.com/), [Lucide Icons](https://lucide.dev/) |
|
||||
| **State** | [TanStack Query](https://tanstack.com/query/latest), [TanStack Router](https://tanstack.com/router/latest) |
|
||||
| **Backend** | [NestJS](https://nestjs.com/) (internal gateway), [ORPC](https://orpc.unnoq.com/) (type-safe RPC) |
|
||||
| **Database** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3), [Drizzle ORM](https://orm.drizzle.team/) |
|
||||
| **Charts** | [Nivo](https://nivo.rocks/) (Line, Bar, Pie) |
|
||||
| **i18n** | [react-i18next](https://react.i18next.com/) |
|
||||
| **Testing** | [Vitest](https://vitest.dev/), [Playwright](https://playwright.dev/) |
|
||||
```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)
|
||||
```
|
||||
|
||||
---
|
||||
## Usage
|
||||
|
||||
## 💻 Development
|
||||
Daily commands, all driven by `pnpm`:
|
||||
|
||||
```bash
|
||||
# Start dev environment (Electron + Vite HMR)
|
||||
npm start
|
||||
|
||||
# Run linting
|
||||
npm run lint
|
||||
|
||||
# Type check
|
||||
npm run type-check
|
||||
|
||||
# Format code
|
||||
npm run format:write
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
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
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
Settings live in the app data directory (`%APPDATA%\Gemini Nexus` on Windows, `~/Library/Application Support/Gemini Nexus` on macOS, `~/.config/Gemini Nexus` on Linux; `${app}-dev` names in development). Account metadata is stored in `~/.geminiNexus-agent/geminiNexus_accounts.json`.
|
||||
|
||||
```plaintext
|
||||
## Screenshots
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  | |
|
||||
|
||||
## 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/
|
||||
├── actions/ # App actions and flow orchestration
|
||||
├── components/ # React UI components
|
||||
│ ├── proxy/ # Proxy page components (advanced tabs)
|
||||
│ ├── usage/ # Usage analytics components (StatCard, ChartCard)
|
||||
│ └── ui/ # Base UI primitives (Radix-based)
|
||||
├── hooks/ # Custom React hooks
|
||||
├── ipc/ # Electron IPC + database handlers
|
||||
├── layouts/ # Layout components (MainLayout + sidebar)
|
||||
├── localization/ # i18n translation resources
|
||||
├── routes/ # TanStack Router pages (index, usage, proxy, settings)
|
||||
├── server/ # NestJS backend (proxy gateway service)
|
||||
├── services/ # Service layer
|
||||
├── types/ # TypeScript type definitions + Zod schemas
|
||||
└── utils/ # Utility functions
|
||||
├── 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
|
||||
```
|
||||
|
||||
---
|
||||
## Verification
|
||||
|
||||
## ❓ FAQ
|
||||
Checks used before merging, all run with `pnpm`:
|
||||
|
||||
<details>
|
||||
<summary><b>Q: "Windows protected your PC" (SmartScreen) warning during installation?</b></summary>
|
||||
| Command | Notes |
|
||||
|---|---|
|
||||
| `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. |
|
||||
|
||||
Yes, this is a common warning for new unsigned applications. Click **"More info"** and then **"Run anyway"**. See the warning at the top of the README for more details.
|
||||
</details>
|
||||
Electron packaging, real account switching and the E2E suite need separate validation on each platform.
|
||||
|
||||
<details>
|
||||
<summary><b>Q: The app won't start when building from source?</b></summary>
|
||||
## Current state and limitations
|
||||
|
||||
1. Make sure all dependencies are installed: `npm install`
|
||||
2. Check if your Node.js version is >= 20.
|
||||
3. Try deleting `node_modules` and reinstalling.
|
||||
4. On Windows, ensure the WiX Toolset is available for `npm run make`.
|
||||
</details>
|
||||
- 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.
|
||||
|
||||
<details>
|
||||
<summary><b>Q: Account login failed?</b></summary>
|
||||
## Documentation
|
||||
|
||||
1. Ensure your network connection is working.
|
||||
2. Try clearing the app data and logging in again.
|
||||
3. Check if the account is restricted by Google/Claude.
|
||||
</details>
|
||||
| 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 |
|
||||
|
||||
<details>
|
||||
<summary><b>Q: My IDE can't connect to the proxy?</b></summary>
|
||||
## License
|
||||
|
||||
1. Make sure the proxy is actually running (look for the green indicator in the status bar).
|
||||
2. Verify the port matches your IDE config (default is `10100`).
|
||||
3. Copy the API key directly from the Proxy page and paste it into your IDE settings.
|
||||
4. Check that at least one account is active in the Accounts page.
|
||||
</details>
|
||||
Dual license:
|
||||
|
||||
<details>
|
||||
<summary><b>Q: Token counts are showing zero?</b></summary>
|
||||
- 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).
|
||||
|
||||
1. This was a known bug in earlier versions — please update to the latest release.
|
||||
2. Token tracking now correctly handles streaming metadata and estimated counts.
|
||||
3. Usage data refreshes every 15 seconds on the Usage Analytics page.
|
||||
</details>
|
||||
The project is being migrated to MIT as the remaining original code is rewritten.
|
||||
|
||||
---
|
||||
## Credits
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please read `CONTRIBUTING.md` for details.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project uses a dual-license structure:
|
||||
|
||||
- **Original code** from [AntigravityManager](https://github.com/Draculabo/AntigravityManager) by [Draculabo](https://github.com/Draculabo): Licensed under [CC BY-NC-SA 4.0](LICENSE).
|
||||
- **All new code, features, and architecture** by [evandrodevbr](https://github.com/evandrodevbr): Licensed under the [MIT License](LICENSE-MIT).
|
||||
|
||||
> 🔄 **Migration Notice:** This project is actively being migrated to a fully open-source license (MIT). As the remaining original code is progressively rewritten, the entire project will transition to MIT. We are committed to making Gemini Nexus 100% open-source.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Credits
|
||||
|
||||
This project was originally forked from [AntigravityManager](https://github.com/Draculabo/AntigravityManager) created by [Draculabo](https://github.com/Draculabo). The initial proxy concept and early Electron scaffolding were based on his work.
|
||||
|
||||
Since the fork, **the vast majority of the codebase has been rewritten, redesigned, and expanded** by [evandrodevbr](https://github.com/evandrodevbr), including but not limited to:
|
||||
|
||||
- Complete UI/UX redesign (navigation, accounts, usage analytics, proxy management)
|
||||
- Usage Analytics dashboard with cost tracking, token averages, and OpenRouter integration
|
||||
- Traffic monitor with per-request token/cost inspection
|
||||
- Environment isolation (dev/prod), CI/CD pipeline, and test infrastructure
|
||||
- IDE Quick Setup (OpenCode, Cursor, VS Code, Claude Code)
|
||||
- Custom model pricing configuration
|
||||
- All documentation and internationalization (EN, PT-BR, ES, ZH-CN)
|
||||
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