Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf5b669133 | ||
|
|
4b3f193bd0 | ||
|
|
fa39fb34fe | ||
|
|
b193bee03f | ||
|
|
e8eaa049d8 | ||
|
|
acb8508bff | ||
|
|
43aa538932 | ||
|
|
8ad3eef53d | ||
|
|
42466c274b | ||
|
|
0dbae81353 | ||
|
|
a4727713d2 | ||
|
|
b13f06c0a3 | ||
|
|
7a6587cb8b | ||
|
|
e1c9a9e34a | ||
|
|
1c26a9e542 | ||
|
|
5026be12d2 | ||
|
|
c05daba98b | ||
|
|
2e3a65b76b | ||
|
|
62634b5511 | ||
|
|
27223f587d | ||
|
|
1d35801980 | ||
|
|
269ad18ed6 | ||
|
|
9b9b977ae6 | ||
|
|
f4daaa8ca1 |
No files matched your search
@@ -118,6 +118,8 @@ jobs:
|
||||
Write-Host "Incompatible WiX Toolset detected: $installed"
|
||||
choco upgrade wixtoolset --version=3.14.0 --allow-downgrade -y
|
||||
}
|
||||
# Ensure 7zip is available for Squirrel.Windows zip operations
|
||||
choco install 7zip -y
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -223,4 +225,16 @@ jobs:
|
||||
fi
|
||||
|
||||
printf 'Uploading %s assets to %s\n' "${#files[@]}" "$RELEASE_TAG"
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber
|
||||
|
||||
# Upload files sequentially with retries to prevent GitHub API rate limits and 404/502 errors
|
||||
for file in "${files[@]}"; do
|
||||
echo "Uploading $file..."
|
||||
if ! gh release upload "$RELEASE_TAG" "$file" --clobber; then
|
||||
echo "::warning::Failed to upload $file. Retrying in 5 seconds..."
|
||||
sleep 5
|
||||
if ! gh release upload "$RELEASE_TAG" "$file" --clobber; then
|
||||
echo "::error::Failed to upload $file after retry."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
@@ -10,3 +10,4 @@ openspec/
|
||||
.agent/
|
||||
.claude/
|
||||
.codex
|
||||
pnpm-lock.yaml
|
||||
@@ -2,6 +2,56 @@
|
||||
|
||||
# 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
|
||||
|
||||
* allow fs-xattr and appdmg to build in pnpm v10 ([1c26a9e](https://github.com/evandrodevbr/GeminiNexus/commit/1c26a9e542f3fa5a05b370b8a332ffe5a727ea92))
|
||||
|
||||
## [0.15.2](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.1...v0.15.2) (2026-05-02)
|
||||
|
||||
### 👷 Build System
|
||||
|
||||
* allow macos-alias and other native modules to build in pnpm v10 ([2e3a65b](https://github.com/evandrodevbr/GeminiNexus/commit/2e3a65b76b91012638c422f6223b79399775c635))
|
||||
|
||||
## [0.15.1](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.0...v0.15.1) (2026-05-02)
|
||||
|
||||
### ✅ Tests
|
||||
|
||||
* add universal electron mock to fix headless CI test runs ([1d35801](https://github.com/evandrodevbr/GeminiNexus/commit/1d35801980e3e54cec1d3e52ed75850eb7eec350))
|
||||
* fix test environments failing due to missing IS_DEV_ENVIRONMENT mock export ([269ad18](https://github.com/evandrodevbr/GeminiNexus/commit/269ad18ed6c09585e881d0d916558b1bcc7ad940))
|
||||
* update proxy-advanced-handler tests to reflect optional service registration ([f4daaa8](https://github.com/evandrodevbr/GeminiNexus/commit/f4daaa8ca13bc75c7e5090905176c06b61ecfedc))
|
||||
|
||||
### 👷 Build System
|
||||
|
||||
* replace MakerSquirrel with MakerZIP for Windows ([27223f5](https://github.com/evandrodevbr/GeminiNexus/commit/27223f587d7facf25beef77aa6984cd248f2703d))
|
||||
|
||||
## [0.15.0](https://github.com/evandrodevbr/GeminiNexus/compare/v0.14.0...v0.15.0) (2026-05-02)
|
||||
|
||||
### ✨ Features
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present evandrodevbr
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+139
-54
@@ -23,27 +23,48 @@
|
||||
<img src="https://img.shields.io/github/license/evandrodevbr/GeminiNexus?style=flat-square" alt="Licencia" />
|
||||
</a>
|
||||
<img src="https://img.shields.io/badge/plataforma-Windows%20%7C%20macOS%20%7C%20Linux-informational?style=flat-square" alt="Plataforma" />
|
||||
<img src="https://img.shields.io/badge/electron-latest-47848F?style=flat-square&logo=electron&logoColor=white" alt="Electron" />
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Usuarios de Windows:** Si ves una advertencia de SmartScreen **"Windows protegió su PC"** durante la instalación, haz clic en **"Más información"** y luego en **"Ejecutar de todas formas"**. Esta es una advertencia de seguridad esperada para aplicaciones nuevas no firmadas.
|
||||
|
||||
---
|
||||
|
||||
## 📖 Tabla de Contenidos
|
||||
|
||||
- [✨ ¿Por qué Gemini Nexus?](#-por-qué-gemini-nexus)
|
||||
- [🎯 Características](#-características)
|
||||
- [📸 Capturas de Pantalla](#-capturas-de-pantalla)
|
||||
- [⚡ Instalación e Inicio Rápido](#-instalación-e-inicio-rápido)
|
||||
- [🛠️ Stack Tecnológico](#️-stack-tecnológico)
|
||||
- [💻 Desarrollo](#-desarrollo)
|
||||
- [❓ Preguntas Frecuentes](#-preguntas-frecuentes)
|
||||
- [🤝 Contribuir](#-contribuir)
|
||||
- [📄 Licencia](#-licencia)
|
||||
|
||||
---
|
||||
|
||||
## ✨ ¿Por qué Gemini Nexus?
|
||||
|
||||
Al usar IDEs y herramientas de programación con IA, ¿te has encontrado con estos problemas?
|
||||
|
||||
- 😫 La cuota de una sola cuenta se agota rápido, requiriendo cambios manuales frecuentes
|
||||
- 🔄 Gestionar múltiples cuentas de Google/Claude es tedioso
|
||||
- 📊 No sabes cuántos tokens has consumido o cuánta cuota queda
|
||||
- 🔌 Necesitas un proxy local confiable que hable los protocolos OpenAI/Anthropic
|
||||
- 😫 **Límites de cuota:** La cuota de una sola cuenta se agota rápido, requiriendo cambios manuales frecuentes.
|
||||
- 🔄 **Gestión de cuentas:** Gestionar múltiples cuentas de Google/Claude es tedioso.
|
||||
- 📊 **Uso a ciegas:** No sabes cuántos tokens has consumido o cuánta cuota queda.
|
||||
- 🔌 **Problemas de integración:** Necesitas un proxy local confiable que hable los protocolos OpenAI/Anthropic de forma nativa.
|
||||
- 🔍 **Falta de transparencia:** No puedes ver qué se envía o recibe exactamente a través del proxy.
|
||||
|
||||
**Gemini Nexus** resuelve todo esto. Es una aplicación de escritorio profesional en Electron que actúa como gateway inteligente entre tus herramientas de desarrollo y Google Gemini / Claude AI:
|
||||
**Gemini Nexus** resuelve todo esto. Es una aplicación de escritorio profesional en Electron que actúa como gateway inteligente entre tus herramientas de desarrollo y Google Gemini / Claude AI.
|
||||
|
||||
- ✅ **Pool de Cuentas Ilimitado** — Agrega cualquier cantidad de cuentas Google Gemini y Claude
|
||||
- ✅ **Cambio Automático Inteligente** — Rota automáticamente a la siguiente cuenta disponible cuando la cuota es baja
|
||||
- ✅ **Analytics de Uso en Tiempo Real** — Dashboard nivel SaaS con gráficos de área, indicadores de tendencia y distribución por modelo
|
||||
- ✅ **Observabilidad Completa del Proxy** — Monitor de tráfico en vivo, replay de solicitudes e inspector de capacidades de modelos
|
||||
- ✅ **Compatible con OpenAI y Anthropic** — Proxy drop-in para Cursor, Windsurf, OpenCode y cualquier herramienta compatible
|
||||
- ✅ **Seguro por Defecto** — Cifrado AES-256-GCM con gestión nativa de credenciales del SO
|
||||
### Propuesta de Valor
|
||||
|
||||
- ✅ **Pool de Cuentas Ilimitado** — Agrega cualquier cantidad de cuentas Google Gemini y Claude.
|
||||
- ✅ **Cambio Automático Inteligente** — Rota automáticamente a la siguiente cuenta disponible cuando la cuota es baja o hay rate limit.
|
||||
- ✅ **Analytics de Uso en Tiempo Real** — Dashboard nivel SaaS con gráficos de área, indicadores de tendencia y distribución por modelo.
|
||||
- ✅ **Observabilidad Completa del Proxy** — Monitor de tráfico en vivo, replay de solicitudes e inspector de capacidades de modelos.
|
||||
- ✅ **Compatible con OpenAI y Anthropic** — Proxy drop-in para Cursor, Windsurf, OpenCode y cualquier herramienta compatible con OpenAI.
|
||||
- ✅ **Seguro por Defecto** — Cifrado AES-256-GCM con gestión nativa de credenciales del sistema operativo.
|
||||
|
||||
---
|
||||
|
||||
@@ -79,7 +100,7 @@ Al usar IDEs y herramientas de programación con IA, ¿te has encontrado con est
|
||||
<ul>
|
||||
<li>Endpoint compatible con OpenAI <code>/v1/chat/completions</code></li>
|
||||
<li>Endpoint compatible con Anthropic <code>/v1/messages</code></li>
|
||||
<li>Soporte completo de streaming SSE</li>
|
||||
<li>Soporte completo de streaming SSE (probado con Cursor, Windsurf, OpenCode)</li>
|
||||
<li>Mapeo de modelos (ej: <code>claude-sonnet-4-6</code> → <code>gemini-3-flash</code>)</li>
|
||||
<li>Puerto, timeout y API key configurables</li>
|
||||
<li>Control de visibilidad de modelos</li>
|
||||
@@ -99,17 +120,17 @@ Al usar IDEs y herramientas de programación con IA, ¿te has encontrado con est
|
||||
<td width="50%">
|
||||
<h3>🔍 Observabilidad del Proxy (Avanzado)</h3>
|
||||
<ul>
|
||||
<li><strong>Monitor de Tráfico</strong> — Log en vivo de solicitudes/respuestas</li>
|
||||
<li><strong>Monitor de Tráfico</strong> — Log en vivo de solicitudes/respuestas con latencia y modelo</li>
|
||||
<li><strong>Replay de Solicitudes</strong> — Reproduce cualquier solicitud para debugging</li>
|
||||
<li><strong>Capacidades de Modelos</strong> — Inspecciona visión, thinking, streaming, modo JSON</li>
|
||||
<li><strong>Herramientas para Devs</strong> — Generación de código cURL y Python</li>
|
||||
<li><strong>Capacidades de Modelos</strong> — Inspecciona soporte de visión, thinking, streaming, modo JSON</li>
|
||||
<li><strong>Herramientas para Devs</strong> — Generación de código cURL y Python, copia con un clic</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>🔐 Seguridad y Cifrado</h3>
|
||||
<ul>
|
||||
<li>Cifrado AES-256-GCM para todos los datos sensibles</li>
|
||||
<li>Integración con gestor de credenciales nativo del SO</li>
|
||||
<li>Integración con gestor de credenciales nativo del SO (Keytar + SafeStorage)</li>
|
||||
<li>Migración automática de datos legacy en texto plano</li>
|
||||
<li>Almacenamiento cifrado de tokens y cuotas por cuenta</li>
|
||||
</ul>
|
||||
@@ -119,8 +140,9 @@ Al usar IDEs y herramientas de programación con IA, ¿te has encontrado con est
|
||||
<td width="50%">
|
||||
<h3>⚙️ Configuración y Personalización</h3>
|
||||
<ul>
|
||||
<li>Tema Oscuro / Claro / Sistema</li>
|
||||
<li>Soporte de temas Oscuro / Claro / Sistema</li>
|
||||
<li>Multi-idioma: English & Português (Brasil)</li>
|
||||
<li>Override de proxy URL por cuenta</li>
|
||||
<li>Toggles de visibilidad de modelos</li>
|
||||
<li>Acceso al directorio de logs</li>
|
||||
</ul>
|
||||
@@ -130,8 +152,9 @@ Al usar IDEs y herramientas de programación con IA, ¿te has encontrado con est
|
||||
<ul>
|
||||
<li>App nativa Electron con integración en bandeja del sistema</li>
|
||||
<li>Sidebar colapsable con estado persistente</li>
|
||||
<li>Layouts responsivos</li>
|
||||
<li>Barra de estado con indicadores en vivo</li>
|
||||
<li>Layouts responsivos en todas las vistas</li>
|
||||
<li>Barra de estado con indicadores en vivo de proxy y conexión</li>
|
||||
<li>Error boundaries con fallbacks amigables al usuario</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -165,31 +188,46 @@ Al usar IDEs y herramientas de programación con IA, ¿te has encontrado con est
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Inicio Rápido
|
||||
## ⚡ Instalación e Inicio Rápido
|
||||
|
||||
### Requisitos Previos
|
||||
### 📦 Descarga
|
||||
|
||||
- **Node.js** v20 o superior
|
||||
- **npm**
|
||||
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).
|
||||
|
||||
### Compilar desde el Código Fuente
|
||||
_(Consulta la advertencia en la parte superior de esta página si usas Windows y te encuentras con el aviso de SmartScreen)._
|
||||
|
||||
```bash
|
||||
git clone https://github.com/evandrodevbr/GeminiNexus.git
|
||||
cd GeminiNexus
|
||||
npm install
|
||||
npm start
|
||||
### 🔌 Uso con IDEs de IA
|
||||
|
||||
# Compilar para producción
|
||||
npm run make
|
||||
```
|
||||
|
||||
### 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 Key: (copiar de la página Proxy en la app)
|
||||
Model: gemini-3-flash
|
||||
Model: gemini-3-flash (o cualquier modelo mapeado)
|
||||
```
|
||||
|
||||
### 🛠️ Compilar desde el Código Fuente
|
||||
|
||||
#### Requisitos Previos
|
||||
|
||||
- **Node.js** v20 o superior
|
||||
- **npm** (este proyecto usa `package-lock.json`)
|
||||
|
||||
#### Pasos
|
||||
|
||||
```bash
|
||||
# Clonar el repositorio
|
||||
git clone https://github.com/evandrodevbr/GeminiNexus.git
|
||||
cd GeminiNexus
|
||||
|
||||
# Instalar dependencias
|
||||
npm install
|
||||
|
||||
# Iniciar en modo desarrollo
|
||||
npm start
|
||||
|
||||
# Compilar para producción (ej: instalador de Windows)
|
||||
npm run make
|
||||
```
|
||||
|
||||
---
|
||||
@@ -209,44 +247,91 @@ Model: gemini-3-flash
|
||||
|
||||
---
|
||||
|
||||
## 💻 Desarrollo
|
||||
|
||||
```bash
|
||||
# Iniciar entorno de dev (Electron + Vite HMR)
|
||||
npm start
|
||||
|
||||
# Ejecutar linting
|
||||
npm run lint
|
||||
|
||||
# Verificación de tipos
|
||||
npm run type-check
|
||||
|
||||
# Formatear código
|
||||
npm run format:write
|
||||
|
||||
# Ejecutar pruebas
|
||||
npm test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❓ Preguntas Frecuentes
|
||||
|
||||
<details>
|
||||
<summary><b>P: ¿La app no inicia?</b></summary>
|
||||
<summary><b>P: ¿Aviso de Windows SmartScreen ("Windows protegió su PC") durante la instalación?</b></summary>
|
||||
|
||||
1. Asegúrate de que las dependencias están instaladas: `npm install`
|
||||
2. Node.js versión >= 20
|
||||
3. Intenta eliminar `node_modules` y reinstalar
|
||||
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>
|
||||
<summary><b>P: ¿El IDE no conecta al proxy?</b></summary>
|
||||
|
||||
1. Asegúrate de que el proxy está corriendo (indicador verde en la barra de estado)
|
||||
2. Confirma que el puerto coincide (por defecto `10100`)
|
||||
3. Copia la API key de la página Proxy
|
||||
4. Asegúrate de tener al menos una cuenta activa
|
||||
<summary><b>P: ¿La app no inicia al compilar desde el código fuente?</b></summary>
|
||||
|
||||
1. Asegúrate de que todas las dependencias están instaladas: `npm install`.
|
||||
2. Verifica que tu versión de Node.js sea >= 20.
|
||||
3. Intenta eliminar `node_modules` y reinstalar.
|
||||
4. En Windows, asegúrate de que el WiX Toolset esté disponible para el comando `npm run make`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>P: ¿El inicio de sesión de la cuenta falló?</b></summary>
|
||||
|
||||
1. Asegúrate de que tu conexión a la red funcione correctamente.
|
||||
2. Intenta borrar los datos de la app y volver a iniciar sesión.
|
||||
3. Comprueba si la cuenta está restringida por Google/Claude.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<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`).
|
||||
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>
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contribuir
|
||||
|
||||
¡Las contribuciones son bienvenidas! Por favor lee `CONTRIBUTING.md` para más detalles.
|
||||
|
||||
---
|
||||
|
||||
## 📄 Licencia
|
||||
|
||||
[CC BY-NC-SA 4.0](LICENSE)
|
||||
Este proyecto utiliza una estructura de licencia dual:
|
||||
|
||||
---
|
||||
- **Código original** de [AntigravityManager](https://github.com/Draculabo/AntigravityManager) por [Draculabo](https://github.com/Draculabo): Licenciado bajo [CC BY-NC-SA 4.0](LICENSE).
|
||||
- **Todo el código nuevo, funcionalidades y arquitectura** por [evandrodevbr](https://github.com/evandrodevbr): Licenciado bajo la [Licencia MIT](LICENSE-MIT).
|
||||
|
||||
## ⚠️ Aviso Legal
|
||||
|
||||
> [!WARNING]
|
||||
> **Solo con Fines Educativos**
|
||||
>
|
||||
> Este proyecto está destinado exclusivamente a fines educativos y de investigación. Se proporciona "tal cual" sin ninguna garantía. **El uso comercial está estrictamente prohibido.**
|
||||
> 🔄 **Aviso de Migración:** Este proyecto está siendo migrado activamente a una licencia completamente open-source (MIT). A medida que el código original restante se reescribe progresivamente, todo el proyecto hará la transición a MIT. Estamos comprometidos a hacer Gemini Nexus 100% open-source.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Créditos
|
||||
|
||||
**Gemini Nexus** es una versión significativamente mejorada y expandida del proyecto original [AntigravityManager](https://github.com/Draculabo/AntigravityManager) creado por [Draculabo](https://github.com/Draculabo). Toda la arquitectura fundamental y diseño fueron realizados por el autor original.
|
||||
Este proyecto fue originalmente un fork de [AntigravityManager](https://github.com/Draculabo/AntigravityManager) creado por [Draculabo](https://github.com/Draculabo). El concepto inicial del proxy y la estructura base de Electron se basaron en su trabajo.
|
||||
|
||||
Desde el fork, **la gran mayoría del código ha sido reescrito, rediseñado y expandido** por [evandrodevbr](https://github.com/evandrodevbr), incluyendo pero no limitado a:
|
||||
|
||||
- Rediseño completo de UI/UX (navegación, cuentas, analíticas de uso, gestión del proxy)
|
||||
- Dashboard de Analíticas de Uso con seguimiento de costos, promedios de tokens e integración con OpenRouter
|
||||
- Monitor de tráfico con inspección de tokens/costos por solicitud
|
||||
- Aislamiento de entorno (dev/prod), pipeline CI/CD e infraestructura de pruebas
|
||||
- Configuración rápida para IDEs (OpenCode, Cursor, VS Code, Claude Code)
|
||||
- Configuración personalizada de precios de modelos
|
||||
- Toda la documentación e internacionalización (EN, PT-BR, ES, ZH-CN)
|
||||
@@ -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,336 +13,246 @@
|
||||
</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>
|
||||
|
||||
---
|
||||
> **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.
|
||||
|
||||
## 📖 Table of Contents
|
||||
## About
|
||||
|
||||
- [Why Gemini Nexus?](#-why-gemini-nexus)
|
||||
- [Features](#-features)
|
||||
- [Screenshots](#-screenshots)
|
||||
- [Quick Start](#-quick-start)
|
||||
- [Tech Stack](#️-tech-stack)
|
||||
- [Development](#-development)
|
||||
- [FAQ](#-faq)
|
||||
- [Contributing](#-contributing)
|
||||
- [License](#-license)
|
||||
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.
|
||||
|
||||
---
|
||||
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.
|
||||
|
||||
## ✨ Why Gemini Nexus?
|
||||
What it does:
|
||||
|
||||
When using AI-powered IDEs and coding tools, have you ever run into these problems?
|
||||
- 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.
|
||||
|
||||
- 😫 Single account quota runs out quickly, requiring frequent manual switching
|
||||
- 🔄 Managing multiple Google/Claude accounts is cumbersome
|
||||
- 📊 Don't know how much quota or tokens you've consumed
|
||||
- 🔌 Need a reliable local API proxy that speaks OpenAI/Anthropic protocols
|
||||
- 🔍 Can't see what's actually being sent/received by the proxy
|
||||
It is a personal desktop tool, not a hosted service: everything runs on your machine.
|
||||
|
||||
**Gemini Nexus** solves all of these. It's a professional Electron desktop application that acts as an intelligent gateway between your development tools and Google Gemini / Claude AI:
|
||||
## How it works
|
||||
|
||||
- ✅ **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
|
||||
```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)
|
||||
```
|
||||
|
||||
---
|
||||
- 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.
|
||||
|
||||
## 🎯 Features
|
||||
## Stack
|
||||
|
||||
<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>
|
||||
| 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) |
|
||||
|
||||
---
|
||||
## Requirements
|
||||
|
||||
## 📸 Screenshots
|
||||
- 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.
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/main.png" alt="Main" width="80%" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/assets/2usage.png" alt="Usage" width="80%" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/assets/3proxy.png" alt="Proxy" width="80%" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/assets/4routing.png" alt="Routing" width="80%" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/assets/5docs.png" alt="Docs" width="80%" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/assets/6connections.png" alt="Connections" width="80%" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/assets/7config.png" alt="Config" width="80%" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** v20 or higher
|
||||
- **npm** (this project uses `package-lock.json`)
|
||||
|
||||
### Build from Source
|
||||
## 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 development
|
||||
npm start
|
||||
|
||||
# Build for production (Windows installer)
|
||||
npm run make
|
||||
# 2. run the desktop app (Electron + Vite HMR); the gateway listens on 8046 in dev
|
||||
pnpm start
|
||||
```
|
||||
|
||||
### Using with AI IDEs
|
||||
Then, in the app:
|
||||
|
||||
Once the proxy is running, configure your IDE:
|
||||
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.
|
||||
|
||||
```plaintext
|
||||
# In Cursor / Windsurf / OpenCode settings:
|
||||
API Base URL: http://localhost:10100/v1
|
||||
API Key: (copy from the Proxy page in the app)
|
||||
Model: gemini-3-flash (or any mapped model)
|
||||
```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
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
| 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/) |
|
||||
|
||||
---
|
||||
|
||||
## 💻 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
|
||||
Previously recorded Linux x64 checks (Node 24.20.0, pnpm 10.11.0), before the review below:
|
||||
|
||||
<details>
|
||||
<summary><b>Q: The app won't start?</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 |
|
||||
|
||||
1. Make sure all dependencies are installed: `npm install`
|
||||
2. Check if 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`
|
||||
The E2E suite is also excluded from CI for the same reason.
|
||||
|
||||
</details>
|
||||
## Current state and limitations
|
||||
|
||||
<details>
|
||||
<summary><b>Q: Account login failed?</b></summary>
|
||||
- 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.
|
||||
|
||||
1. Ensure network connection is working
|
||||
2. Try clearing app data and logging in again
|
||||
3. Check if the account is restricted by Google/Claude
|
||||
## Documentation
|
||||
|
||||
</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: IDE can't connect to the proxy?</b></summary>
|
||||
## License
|
||||
|
||||
1. Make sure the proxy is running (green indicator in the status bar)
|
||||
2. Verify the port matches your IDE config (default: `10100`)
|
||||
3. Copy the API key from the Proxy page and paste it in your IDE settings
|
||||
4. Check that at least one account is active in the Accounts page
|
||||
Dual license:
|
||||
|
||||
</details>
|
||||
- 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).
|
||||
|
||||
<details>
|
||||
<summary><b>Q: Token counts show zero?</b></summary>
|
||||
The project is being migrated to MIT as the remaining original code is rewritten.
|
||||
|
||||
1. This was a known bug in earlier versions — update to the latest version
|
||||
2. Token tracking now correctly handles streaming metadata and estimated counts
|
||||
3. Usage data refreshes every 15 seconds on the Usage Analytics page
|
||||
## Credits
|
||||
|
||||
</details>
|
||||
Originally forked from [AntigravityManager](https://github.com/Draculabo/AntigravityManager). Since the fork the codebase has been largely rewritten by [evandrodevbr](https://github.com/evandrodevbr): UI/UX, usage analytics, traffic monitor, environment isolation, CI/CD, test infrastructure and documentation.
|
||||
|
||||
---
|
||||
## Review checks (2026-09-30)
|
||||
|
||||
## 🤝 Contributing
|
||||
Retry durations are parsed as complete values: `500ms` is milliseconds, and fractional components are rounded after summation. Invalid trailing text is rejected. Account and model lockouts honor the later expiration. IDE fallback configuration remains available at loopback when the OS denies network-interface enumeration.
|
||||
|
||||
Contributions are welcome! Please read `CONTRIBUTING.md` for details.
|
||||
Focused checks: `pnpm exec vitest run src/tests/unit/rate-limit-tracker.test.ts src/tests/unit/ipc/proxy-advanced-handler.test.ts`. Run `pnpm test`, `pnpm type-check`, `pnpm lint` and `pnpm format` for the full non-E2E gates. Native SQLite/keytar are mocked by the unit-test configuration; Electron packaging, real account switching and E2E still require separate validation.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
[CC BY-NC-SA 4.0](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
> [!WARNING]
|
||||
> **For Educational Purposes Only**
|
||||
>
|
||||
> This project is intended solely for educational and research purposes. It is provided "as-is" without any warranty. **Commercial use is strictly prohibited.**
|
||||
>
|
||||
> By using this software, you agree that you will not use it for any commercial purposes, and you are solely responsible for ensuring your use complies with all applicable laws and regulations. The authors and contributors are not responsible for any misuse or damages arising from the use of this software.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Credits
|
||||
|
||||
**Gemini Nexus** is a significantly enhanced and expanded version of the original project [AntigravityManager](https://github.com/Draculabo/AntigravityManager) created by [Draculabo](https://github.com/Draculabo). All fundamental architecture and design were made by the original author.
|
||||
This review passes all 509 tests across 54 files, type checking, lint (327 existing warnings, no errors) and formatting with Node 24.19.0 and pnpm 10.11.0. The original lockfile is retained: its audit still reports two critical findings, in `tar` and `seroval`, requiring a separately validated dependency update.
|
||||
+89
-70
@@ -26,19 +26,22 @@
|
||||
<img src="https://img.shields.io/badge/electron-latest-47848F?style=flat-square&logo=electron&logoColor=white" alt="Electron" />
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Usuários do Windows:** Se você vir um aviso do SmartScreen **"O Windows protegeu o seu computador"** durante a instalação, clique em **"Mais informações"** e depois em **"Executar mesmo assim"**. Isso é um aviso de segurança esperado para aplicativos novos e não assinados.
|
||||
|
||||
---
|
||||
|
||||
## 📖 Sumário
|
||||
|
||||
- [Por que o Gemini Nexus?](#-por-que-o-gemini-nexus)
|
||||
- [Funcionalidades](#-funcionalidades)
|
||||
- [Screenshots](#-screenshots)
|
||||
- [Início Rápido](#-início-rápido)
|
||||
- [Stack Tecnológica](#️-stack-tecnológica)
|
||||
- [Desenvolvimento](#-desenvolvimento)
|
||||
- [FAQ](#-faq)
|
||||
- [Contribuindo](#-contribuindo)
|
||||
- [Licença](#-licença)
|
||||
- [✨ Por que o Gemini Nexus?](#-por-que-o-gemini-nexus)
|
||||
- [🎯 Funcionalidades](#-funcionalidades)
|
||||
- [📸 Screenshots](#-screenshots)
|
||||
- [⚡ Instalação e Início Rápido](#-instalação-e-início-rápido)
|
||||
- [🛠️ Stack Tecnológica](#️-stack-tecnológica)
|
||||
- [💻 Desenvolvimento](#-desenvolvimento)
|
||||
- [❓ FAQ](#-faq)
|
||||
- [🤝 Contribuindo](#-contribuindo)
|
||||
- [📄 Licença](#-licença)
|
||||
|
||||
---
|
||||
|
||||
@@ -46,20 +49,22 @@
|
||||
|
||||
Ao usar IDEs e ferramentas de programação com IA, você já enfrentou estes problemas?
|
||||
|
||||
- 😫 A cota da conta única acaba rápido, exigindo trocas manuais frequentes
|
||||
- 🔄 Gerenciar múltiplas contas do Google/Claude é cansativo
|
||||
- 📊 Não sabe quantos tokens já consumiu ou quanta cota resta
|
||||
- 🔌 Precisa de um proxy local confiável que fale os protocolos OpenAI/Anthropic
|
||||
- 🔍 Não consegue ver o que está sendo enviado/recebido pelo proxy
|
||||
- 😫 **Limites de cota:** A cota da conta única acaba rápido, exigindo trocas manuais frequentes.
|
||||
- 🔄 **Gestão de contas:** Gerenciar múltiplas contas do Google/Claude é cansativo.
|
||||
- 📊 **Uso às cegas:** Você não sabe quantos tokens já consumiu ou quanta cota resta.
|
||||
- 🔌 **Problemas de integração:** Precisa de um proxy local confiável que fale os protocolos OpenAI/Anthropic nativamente.
|
||||
- 🔍 **Falta de transparência:** Não consegue ver o que está sendo enviado ou recebido pelo proxy nos bastidores.
|
||||
|
||||
**Gemini Nexus** resolve tudo isso. É um aplicativo desktop profissional em Electron que funciona como um gateway inteligente entre suas ferramentas de desenvolvimento e o Google Gemini / Claude AI:
|
||||
O **Gemini Nexus** resolve tudo isso. É um aplicativo desktop profissional em Electron que funciona como um gateway inteligente entre suas ferramentas de desenvolvimento e o Google Gemini / Claude AI.
|
||||
|
||||
- ✅ **Pool de Contas Ilimitado** — Adicione quantas contas Google Gemini & Claude quiser
|
||||
- ✅ **Troca Automática Inteligente** — Rotaciona automaticamente para a próxima conta disponível quando a cota está baixa ou com rate limit
|
||||
- ✅ **Analytics de Uso em Tempo Real** — Dashboard nível SaaS com gráficos de área, indicadores de tendência e distribuição por modelo
|
||||
- ✅ **Observabilidade Completa do Proxy** — Monitor de tráfego ao vivo, replay de requisições e inspetor de capacidades dos modelos
|
||||
- ✅ **Compatível com OpenAI & Anthropic** — Proxy drop-in para Cursor, Windsurf, OpenCode e qualquer ferramenta compatível com OpenAI
|
||||
- ✅ **Seguro por Padrão** — Criptografia AES-256-GCM com gerenciamento nativo de credenciais do SO
|
||||
### Principais Vantagens
|
||||
|
||||
- ✅ **Pool de Contas Ilimitado** — Adicione quantas contas Google Gemini & Claude quiser.
|
||||
- ✅ **Troca Automática Inteligente** — Rotaciona automaticamente para a próxima conta disponível quando a cota está baixa ou com rate limit.
|
||||
- ✅ **Analytics de Uso em Tempo Real** — Dashboard nível SaaS com gráficos de área, indicadores de tendência e distribuição por modelo.
|
||||
- ✅ **Observabilidade Completa do Proxy** — Monitor de tráfego ao vivo, replay de requisições e inspetor de capacidades dos modelos.
|
||||
- ✅ **Compatível com OpenAI & Anthropic** — Proxy drop-in para Cursor, Windsurf, OpenCode e qualquer ferramenta compatível com OpenAI.
|
||||
- ✅ **Seguro por Padrão** — Criptografia AES-256-GCM com gerenciamento nativo de credenciais do sistema operacional.
|
||||
|
||||
---
|
||||
|
||||
@@ -98,7 +103,7 @@ Ao usar IDEs e ferramentas de programação com IA, você já enfrentou estes pr
|
||||
<li>Streaming SSE completo (testado com Cursor, Windsurf, OpenCode)</li>
|
||||
<li>Mapeamento de modelos (ex: <code>claude-sonnet-4-6</code> → <code>gemini-3-flash</code>)</li>
|
||||
<li>Porta, timeout e API key configuráveis</li>
|
||||
<li>Controle de visibilidade de modelos (mostrar/ocultar modelos específicos)</li>
|
||||
<li>Controle de visibilidade de modelos</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -115,9 +120,9 @@ Ao usar IDEs e ferramentas de programação com IA, você já enfrentou estes pr
|
||||
<td width="50%">
|
||||
<h3>🔍 Observabilidade do Proxy (Avançado)</h3>
|
||||
<ul>
|
||||
<li><strong>Monitor de Tráfego</strong> — Log ao vivo de requisições/respostas com latência, status e informações do modelo</li>
|
||||
<li><strong>Monitor de Tráfego</strong> — Log ao vivo de requisições/respostas com latência e informações do modelo</li>
|
||||
<li><strong>Replay de Requisições</strong> — Reproduza qualquer requisição registrada para debugging</li>
|
||||
<li><strong>Capacidades dos Modelos</strong> — Inspecione suporte a visão, thinking, streaming e modo JSON por modelo</li>
|
||||
<li><strong>Capacidades dos Modelos</strong> — Inspecione suporte a visão, thinking, streaming e modo JSON</li>
|
||||
<li><strong>Ferramentas para Devs</strong> — Geração de código cURL & Python, cópia com um clique</li>
|
||||
</ul>
|
||||
</td>
|
||||
@@ -125,7 +130,7 @@ Ao usar IDEs e ferramentas de programação com IA, você já enfrentou estes pr
|
||||
<h3>🔐 Segurança e Criptografia</h3>
|
||||
<ul>
|
||||
<li>Criptografia AES-256-GCM para todos os dados sensíveis</li>
|
||||
<li>Integração com gerenciador de credenciais nativo do SO (Keytar + SafeStorage)</li>
|
||||
<li>Integração com gerenciador de credenciais nativo (Keytar + SafeStorage)</li>
|
||||
<li>Migração automática de dados legados em texto plano</li>
|
||||
<li>Armazenamento criptografado de tokens & cotas por conta</li>
|
||||
</ul>
|
||||
@@ -183,14 +188,32 @@ Ao usar IDEs e ferramentas de programação com IA, você já enfrentou estes pr
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Início Rápido
|
||||
## ⚡ Instalação e Início Rápido
|
||||
|
||||
### Pré-requisitos
|
||||
### 📦 Download
|
||||
|
||||
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)._
|
||||
|
||||
### 🔌 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 Key: (copie da página Proxy no app)
|
||||
Model: gemini-3-flash (ou qualquer modelo mapeado)
|
||||
```
|
||||
|
||||
### 🛠️ Compilando pelo Código Fonte
|
||||
|
||||
#### Pré-requisitos
|
||||
|
||||
- **Node.js** v20 ou superior
|
||||
- **npm** (este projeto usa `package-lock.json`)
|
||||
|
||||
### Compilando pelo Código Fonte
|
||||
#### Passos
|
||||
|
||||
```bash
|
||||
# Clone o repositório
|
||||
@@ -203,21 +226,10 @@ npm install
|
||||
# Inicie o modo de desenvolvimento
|
||||
npm start
|
||||
|
||||
# Compile para produção (instalador Windows)
|
||||
# Compile para produção (ex: instalador Windows)
|
||||
npm run make
|
||||
```
|
||||
|
||||
### Usando com IDEs de IA
|
||||
|
||||
Com o proxy em execução, configure sua IDE:
|
||||
|
||||
```plaintext
|
||||
# Nas configurações do Cursor / Windsurf / OpenCode:
|
||||
API Base URL: http://localhost:10100/v1
|
||||
API Key: (copie da página Proxy no app)
|
||||
Model: gemini-3-flash (ou qualquer modelo mapeado)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Stack Tecnológica
|
||||
@@ -280,41 +292,44 @@ src/
|
||||
## ❓ FAQ
|
||||
|
||||
<details>
|
||||
<summary><b>P: O app não inicia?</b></summary>
|
||||
<summary><b>P: Aviso do Windows SmartScreen ("O Windows protegeu o seu computador") durante a instalação?</b></summary>
|
||||
|
||||
1. Certifique-se de que todas as dependências estão instaladas: `npm install`
|
||||
2. Verifique se a versão do Node.js é >= 20
|
||||
3. Tente deletar `node_modules` e reinstalar
|
||||
4. No Windows, certifique-se de que o WiX Toolset está disponível para `npm run make`
|
||||
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>
|
||||
<summary><b>P: O app não inicia ao compilar pelo código fonte?</b></summary>
|
||||
|
||||
1. Certifique-se de que todas as dependências estão instaladas: `npm install`.
|
||||
2. Verifique se a versão do Node.js é >= 20.
|
||||
3. Tente deletar `node_modules` e reinstalar.
|
||||
4. No Windows, certifique-se de que o WiX Toolset está disponível para o comando `npm run make`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>P: O login da conta falhou?</b></summary>
|
||||
|
||||
1. Verifique se a conexão com a internet está funcionando
|
||||
2. Tente limpar os dados do app e fazer login novamente
|
||||
3. Verifique se a conta está restrita pelo Google/Claude
|
||||
|
||||
1. Verifique se a conexão com a internet está funcionando.
|
||||
2. Tente limpar os dados do app e fazer login novamente.
|
||||
3. Verifique se a conta está restrita pelo Google/Claude.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>P: A IDE não conecta no 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`)
|
||||
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
|
||||
<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`).
|
||||
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>
|
||||
|
||||
<details>
|
||||
<summary><b>P: A contagem de tokens mostra zero?</b></summary>
|
||||
|
||||
1. Este era um bug conhecido em versões anteriores — atualize para a versão mais recente
|
||||
2. O rastreamento de tokens agora lida corretamente com metadados de streaming e contagens estimadas
|
||||
3. Os dados de uso são atualizados a cada 15 segundos na página de Analytics de Uso
|
||||
|
||||
1. Este era um bug conhecido em versões anteriores — por favor, atualize para a versão mais recente.
|
||||
2. O rastreamento de tokens agora lida corretamente com metadados de streaming e contagens estimadas.
|
||||
3. Os dados de uso são atualizados a cada 15 segundos na página de Analytics de Uso.
|
||||
</details>
|
||||
|
||||
---
|
||||
@@ -327,21 +342,25 @@ Contribuições são bem-vindas! Leia o `CONTRIBUTING.md` para mais detalhes.
|
||||
|
||||
## 📄 Licença
|
||||
|
||||
[CC BY-NC-SA 4.0](LICENSE)
|
||||
Este projeto utiliza uma estrutura de licença dual:
|
||||
|
||||
---
|
||||
- **Código original** do [AntigravityManager](https://github.com/Draculabo/AntigravityManager) por [Draculabo](https://github.com/Draculabo): Licenciado sob [CC BY-NC-SA 4.0](LICENSE).
|
||||
- **Todo o código novo, funcionalidades e arquitetura** por [evandrodevbr](https://github.com/evandrodevbr): Licenciado sob a [Licença MIT](LICENSE-MIT).
|
||||
|
||||
## ⚠️ Aviso Legal
|
||||
|
||||
> [!WARNING]
|
||||
> **Apenas para Fins Educacionais**
|
||||
>
|
||||
> Este projeto destina-se exclusivamente a fins de pesquisa e educação. Ele é fornecido "como está" sem qualquer garantia. **O uso comercial é estritamente proibido.**
|
||||
>
|
||||
> Ao usar este software, você concorda que não o utilizará para quaisquer fins comerciais, e que é o único responsável por garantir que seu uso esteja em conformidade com todas as leis e regulamentações aplicáveis. Os autores e contribuidores não se responsabilizam por qualquer uso indevido ou danos decorrentes do uso deste software.
|
||||
> 🔄 **Aviso de Migração:** Este projeto está sendo ativamente migrado para uma licença totalmente open-source (MIT). À medida que o código original restante é progressivamente reescrito, todo o projeto será transicionado para MIT. Estamos comprometidos em tornar o Gemini Nexus 100% open-source.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Créditos
|
||||
|
||||
**Gemini Nexus** é uma versão significativamente aprimorada e expandida do projeto original [AntigravityManager](https://github.com/Draculabo/AntigravityManager) criado por [Draculabo](https://github.com/Draculabo). Toda a arquitetura fundamental e design foram criados pelo autor original.
|
||||
Este projeto foi originalmente um fork de [AntigravityManager](https://github.com/Draculabo/AntigravityManager) criado por [Draculabo](https://github.com/Draculabo). O conceito inicial de proxy e a estrutura base do Electron foram baseados em seu trabalho.
|
||||
|
||||
Desde o fork, **a grande maioria do código base foi reescrita, redesenhada e expandida** por [evandrodevbr](https://github.com/evandrodevbr), incluindo mas não se limitando a:
|
||||
|
||||
- Redesign completo de UI/UX (navegação, contas, analytics de uso, gerenciamento de proxy)
|
||||
- Dashboard de Analytics de Uso com rastreamento de custos, médias de tokens e integração com OpenRouter
|
||||
- Monitor de tráfego com inspeção de tokens/custos por requisição
|
||||
- Isolamento de ambiente (dev/prod), pipeline CI/CD e infraestrutura de testes
|
||||
- Configuração Rápida para IDEs (OpenCode, Cursor, VS Code, Claude Code)
|
||||
- Configuração personalizada de preços de modelos
|
||||
- Toda a documentação e internacionalização (EN, PT-BR, ES, ZH-CN)
|
||||
+148
-53
@@ -23,25 +23,48 @@
|
||||
<img src="https://img.shields.io/github/license/evandrodevbr/GeminiNexus?style=flat-square" alt="许可证" />
|
||||
</a>
|
||||
<img src="https://img.shields.io/badge/平台-Windows%20%7C%20macOS%20%7C%20Linux-informational?style=flat-square" alt="平台" />
|
||||
<img src="https://img.shields.io/badge/electron-latest-47848F?style=flat-square&logo=electron&logoColor=white" alt="Electron" />
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Windows 用户:** 如果在安装过程中看到 **“Windows 已保护你的电脑”** (SmartScreen) 警告,请点击 **“更多信息”** (More info),然后点击 **“仍要运行”** (Run anyway)。这是未签名的新应用预期的安全提示,属于正常现象。
|
||||
|
||||
---
|
||||
|
||||
## 📖 目录
|
||||
|
||||
- [✨ 为什么选择 Gemini Nexus?](#-为什么选择-gemini-nexus)
|
||||
- [🎯 功能特性](#-功能特性)
|
||||
- [📸 截图](#-截图)
|
||||
- [⚡ 安装与快速开始](#-安装与快速开始)
|
||||
- [🛠️ 技术栈](#️-技术栈)
|
||||
- [💻 开发](#-开发)
|
||||
- [❓ 常见问题](#-常见问题)
|
||||
- [🤝 参与贡献](#-参与贡献)
|
||||
- [📄 许可证](#-许可证)
|
||||
|
||||
---
|
||||
|
||||
## ✨ 为什么选择 Gemini Nexus?
|
||||
|
||||
- 😫 单个账户配额很快用完,需要频繁手动切换
|
||||
- 🔄 管理多个 Google/Claude 账户非常繁琐
|
||||
- 📊 不知道已经消耗了多少 Token 或还剩多少配额
|
||||
- 🔌 需要一个可靠的本地 API 代理,支持 OpenAI/Anthropic 协议
|
||||
在使用 AI 驱动的 IDE 和编程工具时,你是否遇到过这些问题?
|
||||
|
||||
**Gemini Nexus** 解决所有这些问题。它是一个专业的 Electron 桌面应用,作为你的开发工具和 Google Gemini / Claude AI 之间的智能网关:
|
||||
- 😫 **配额限制:** 单个账户配额很快用完,需要频繁手动切换。
|
||||
- 🔄 **账户管理:** 管理多个 Google/Claude 账户非常繁琐。
|
||||
- 📊 **盲目使用:** 不知道已经消耗了多少 Token 或还剩多少配额。
|
||||
- 🔌 **集成问题:** 需要一个可靠的本地 API 代理,并且必须原生支持 OpenAI/Anthropic 协议。
|
||||
- 🔍 **缺乏透明度:** 无法查看代理在后台实际发送或接收的数据。
|
||||
|
||||
- ✅ **无限账户池** — 添加任意数量的 Google Gemini 和 Claude 账户
|
||||
- ✅ **智能自动切换** — 当配额不足或被限速时自动轮换到下一个可用账户
|
||||
- ✅ **实时使用分析** — SaaS 级仪表板,配备面积图、趋势指标和模型分布
|
||||
- ✅ **完整的代理可观测性** — 实时流量监控、请求重放和模型能力检查器
|
||||
- ✅ **兼容 OpenAI 和 Anthropic** — 即插即用的代理,支持 Cursor、Windsurf、OpenCode
|
||||
- ✅ **默认安全** — AES-256-GCM 加密,配合操作系统原生凭证管理
|
||||
**Gemini Nexus** 解决所有这些问题。它是一个专业的 Electron 桌面应用,作为你的开发工具和 Google Gemini / Claude AI 之间的智能网关。
|
||||
|
||||
### 核心价值主张
|
||||
|
||||
- ✅ **无限账户池** — 添加任意数量的 Google Gemini 和 Claude 账户。
|
||||
- ✅ **智能自动切换** — 当配额不足或被限速时自动轮换到下一个可用账户。
|
||||
- ✅ **实时使用分析** — SaaS 级仪表板,配备面积图、趋势指标和模型分布。
|
||||
- ✅ **完整的代理可观测性** — 实时流量监控、请求重放和模型能力检查器。
|
||||
- ✅ **兼容 OpenAI 和 Anthropic** — 即插即用的代理,支持 Cursor、Windsurf、OpenCode 以及任何兼容 OpenAI 的工具。
|
||||
- ✅ **默认安全** — AES-256-GCM 加密,配合操作系统原生凭证管理。
|
||||
|
||||
---
|
||||
|
||||
@@ -77,10 +100,10 @@
|
||||
<ul>
|
||||
<li>兼容 OpenAI <code>/v1/chat/completions</code></li>
|
||||
<li>兼容 Anthropic <code>/v1/messages</code></li>
|
||||
<li>完整的 SSE 流式传输支持</li>
|
||||
<li>完整的 SSE 流式传输支持(已在 Cursor, Windsurf, OpenCode 测试)</li>
|
||||
<li>模型映射(如 <code>claude-sonnet-4-6</code> → <code>gemini-3-flash</code>)</li>
|
||||
<li>可配置端口、超时时间和 API 密钥</li>
|
||||
<li>模型可见性控制</li>
|
||||
<li>模型可见性控制(隐藏/显示特定模型)</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -97,17 +120,17 @@
|
||||
<td width="50%">
|
||||
<h3>🔍 代理可观测性(高级)</h3>
|
||||
<ul>
|
||||
<li><strong>流量监控</strong> — 实时请求/响应日志</li>
|
||||
<li><strong>流量监控</strong> — 实时请求/响应日志,含延迟和模型信息</li>
|
||||
<li><strong>请求重放</strong> — 重放任意请求进行调试</li>
|
||||
<li><strong>模型能力</strong> — 检查视觉、思考、流式、JSON 模式</li>
|
||||
<li><strong>开发者工具</strong> — cURL 和 Python 代码生成</li>
|
||||
<li><strong>模型能力</strong> — 检查每个模型对视觉、思考、流式、JSON 模式的支持</li>
|
||||
<li><strong>开发者工具</strong> — cURL 和 Python 代码生成,一键复制</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>🔐 安全与加密</h3>
|
||||
<ul>
|
||||
<li>所有敏感数据采用 AES-256-GCM 加密</li>
|
||||
<li>集成操作系统原生凭证管理器</li>
|
||||
<li>集成操作系统原生凭证管理器 (Keytar + SafeStorage)</li>
|
||||
<li>自动迁移旧版明文数据</li>
|
||||
<li>每个账户的 Token 和配额均加密存储</li>
|
||||
</ul>
|
||||
@@ -119,6 +142,7 @@
|
||||
<ul>
|
||||
<li>深色 / 浅色 / 跟随系统主题</li>
|
||||
<li>多语言:English & Português (Brasil)</li>
|
||||
<li>每个账户可独立覆盖代理 URL</li>
|
||||
<li>模型可见性开关</li>
|
||||
<li>日志目录访问</li>
|
||||
</ul>
|
||||
@@ -128,8 +152,9 @@
|
||||
<ul>
|
||||
<li>原生 Electron 应用,系统托盘集成</li>
|
||||
<li>可折叠侧边栏,状态持久化</li>
|
||||
<li>响应式布局</li>
|
||||
<li>响应式布局适配所有视图</li>
|
||||
<li>状态栏实时显示代理和连接指标</li>
|
||||
<li>错误边界处理以及友好的用户提示</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -163,31 +188,46 @@
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 快速开始
|
||||
## ⚡ 安装与快速开始
|
||||
|
||||
### 前置要求
|
||||
### 📦 下载
|
||||
|
||||
- **Node.js** v20 或更高版本
|
||||
- **npm**
|
||||
您可以从我们的 [Releases 页面](https://github.com/evandrodevbr/GeminiNexus/releases) 下载适用于 Windows、macOS 和 Linux 的最新预编译版本。
|
||||
|
||||
### 从源码构建
|
||||
_(如果您使用的是 Windows 并在安装时遇到了 SmartScreen 提示,请参阅页面顶部的警告)。_
|
||||
|
||||
```bash
|
||||
git clone https://github.com/evandrodevbr/GeminiNexus.git
|
||||
cd GeminiNexus
|
||||
npm install
|
||||
npm start
|
||||
### 🔌 配合 AI IDE 使用
|
||||
|
||||
# 构建生产版本
|
||||
npm run make
|
||||
```
|
||||
|
||||
### 配合 AI IDE 使用
|
||||
在代理运行并至少添加一个账户后,配置您首选的 IDE(Cursor、Windsurf、OpenCode 等):
|
||||
|
||||
```plaintext
|
||||
API Base URL: http://localhost:10100/v1
|
||||
API Key: (从应用的 Proxy 页面复制)
|
||||
Model: gemini-3-flash
|
||||
Model: gemini-3-flash (或您选择的任何映射模型)
|
||||
```
|
||||
|
||||
### 🛠️ 从源码构建
|
||||
|
||||
#### 前置要求
|
||||
|
||||
- **Node.js** v20 或更高版本
|
||||
- **npm** (本项目使用 `package-lock.json`)
|
||||
|
||||
#### 步骤
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/evandrodevbr/GeminiNexus.git
|
||||
cd GeminiNexus
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发环境
|
||||
npm start
|
||||
|
||||
# 构建生产版本(例如 Windows 安装程序)
|
||||
npm run make
|
||||
```
|
||||
|
||||
---
|
||||
@@ -207,44 +247,99 @@ Model: gemini-3-flash
|
||||
|
||||
---
|
||||
|
||||
## 💻 开发
|
||||
|
||||
```bash
|
||||
# 启动开发环境 (Electron + Vite HMR)
|
||||
npm start
|
||||
|
||||
# 运行代码规范检查
|
||||
npm run lint
|
||||
|
||||
# 类型检查
|
||||
npm run type-check
|
||||
|
||||
# 格式化代码
|
||||
npm run format:write
|
||||
|
||||
# 运行测试
|
||||
npm test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
<details>
|
||||
<summary><b>问:应用无法启动?</b></summary>
|
||||
<summary><b>问:安装时出现 Windows SmartScreen 警告("Windows 已保护你的电脑")?</b></summary>
|
||||
|
||||
1. 确保依赖已安装:`npm install`
|
||||
2. Node.js 版本 >= 20
|
||||
3. 尝试删除 `node_modules` 并重新安装
|
||||
是的,对于未签名的新应用,这是一个常见的警告。请点击 **"更多信息"** (More info),然后点击 **"仍要运行"** (Run anyway)。详情请参阅 README 顶部的提示。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>问:IDE 无法连接代理?</b></summary>
|
||||
|
||||
1. 确保代理正在运行(状态栏绿色指示器)
|
||||
2. 确认端口一致(默认 `10100`)
|
||||
3. 从 Proxy 页面复制 API 密钥
|
||||
4. 确保至少有一个账户处于活跃状态
|
||||
<summary><b>问:从源码构建时应用无法启动?</b></summary>
|
||||
|
||||
1. 确保所有依赖均已安装:`npm install`
|
||||
2. 检查 Node.js 版本是否 >= 20。
|
||||
3. 尝试删除 `node_modules` 并重新安装。
|
||||
4. 在 Windows 上,请确保已安装 WiX Toolset 以便执行 `npm run make` 命令。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>问:账户登录失败?</b></summary>
|
||||
|
||||
1. 确保您的网络连接正常。
|
||||
2. 尝试清除应用数据并重新登录。
|
||||
3. 检查该账户是否已被 Google/Claude 限制。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>问:IDE 无法连接到代理?</b></summary>
|
||||
|
||||
1. 确保代理正在运行(查看状态栏的绿色指示器)。
|
||||
2. 确认端口与您 IDE 中的配置一致(默认为 `10100`)。
|
||||
3. 直接从 Proxy 页面复制 API 密钥并粘贴到 IDE 设置中。
|
||||
4. 确保在 Accounts 页面中至少有一个活跃的账户。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>问:Token 计数显示为零?</b></summary>
|
||||
|
||||
1. 这是早期版本中的一个已知漏洞 — 请更新至最新版本。
|
||||
2. 目前 Token 追踪功能已能正确处理流式元数据及预估计数。
|
||||
3. 使用数据会在 Usage Analytics 页面每 15 秒自动刷新。
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🤝 参与贡献
|
||||
|
||||
欢迎贡献代码!详情请参阅 `CONTRIBUTING.md`。
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
[CC BY-NC-SA 4.0](LICENSE)
|
||||
本项目采用双重许可结构:
|
||||
|
||||
---
|
||||
- **原始代码**来自 [Draculabo](https://github.com/Draculabo) 的 [AntigravityManager](https://github.com/Draculabo/AntigravityManager):采用 [CC BY-NC-SA 4.0](LICENSE) 许可。
|
||||
- **所有新代码、功能和架构**由 [evandrodevbr](https://github.com/evandrodevbr) 开发:采用 [MIT 许可证](LICENSE-MIT)。
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
> [!WARNING]
|
||||
> **仅供教育目的**
|
||||
>
|
||||
> 本项目仅供教育和研究目的使用。按"原样"提供,不提供任何担保。**严禁商业用途。**
|
||||
> 🔄 **迁移通知:** 本项目正在积极迁移到完全开源许可证(MIT)。随着剩余原始代码的逐步重写,整个项目将过渡到 MIT。我们致力于将 Gemini Nexus 打造为 100% 开源项目。
|
||||
|
||||
---
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
**Gemini Nexus** 基于 [Draculabo](https://github.com/Draculabo) 创建的原始项目 [AntigravityManager](https://github.com/Draculabo/AntigravityManager) 大幅增强和扩展。所有基础架构和设计均由原作者完成。
|
||||
本项目最初是从 [Draculabo](https://github.com/Draculabo) 创建的 [AntigravityManager](https://github.com/Draculabo/AntigravityManager) 分支而来的。最初的代理概念和早期的 Electron 框架基于他的工作。
|
||||
|
||||
自从分支以来,[evandrodevbr](https://github.com/evandrodevbr) **重写、重新设计并扩展了绝大部分代码库**,包括但不限于:
|
||||
|
||||
- 完整的 UI/UX 重新设计(导航、账户、使用分析、代理管理)
|
||||
- 使用分析仪表板,包含成本跟踪、Token 平均值以及 OpenRouter 集成
|
||||
- 流量监视器,提供按请求查看的 Token/成本检查
|
||||
- 环境隔离(dev/prod)、CI/CD 流水线和测试基础设施
|
||||
- IDE 快速设置(OpenCode、Cursor、VS Code、Claude Code)
|
||||
- 自定义模型定价配置
|
||||
- 所有文档和国际化(EN, PT-BR, ES, ZH-CN)
|
||||
@@ -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
-6
@@ -10,7 +10,6 @@ import type {
|
||||
import { MakerDeb } from '@electron-forge/maker-deb';
|
||||
import { MakerDMG } from '@electron-forge/maker-dmg';
|
||||
import { MakerRpm } from '@electron-forge/maker-rpm';
|
||||
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
|
||||
import { MakerWix } from '@electron-forge/maker-wix';
|
||||
import { MakerZIP } from '@electron-forge/maker-zip';
|
||||
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
|
||||
@@ -441,10 +440,6 @@ const config: ForgeConfig = {
|
||||
},
|
||||
},
|
||||
makers: [
|
||||
new MakerSquirrel({
|
||||
setupIcon: 'images/icon.ico',
|
||||
iconUrl: 'https://raw.githubusercontent.com/evandrodevbr/GeminiNexus/main/images/icon.ico',
|
||||
}),
|
||||
...(process.platform === 'win32' && process.arch === 'x64' && isWixAvailable()
|
||||
? [
|
||||
new MakerWix({
|
||||
@@ -463,7 +458,7 @@ const config: ForgeConfig = {
|
||||
},
|
||||
['darwin'],
|
||||
),
|
||||
new MakerZIP({}, ['darwin']),
|
||||
new MakerZIP({}, ['darwin', 'win32']),
|
||||
appImageMaker,
|
||||
new MakerRpm({}),
|
||||
new MakerDeb({}),
|
||||
|
||||
+25
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gemini-nexus",
|
||||
"productName": "Gemini Nexus",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.5",
|
||||
"description": "Gemini Nexus - Electron App",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
@@ -32,7 +32,6 @@
|
||||
"@electron-forge/maker-deb": "^7.11.1",
|
||||
"@electron-forge/maker-dmg": "^7.11.1",
|
||||
"@electron-forge/maker-rpm": "^7.11.1",
|
||||
"@electron-forge/maker-squirrel": "^7.11.1",
|
||||
"@electron-forge/maker-wix": "^7.9.0",
|
||||
"@electron-forge/maker-zip": "^7.11.1",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.11.1",
|
||||
@@ -171,9 +170,32 @@
|
||||
"@nestjs/core",
|
||||
"@sentry/cli",
|
||||
"@swc/core",
|
||||
"protobufjs"
|
||||
"protobufjs",
|
||||
"macos-alias",
|
||||
"better-sqlite3",
|
||||
"keytar",
|
||||
"electron",
|
||||
"electron-winstaller",
|
||||
"esbuild",
|
||||
"fs-xattr",
|
||||
"appdmg"
|
||||
],
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@bitdisaster/exe-icon-extractor",
|
||||
"@nestjs/core",
|
||||
"@sentry/cli",
|
||||
"@swc/core",
|
||||
"protobufjs",
|
||||
"macos-alias",
|
||||
"better-sqlite3",
|
||||
"keytar",
|
||||
"electron",
|
||||
"electron-winstaller",
|
||||
"esbuild",
|
||||
"fs-xattr",
|
||||
"appdmg"
|
||||
],
|
||||
"overrides": {
|
||||
"node-abi": "^4.28.0",
|
||||
"fastify": "^5.8.5",
|
||||
|
||||
Generated
+2986
-8662
File diff suppressed because it is too large.
Load diff
@@ -8,5 +8,8 @@ allowBuilds:
|
||||
electron-winstaller: true
|
||||
esbuild: true
|
||||
keytar: true
|
||||
macos-alias: true
|
||||
protobufjs: true
|
||||
sharp: true
|
||||
fs-xattr: true
|
||||
appdmg: 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;
|
||||
@@ -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
|
||||
|
||||
+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}
|
||||
|
||||
@@ -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;
|
||||
@@ -108,19 +109,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 {
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock('../../utils/paths', async () => {
|
||||
const path = await import('path');
|
||||
const agentDir = path.join(process.cwd(), 'temp_test_agent');
|
||||
return {
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getAgentDir: vi.fn(() => agentDir),
|
||||
getAccountsFilePath: vi.fn(() => path.join(agentDir, 'accounts.json')),
|
||||
getBackupsDir: vi.fn(() => path.join(agentDir, 'backups')),
|
||||
|
||||
@@ -58,6 +58,7 @@ vi.mock('../../ipc/database/dbConnection', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/paths', () => ({
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getGeminiNexusDbPaths: () => ['mock-db'],
|
||||
getCloudAccountsDbPath: () => 'mock-cloud-db',
|
||||
getAgentDir: vi.fn(() => '/mock/agent/dir'),
|
||||
|
||||
@@ -22,6 +22,7 @@ vi.mock('../../ipc/database/dbConnection', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/paths', () => ({
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getGeminiNexusDbPaths: () => ['mock-db'],
|
||||
}));
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock('../../utils/logger', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/paths', () => ({
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/test.vscdb']),
|
||||
}));
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ vi.mock('../../../ipc/database/dbConnection', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/paths', () => ({
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getCloudAccountsDbPath: vi.fn(() => '/mock/db/path.sqlite'),
|
||||
}));
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ vi.mock('../../../utils/logger', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/paths', () => ({
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/test.vscdb']),
|
||||
}));
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ vi.mock('../../../utils/logger', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/paths', () => ({
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getGeminiNexusStoragePaths: vi.fn().mockReturnValue(['/tmp/storage.json']),
|
||||
getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/state.vscdb']),
|
||||
getAgentDir: vi.fn().mockReturnValue('/tmp/agent'),
|
||||
|
||||
@@ -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(),
|
||||
@@ -48,20 +59,26 @@ vi.mock('../../../ipc/proxy-advanced/service-registry', () => {
|
||||
function getServiceOrThrow(key: string) {
|
||||
const service = (proxyAdvancedRegistry as Record<string, unknown>)[key];
|
||||
if (!service) {
|
||||
throw new Error(`${key} is not registered`);
|
||||
throw new Error(`ProxyAdvanced service '${key}' is not registered`);
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
function getServiceOptional(key: string) {
|
||||
return (proxyAdvancedRegistry as Record<string, unknown>)[key];
|
||||
}
|
||||
|
||||
return {
|
||||
proxyAdvancedRegistry,
|
||||
getServiceOrThrow,
|
||||
getServiceOptional,
|
||||
};
|
||||
});
|
||||
|
||||
describe('Proxy Advanced Handler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(networkInterfaces).mockReset().mockReturnValue({});
|
||||
});
|
||||
|
||||
describe('getRecentTrafficLogs', () => {
|
||||
@@ -176,10 +193,10 @@ describe('Proxy Advanced Handler', () => {
|
||||
});
|
||||
|
||||
describe('getParityCounters', () => {
|
||||
it('should return error when tokenManager is null', async () => {
|
||||
it('should return empty counters when tokenManager is null', async () => {
|
||||
const result = await getParityCounters();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Proxy advanced service not initialized');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.totalRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('should return counters from tokenManager', async () => {
|
||||
@@ -200,10 +217,10 @@ describe('Proxy Advanced Handler', () => {
|
||||
});
|
||||
|
||||
describe('getCircuitBreakerStatus', () => {
|
||||
it('should return error when tokenManager is null', async () => {
|
||||
it('should return empty status when tokenManager is null', async () => {
|
||||
const result = await getCircuitBreakerStatus();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Proxy advanced service not initialized');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.states).toEqual({});
|
||||
});
|
||||
|
||||
it('should return mapped status from tokenManager', async () => {
|
||||
@@ -222,10 +239,10 @@ describe('Proxy Advanced Handler', () => {
|
||||
});
|
||||
|
||||
describe('getProxyMetrics', () => {
|
||||
it('should return error when proxyMetrics is null', async () => {
|
||||
it('should return zero metrics when proxyMetrics is null', async () => {
|
||||
const result = await getProxyMetrics();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Proxy advanced service not initialized');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.totalRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('should return metrics from proxyMetrics service', async () => {
|
||||
@@ -249,10 +266,10 @@ describe('Proxy Advanced Handler', () => {
|
||||
});
|
||||
|
||||
describe('getRecentRequests', () => {
|
||||
it('should return error when proxyReplay is null', async () => {
|
||||
it('should return empty array when proxyReplay is null', async () => {
|
||||
const result = await getRecentRequests();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Proxy advanced service not initialized');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return recent requests from proxyReplay', async () => {
|
||||
@@ -410,10 +427,39 @@ describe('Proxy Advanced Handler', () => {
|
||||
});
|
||||
|
||||
describe('generateIdeConfig', () => {
|
||||
it('should return error when proxyIdeConfig is null', async () => {
|
||||
it('uses loopback when network enumeration fails', async () => {
|
||||
vi.mocked(networkInterfaces).mockImplementationOnce(() => {
|
||||
throw new Error('network enumeration denied');
|
||||
});
|
||||
const result = await generateIdeConfig('vscode');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Proxy advanced service not initialized');
|
||||
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, result.error).toBe(true);
|
||||
expect(result.data?.name).toBe('VS Code');
|
||||
});
|
||||
|
||||
it('should return error for unsupported IDE', async () => {
|
||||
@@ -428,7 +474,8 @@ describe('Proxy Advanced Handler', () => {
|
||||
vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = {
|
||||
generateConfigForCurrentServer: vi.fn().mockReturnValue({
|
||||
ide: 'vscode',
|
||||
content: null,
|
||||
format: 'json',
|
||||
content: { 'openai.url': 'http://localhost:8045/v1', 'openai.apiKey': 'key' },
|
||||
}),
|
||||
} as any;
|
||||
|
||||
@@ -443,7 +490,8 @@ describe('Proxy Advanced Handler', () => {
|
||||
vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = {
|
||||
generateConfigForCurrentServer: vi.fn().mockReturnValue({
|
||||
ide: 'cursor',
|
||||
content: null,
|
||||
format: 'json',
|
||||
content: { OPENAI_BASE_URL: 'http://localhost:8045/v1', OPENAI_API_KEY: 'key' },
|
||||
}),
|
||||
} as any;
|
||||
|
||||
@@ -453,16 +501,5 @@ describe('Proxy Advanced Handler', () => {
|
||||
expect(result.data?.proxySetting).toBe('OPENAI_BASE_URL');
|
||||
vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = null;
|
||||
});
|
||||
|
||||
it('should handle null result from config generator', async () => {
|
||||
vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = {
|
||||
generateConfigForCurrentServer: vi.fn().mockReturnValue(null),
|
||||
} as any;
|
||||
|
||||
const result = await generateIdeConfig('vscode');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Failed to generate IDE config: server not configured');
|
||||
vi.mocked(proxyAdvancedRegistry).proxyIdeConfig = null;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ vi.mock('../../../ipc/database/cloudHandler', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/paths', () => ({
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getCloudAccountsDbPath: vi.fn(() => '/mock/db/path.sqlite'),
|
||||
}));
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from 'path';
|
||||
vi.mock('../../utils/paths', async () => {
|
||||
const path = await import('path');
|
||||
return {
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getAgentDir: vi.fn(() => path.join(process.cwd(), 'temp_test_logs')),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -59,3 +59,81 @@ class MockIntersectionObserver {
|
||||
disconnect() {}
|
||||
}
|
||||
(globalThis as any).IntersectionObserver = MockIntersectionObserver;
|
||||
|
||||
// Universal Electron mock for testing environments
|
||||
vi.mock('electron', () => {
|
||||
return {
|
||||
app: {
|
||||
getPath: vi.fn((name) => `/mock/path/${name}`),
|
||||
getName: vi.fn(() => 'Gemini Nexus Test'),
|
||||
getVersion: vi.fn(() => '1.0.0'),
|
||||
isPackaged: false,
|
||||
getLocale: vi.fn(() => 'en-US'),
|
||||
getPreferredSystemLanguages: vi.fn(() => ['en-US']),
|
||||
requestSingleInstanceLock: vi.fn(() => true),
|
||||
quit: vi.fn(),
|
||||
disableHardwareAcceleration: vi.fn(),
|
||||
setName: vi.fn(),
|
||||
commandLine: {
|
||||
appendSwitch: vi.fn(),
|
||||
},
|
||||
},
|
||||
ipcMain: {
|
||||
on: vi.fn(),
|
||||
handle: vi.fn(),
|
||||
removeHandler: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
},
|
||||
ipcRenderer: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
invoke: vi.fn(),
|
||||
send: vi.fn(),
|
||||
postMessage: vi.fn(),
|
||||
},
|
||||
BrowserWindow: vi.fn().mockImplementation(() => ({
|
||||
loadURL: vi.fn(),
|
||||
loadFile: vi.fn(),
|
||||
show: vi.fn(),
|
||||
close: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
webContents: {
|
||||
send: vi.fn(),
|
||||
on: vi.fn(),
|
||||
session: {
|
||||
webRequest: {
|
||||
onBeforeSendHeaders: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
on: vi.fn(),
|
||||
})),
|
||||
dialog: {
|
||||
showMessageBox: vi.fn().mockResolvedValue({ response: 0 }),
|
||||
showOpenDialog: vi.fn().mockResolvedValue({ canceled: true, filePaths: [] }),
|
||||
showSaveDialog: vi.fn().mockResolvedValue({ canceled: true, filePath: '' }),
|
||||
},
|
||||
shell: {
|
||||
openPath: vi.fn(),
|
||||
openExternal: vi.fn(),
|
||||
},
|
||||
contextBridge: {
|
||||
exposeInMainWorld: vi.fn(),
|
||||
},
|
||||
Tray: vi.fn().mockImplementation(() => ({
|
||||
setToolTip: vi.fn(),
|
||||
setContextMenu: vi.fn(),
|
||||
on: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
})),
|
||||
Menu: {
|
||||
buildFromTemplate: vi.fn(),
|
||||
},
|
||||
nativeTheme: {
|
||||
themeSource: 'system',
|
||||
shouldUseDarkColors: true,
|
||||
on: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -26,6 +26,7 @@ vi.mock('../../ipc/database/cloudHandler', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/paths', () => ({
|
||||
IS_DEV_ENVIRONMENT: false,
|
||||
getCloudAccountsDbPath: vi.fn(() => '/mock/db/path.sqlite'),
|
||||
}));
|
||||
|
||||
|
||||
Reference in new issue
Block a user