Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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..."
|
||||
|
||||
@@ -10,3 +10,4 @@ openspec/
|
||||
.agent/
|
||||
.claude/
|
||||
.codex
|
||||
pnpm-lock.yaml
|
||||
@@ -2,6 +2,22 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
+2
-1
@@ -194,7 +194,7 @@ Al usar IDEs y herramientas de programación con IA, ¿te has encontrado con est
|
||||
|
||||
Puedes descargar los binarios precompilados más recientes para Windows, macOS y Linux desde nuestra [página de Releases](https://github.com/evandrodevbr/GeminiNexus/releases).
|
||||
|
||||
*(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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,238 @@
|
||||
</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`; this audit ran on Node 24.20.0.
|
||||
- pnpm `10.11.0` (declared in `package.json`). With Corepack enabled, `pnpm` resolves to that version automatically.
|
||||
- Native build toolchain for `better-sqlite3` and `keytar`: on Linux, `base-devel`/`build-essential`, `python3` and `libsecret`; on macOS, Xcode Command Line Tools; on Windows, Visual Studio Build Tools with the C++ workload. `pnpm install` runs `scripts/setup-dev.js --check-only`, which reports what is missing.
|
||||
- A graphical session to run the desktop app itself (Electron).
|
||||
- For installers: `dpkg`/`rpmbuild` on Linux, WiX Toolset on Windows.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# 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, which is only sensible while it is bound to localhost.
|
||||
|
||||
## Build and release
|
||||
|
||||
```bash
|
||||
pnpm run package # app bundle in out/<product>-<platform>-<arch>
|
||||
pnpm run make # installers (deb, rpm, AppImage, zip on Linux; dmg/zip on macOS; WiX installer on Windows)
|
||||
pnpm run make:win # Windows helper script
|
||||
```
|
||||
|
||||
The gateway is statically bundled into the Electron app; there is no separate server deployment.
|
||||
|
||||
Release automation in `.github/workflows/`:
|
||||
|
||||
- `format.yaml` and `lint.yaml` run Prettier and ESLint on pull requests to `main`.
|
||||
- `testing.yaml` runs the unit suite and `tsc --noEmit` on pushes and pull requests. E2E is intentionally excluded because it needs a graphical environment.
|
||||
- `release.yml` runs semantic-release on `main`, which tags the version, updates `CHANGELOG.md` and creates the GitHub release.
|
||||
- `publish.yaml` builds `win32`, `darwin` and `linux` for x64 and arm64 when the Release workflow succeeds, and uploads the installers to the release.
|
||||
|
||||
## Project structure
|
||||
|
||||
```text
|
||||
src/
|
||||
├── 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
|
||||
What exists and what was run on Linux x64 (Node 24.20.0, pnpm 10.11.0):
|
||||
|
||||
<details>
|
||||
<summary><b>Q: "Windows protected your PC" (SmartScreen) warning during installation?</b></summary>
|
||||
| Command | Result |
|
||||
|---|---|
|
||||
| `pnpm install --frozen-lockfile` | exit 0 |
|
||||
| `pnpm test` | 54 test files, 499 tests, all passing |
|
||||
| `pnpm run type-check` | exit 0 |
|
||||
| `pnpm run lint` | exit 0 (327 warnings, no errors) |
|
||||
| `pnpm run format` | all files formatted |
|
||||
| `pnpm run package` | exit 0, `out/Gemini Nexus-linux-x64` |
|
||||
| `pnpm run test:e2e` | not run: Playwright drives Electron and needs a graphical session |
|
||||
|
||||
Yes, this is a common warning for new unsigned applications. Click **"More info"** and then **"Run anyway"**. See the warning at the top of the README for more details.
|
||||
</details>
|
||||
The E2E suite is also excluded from CI for the same reason.
|
||||
|
||||
<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.
|
||||
- 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.
|
||||
+2
-1
@@ -194,7 +194,7 @@ O **Gemini Nexus** resolve tudo isso. É um aplicativo desktop profissional em E
|
||||
|
||||
Você pode baixar os binários pré-compilados mais recentes para Windows, macOS e Linux na nossa [página de Releases](https://github.com/evandrodevbr/GeminiNexus/releases).
|
||||
|
||||
*(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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+2
-1
@@ -194,7 +194,7 @@
|
||||
|
||||
您可以从我们的 [Releases 页面](https://github.com/evandrodevbr/GeminiNexus/releases) 下载适用于 Windows、macOS 和 Linux 的最新预编译版本。
|
||||
|
||||
*(如果您使用的是 Windows 并在安装时遇到了 SmartScreen 提示,请参阅页面顶部的警告)。*
|
||||
_(如果您使用的是 Windows 并在安装时遇到了 SmartScreen 提示,请参阅页面顶部的警告)。_
|
||||
|
||||
### 🔌 配合 AI IDE 使用
|
||||
|
||||
@@ -274,6 +274,7 @@ npm test
|
||||
<summary><b>问:安装时出现 Windows SmartScreen 警告("Windows 已保护你的电脑")?</b></summary>
|
||||
|
||||
是的,对于未签名的新应用,这是一个常见的警告。请点击 **"更多信息"** (More info),然后点击 **"仍要运行"** (Run anyway)。详情请参阅 README 顶部的提示。
|
||||
|
||||
</details>
|
||||
|
||||
<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
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gemini-nexus",
|
||||
"productName": "Gemini Nexus",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.4",
|
||||
"description": "Gemini Nexus - Electron App",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+276
-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,301 @@ 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)}
|
||||
>
|
||||
<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}
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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']),
|
||||
}));
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -460,5 +460,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')),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -104,8 +104,8 @@ vi.mock('electron', () => {
|
||||
session: {
|
||||
webRequest: {
|
||||
onBeforeSendHeaders: vi.fn(),
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
on: vi.fn(),
|
||||
})),
|
||||
|
||||
@@ -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'),
|
||||
}));
|
||||
|
||||
Reference in new issue
Block a user