From 0be7d7fea25e848651dcd4862869191ad48f142d Mon Sep 17 00:00:00 2001 From: Gemini Nexus Bot Date: Thu, 23 Apr 2026 00:57:01 -0300 Subject: [PATCH] feat(proxy): implement metrics persistence, traffic logs DB, and advanced monitoring - Add SQLite schema for proxy metrics and traffic logs - Implement ProxyMetricsRepo with historical aggregates - Add TrafficLogsRepo for DB-backed traffic log queries - Create proxy-advanced IPC handlers (metrics, traffic logs, circuit breaker, parity) - Add ProxyMetricsService with connection snapshots and cleanup - Add ProxyReplayService and ProxyIdeConfigService - Implement MonitorTab and ToolsTab for advanced proxy features - Add TrafficLogTable with sorting and filtering - Fix i18n keys for proxy metrics - Fix generateIdeConfig type mapping - Update proxy routes with advanced tab integration --- docs/OpencodeAPI.md | 56 +- forge.config.ts | 3 +- package-lock.json | 643 +++++++--- package.json | 4 +- src/components/proxy/ProxyAdvancedTab.tsx | 29 + src/components/proxy/advanced/MonitorTab.tsx | 202 ++++ src/components/proxy/advanced/ToolsTab.tsx | 150 +++ .../proxy/advanced/TrafficLogTable.tsx | 190 +++ src/components/usage/CustomTooltip.tsx | 36 - src/components/usage/NivoTooltip.tsx | 60 + src/ipc/database/cloudHandler.ts | 14 +- src/ipc/database/proxyMetricsHandler.ts | 452 +++++++ src/ipc/database/schema.ts | 36 + src/ipc/database/usageHandler.ts | 9 +- src/ipc/proxy-advanced/handler.ts | 325 ++++++ src/ipc/proxy-advanced/router.ts | 172 +++ src/ipc/proxy-advanced/service-registry.ts | 31 + src/ipc/router.ts | 2 + src/lib/geminiNexus/ClaudeRequestMapper.ts | 13 +- src/lib/geminiNexus/ClaudeResponseMapper.ts | 5 +- src/lib/nivo-theme.ts | 61 + src/localization/i18n.ts | 255 ++++ src/localization/vi.ts | 49 + src/routes/proxy.tsx | 1038 ++++++++++------- src/routes/usage.tsx | 260 +++-- src/server/logging/api-logger.ts | 9 +- .../modules/proxy/proxy-ide-config.service.ts | 126 ++ .../modules/proxy/proxy-metrics.service.ts | 111 ++ .../modules/proxy/proxy-replay.service.ts | 299 +++++ src/server/modules/proxy/proxy.controller.ts | 379 +++++- src/server/modules/proxy/proxy.module.ts | 5 +- src/server/modules/proxy/proxy.service.ts | 153 ++- .../modules/proxy/token-manager.service.ts | 128 +- .../scripts/project-id-request-validation.ts | 6 +- .../unit/proxy-controller.integration.test.ts | 44 +- src/utils/traffic-logger.ts | 78 +- 36 files changed, 4551 insertions(+), 882 deletions(-) create mode 100644 src/components/proxy/ProxyAdvancedTab.tsx create mode 100644 src/components/proxy/advanced/MonitorTab.tsx create mode 100644 src/components/proxy/advanced/ToolsTab.tsx create mode 100644 src/components/proxy/advanced/TrafficLogTable.tsx delete mode 100644 src/components/usage/CustomTooltip.tsx create mode 100644 src/components/usage/NivoTooltip.tsx create mode 100644 src/ipc/database/proxyMetricsHandler.ts create mode 100644 src/ipc/proxy-advanced/handler.ts create mode 100644 src/ipc/proxy-advanced/router.ts create mode 100644 src/ipc/proxy-advanced/service-registry.ts create mode 100644 src/lib/nivo-theme.ts create mode 100644 src/server/modules/proxy/proxy-ide-config.service.ts create mode 100644 src/server/modules/proxy/proxy-metrics.service.ts create mode 100644 src/server/modules/proxy/proxy-replay.service.ts diff --git a/docs/OpencodeAPI.md b/docs/OpencodeAPI.md index 31f79d0..c36d8a9 100644 --- a/docs/OpencodeAPI.md +++ b/docs/OpencodeAPI.md @@ -57,6 +57,7 @@ Crie ou edite o arquivo de configuração (recomendado: `~/.config/opencode/open Depois de salvar, reinicie o OpenCode ou use `/models` para selecionar. **Comando rápido para adicionar chave:** + ```bash opencode /connect # Escolha "Other" → digite o provider_id (ex: minha-ia-custom) → cole a API key @@ -73,6 +74,7 @@ POST {baseURL}/chat/completions ``` **Headers enviados:** + - `Authorization: Bearer ` - `Content-Type: application/json` - Headers customizados que você definir em `options.headers` @@ -95,12 +97,15 @@ POST {baseURL}/chat/completions "stream": true, "temperature": 0.7, "max_tokens": 8192, - "tools": [ /* array de tools se o agente estiver usando ferramentas */ ], + "tools": [ + /* array de tools se o agente estiver usando ferramentas */ + ], "tool_choice": "auto" } ``` **Parâmetros comuns que o OpenCode envia:** + - `messages` (array completo do histórico) - `stream: true` (quase sempre, para UX em tempo real) - `temperature`, `max_tokens`, `top_p` @@ -165,19 +170,23 @@ Se sua API suportar `tools`, o OpenCode pode: ```json { - "choices": [{ - "message": { - "role": "assistant", - "tool_calls": [{ - "id": "call_123", - "type": "function", - "function": { - "name": "read_file", - "arguments": "{\"path\": \"src/app.ts\"}" - } - }] + "choices": [ + { + "message": { + "role": "assistant", + "tool_calls": [ + { + "id": "call_123", + "type": "function", + "function": { + "name": "read_file", + "arguments": "{\"path\": \"src/app.ts\"}" + } + } + ] + } } - }] + ] } ``` @@ -198,15 +207,15 @@ O OpenCode usa o TUI (Terminal User Interface) baseado em React/Ink para renderi ### 5. Requisitos Mínimos da Sua API para Funcionar Perfeitamente -| Recurso | Obrigatório? | Recomendado? | Observação | -|----------------------|--------------|--------------|----------| -| `/v1/chat/completions` | Sim | - | Endpoint principal | -| Streaming (SSE) | Sim | - | Essencial para UX | -| Tool Calling | Não | Sim | Para agentes completos | -| System messages | Sim | - | - | -| Multi-turn history | Sim | - | - | -| Usage stats | Não | Sim | Mostra tokens gastos | -| Error handling | Sim | - | Retorne JSON de erro padrão OpenAI | +| Recurso | Obrigatório? | Recomendado? | Observação | +| ---------------------- | ------------ | ------------ | ---------------------------------- | +| `/v1/chat/completions` | Sim | - | Endpoint principal | +| Streaming (SSE) | Sim | - | Essencial para UX | +| Tool Calling | Não | Sim | Para agentes completos | +| System messages | Sim | - | - | +| Multi-turn history | Sim | - | - | +| Usage stats | Não | Sim | Mostra tokens gastos | +| Error handling | Sim | - | Retorne JSON de erro padrão OpenAI | **Endpoint exato esperado:** `POST https://sua-api.com/v1/chat/completions` @@ -256,6 +265,7 @@ O OpenCode usa o TUI (Terminal User Interface) baseado em React/Ink para renderi Sua API **precisa ser 100% compatível** com o endpoint OpenAI `/v1/chat/completions` (streaming + tool calling recomendado). O OpenCode vai: + 1. Configurar via `opencode.json` (baseURL + apiKey) 2. Enviar requisições padrão OpenAI 3. Receber streaming ou resposta completa @@ -263,4 +273,4 @@ O OpenCode vai: Se você implementar exatamente o padrão OpenAI Chat Completions, vai funcionar perfeitamente com o OpenCode. -Quer que eu monte um **exemplo de servidor mínimo** (em Node.js/Fastify ou Python/FastAPI) que já funcione com o OpenCode? Posso criar um template completo para você. \ No newline at end of file +Quer que eu monte um **exemplo de servidor mínimo** (em Node.js/Fastify ou Python/FastAPI) que já funcione com o OpenCode? Posso criar um template completo para você. diff --git a/forge.config.ts b/forge.config.ts index a8fde12..b68070e 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -449,8 +449,7 @@ const config: ForgeConfig = { makers: [ new MakerSquirrel({ setupIcon: 'images/icon.ico', - iconUrl: - 'https://raw.githubusercontent.com/evandrodevbr/GeminiNexus/main/images/icon.ico', + iconUrl: 'https://raw.githubusercontent.com/evandrodevbr/GeminiNexus/main/images/icon.ico', }), ...(process.platform === 'win32' && process.arch === 'x64' ? [ diff --git a/package-lock.json b/package-lock.json index cfde7e0..dae757d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,9 @@ "@nestjs/microservices": "^11.1.18", "@nestjs/platform-express": "^11.1.18", "@nestjs/platform-fastify": "^11.1.18", + "@nivo/bar": "^0.99.0", + "@nivo/core": "^0.99.0", + "@nivo/pie": "^0.99.0", "@orpc/client": "^1.13.14", "@orpc/server": "^1.13.14", "@radix-ui/react-avatar": "^1.1.11", @@ -61,7 +64,6 @@ "react-dom": "^19.2.0", "react-error-boundary": "^6.1.0", "react-i18next": "^16.2.4", - "recharts": "^3.8.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "tailwind-merge": "^3.4.0", @@ -4130,6 +4132,341 @@ } } }, + "node_modules/@nivo/annotations": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/annotations/-/annotations-0.99.0.tgz", + "integrity": "sha512-jCuuXPbvpaqaz4xF7k5dv0OT2ubn5Nt0gWryuTe/8oVsC/9bzSuK8bM9vBty60m9tfO+X8vUYliuaCDwGksC2g==", + "license": "MIT", + "dependencies": { + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/arcs": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/arcs/-/arcs-0.99.0.tgz", + "integrity": "sha512-UcvWLQPl+A3APk2Gm74N5xDfT+ATnVs2XkP73WxhYPWJk+dBzF00cndA5g/dptOwdFBvvo62VgcCsNiwUsjKTw==", + "license": "MIT", + "dependencies": { + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/core": "9.4.5 || ^9.7.2 || ^10.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-shape": "^3.1.6", + "d3-shape": "^3.2.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/axes": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/axes/-/axes-0.99.0.tgz", + "integrity": "sha512-3KschnmEL0acRoa7INSSOSEFwJLm54aZwSev7/r8XxXlkgRBriu6ReZy/FG0wfN+ljZ4GMvx+XyIIf6kxzvrZg==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/scales": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-format": "^1.4.1", + "@types/d3-time-format": "^2.3.1", + "d3-format": "^1.4.4", + "d3-time-format": "^3.0.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/axes/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/@nivo/axes/node_modules/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@nivo/axes/node_modules/d3-time": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", + "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "2" + } + }, + "node_modules/@nivo/axes/node_modules/d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1 - 2" + } + }, + "node_modules/@nivo/axes/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/@nivo/bar": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/bar/-/bar-0.99.0.tgz", + "integrity": "sha512-9yfMn7H6UF/TqtCwVZ/vihVAXUff9wWvSaeF2Z1DCfgr5S07qs31Qb2p0LZA+YgCWpaU7zqkeb3VZ4WCpZbrDA==", + "license": "MIT", + "dependencies": { + "@nivo/annotations": "0.99.0", + "@nivo/axes": "0.99.0", + "@nivo/canvas": "0.99.0", + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/legends": "0.99.0", + "@nivo/scales": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-scale": "^4.0.8", + "@types/d3-shape": "^3.1.6", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/canvas": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/canvas/-/canvas-0.99.0.tgz", + "integrity": "sha512-UxA8zb+NPwqmNm81hoyUZSMAikgjU1ukLf4KybVNyV8ejcJM+BUFXsb8DxTcLdt4nmCFHqM56GaJQv2hnAHmzg==", + "license": "MIT" + }, + "node_modules/@nivo/colors": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/colors/-/colors-0.99.0.tgz", + "integrity": "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@types/d3-color": "^3.0.0", + "@types/d3-scale": "^4.0.8", + "@types/d3-scale-chromatic": "^3.0.0", + "d3-color": "^3.1.0", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/core": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/core/-/core-0.99.0.tgz", + "integrity": "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g==", + "license": "MIT", + "dependencies": { + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-shape": "^3.1.6", + "d3-color": "^3.1.0", + "d3-format": "^1.4.4", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "d3-shape": "^3.2.0", + "d3-time-format": "^3.0.0", + "lodash": "^4.17.21", + "react-virtualized-auto-sizer": "^1.0.26", + "use-debounce": "^10.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nivo/donate" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/core/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/@nivo/core/node_modules/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@nivo/core/node_modules/d3-time": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", + "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "2" + } + }, + "node_modules/@nivo/core/node_modules/d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1 - 2" + } + }, + "node_modules/@nivo/core/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/@nivo/legends": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/legends/-/legends-0.99.0.tgz", + "integrity": "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA==", + "license": "MIT", + "dependencies": { + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@types/d3-scale": "^4.0.8", + "d3-scale": "^4.0.2" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/pie": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/pie/-/pie-0.99.0.tgz", + "integrity": "sha512-zUbo8UdLndp2RMljrOqitAKKEnl7YypkJrOzjKLk8jQGU7qqUKtgFoJIPhiBsvNPs3xtX2KwgtS1+JKNTNns7A==", + "license": "MIT", + "dependencies": { + "@nivo/arcs": "0.99.0", + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/legends": "0.99.0", + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@types/d3-shape": "^3.1.6", + "d3-shape": "^3.2.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/scales": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/scales/-/scales-0.99.0.tgz", + "integrity": "sha512-g/2K4L6L8si6E2BWAHtFVGahtDKbUcO6xHJtlIZMwdzaJc7yB16EpWLK8AfI/A42KadLhJSJqBK3mty+c7YZ+w==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "^3.0.4", + "@types/d3-scale": "^4.0.8", + "@types/d3-time": "^1.1.1", + "@types/d3-time-format": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-time": "^1.0.11", + "d3-time-format": "^3.0.0", + "lodash": "^4.17.21" + } + }, + "node_modules/@nivo/scales/node_modules/@types/d3-time": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-1.1.4.tgz", + "integrity": "sha512-JIvy2HjRInE+TXOmIGN5LCmeO0hkFZx5f9FZ7kiN+D+YTcc8pptsiLiuHsvwxwC7VVKmJ2ExHUgNlAiV7vQM9g==", + "license": "MIT" + }, + "node_modules/@nivo/scales/node_modules/@types/d3-time-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.4.tgz", + "integrity": "sha512-or9DiDnYI1h38J9hxKEsw513+KVuFbEVhl7qdxcaudoiqWWepapUen+2vAriFGexr6W5+P4l9+HJrB39GG+oRg==", + "license": "MIT" + }, + "node_modules/@nivo/scales/node_modules/d3-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", + "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==", + "license": "BSD-3-Clause" + }, + "node_modules/@nivo/scales/node_modules/d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1 - 2" + } + }, + "node_modules/@nivo/text": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/text/-/text-0.99.0.tgz", + "integrity": "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/theming": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/theming/-/theming-0.99.0.tgz", + "integrity": "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/tooltip": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/tooltip/-/tooltip-0.99.0.tgz", + "integrity": "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -7415,40 +7752,76 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", - "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "node_modules/@react-spring/animated": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.3.tgz", + "integrity": "sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==", "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" }, "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", - "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "node_modules/@react-spring/core": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.3.tgz", + "integrity": "sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==", "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/immer" + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.0.3.tgz", + "integrity": "sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.0.3.tgz", + "integrity": "sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.0.3.tgz", + "integrity": "sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ==", + "license": "MIT" + }, + "node_modules/@react-spring/web": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.3.tgz", + "integrity": "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/core": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@rolldown/pluginutils": { @@ -9412,12 +9785,6 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" - }, "node_modules/@swc/core": { "version": "1.15.8", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.8.tgz", @@ -10700,22 +11067,16 @@ "@types/node": "*" } }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" - }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "node_modules/@types/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-mLxrC1MSWupOSncXN/HOlWUAAIffAEBaI4+PKy2uMPsKe4FNZlk7qrbTjmzJXITQQqBHivaks4Td18azgqnotA==", "license": "MIT" }, "node_modules/@types/d3-interpolate": { @@ -10742,6 +11103,12 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -10757,10 +11124,10 @@ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "node_modules/@types/d3-time-format": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.3.4.tgz", + "integrity": "sha512-xdDXbpVO74EvadI3UDxjxTdR6QIxm1FKzEA/+F8tL4GWWUg/hgvBqf6chql64U5A9ZUGWo7pEu4eNlyLwbKdhg==", "license": "MIT" }, "node_modules/@types/debug": { @@ -11010,12 +11377,6 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" - }, "node_modules/@types/uuid": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz", @@ -14590,15 +14951,6 @@ "node": ">=12" } }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -14645,6 +14997,19 @@ "node": ">=12" } }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -14681,15 +15046,6 @@ "node": ">=12" } }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/data-urls": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", @@ -14802,12 +15158,6 @@ "dev": true, "license": "MIT" }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "license": "MIT" - }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -16551,16 +16901,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-toolkit": { - "version": "1.46.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.0.tgz", - "integrity": "sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", @@ -16986,6 +17326,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, "license": "MIT" }, "node_modules/events": { @@ -19516,16 +19857,6 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, - "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -21363,7 +21694,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/lodash-es": { @@ -26420,32 +26750,10 @@ "version": "17.0.2", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, "license": "MIT", "peer": true }, - "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "license": "MIT", - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -26525,6 +26833,16 @@ } } }, + "node_modules/react-virtualized-auto-sizer": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz", + "integrity": "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==", + "license": "MIT", + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", @@ -26864,36 +27182,6 @@ "node": ">=0.10.0" } }, - "node_modules/recharts": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", - "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", - "license": "MIT", - "workspaces": [ - "www" - ], - "dependencies": { - "@reduxjs/toolkit": "^1.9.0 || 2.x.x", - "clsx": "^2.1.1", - "decimal.js-light": "^2.5.1", - "es-toolkit": "^1.39.3", - "eventemitter3": "^5.0.1", - "immer": "^10.1.1", - "react-redux": "8.x.x || 9.x.x", - "reselect": "5.1.1", - "tiny-invariant": "^1.3.3", - "use-sync-external-store": "^1.2.2", - "victory-vendor": "^37.0.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -26921,21 +27209,6 @@ "node": ">=8" } }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -27059,12 +27332,6 @@ "url": "https://github.com/sponsors/jet2jet" } }, - "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -30598,6 +30865,18 @@ } } }, + "node_modules/use-debounce": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.1.tgz", + "integrity": "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/use-sidecar": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", @@ -30731,28 +31010,6 @@ "optional": true, "peer": true }, - "node_modules/victory-vendor": { - "version": "37.3.6", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/package.json b/package.json index 2dc7e6c..355befd 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,9 @@ "@nestjs/microservices": "^11.1.18", "@nestjs/platform-express": "^11.1.18", "@nestjs/platform-fastify": "^11.1.18", + "@nivo/bar": "^0.99.0", + "@nivo/core": "^0.99.0", + "@nivo/pie": "^0.99.0", "@orpc/client": "^1.13.14", "@orpc/server": "^1.13.14", "@radix-ui/react-avatar": "^1.1.11", @@ -146,7 +149,6 @@ "react-dom": "^19.2.0", "react-error-boundary": "^6.1.0", "react-i18next": "^16.2.4", - "recharts": "^3.8.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "tailwind-merge": "^3.4.0", diff --git a/src/components/proxy/ProxyAdvancedTab.tsx b/src/components/proxy/ProxyAdvancedTab.tsx new file mode 100644 index 0000000..e7b5880 --- /dev/null +++ b/src/components/proxy/ProxyAdvancedTab.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { MonitorTab } from './advanced/MonitorTab'; +import { ToolsTab } from './advanced/ToolsTab'; +import type { ProxyConfig } from '@/types/config'; + +interface ProxyAdvancedTabProps { + proxyConfig: ProxyConfig; +} + +export const ProxyAdvancedTab: React.FC = () => { + const { t } = useTranslation(); + + return ( + + + {t('proxy.advanced.monitor')} + {t('proxy.advanced.tabs.tools')} + + + + + + + + + ); +}; diff --git a/src/components/proxy/advanced/MonitorTab.tsx b/src/components/proxy/advanced/MonitorTab.tsx new file mode 100644 index 0000000..c5c5aad --- /dev/null +++ b/src/components/proxy/advanced/MonitorTab.tsx @@ -0,0 +1,202 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from '@tanstack/react-query'; +import { ipc } from '@/ipc/manager'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useToast } from '@/components/ui/use-toast'; +import { StatCard } from '@/components/usage/StatCard'; +import { TrafficLogTable } from './TrafficLogTable'; +import { Loader2, Zap, Clock, AlertTriangle, Network } from 'lucide-react'; + +export const MonitorTab: React.FC = () => { + const { t } = useTranslation(); + const { toast } = useToast(); + const [direction, setDirection] = useState('all'); + + const { data: metricsData, isLoading: metricsLoading } = useQuery({ + queryKey: ['proxyAdvanced', 'metrics'], + queryFn: () => ipc.client.proxyAdvanced.getProxyMetrics(), + refetchInterval: 30000, + }); + + const { data: cbData, isLoading: cbLoading } = useQuery({ + queryKey: ['proxyAdvanced', 'circuitBreaker'], + queryFn: () => ipc.client.proxyAdvanced.getCircuitBreakerStatus(), + refetchInterval: 30000, + }); + + const { data: logsData, isLoading: logsLoading } = useQuery({ + queryKey: ['proxyAdvanced', 'logs', direction], + queryFn: () => + ipc.client.proxyAdvanced.getRecentTrafficLogs({ + limit: 50, + direction: direction === 'all' ? undefined : direction, + }), + refetchInterval: 30000, + }); + + const { data: statsData } = useQuery({ + queryKey: ['proxyAdvanced', 'stats'], + queryFn: () => ipc.client.proxyAdvanced.getTrafficLogStats(), + refetchInterval: 30000, + }); + + const metrics = metricsData?.success ? metricsData.data : undefined; + const circuitBreakers = cbData?.success ? cbData.data : undefined; + const logs = logsData?.success ? logsData.data : []; + const stats = statsData?.success ? statsData.data : undefined; + + const handleExport = async (format: 'json' | 'csv') => { + const result = await ipc.client.proxyAdvanced.exportTrafficLogs({ format }); + if (result.success && result.data) { + navigator.clipboard.writeText(result.data); + toast({ title: t('proxy.advanced.tools.copyConfig') }); + } else { + toast({ title: t('common.error'), description: result.error }); + } + }; + + const getCircuitBadgeVariant = (state: string): React.ComponentProps['variant'] => { + if (state === 'closed') return 'default'; + if (state === 'open') return 'destructive'; + return 'secondary'; + }; + + return ( +
+ {/* Metrics */} +
+ + + + +
+ + {/* Circuit Breakers */} + + + + {t('proxy.advanced.circuitBreaker.title')} + + + + {cbLoading ? ( +
+ +
+ ) : !circuitBreakers ? ( +

No data

+ ) : ( +
+ {Object.entries(circuitBreakers.states).map(([api, state]) => ( + + +
+ {api} + + {state.state} + +
+
+ {t('proxy.advanced.circuitBreaker.failures')}: {state.failures} +
+ {state.lastFailure && ( +
+ {t('proxy.advanced.circuitBreaker.lastFailure')}:{' '} + {new Date(state.lastFailure).toLocaleString()} +
+ )} +
+
+ ))} +
+ )} +
+
+ + {/* Traffic Logs */} +
+
+
+

+ {t('proxy.advanced.trafficLog.title')} +

+ {stats && ( + + Total: {stats.total} + + )} +
+
+ + + +
+
+ +
+
+ ); +}; diff --git a/src/components/proxy/advanced/ToolsTab.tsx b/src/components/proxy/advanced/ToolsTab.tsx new file mode 100644 index 0000000..4074d28 --- /dev/null +++ b/src/components/proxy/advanced/ToolsTab.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery, useQueries } from '@tanstack/react-query'; +import { ipc } from '@/ipc/manager'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { useToast } from '@/components/ui/use-toast'; +import { StatCard } from '@/components/usage/StatCard'; +import { Loader2, Copy, CheckCircle, ShieldAlert, XCircle, Terminal, Code2, Braces } from 'lucide-react'; + +const IDE_CONFIGS = [ + { key: 'vscode', label: 'VS Code', icon: Code2 }, + { key: 'cursor', label: 'Cursor', icon: Terminal }, + { key: 'claude-code', label: 'Claude Code', icon: Braces }, +] as const; + +export const ToolsTab: React.FC = () => { + const { t } = useTranslation(); + const { toast } = useToast(); + + const { data: parityData, isLoading: parityLoading } = useQuery({ + queryKey: ['proxyAdvanced', 'parityCounters'], + queryFn: () => ipc.client.proxyAdvanced.getParityCounters(), + refetchInterval: 30000, + }); + + const ideQueries = useQueries({ + queries: [ + { + queryKey: ['proxyAdvanced', 'ideConfig', 'vscode'], + queryFn: () => ipc.client.proxyAdvanced.generateIdeConfig({ ide: 'vscode' }), + }, + { + queryKey: ['proxyAdvanced', 'ideConfig', 'cursor'], + queryFn: () => ipc.client.proxyAdvanced.generateIdeConfig({ ide: 'cursor' }), + }, + ], + }); + + const parity = parityData?.success ? parityData.data : undefined; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast({ title: t('proxy.advanced.tools.copyConfig') }); + }; + + const getIdeInstructions = (key: string): string => { + if (key === 'claude-code') { + return 'export ANTHROPIC_BASE_URL=http://localhost:8045/v1\nexport ANTHROPIC_API_KEY=YOUR_API_KEY'; + } + const index = key === 'vscode' ? 0 : 1; + const result = ideQueries[index].data; + if (result?.success && result.data) { + const d = result.data; + return `${d.instructions}\n${d.proxySetting}: http://localhost:8045\n${d.apiKeySetting}: YOUR_API_KEY`; + } + return ''; + }; + + const isIdeLoading = ideQueries.some((q) => q.isLoading); + + return ( +
+ {/* Parity Counters */} +
+ + + +
+ + {/* IDE Quick Setup */} + + + {t('proxy.advanced.tools.ideSetup')} + Quick configuration for popular IDEs + + + {isIdeLoading ? ( +
+ +
+ ) : ( + IDE_CONFIGS.map(({ key, label, icon: Icon }) => { + const instructions = getIdeInstructions(key); + return ( +
+
+
+ + {label} +
+ +
+ {instructions && ( +
+                      {instructions}
+                    
+ )} +
+ ); + }) + )} +
+
+ + {/* Request Replay Placeholder */} + + + {t('proxy.advanced.tools.requestReplay')} + Replay recent requests for debugging + + +
+ {t('proxy.advanced.tools.comingSoon')} +

+ Request replay will be available in a future update +

+
+
+
+
+ ); +}; diff --git a/src/components/proxy/advanced/TrafficLogTable.tsx b/src/components/proxy/advanced/TrafficLogTable.tsx new file mode 100644 index 0000000..d3b81f6 --- /dev/null +++ b/src/components/proxy/advanced/TrafficLogTable.tsx @@ -0,0 +1,190 @@ +import React, { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader2, ArrowUp, ArrowDown } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; + +interface TrafficLogEntry { + timestamp: string; + direction: string; + requestId: string; + endpoint: string; + method?: string; + status?: number; + durationMs?: number; +} + +interface TrafficLogTableProps { + logs: TrafficLogEntry[]; + isLoading: boolean; +} + +type SortKey = 'timestamp' | 'direction' | 'endpoint' | 'method' | 'status' | 'duration'; +type SortDir = 'asc' | 'desc'; + +export const TrafficLogTable: React.FC = ({ logs, isLoading }) => { + const { t } = useTranslation(); + const [sortKey, setSortKey] = useState('timestamp'); + const [sortDir, setSortDir] = useState('desc'); + + const toggleSort = (key: SortKey) => { + if (sortKey === key) { + setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc')); + } else { + setSortKey(key); + setSortDir('asc'); + } + }; + + const sortedLogs = useMemo(() => { + const dir = sortDir === 'asc' ? 1 : -1; + return [...logs].sort((a, b) => { + switch (sortKey) { + case 'timestamp': + return (new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) * dir; + case 'direction': + return (a.direction || '').localeCompare(b.direction || '') * dir; + case 'endpoint': + return (a.endpoint || '').localeCompare(b.endpoint || '') * dir; + case 'method': + return (a.method || '').localeCompare(b.method || '') * dir; + case 'status': { + const aStatus = a.status ?? -1; + const bStatus = b.status ?? -1; + return (aStatus - bStatus) * dir; + } + case 'duration': { + const aDur = a.durationMs ?? -1; + const bDur = b.durationMs ?? -1; + return (aDur - bDur) * dir; + } + default: + return 0; + } + }); + }, [logs, sortKey, sortDir]); + + const SortIcon = ({ column }: { column: SortKey }) => { + if (sortKey !== column) return ; + return sortDir === 'asc' ? ( + + ) : ( + + ); + }; + + return ( + + + + {t('proxy.advanced.trafficLog.title')} + + + + {isLoading ? ( +
+ +
+ ) : logs.length === 0 ? ( +
+ {t('proxy.advanced.trafficLog.noLogs')} +
+ ) : ( +
+ + + + + + + + + + + + + {sortedLogs.map((log, i) => ( + + + + + + + + + ))} + +
toggleSort('timestamp')} + > + {t('proxy.advanced.trafficLog.timestamp')} + + toggleSort('direction')} + > + {t('proxy.advanced.trafficLog.direction')} + + toggleSort('endpoint')} + > + {t('proxy.advanced.trafficLog.endpoint')} + + toggleSort('method')} + > + {t('proxy.advanced.trafficLog.method')} + + toggleSort('status')} + > + {t('proxy.advanced.trafficLog.status')} + + toggleSort('duration')} + > + {t('proxy.advanced.trafficLog.duration')} + +
+ {new Date(log.timestamp).toLocaleTimeString()} + + + {log.direction} + + {log.endpoint}{log.method || '—'} + {log.status != null ? ( + = 400 ? 'destructive' : 'secondary'} + className="text-xs" + > + {log.status} + + ) : ( + '—' + )} + + {log.durationMs != null ? `${log.durationMs}ms` : '—'} +
+
+ )} +
+
+ ); +}; diff --git a/src/components/usage/CustomTooltip.tsx b/src/components/usage/CustomTooltip.tsx deleted file mode 100644 index dd70da3..0000000 --- a/src/components/usage/CustomTooltip.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; - -interface TooltipPayloadItem { - name?: string; - value?: number; - color?: string; -} - -interface CustomTooltipProps { - active?: boolean; - payload?: TooltipPayloadItem[]; - label?: string; -} - -export const CustomTooltip: React.FC = ({ active, payload, label }) => { - if (!active || !payload || payload.length === 0) { - return null; - } - - return ( -
-

{label}

-
- {payload.map((entry, index) => ( -
- - {entry.name}: - - {typeof entry.value === 'number' ? entry.value.toLocaleString() : entry.value} - -
- ))} -
-
- ); -}; diff --git a/src/components/usage/NivoTooltip.tsx b/src/components/usage/NivoTooltip.tsx new file mode 100644 index 0000000..4e26e79 --- /dev/null +++ b/src/components/usage/NivoTooltip.tsx @@ -0,0 +1,60 @@ +export const COLOR_PROMPT = '#5b9bd5'; +export const COLOR_COMPLETION = '#70ad47'; +export const COLOR_TOTAL = '#a5a5a5'; +export const PIE_COLORS = [ + '#5b9bd5', + '#ed7d31', + '#70ad47', + '#a5a5a5', + '#ffc000', + '#4472c4', + '#91d1c2', + '#c55a5a', +]; + +const colorMap: Record = { + Prompt: COLOR_PROMPT, + Completion: COLOR_COMPLETION, + Total: COLOR_TOTAL, +}; + +export function BarTooltip({ indexValue, data }: any) { + const keys = Object.keys(data).filter((k: string) => k !== 'name' && colorMap[k]); + + return ( +
+

{indexValue}

+
+ {keys.map((key: string) => ( +
+ + {key}: + + {typeof data[key] === 'number' ? data[key].toLocaleString() : data[key]} + +
+ ))} +
+
+ ); +} + +export function PieTooltip({ datum }: any) { + return ( +
+
+ + {datum.id}: + + {typeof datum.value === 'number' ? datum.value.toLocaleString() : datum.value} + +
+
+ ); +} diff --git a/src/ipc/database/cloudHandler.ts b/src/ipc/database/cloudHandler.ts index 87a09e1..0db03f5 100644 --- a/src/ipc/database/cloudHandler.ts +++ b/src/ipc/database/cloudHandler.ts @@ -144,7 +144,11 @@ export function ensureDatabaseInitialized(dbPath: string): void { // Migration: Check if request_type column exists on token_usage const tokenUsageTableInfoRaw = db.pragma('table_info(token_usage)') as any[]; - const tokenUsageTableInfo = parseRows(TableInfoRowSchema, tokenUsageTableInfoRaw, 'cloud.token_usage.tableInfo'); + const tokenUsageTableInfo = parseRows( + TableInfoRowSchema, + tokenUsageTableInfoRaw, + 'cloud.token_usage.tableInfo', + ); const hasRequestType = tokenUsageTableInfo.some((col) => col.name === 'request_type'); if (!hasRequestType) { db.exec('ALTER TABLE token_usage ADD COLUMN request_type TEXT'); @@ -154,7 +158,9 @@ export function ensureDatabaseInitialized(dbPath: string): void { db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_account ON token_usage(account_id);`); db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_timestamp ON token_usage(timestamp);`); db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_model ON token_usage(model);`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_account_timestamp ON token_usage(account_id, timestamp);`); + db.exec( + `CREATE INDEX IF NOT EXISTS idx_token_usage_account_timestamp ON token_usage(account_id, timestamp);`, + ); } catch (error) { logger.error('Failed to initialize cloud database schema', error); throw error; @@ -551,9 +557,7 @@ export class CloudAccountRepo { const rows = orm.select().from(accounts).orderBy(desc(accounts.lastUsed)).all(); const activeRows = rows.filter((r) => r.isActive); - logger.debug( - `getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`, - ); + logger.debug(`getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`); // Auto-activate the single account if none are active if (rows.length > 0 && activeRows.length === 0) { diff --git a/src/ipc/database/proxyMetricsHandler.ts b/src/ipc/database/proxyMetricsHandler.ts new file mode 100644 index 0000000..c83b154 --- /dev/null +++ b/src/ipc/database/proxyMetricsHandler.ts @@ -0,0 +1,452 @@ +import { logger } from '../../utils/logger'; +import Database from 'better-sqlite3'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { openDrizzleConnection } from './dbConnection'; +import { getCloudAccountsDbPath } from '../../utils/paths'; +import { ensureDatabaseInitialized } from './cloudHandler'; +import { proxyRequestMetrics, proxyConnectionSnapshots, trafficLogs } from './schema'; + +export interface RecordProxyRequestParams { + endpoint: string; + statusCode: number; + durationMs: number; + timestamp: number; + accountId?: string | null; + tokensPrompt?: number | null; + tokensCompletion?: number | null; + errorMessage?: string | null; +} + +let proxyMetricsDbConnection: { + raw: Database.Database; + orm: BetterSQLite3Database; +} | null = null; + +function getProxyMetricsDb() { + if (!proxyMetricsDbConnection) { + const dbPath = getCloudAccountsDbPath(); + ensureDatabaseInitialized(dbPath); + const conn = openDrizzleConnection( + dbPath, + { readonly: false, fileMustExist: false }, + { busyTimeoutMs: 3000 }, + ); + ensureProxyMetricsTable(conn.raw); + proxyMetricsDbConnection = conn; + } + return proxyMetricsDbConnection; +} + +export function ensureProxyMetricsTable(rawDb: Database.Database): void { + try { + rawDb.exec(` + CREATE TABLE IF NOT EXISTS proxy_request_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + endpoint TEXT NOT NULL, + status_code INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + timestamp INTEGER NOT NULL, + account_id TEXT, + tokens_prompt INTEGER, + tokens_completion INTEGER, + error_message TEXT + ); + `); + rawDb.exec( + `CREATE INDEX IF NOT EXISTS idx_proxy_metrics_timestamp ON proxy_request_metrics(timestamp);`, + ); + rawDb.exec( + `CREATE INDEX IF NOT EXISTS idx_proxy_metrics_status ON proxy_request_metrics(status_code);`, + ); + + rawDb.exec(` + CREATE TABLE IF NOT EXISTS proxy_connection_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + count INTEGER NOT NULL DEFAULT 0 + ); + `); + rawDb.exec( + `CREATE INDEX IF NOT EXISTS idx_proxy_conn_timestamp ON proxy_connection_snapshots(timestamp);`, + ); + + rawDb.exec(` + CREATE TABLE IF NOT EXISTS traffic_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + direction TEXT NOT NULL, + request_id TEXT NOT NULL, + endpoint TEXT NOT NULL, + method TEXT, + status INTEGER, + headers_json TEXT, + body_json TEXT, + chunk_json TEXT, + duration_ms INTEGER, + error_message TEXT, + error_stack TEXT, + error_status INTEGER, + metadata_json TEXT + ); + `); + rawDb.exec( + `CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`, + ); + rawDb.exec( + `CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`, + ); + } catch (error) { + logger.error('Failed to ensure proxy_request_metrics table', error); + throw error; + } +} + +export class ProxyMetricsRepo { + static recordRequest(params: RecordProxyRequestParams): boolean { + const { orm } = getProxyMetricsDb(); + try { + orm + .insert(proxyRequestMetrics) + .values({ + endpoint: params.endpoint, + statusCode: params.statusCode, + durationMs: params.durationMs, + timestamp: params.timestamp, + accountId: params.accountId ?? null, + tokensPrompt: params.tokensPrompt ?? null, + tokensCompletion: params.tokensCompletion ?? null, + errorMessage: params.errorMessage ?? null, + }) + .run(); + return true; + } catch (error) { + logger.error('Failed to record proxy request metric', error); + return false; + } + } + + static getRecentAggregates(): { + requestsPerMinute: number; + avgLatencyMs: number; + errorRatePercent: number; + } { + const { raw } = getProxyMetricsDb(); + const now = Date.now(); + const oneMinuteAgo = now - 60_000; + const fiveMinutesAgo = now - 5 * 60_000; + + try { + const requestsPerMinuteRow = raw + .prepare(`SELECT COUNT(*) as count FROM proxy_request_metrics WHERE timestamp >= ?`) + .get(oneMinuteAgo) as { count: number } | undefined; + const requestsPerMinute = requestsPerMinuteRow?.count ?? 0; + + const aggregatesRow = raw + .prepare( + `SELECT + COUNT(*) as total, + AVG(duration_ms) as avgLatency, + CASE + WHEN COUNT(*) = 0 THEN 0 + ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*)) + END as errorRate + FROM proxy_request_metrics + WHERE timestamp >= ?`, + ) + .get(fiveMinutesAgo) as + | { total: number; avgLatency: number | null; errorRate: number } + | undefined; + + const avgLatencyMs = aggregatesRow?.avgLatency ? Math.round(aggregatesRow.avgLatency) : 0; + const errorRatePercent = aggregatesRow?.errorRate ?? 0; + + return { + requestsPerMinute, + avgLatencyMs, + errorRatePercent, + }; + } catch (error) { + logger.error('Failed to get recent proxy metrics aggregates', error); + return { + requestsPerMinute: 0, + avgLatencyMs: 0, + errorRatePercent: 0, + }; + } + } + + static recordConnectionSnapshot(count: number): boolean { + const { orm } = getProxyMetricsDb(); + try { + orm.insert(proxyConnectionSnapshots).values({ timestamp: Date.now(), count }).run(); + return true; + } catch (error) { + logger.error('Failed to record connection snapshot', error); + return false; + } + } + + static getHistoricalAggregates(windowMs: number = 60 * 60 * 1000): { + totalRequests: number; + requestsPerMinute: number; + avgLatencyMs: number; + errorRatePercent: number; + avgActiveConnections: number; + } { + const { raw } = getProxyMetricsDb(); + const now = Date.now(); + const cutoff = now - windowMs; + + try { + const reqRow = raw + .prepare( + `SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency, + CASE WHEN COUNT(*) = 0 THEN 0 + ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*)) + END as errorRate + FROM proxy_request_metrics WHERE timestamp >= ?`, + ) + .get(cutoff) as + | { total: number; avgLatency: number | null; errorRate: number } + | undefined; + + const totalRequests = reqRow?.total ?? 0; + const minutes = Math.max(windowMs / 60_000, 1); + const requestsPerMinute = Math.round((totalRequests / minutes) * 100) / 100; + const avgLatencyMs = reqRow?.avgLatency ? Math.round(reqRow.avgLatency) : 0; + const errorRatePercent = reqRow?.errorRate ?? 0; + + const connRow = raw + .prepare(`SELECT AVG(count) as avg FROM proxy_connection_snapshots WHERE timestamp >= ?`) + .get(cutoff) as { avg: number | null } | undefined; + const avgActiveConnections = connRow?.avg ? Math.round(connRow.avg) : 0; + + return { + totalRequests, + requestsPerMinute, + avgLatencyMs, + errorRatePercent, + avgActiveConnections, + }; + } catch (error) { + logger.error('Failed to get historical proxy metrics aggregates', error); + return { + totalRequests: 0, + requestsPerMinute: 0, + avgLatencyMs: 0, + errorRatePercent: 0, + avgActiveConnections: 0, + }; + } + } + + static deleteOldRecords(cutoffTimestamp: number): boolean { + const { raw } = getProxyMetricsDb(); + try { + raw.prepare(`DELETE FROM proxy_request_metrics WHERE timestamp < ?`).run(cutoffTimestamp); + raw.prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`).run(cutoffTimestamp); + return true; + } catch (error) { + logger.error('Failed to delete old proxy metrics records', error); + return false; + } + } +} + +export interface InsertTrafficLogParams { + timestamp: number; + direction: string; + requestId: string; + endpoint: string; + method?: string; + status?: number; + headers?: Record; + body?: unknown; + chunk?: unknown; + durationMs?: number; + error?: { message: string; stack?: string; status?: number; body?: unknown }; + metadata?: Record; +} + +export class TrafficLogsRepo { + static insert(params: InsertTrafficLogParams): boolean { + const { orm } = getProxyMetricsDb(); + try { + orm + .insert(trafficLogs) + .values({ + timestamp: params.timestamp, + direction: params.direction, + requestId: params.requestId, + endpoint: params.endpoint, + method: params.method ?? null, + status: params.status ?? null, + headersJson: params.headers ? JSON.stringify(params.headers) : null, + bodyJson: params.body != null ? JSON.stringify(params.body) : null, + chunkJson: params.chunk != null ? JSON.stringify(params.chunk) : null, + durationMs: params.durationMs ?? null, + errorMessage: params.error?.message ?? null, + errorStack: params.error?.stack ?? null, + errorStatus: params.error?.status ?? null, + metadataJson: params.metadata ? JSON.stringify(params.metadata) : null, + }) + .run(); + return true; + } catch (error) { + logger.error('Failed to insert traffic log', error); + return false; + } + } + + static getRecent(limit: number = 50, direction?: string): Array<{ + timestamp: number; + direction: string; + requestId: string; + endpoint: string; + method?: string; + status?: number; + headers?: Record; + body?: unknown; + chunk?: unknown; + durationMs?: number; + error?: { message: string; stack?: string; status?: number; body?: unknown }; + metadata?: Record; + }> { + const { raw } = getProxyMetricsDb(); + try { + let sql = `SELECT * FROM traffic_logs`; + const args: (number | string)[] = []; + if (direction) { + sql += ` WHERE direction = ?`; + args.push(direction); + } + sql += ` ORDER BY timestamp DESC LIMIT ?`; + args.push(limit); + const rows = raw.prepare(sql).all(...args) as Array<{ + timestamp: number; + direction: string; + request_id: string; + endpoint: string; + method: string | null; + status: number | null; + headers_json: string | null; + body_json: string | null; + chunk_json: string | null; + duration_ms: number | null; + error_message: string | null; + error_stack: string | null; + error_status: number | null; + metadata_json: string | null; + }>; + return rows.map((r) => ({ + timestamp: r.timestamp, + direction: r.direction, + requestId: r.request_id, + endpoint: r.endpoint, + method: r.method ?? undefined, + status: r.status ?? undefined, + headers: r.headers_json ? JSON.parse(r.headers_json) : undefined, + body: r.body_json ? JSON.parse(r.body_json) : undefined, + chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined, + durationMs: r.duration_ms ?? undefined, + error: + r.error_message + ? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined } + : undefined, + metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined, + })); + } catch (error) { + logger.error('Failed to get recent traffic logs', error); + return []; + } + } + + static getStats(): { byDirection: Record; byEndpoint: Record; total: number } { + const { raw } = getProxyMetricsDb(); + try { + const rows = raw.prepare(`SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`).all() as Array<{ + direction: string; + endpoint: string; + cnt: number; + }>; + const byDirection: Record = {}; + const byEndpoint: Record = {}; + let total = 0; + for (const r of rows) { + total += r.cnt; + byDirection[r.direction] = (byDirection[r.direction] || 0) + r.cnt; + byEndpoint[r.endpoint] = (byEndpoint[r.endpoint] || 0) + r.cnt; + } + return { byDirection, byEndpoint, total }; + } catch (error) { + logger.error('Failed to get traffic log stats', error); + return { byDirection: {}, byEndpoint: {}, total: 0 }; + } + } + + static getAllForExport(): Array<{ + timestamp: number; + direction: string; + requestId: string; + endpoint: string; + method?: string; + status?: number; + headers?: Record; + body?: unknown; + chunk?: unknown; + durationMs?: number; + error?: { message: string; stack?: string; status?: number; body?: unknown }; + metadata?: Record; + }> { + const { raw } = getProxyMetricsDb(); + try { + const rows = raw.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`).all() as Array<{ + timestamp: number; + direction: string; + request_id: string; + endpoint: string; + method: string | null; + status: number | null; + headers_json: string | null; + body_json: string | null; + chunk_json: string | null; + duration_ms: number | null; + error_message: string | null; + error_stack: string | null; + error_status: number | null; + metadata_json: string | null; + }>; + return rows.map((r) => ({ + timestamp: r.timestamp, + direction: r.direction, + requestId: r.request_id, + endpoint: r.endpoint, + method: r.method ?? undefined, + status: r.status ?? undefined, + headers: r.headers_json ? JSON.parse(r.headers_json) : undefined, + body: r.body_json ? JSON.parse(r.body_json) : undefined, + chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined, + durationMs: r.duration_ms ?? undefined, + error: + r.error_message + ? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined } + : undefined, + metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined, + })); + } catch (error) { + logger.error('Failed to get all traffic logs for export', error); + return []; + } + } + + static deleteOld(cutoffTimestamp: number): boolean { + const { raw } = getProxyMetricsDb(); + try { + raw.prepare(`DELETE FROM traffic_logs WHERE timestamp < ?`).run(cutoffTimestamp); + return true; + } catch (error) { + logger.error('Failed to delete old traffic logs', error); + return false; + } + } +} diff --git a/src/ipc/database/schema.ts b/src/ipc/database/schema.ts index 0e5d6f6..797e158 100644 --- a/src/ipc/database/schema.ts +++ b/src/ipc/database/schema.ts @@ -39,3 +39,39 @@ export const tokenUsage = sqliteTable('token_usage', { requestType: text('request_type'), isEstimated: integer('is_estimated'), }); + +export const proxyRequestMetrics = sqliteTable('proxy_request_metrics', { + id: integer('id').primaryKey({ autoIncrement: true }), + endpoint: text('endpoint').notNull(), + statusCode: integer('status_code').notNull(), + durationMs: integer('duration_ms').notNull(), + timestamp: integer('timestamp').notNull(), + accountId: text('account_id'), + tokensPrompt: integer('tokens_prompt'), + tokensCompletion: integer('tokens_completion'), + errorMessage: text('error_message'), +}); + +export const proxyConnectionSnapshots = sqliteTable('proxy_connection_snapshots', { + id: integer('id').primaryKey({ autoIncrement: true }), + timestamp: integer('timestamp').notNull(), + count: integer('count').notNull().default(0), +}); + +export const trafficLogs = sqliteTable('traffic_logs', { + id: integer('id').primaryKey({ autoIncrement: true }), + timestamp: integer('timestamp').notNull(), + direction: text('direction').notNull(), + requestId: text('request_id').notNull(), + endpoint: text('endpoint').notNull(), + method: text('method'), + status: integer('status'), + headersJson: text('headers_json'), + bodyJson: text('body_json'), + chunkJson: text('chunk_json'), + durationMs: integer('duration_ms'), + errorMessage: text('error_message'), + errorStack: text('error_stack'), + errorStatus: integer('error_status'), + metadataJson: text('metadata_json'), +}); diff --git a/src/ipc/database/usageHandler.ts b/src/ipc/database/usageHandler.ts index f9b43b9..459cb37 100644 --- a/src/ipc/database/usageHandler.ts +++ b/src/ipc/database/usageHandler.ts @@ -24,7 +24,10 @@ export interface RecordUsageParams { isEstimated?: boolean; } -let usageDbConnection: { raw: Database.Database; orm: BetterSQLite3Database } | null = null; +let usageDbConnection: { + raw: Database.Database; + orm: BetterSQLite3Database; +} | null = null; function getUsageDb() { if (!usageDbConnection) { @@ -57,9 +60,7 @@ function buildDateFilter(start?: number, end?: number): { sql: string; params: n } function buildAccountFilter(accountId?: string): { sql: string; params: unknown[] } { - return accountId - ? { sql: 'AND account_id = ?', params: [accountId] } - : { sql: '', params: [] }; + return accountId ? { sql: 'AND account_id = ?', params: [accountId] } : { sql: '', params: [] }; } export class TokenUsageRepo { diff --git a/src/ipc/proxy-advanced/handler.ts b/src/ipc/proxy-advanced/handler.ts new file mode 100644 index 0000000..1561e1d --- /dev/null +++ b/src/ipc/proxy-advanced/handler.ts @@ -0,0 +1,325 @@ +import { logger } from '../../utils/logger'; +import { proxyAdvancedRegistry } from './service-registry'; +import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service'; +import { TrafficLogsRepo } from '../database/proxyMetricsHandler'; + +export interface TrafficLogEntryParsed { + timestamp: string; + direction: string; + requestId: string; + endpoint: string; + method?: string; + status?: number; + headers?: Record; + body?: unknown; + chunk?: unknown; + durationMs?: number; + error?: { + message: string; + stack?: string; + status?: number; + body?: unknown; + }; + metadata?: Record; +} + +export async function getRecentTrafficLogs( + limit: number = 50, + direction?: string, +): Promise<{ success: boolean; data?: TrafficLogEntryParsed[]; error?: string }> { + try { + const rows = TrafficLogsRepo.getRecent(limit, direction); + const entries: TrafficLogEntryParsed[] = rows.map((r) => ({ + timestamp: new Date(r.timestamp).toISOString(), + direction: r.direction, + requestId: r.requestId, + endpoint: r.endpoint, + method: r.method, + status: r.status, + headers: r.headers, + body: r.body, + chunk: r.chunk, + durationMs: r.durationMs, + error: r.error, + metadata: r.metadata, + })); + + return { success: true, data: entries }; + } catch (error) { + logger.error('Failed to get recent traffic logs', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +export async function getTrafficLogStats(): Promise<{ + success: boolean; + data?: { byDirection: Record; byEndpoint: Record; total: number }; + error?: string; +}> { + try { + const stats = TrafficLogsRepo.getStats(); + return { success: true, data: stats }; + } catch (error) { + logger.error('Failed to get traffic log stats', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +export async function exportTrafficLogs( + format: 'json' | 'csv', +): Promise<{ success: boolean; data?: string; error?: string }> { + try { + const rows = TrafficLogsRepo.getAllForExport(); + + if (format === 'json') { + return { success: true, data: JSON.stringify(rows, null, 2) }; + } + + // CSV format + const headers = ['timestamp', 'direction', 'requestId', 'endpoint', 'method', 'status', 'durationMs']; + const rowsCsv = rows.map((e) => + [ + new Date(e.timestamp).toISOString(), + e.direction, + e.requestId, + e.endpoint, + e.method || '', + e.status != null ? e.status : '', + e.durationMs != null ? e.durationMs : '', + ] + .map((v) => `"${String(v).replace(/"/g, '""')}"`) + .join(','), + ); + + return { success: true, data: [headers.join(','), ...rowsCsv].join('\n') }; + } catch (error) { + logger.error('Failed to export traffic logs', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +export async function getParityCounters(): Promise<{ + success: boolean; + data?: { totalRequests: number; matchedRequests: number; parityViolations: number; lastUpdated: string }; + error?: string; +}> { + try { + const tokenManager = proxyAdvancedRegistry.tokenManager; + if (!tokenManager) { + return { + success: true, + data: { + totalRequests: 0, + matchedRequests: 0, + parityViolations: 0, + lastUpdated: new Date().toISOString(), + }, + }; + } + return { + success: true, + data: tokenManager.getParityCounters(), + }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +export async function getCircuitBreakerStatus(): Promise<{ + success: boolean; + data?: { states: Record; globalEnabled: boolean }; + error?: string; +}> { + try { + const tokenManager = proxyAdvancedRegistry.tokenManager; + if (!tokenManager) { + return { + success: true, + data: { + states: {}, + globalEnabled: true, + }, + }; + } + + const rawStatus = tokenManager.getCircuitBreakerStatus(); + const states: Record = {}; + for (const [accountId, entry] of Object.entries(rawStatus)) { + states[accountId] = { + state: entry.state, + failures: entry.failureCount, + lastFailure: entry.lastFailureTime ? new Date(entry.lastFailureTime).toISOString() : null, + }; + } + + return { + success: true, + data: { + states, + globalEnabled: true, + }, + }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +export async function getProxyMetrics(): Promise<{ + success: boolean; + data?: { + uptimeSeconds: number; + totalRequests: number; + activeConnections: number; + avgLatency: number; + errorRate: number; + requestsPerMinute: number; + }; + error?: string; +}> { + try { + const proxyMetrics = proxyAdvancedRegistry.proxyMetrics; + if (!proxyMetrics) { + return { + success: true, + data: { + uptimeSeconds: 0, + totalRequests: 0, + activeConnections: 0, + avgLatency: 0, + errorRate: 0, + requestsPerMinute: 0, + }, + }; + } + + const metrics = proxyMetrics.getMetrics(); + return { + success: true, + data: { + uptimeSeconds: Math.floor(process.uptime()), + totalRequests: metrics.totalRequests, + activeConnections: metrics.activeConnections, + avgLatency: metrics.avgLatency, + errorRate: metrics.errorRate, + requestsPerMinute: metrics.requestsPerMinute, + }, + }; + } catch (error) { + logger.error('Failed to get proxy metrics', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +export async function getRecentRequests(): Promise<{ success: boolean; data?: unknown[]; error?: string }> { + try { + const proxyReplay = proxyAdvancedRegistry.proxyReplay; + if (!proxyReplay) { + return { + success: true, + data: [], + }; + } + return { + success: true, + data: proxyReplay.getRecentRequests(20), + }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +function mapIdeConfigResult( + result: IdeConfigResult, +): { + name: string; + settingsPath: string; + proxySetting: string; + apiKeySetting: string; + instructions: string; +} { + switch (result.ide) { + case 'vscode': + return { + name: 'VS Code', + settingsPath: 'settings.json', + proxySetting: 'openai.url', + apiKeySetting: 'openai.apiKey', + instructions: 'Add these settings to your VS Code settings.json', + }; + case 'cursor': + return { + name: 'Cursor', + settingsPath: 'settings.json', + proxySetting: 'OPENAI_BASE_URL', + apiKeySetting: 'OPENAI_API_KEY', + instructions: 'Set these environment variables for Cursor', + }; + case 'claude-code': + return { + name: 'Claude Code', + settingsPath: 'Shell environment', + proxySetting: 'ANTHROPIC_BASE_URL', + apiKeySetting: 'ANTHROPIC_API_KEY', + instructions: 'Run these export commands before starting Claude Code', + }; + case 'jetbrains': + return { + name: 'JetBrains', + settingsPath: 'IDE Settings', + proxySetting: 'Base URL', + apiKeySetting: 'API Key', + instructions: String(result.content), + }; + case 'opencide': + return { + name: 'Opencide', + settingsPath: '.env', + proxySetting: 'OPENAI_BASE_URL', + apiKeySetting: 'OPENAI_API_KEY', + instructions: 'Add these lines to your .env file', + }; + default: { + const exhaustiveCheck: never = result.ide; + throw new Error(`Unsupported IDE: ${String(exhaustiveCheck)}`); + } + } +} + +export async function generateIdeConfig( + ide: string, +): Promise<{ + success: boolean; + data?: { + name: string; + settingsPath: string; + proxySetting: string; + apiKeySetting: string; + instructions: string; + }; + error?: string; +}> { + try { + const proxyIdeConfig = proxyAdvancedRegistry.proxyIdeConfig; + if (!proxyIdeConfig) { + return { success: false, error: 'IDE config service not available' }; + } + + const supportedIdes = ['cursor', 'vscode', 'claude-code', 'jetbrains', 'opencide'] as const; + const normalizedIde = ide.toLowerCase() as typeof supportedIdes[number]; + if (!supportedIdes.includes(normalizedIde)) { + return { success: false, error: `Unsupported IDE: ${ide}` }; + } + + const result = proxyIdeConfig.generateConfigForCurrentServer(normalizedIde); + if (!result) { + return { success: false, error: 'Failed to generate IDE config: server not configured' }; + } + + return { + success: true, + data: mapIdeConfigResult(result), + }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/src/ipc/proxy-advanced/router.ts b/src/ipc/proxy-advanced/router.ts new file mode 100644 index 0000000..68b78b1 --- /dev/null +++ b/src/ipc/proxy-advanced/router.ts @@ -0,0 +1,172 @@ +import { z } from 'zod'; +import { os } from '@orpc/server'; +import { + getRecentTrafficLogs, + getTrafficLogStats, + exportTrafficLogs, + getParityCounters, + getCircuitBreakerStatus, + getProxyMetrics, + getRecentRequests, + generateIdeConfig, +} from './handler'; + +const TrafficLogEntrySchema = z.object({ + timestamp: z.string(), + direction: z.string(), + requestId: z.string(), + endpoint: z.string(), + method: z.string().optional(), + status: z.number().optional(), + headers: z.record(z.string(), z.unknown()).optional(), + body: z.unknown().optional(), + chunk: z.unknown().optional(), + durationMs: z.number().optional(), + error: z + .object({ + message: z.string(), + stack: z.string().optional(), + status: z.number().optional(), + body: z.unknown().optional(), + }) + .optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +const TrafficLogsResponseSchema = z.object({ + success: z.boolean(), + data: z.array(TrafficLogEntrySchema).optional(), + error: z.string().optional(), +}); + +const TrafficLogStatsResponseSchema = z.object({ + success: z.boolean(), + data: z + .object({ + byDirection: z.record(z.string(), z.number()), + byEndpoint: z.record(z.string(), z.number()), + total: z.number(), + }) + .optional(), + error: z.string().optional(), +}); + +const ExportTrafficLogsResponseSchema = z.object({ + success: z.boolean(), + data: z.string().optional(), + error: z.string().optional(), +}); + +const ParityCountersResponseSchema = z.object({ + success: z.boolean(), + data: z + .object({ + totalRequests: z.number(), + matchedRequests: z.number(), + parityViolations: z.number(), + lastUpdated: z.string(), + }) + .optional(), + error: z.string().optional(), +}); + +const CircuitBreakerStatusResponseSchema = z.object({ + success: z.boolean(), + data: z + .object({ + states: z.record( + z.string(), + z.object({ + state: z.string(), + failures: z.number(), + lastFailure: z.string().nullable(), + }), + ), + globalEnabled: z.boolean(), + }) + .optional(), + error: z.string().optional(), +}); + +const ProxyMetricsResponseSchema = z.object({ + success: z.boolean(), + data: z + .object({ + uptimeSeconds: z.number(), + totalRequests: z.number(), + activeConnections: z.number(), + avgLatency: z.number(), + errorRate: z.number(), + requestsPerMinute: z.number(), + }) + .optional(), + error: z.string().optional(), +}); + +const RecentRequestsResponseSchema = z.object({ + success: z.boolean(), + data: z.array(z.unknown()).optional(), + error: z.string().optional(), +}); + +const IdeConfigResponseSchema = z.object({ + success: z.boolean(), + data: z + .object({ + name: z.string(), + settingsPath: z.string(), + proxySetting: z.string(), + apiKeySetting: z.string(), + instructions: z.string(), + }) + .optional(), + error: z.string().optional(), +}); + +export const proxyAdvancedRouter = os.router({ + getRecentTrafficLogs: os + .input( + z.object({ + limit: z.number().int().min(1).max(1000).optional(), + direction: z.string().optional(), + }), + ) + .output(TrafficLogsResponseSchema) + .handler(async ({ input }) => { + return getRecentTrafficLogs(input.limit, input.direction); + }), + + getTrafficLogStats: os.output(TrafficLogStatsResponseSchema).handler(async () => { + return getTrafficLogStats(); + }), + + exportTrafficLogs: os + .input(z.object({ format: z.enum(['json', 'csv']) })) + .output(ExportTrafficLogsResponseSchema) + .handler(async ({ input }) => { + return exportTrafficLogs(input.format); + }), + + getParityCounters: os.output(ParityCountersResponseSchema).handler(async () => { + return getParityCounters(); + }), + + getCircuitBreakerStatus: os.output(CircuitBreakerStatusResponseSchema).handler(async () => { + return getCircuitBreakerStatus(); + }), + + getProxyMetrics: os.output(ProxyMetricsResponseSchema).handler(async () => { + return getProxyMetrics(); + }), + + getRecentRequests: os.output(RecentRequestsResponseSchema).handler(async () => { + return getRecentRequests(); + }), + + generateIdeConfig: os + .input(z.object({ ide: z.string() })) + .output(IdeConfigResponseSchema) + .handler(async ({ input }) => { + return generateIdeConfig(input.ide); + }), +}); diff --git a/src/ipc/proxy-advanced/service-registry.ts b/src/ipc/proxy-advanced/service-registry.ts new file mode 100644 index 0000000..e815e6f --- /dev/null +++ b/src/ipc/proxy-advanced/service-registry.ts @@ -0,0 +1,31 @@ +/** + * Service registry for proxy-advanced IPC handlers to access + * NestJS service instances without DI coupling. + * + * Services auto-register on construction. + */ +import type { TokenManagerService } from '../../server/modules/proxy/token-manager.service'; +import type { ProxyMetricsService } from '../../server/modules/proxy/proxy-metrics.service'; +import type { ProxyReplayService } from '../../server/modules/proxy/proxy-replay.service'; +import type { ProxyIdeConfigService } from '../../server/modules/proxy/proxy-ide-config.service'; + +interface ProxyAdvancedRegistry { + tokenManager: TokenManagerService | null; + proxyMetrics: ProxyMetricsService | null; + proxyReplay: ProxyReplayService | null; + proxyIdeConfig: ProxyIdeConfigService | null; +} + +export const proxyAdvancedRegistry: ProxyAdvancedRegistry = { + tokenManager: null, + proxyMetrics: null, + proxyReplay: null, + proxyIdeConfig: null, +}; + +export function registerProxyAdvancedService( + key: K, + instance: ProxyAdvancedRegistry[K], +): void { + proxyAdvancedRegistry[key] = instance; +} diff --git a/src/ipc/router.ts b/src/ipc/router.ts index 5511835..6387841 100644 --- a/src/ipc/router.ts +++ b/src/ipc/router.ts @@ -7,6 +7,7 @@ import { cloudRouter } from './cloud/router'; import { configRouter } from './config/router'; import { gatewayRouter } from './gateway/router'; import { usageRouter } from './usage/router'; +import { proxyAdvancedRouter } from './proxy-advanced/router'; import { os } from '@orpc/server'; import { z } from 'zod'; @@ -55,5 +56,6 @@ export const router = os.use(logMiddleware).router({ config: configRouter, gateway: gatewayRouter, usage: usageRouter, + proxyAdvanced: proxyAdvancedRouter, system: systemHandler, }); diff --git a/src/lib/geminiNexus/ClaudeRequestMapper.ts b/src/lib/geminiNexus/ClaudeRequestMapper.ts index 53a1e7a..143d9c7 100644 --- a/src/lib/geminiNexus/ClaudeRequestMapper.ts +++ b/src/lib/geminiNexus/ClaudeRequestMapper.ts @@ -694,7 +694,13 @@ function buildGenerationConfig( if (maxTokens !== undefined) { config.maxOutputTokens = maxTokens; } - const defaultStopSequences = ['<|user|>', '<|endoftext|>', '<|end_of_turn|>', '[DONE]', '\n\nHuman:']; + const defaultStopSequences = [ + '<|user|>', + '<|endoftext|>', + '<|end_of_turn|>', + '[DONE]', + '\n\nHuman:', + ]; if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) { config.stopSequences = [...new Set([...claudeReq.stop_sequences, ...defaultStopSequences])]; } else { @@ -705,7 +711,10 @@ function buildGenerationConfig( if (claudeReq.response_format) { if (claudeReq.response_format.type === 'json_object') { config.responseMimeType = 'application/json'; - } else if (claudeReq.response_format.type === 'json_schema' && claudeReq.response_format.json_schema) { + } else if ( + claudeReq.response_format.type === 'json_schema' && + claudeReq.response_format.json_schema + ) { config.responseMimeType = 'application/json'; config.responseSchema = cleanJsonSchema(claudeReq.response_format.json_schema); } diff --git a/src/lib/geminiNexus/ClaudeResponseMapper.ts b/src/lib/geminiNexus/ClaudeResponseMapper.ts index 51c71a2..4fdeb40 100644 --- a/src/lib/geminiNexus/ClaudeResponseMapper.ts +++ b/src/lib/geminiNexus/ClaudeResponseMapper.ts @@ -254,7 +254,10 @@ class NonStreamingProcessor { /** * Public API: Transform Gemini Response to Claude Response */ -export function transformResponse(geminiResponse: GeminiResponse, modelName?: string): ClaudeResponse { +export function transformResponse( + geminiResponse: GeminiResponse, + modelName?: string, +): ClaudeResponse { const processor = new NonStreamingProcessor(); return processor.process(geminiResponse, modelName); } diff --git a/src/lib/nivo-theme.ts b/src/lib/nivo-theme.ts new file mode 100644 index 0000000..00c583a --- /dev/null +++ b/src/lib/nivo-theme.ts @@ -0,0 +1,61 @@ +import type { PartialTheme } from '@nivo/theming'; + +export const nivoTheme: PartialTheme = { + background: 'transparent', + text: { + fontFamily: "Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif", + fontSize: 11, + fill: '#b8b8b8', + }, + axis: { + domain: { + line: { + stroke: 'rgba(255,255,255,0.1)', + strokeWidth: 1, + }, + }, + ticks: { + line: { + stroke: 'transparent', + strokeWidth: 0, + }, + text: { + fontFamily: "Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif", + fontSize: 11, + fill: '#b8b8b8', + }, + }, + legend: { + text: { + fontFamily: "Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif", + fontSize: 12, + fill: '#b8b8b8', + }, + }, + }, + grid: { + line: { + stroke: 'rgba(255,255,255,0.06)', + strokeWidth: 1, + strokeDasharray: '4 4', + }, + }, + legends: { + text: { + fontFamily: "Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif", + fontSize: 12, + fill: '#b8b8b8', + }, + }, + tooltip: { + container: { + background: '#2e2e2e', + color: '#fff', + fontFamily: "Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif", + fontSize: 12, + borderRadius: 6, + boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', + padding: '8px 12px', + }, + }, +}; diff --git a/src/localization/i18n.ts b/src/localization/i18n.ts index ca93070..d068d47 100644 --- a/src/localization/i18n.ts +++ b/src/localization/i18n.ts @@ -489,10 +489,95 @@ i18n maps_to: 'Maps to', restore: 'Restore Defaults', }, + routing: { + currentMappings: 'Current Model Mappings', + modelMapping: 'Anthropic model routing configuration', + noMapping: 'No model mappings configured.', + upstreamProxy: 'Outgoing proxy configuration', + status: 'Status', + enabled: 'Enabled', + disabled: 'Disabled', + url: 'URL', + }, examples: { title: 'Usage Examples', description: 'Example commands to call the local API proxy.', }, + advanced: { + title: 'Advanced', + monitor: 'Monitor', + tabs: { + tools: 'Tools', + }, + metrics: { + title: 'Performance Metrics', + requestsPerMinute: 'Requests/min', + avgLatency: 'Avg Latency', + errorRate: 'Error Rate', + activeConnections: 'Active Connections', + }, + circuitBreaker: { + title: 'Circuit Breaker Status', + healthy: 'Healthy', + degraded: 'Degraded', + unhealthy: 'Unhealthy', + failureRate: 'Failure Rate', + lastFailure: 'Last Failure', + failures: 'Failures', + }, + trafficLog: { + title: 'Traffic Logs', + direction: 'Filter by direction', + all: 'All directions', + inbound: 'Inbound', + outbound: 'Outbound', + exportJson: 'Export JSON', + exportCsv: 'Export CSV', + noLogs: 'No traffic logs found', + timestamp: 'Timestamp', + endpoint: 'Endpoint', + method: 'Method', + status: 'Status', + duration: 'Duration', + }, + tools: { + totalRequests: 'Total Requests', + matchedRequests: 'Matched Requests', + parityViolations: 'Parity Violations', + ideSetup: 'IDE Quick Setup', + copyConfig: 'Copy Config', + requestReplay: 'Request Replay', + comingSoon: 'Coming soon', + }, + trafficLogs: { + title: 'Traffic Logs', + filterDirection: 'Filter by direction', + allDirections: 'All directions', + exportJson: 'Export JSON', + exportCsv: 'Export CSV', + noLogs: 'No traffic logs found', + }, + parity: { + title: 'Parity Counters', + requests: 'Parity Requests', + errors: 'Parity Errors', + shadowComparisons: 'Shadow Comparisons', + shadowMismatches: 'Shadow Mismatches', + noGoBlocked: 'No-Go Blocked', + }, + ideSetup: { + title: 'IDE Quick Setup', + cursor: 'Cursor', + vscode: 'VS Code', + claudeCode: 'Claude Code', + copyConfig: 'Copy Config', + configCopied: 'Config copied!', + }, + replay: { + title: 'Request Replay', + comingSoon: 'Coming soon', + }, + }, }, usage: { title: 'Token Usage', @@ -990,10 +1075,95 @@ i18n maps_to: '映射到', restore: '恢复默认', }, + routing: { + currentMappings: '当前模型映射', + modelMapping: 'Anthropic 模型路由配置', + noMapping: '未配置模型映射。', + upstreamProxy: '出站代理配置', + status: '状态', + enabled: '已启用', + disabled: '已禁用', + url: 'URL', + }, examples: { title: '使用示例', description: '调用本地 API 代理的示例命令。', }, + advanced: { + title: '高级', + monitor: '监控', + tabs: { + tools: '工具', + }, + metrics: { + title: '性能指标', + requestsPerMinute: '请求/分钟', + avgLatency: '平均延迟', + errorRate: '错误率', + activeConnections: '活跃连接', + }, + circuitBreaker: { + title: '熔断器状态', + healthy: '健康', + degraded: '降级', + unhealthy: '不健康', + failureRate: '失败率', + lastFailure: '上次失败', + failures: '失败次数', + }, + trafficLog: { + title: '流量日志', + direction: '按方向筛选', + all: '所有方向', + inbound: '入站', + outbound: '出站', + exportJson: '导出 JSON', + exportCsv: '导出 CSV', + noLogs: '暂无流量日志', + timestamp: '时间戳', + endpoint: '端点', + method: '方法', + status: '状态', + duration: '耗时', + }, + tools: { + totalRequests: '总请求数', + matchedRequests: '匹配请求数', + parityViolations: '一致性违规', + ideSetup: 'IDE 快速配置', + copyConfig: '复制配置', + requestReplay: '请求重放', + comingSoon: '即将推出', + }, + trafficLogs: { + title: '流量日志', + filterDirection: '按方向筛选', + allDirections: '所有方向', + exportJson: '导出 JSON', + exportCsv: '导出 CSV', + noLogs: '暂无流量日志', + }, + parity: { + title: '一致性计数器', + requests: '一致性请求', + errors: '一致性错误', + shadowComparisons: '影子对比', + shadowMismatches: '影子不匹配', + noGoBlocked: '禁止通过', + }, + ideSetup: { + title: 'IDE 快速配置', + cursor: 'Cursor', + vscode: 'VS Code', + claudeCode: 'Claude Code', + copyConfig: '复制配置', + configCopied: '配置已复制!', + }, + replay: { + title: '请求重放', + comingSoon: '即将推出', + }, + }, }, usage: { title: 'Token 用量', @@ -1496,10 +1666,95 @@ i18n maps_to: '->', restore: 'Сброс', }, + routing: { + currentMappings: 'Текущие сопоставления моделей', + modelMapping: 'Конфигурация маршрутизации моделей Anthropic', + noMapping: 'Сопоставления моделей не настроены.', + upstreamProxy: 'Конфигурация исходящего прокси', + status: 'Статус', + enabled: 'Включено', + disabled: 'Отключено', + url: 'URL', + }, examples: { title: 'Примеры', description: 'Примеры использования локального прокси.', }, + advanced: { + title: 'Расширенные', + monitor: 'Монитор', + tabs: { + tools: 'Инструменты', + }, + metrics: { + title: 'Метрики производительности', + requestsPerMinute: 'Запросов/мин', + avgLatency: 'Средняя задержка', + errorRate: 'Частота ошибок', + activeConnections: 'Активные соединения', + }, + circuitBreaker: { + title: 'Состояние предохранителя', + healthy: 'Здоровый', + degraded: 'Деградированный', + unhealthy: 'Нездоровый', + failureRate: 'Частота отказов', + lastFailure: 'Последний сбой', + failures: 'Сбои', + }, + trafficLog: { + title: 'Журналы трафика', + direction: 'Фильтр по направлению', + all: 'Все направления', + inbound: 'Входящий', + outbound: 'Исходящий', + exportJson: 'Экспорт JSON', + exportCsv: 'Экспорт CSV', + noLogs: 'Журналы трафика не найдены', + timestamp: 'Время', + endpoint: 'Конечная точка', + method: 'Метод', + status: 'Статус', + duration: 'Длительность', + }, + tools: { + totalRequests: 'Всего запросов', + matchedRequests: 'Совпавшие запросы', + parityViolations: 'Нарушения паритета', + ideSetup: 'Быстрая настройка IDE', + copyConfig: 'Копировать конфиг', + requestReplay: 'Повтор запросов', + comingSoon: 'Скоро', + }, + trafficLogs: { + title: 'Журналы трафика', + filterDirection: 'Фильтр по направлению', + allDirections: 'Все направления', + exportJson: 'Экспорт JSON', + exportCsv: 'Экспорт CSV', + noLogs: 'Журналы трафика не найдены', + }, + parity: { + title: 'Счетчики паритета', + requests: 'Запросы паритета', + errors: 'Ошибки паритета', + shadowComparisons: 'Теневые сравнения', + shadowMismatches: 'Теневые несоответствия', + noGoBlocked: 'Блокировка No-Go', + }, + ideSetup: { + title: 'Быстрая настройка IDE', + cursor: 'Cursor', + vscode: 'VS Code', + claudeCode: 'Claude Code', + copyConfig: 'Копировать конфиг', + configCopied: 'Конфиг скопирован!', + }, + replay: { + title: 'Повтор запроса', + comingSoon: 'Скоро', + }, + }, }, usage: { title: 'Использование токенов', diff --git a/src/localization/vi.ts b/src/localization/vi.ts index a3b6c74..93040d8 100644 --- a/src/localization/vi.ts +++ b/src/localization/vi.ts @@ -472,6 +472,55 @@ const vi = { title: 'Ví dụ sử dụng', description: 'Ví dụ lệnh để gọi local API proxy.', }, + advanced: { + title: 'Nâng cao', + monitor: 'Giám sát', + routing: 'Định tuyến', + tools: 'Công cụ', + metrics: { + title: 'Chỉ số hiệu suất', + requestsPerMinute: 'Yêu cầu/phút', + avgLatency: 'Độ trễ TB', + errorRate: 'Tỷ lệ lỗi', + activeConnections: 'Kết nối hoạt động', + }, + circuitBreaker: { + title: 'Trạng thái Circuit Breaker', + healthy: 'Khỏe', + degraded: 'Suy giảm', + unhealthy: 'Không khỏe', + failureRate: 'Tỷ lệ thất bại', + lastFailure: 'Lỗi gần nhất', + }, + trafficLogs: { + title: 'Nhật ký lưu lượng', + filterDirection: 'Lọc theo hướng', + allDirections: 'Tất cả hướng', + exportJson: 'Xuất JSON', + exportCsv: 'Xuất CSV', + noLogs: 'Không tìm thấy nhật ký', + }, + parity: { + title: 'Bộ đếm Parity', + requests: 'Yêu cầu Parity', + errors: 'Lỗi Parity', + shadowComparisons: 'So sánh Shadow', + shadowMismatches: 'Không khớp Shadow', + noGoBlocked: 'No-Go đã chặn', + }, + ideSetup: { + title: 'Thiết lập IDE nhanh', + cursor: 'Cursor', + vscode: 'VS Code', + claudeCode: 'Claude Code', + copyConfig: 'Sao chép cấu hình', + configCopied: 'Đã sao chép cấu hình!', + }, + replay: { + title: 'Phát lại yêu cầu', + comingSoon: 'Sắp ra mắt', + }, + }, }, usage: { title: 'Token Usage', diff --git a/src/routes/proxy.tsx b/src/routes/proxy.tsx index 06d31c2..8f13ba1 100644 --- a/src/routes/proxy.tsx +++ b/src/routes/proxy.tsx @@ -2,11 +2,11 @@ * API Proxy Service Page * Provides service control, model mapping, and usage examples */ -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, Link } from '@tanstack/react-router'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { ipc } from '@/ipc/manager'; -import { useEffect, useState, useMemo, type ReactNode } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import { useAppConfig } from '@/hooks/useAppConfig'; import { ProxyConfig } from '@/types/config'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -14,17 +14,19 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useToast } from '@/components/ui/use-toast'; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, - SelectSeparator, SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Loader2, Copy, CheckCircle, Code, Terminal, Eye, EyeOff } from 'lucide-react'; +import { Loader2, Copy, Code, Terminal, Eye, EyeOff, ServerOff, ChevronDown, ChevronUp } from 'lucide-react'; import { Dialog, DialogContent, @@ -35,6 +37,7 @@ import { } from '@/components/ui/dialog'; import { useCloudAccounts } from '@/hooks/useCloudAccounts'; +import { ProxyAdvancedTab } from '@/components/proxy/ProxyAdvancedTab'; import { flatMap, sortBy, uniq } from 'lodash-es'; type ProxyProtocol = 'openai' | 'anthropic'; @@ -78,6 +81,7 @@ function resolveAnthropicMappingValue( function ProxyPage() { const { t } = useTranslation(); + const { toast } = useToast(); const { config, isLoading, saveConfig } = useAppConfig(); // Query all available local IPs @@ -151,7 +155,15 @@ function ProxyPage() { const { data: accounts } = useCloudAccounts(); const [selectedProtocol, setSelectedProtocol] = useState('openai'); const [activeModelTab, setActiveModelTab] = useState('gemini-3.1-pro-high'); - const [copied, setCopied] = useState(null); + const [showCurl, setShowCurl] = useState(true); + const [showPython, setShowPython] = useState(true); + + const { data: metricsData } = useQuery({ + queryKey: ['proxyAdvanced', 'metrics'], + queryFn: () => ipc.client.proxyAdvanced.getProxyMetrics(), + refetchInterval: 30000, + enabled: proxyConfig?.enabled ?? false, + }); const availableModelIds: string[] = useMemo(() => { if (!accounts || accounts.length === 0) { @@ -217,10 +229,9 @@ function ProxyPage() { }); }; - const copyToClipboard = (text: string, type: string) => { + const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); - setCopied(type); - setTimeout(() => setCopied(null), 2000); + toast({ title: t('proxy.copied') }); }; const getCurlExample = (modelId: string) => { @@ -287,434 +298,631 @@ print(response.choices[0].message.content)`;

{t('proxy.title')}

{t('proxy.description')}

- - {/* Local Access Info Banner */} - {proxyConfig?.enabled && ( -
-
-
{t('proxy.config.local_access')}
- - http://{selectedIp || 'localhost'}:{proxyConfig.port}/v1 - - {/* IP Selector Dropdown */} - {localIps && localIps.length > 1 && ( - - )} -
- {!proxyConfig.api_key && ( -
- {t('proxy.config.no_token_warning')} -
- )} -
- )}
- {/* Service Control Card */} - - -
-
- {t('proxy.service.title')} - {t('proxy.service.description')} -
-
-
- - {proxyConfig.enabled ? t('proxy.service.running') : t('proxy.service.stopped')} - -
-
-
- - {/* Start/Stop Button */} -
- -
+ + + Status + Routing + API Docs + Advanced + - {/* Port & Timeout Configuration */} -
-
- - - updateProxyConfig({ ...proxyConfig, port: parseInt(e.target.value) || 8045 }) - } - disabled={proxyConfig.enabled} - /> -
-
- - - updateProxyConfig({ - ...proxyConfig, - request_timeout: parseInt(e.target.value) || 120, - }) - } - /> -
-
- - {/* API Key */} -
- -
-
- + {/* Status Tab */} + + {/* Service Control Card */} + + +
+
+ {t('proxy.service.title')} + {t('proxy.service.description')} +
+
+
+ + {proxyConfig.enabled ? t('proxy.service.running') : t('proxy.service.stopped')} + +
+
+
+ + {/* Start/Stop Button */} +
- - -
- - - - {t('proxy.regenerateConfirm.title')} - {t('proxy.regenerateConfirm.description')} - - - + + {/* Port & Timeout Configuration */} +
+
+ + + updateProxyConfig({ ...proxyConfig, port: parseInt(e.target.value) || 8045 }) + } + disabled={proxyConfig.enabled} + /> +
+
+ + + updateProxyConfig({ + ...proxyConfig, + request_timeout: parseInt(e.target.value) || 120, + }) + } + /> +
+
+ + {/* API Key */} +
+ +
+
+ + +
- - -
-
- - {/* Auto Start Toggle */} -
-
- -

{t('proxy.config.auto_start_desc')}

-
- - updateProxyConfig({ ...proxyConfig, auto_start: checked }) - } - /> -
- - - - {/* Model Mapping Card */} - - - {t('proxy.mapping.title')} - {t('proxy.mapping.description')} - - -
- {/* Sonnet 4.6 Card */} -
-
-
-

- Claude Sonnet 4.6 (Thinking) -

-
-

- {t('proxy.mapping.maps_to')} -

- -
- - {/* Opus 4.6 Card */} -
-
-
-

- Claude Opus 4.6 (Thinking) -

+ + + + {t('proxy.regenerateConfirm.title')} + {t('proxy.regenerateConfirm.description')} + + + + + + +
-

- {t('proxy.mapping.maps_to')} -

- -
-
-
- -
-
-
+ {/* Auto Start Toggle */} +
+
+ +

{t('proxy.config.auto_start_desc')}

+
+ + updateProxyConfig({ ...proxyConfig, auto_start: checked }) + } + /> +
+ + - {/* Usage Examples Card */} - - - - - {t('proxy.examples.title')} - - {t('proxy.examples.description')} - - - {/* Protocol Selector Cards */} -
- {/* OpenAI Protocol Card */} -
setSelectedProtocol('openai')} - > -
-
- - {t('settings.examples.openai_protocol')} - -
-
- - POST /v1/chat/completions - -
-

- {t('settings.examples.openai_tools')} -

-
+ {/* Account Quota Context */} + + + Account Pool + Quota and routing context + + + {accounts && accounts.length > 0 ? ( +
+
+
+

Primary Account

+

{accounts[0].email}

+
+ {accounts.length > 1 && ( + Pool Mode: Active ({accounts.length} accounts) + )} +
+ +
+ ) : ( +
+

No accounts configured

+ +
+ )} +
+
- {/* Anthropic Protocol Card */} -
setSelectedProtocol('anthropic')} - > -
-
- - {t('settings.examples.anthropic_protocol')} - -
-
- - POST /v1/messages - -
-

- {t('settings.examples.anthropic_tools')} -

-
-
- - {/* Model Selector & Copy API ID */} -
-
- - + + + + + {localIps.map((ip) => ( + + {ip.address} ({ip.name}){ip.isRecommended && ' ★'} ))} - - ); - })} - - + + + )} +
+ {!proxyConfig.api_key && ( +
+ {t('proxy.config.no_token_warning')} +
+ )} +
+ + {/* Health Metrics */} +
+ {(() => { + const metrics = metricsData?.success ? metricsData.data : undefined; + const items = [ + { + label: t('proxy.advanced.metrics.requestsPerMinute'), + value: metrics?.requestsPerMinute ?? 0, + suffix: '', + }, + { + label: t('proxy.advanced.metrics.avgLatency'), + value: metrics?.avgLatency ?? 0, + suffix: 'ms', + }, + { + label: t('proxy.advanced.metrics.errorRate'), + value: metrics?.errorRate ?? 0, + suffix: '%', + }, + { + label: t('proxy.advanced.metrics.activeConnections'), + value: metrics?.activeConnections ?? 0, + suffix: '', + }, + ]; + return items.map((metric) => ( + + +

{metric.label}

+
+ + {metric.value} + {metric.suffix} + +
+
+
+ )); + })()} +
+ + ) : ( +
+ +
+

Proxy is not running

+

Start the proxy to enable local API access

+
+
- +
+
+
+ + {/* Current Mappings */} + + + {t('proxy.routing.currentMappings')} + {t('proxy.routing.modelMapping')} + + + {(() => { + const mappingEntries = Object.entries(proxyConfig.anthropic_mapping || {}); + const customEntries = Object.entries(proxyConfig.custom_mapping || {}); + return mappingEntries.length === 0 && customEntries.length === 0 ? ( +

{t('proxy.routing.noMapping')}

+ ) : ( +
+
+
{t('proxy.mapping.maps_to')}
+
{t('settings.modelMapping.targetGemini')}
+
Provider
+
+ {mappingEntries.map(([key, value]) => ( +
+
{key}
+
{value}
+ Anthropic +
+ ))} + {customEntries.map(([key, value]) => ( +
+
{key}
+
{value}
+ Custom +
+ ))} +
+ ); + })()} +
+
+ + {/* Upstream Proxy Status */} + + + {t('proxy.routing.upstreamProxy')} + {t('proxy.routing.upstreamProxy')} + + +
+ {t('proxy.routing.status')} + + {proxyConfig.upstream_proxy?.enabled ? t('proxy.routing.enabled') : t('proxy.routing.disabled')} + +
+ {proxyConfig.upstream_proxy?.enabled && ( +
+ {t('proxy.routing.url')} + {proxyConfig.upstream_proxy?.url || '—'} +
)} - Copy API Name - - +
+
+ - {/* cURL Example */} -
-
- - - cURL - - -
-
-              {getCurlExample(activeModelTab)}
-            
-
+ {/* API Docs Tab */} + + + + + + {t('proxy.examples.title')} + + {t('proxy.examples.description')} + + + {/* Compact Protocol Selector */} +
+ + +
- {/* Python Example */} -
-
- - - Python - - -
-
-              {getPythonExample(activeModelTab)}
-            
-
-
-
+ {/* Model Selector & Copy API ID */} +
+
+ + +
+ +
+ + {/* Collapsible Code Examples */} +
+
+ + +
+ + {/* cURL */} +
+
setShowCurl(!showCurl)} + > + + + cURL + +
+ + {showCurl ? : } +
+
+ {showCurl && ( +
+                      {getCurlExample(activeModelTab)}
+                    
+ )} +
+ + {/* Python */} +
+
setShowPython(!showPython)} + > + + + Python + +
+ + {showPython ? : } +
+
+ {showPython && ( +
+                      {getPythonExample(activeModelTab)}
+                    
+ )} +
+
+ + +
+ + {/* Advanced Tab */} + + + + ); } diff --git a/src/routes/usage.tsx b/src/routes/usage.tsx index 3ef6feb..b6bb979 100644 --- a/src/routes/usage.tsx +++ b/src/routes/usage.tsx @@ -2,19 +2,8 @@ import { useMemo, useState } from 'react'; import { createFileRoute } from '@tanstack/react-router'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - PieChart, - Pie, - Cell, - Legend, -} from 'recharts'; +import { ResponsiveBar } from '@nivo/bar'; +import { ResponsivePie } from '@nivo/pie'; import { Select, SelectContent, @@ -27,7 +16,15 @@ import { ipc } from '@/ipc/manager'; import { useCloudAccounts } from '@/hooks/useCloudAccounts'; import { StatCard } from '@/components/usage/StatCard'; import { ChartCard } from '@/components/usage/ChartCard'; -import { CustomTooltip } from '@/components/usage/CustomTooltip'; +import { + COLOR_PROMPT, + COLOR_COMPLETION, + COLOR_TOTAL, + PIE_COLORS, + BarTooltip, + PieTooltip, +} from '@/components/usage/NivoTooltip'; +import { nivoTheme } from '@/lib/nivo-theme'; import { Activity, ArrowUpRight, @@ -38,23 +35,6 @@ import { Zap, } from 'lucide-react'; -/* ------------------------------------------------------------------ */ -/* Color tokens — muted, dark-background-friendly palette */ -/* ------------------------------------------------------------------ */ -const COLOR_PROMPT = '#5b9bd5'; -const COLOR_COMPLETION = '#70ad47'; -const COLOR_TOTAL = '#a5a5a5'; -const PIE_COLORS = [ - '#5b9bd5', - '#ed7d31', - '#70ad47', - '#a5a5a5', - '#ffc000', - '#4472c4', - '#91d1c2', - '#c55a5a', -]; - /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ @@ -108,6 +88,13 @@ function formatBucketLabel(bucket: string, range: TimeRange): string { return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } +function barColor(bar: any): string { + if (bar.id === 'Prompt') return COLOR_PROMPT; + if (bar.id === 'Completion') return COLOR_COMPLETION; + if (bar.id === 'Total') return COLOR_TOTAL; + return '#a5a5a5'; +} + /* ------------------------------------------------------------------ */ /* Main Component */ /* ------------------------------------------------------------------ */ @@ -201,7 +188,7 @@ function UsagePage() { const pieData = useMemo(() => { return (modelData || []).map((d) => ({ - name: d.model, + id: d.model, value: d.totalTokens, })); }, [modelData]); @@ -215,6 +202,10 @@ function UsagePage() { })); }, [hourlyData]); + const hourlyTickValues = useMemo(() => { + return hourlyChartData.filter((_, i) => i % 3 === 0).map((d) => d.name); + }, [hourlyChartData]); + const isDailyEmpty = !dailyLoading && !dailyError && (!dailyData || dailyData.length === 0); const isModelEmpty = !modelLoading && !modelError && (!modelData || modelData.length === 0); const isHourlyEmpty = !hourlyLoading && !hourlyError && (!hourlyData || hourlyData.length === 0); @@ -343,35 +334,52 @@ function UsagePage() { errorMessage={t('usage.loadFailed')} retryLabel={t('usage.retry')} > - - - - - (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`)} - /> - } /> - - - - - +
+ (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`), + }} + tooltip={BarTooltip} + legends={[ + { + dataFrom: 'keys', + anchor: 'bottom', + direction: 'row', + justify: false, + translateX: 0, + translateY: 30, + itemsSpacing: 16, + itemWidth: 80, + itemHeight: 16, + itemDirection: 'left-to-right', + symbolSize: 8, + symbolShape: 'circle', + }, + ]} + animate={true} + /> +
{/* Model Usage Donut Chart */} @@ -386,30 +394,34 @@ function UsagePage() { errorMessage={t('usage.loadFailed')} retryLabel={t('usage.retry')} > - - - - {pieData.map((_, index) => ( - - ))} - - } /> - - - +
+ +
@@ -426,37 +438,53 @@ function UsagePage() { errorMessage={t('usage.loadFailed')} retryLabel={t('usage.retry')} > - - - - - (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`)} - /> - } /> - - - - - - +
+ (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`), + }} + tooltip={BarTooltip} + legends={[ + { + dataFrom: 'keys', + anchor: 'bottom', + direction: 'row', + justify: false, + translateX: 0, + translateY: 30, + itemsSpacing: 16, + itemWidth: 80, + itemHeight: 16, + itemDirection: 'left-to-right', + symbolSize: 8, + symbolShape: 'circle', + }, + ]} + animate={true} + /> +
)} diff --git a/src/server/logging/api-logger.ts b/src/server/logging/api-logger.ts index 82bd82b..706af3c 100644 --- a/src/server/logging/api-logger.ts +++ b/src/server/logging/api-logger.ts @@ -82,12 +82,17 @@ export function safeApiLogValue(value: unknown, maxLength = 50_000): unknown { const sanitized = sanitizeObject(value); if (typeof sanitized === 'string' && sanitized.length > maxLength) { - return sanitized.substring(0, maxLength) + `... [truncated ${sanitized.length - maxLength} chars]`; + return ( + sanitized.substring(0, maxLength) + `... [truncated ${sanitized.length - maxLength} chars]` + ); } const stringified = JSON.stringify(sanitized); if (stringified.length > maxLength) { - return stringified.substring(0, maxLength) + `... [truncated ${stringified.length - maxLength} chars]`; + return ( + stringified.substring(0, maxLength) + + `... [truncated ${stringified.length - maxLength} chars]` + ); } return sanitized; diff --git a/src/server/modules/proxy/proxy-ide-config.service.ts b/src/server/modules/proxy/proxy-ide-config.service.ts new file mode 100644 index 0000000..8c9d0f3 --- /dev/null +++ b/src/server/modules/proxy/proxy-ide-config.service.ts @@ -0,0 +1,126 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { networkInterfaces } from 'os'; +import { getServerConfig } from '../../server-config'; +import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry'; + +export type SupportedIde = 'cursor' | 'vscode' | 'claude-code' | 'jetbrains' | 'opencide'; + +export interface IdeConfigResult { + ide: SupportedIde; + format: 'json' | 'shell' | 'env' | 'instructions'; + content: Record | string; +} + +@Injectable() +export class ProxyIdeConfigService { + private readonly logger = new Logger(ProxyIdeConfigService.name); + + constructor() { + registerProxyAdvancedService('proxyIdeConfig', this); + } + + /** + * Return the first non-internal IPv4 address found on the machine. + * Falls back to '127.0.0.1' if no external interface is available. + */ + getLocalIp(): string { + const interfaces = networkInterfaces(); + for (const iface of Object.values(interfaces)) { + if (!iface) { + continue; + } + for (const entry of iface) { + if (entry.family === 'IPv4' && !entry.internal) { + return entry.address; + } + } + } + return '127.0.0.1'; + } + + /** + * Generate a ready-to-paste IDE configuration for the given IDE. + */ + generateIdeConfig( + ide: SupportedIde, + port: number, + apiKey: string, + localIp: string, + ): IdeConfigResult { + const baseUrl = `http://${localIp}:${port}/v1`; + + switch (ide) { + case 'cursor': { + return { + ide, + format: 'json', + content: { + OPENAI_API_KEY: apiKey, + OPENAI_BASE_URL: baseUrl, + }, + }; + } + case 'vscode': { + return { + ide, + format: 'json', + content: { + 'openai.url': baseUrl, + 'openai.apiKey': apiKey, + }, + }; + } + case 'claude-code': { + return { + ide, + format: 'shell', + content: `export OPENAI_API_KEY=${apiKey} && export OPENAI_BASE_URL=${baseUrl}`, + }; + } + case 'jetbrains': { + return { + ide, + format: 'instructions', + content: [ + 'JetBrains AI Assistant Setup:', + '1. Open Settings (File > Settings or Ctrl+Alt+S).', + '2. Navigate to Languages & Frameworks > AI Assistant.', + '3. Under "Custom OpenAI Endpoint", set:', + ` - Base URL: ${baseUrl}`, + ` - API Key: ${apiKey}`, + '4. Click "Apply" and restart the IDE if prompted.', + ].join('\n'), + }; + } + case 'opencide': { + return { + ide, + format: 'env', + content: `OPENAI_API_KEY=${apiKey}\nOPENAI_BASE_URL=${baseUrl}`, + }; + } + default: { + // Exhaustive fallback to satisfy TypeScript + const exhaustiveCheck: never = ide; + this.logger.warn(`Unsupported IDE requested: ${String(exhaustiveCheck)}`); + throw new Error(`Unsupported IDE: ${String(exhaustiveCheck)}`); + } + } + } + + /** + * Convenience helper that reads the current server config and local IP, + * then delegates to generateIdeConfig. + */ + generateConfigForCurrentServer(ide: SupportedIde): IdeConfigResult | null { + const config = getServerConfig(); + if (!config) { + this.logger.warn('Server config not available; cannot generate IDE config'); + return null; + } + const port = config.port; + const apiKey = config.api_key; + const localIp = this.getLocalIp(); + return this.generateIdeConfig(ide, port, apiKey, localIp); + } +} diff --git a/src/server/modules/proxy/proxy-metrics.service.ts b/src/server/modules/proxy/proxy-metrics.service.ts new file mode 100644 index 0000000..49a63c1 --- /dev/null +++ b/src/server/modules/proxy/proxy-metrics.service.ts @@ -0,0 +1,111 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry'; +import { ProxyMetricsRepo } from '../../../ipc/database/proxyMetricsHandler'; + +interface RequestRecord { + timestamp: number; + duration: number; + status: number; + endpoint: string; +} + +const HISTORICAL_WINDOW_MS = 60 * 60 * 1000; // 1 hour +const CONNECTION_SNAPSHOT_INTERVAL_MS = 30_000; // 30 seconds +const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours +const RECORDS_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +@Injectable() +export class ProxyMetricsService implements OnModuleInit { + private readonly maxRecords = 100; + private records: RequestRecord[] = []; + private activeConnectionsCount = 0; + private tokenEvents: Array<{ timestamp: number; tokens: number }> = []; + + constructor() { + registerProxyAdvancedService('proxyMetrics', this); + } + + onModuleInit(): void { + this.cleanupOldRecords(); + setInterval(() => this.cleanupOldRecords(), CLEANUP_INTERVAL_MS); + + this.recordConnectionSnapshot(); + setInterval(() => this.recordConnectionSnapshot(), CONNECTION_SNAPSHOT_INTERVAL_MS); + } + + incrementActiveConnections(): void { + this.activeConnectionsCount++; + } + + decrementActiveConnections(): void { + this.activeConnectionsCount = Math.max(0, this.activeConnectionsCount - 1); + } + + recordRequest(record: RequestRecord): void { + this.records.push(record); + if (this.records.length > this.maxRecords) { + this.records.shift(); + } + + ProxyMetricsRepo.recordRequest({ + endpoint: record.endpoint, + statusCode: record.status, + durationMs: record.duration, + timestamp: record.timestamp, + accountId: null, + tokensPrompt: null, + tokensCompletion: null, + errorMessage: null, + }); + } + + recordTokens(tokens: number): void { + if (tokens <= 0) { + return; + } + this.tokenEvents.push({ timestamp: Date.now(), tokens }); + this.trimTokenEvents(); + } + + getMetrics(): { + totalRequests: number; + requestsPerMinute: number; + avgLatency: number; + errorRate: number; + activeConnections: number; + tokenThroughput: number; + } { + const aggregates = ProxyMetricsRepo.getHistoricalAggregates(HISTORICAL_WINDOW_MS); + + this.trimTokenEvents(); + const tokenThroughput = this.tokenEvents.reduce((sum, e) => sum + e.tokens, 0); + + return { + totalRequests: aggregates.totalRequests, + requestsPerMinute: Math.round(aggregates.requestsPerMinute * 100) / 100, + avgLatency: Math.round(aggregates.avgLatencyMs * 100) / 100, + errorRate: Math.round(aggregates.errorRatePercent * 100) / 100, + activeConnections: Math.round(aggregates.avgActiveConnections * 100) / 100, + tokenThroughput, + }; + } + + cleanupOldRecords(): void { + const cutoff = Date.now() - RECORDS_TTL_MS; + ProxyMetricsRepo.deleteOldRecords(cutoff); + } + + private recordConnectionSnapshot(): void { + ProxyMetricsRepo.recordConnectionSnapshot(this.activeConnectionsCount); + } + + private trimTokenEvents(): void { + const cutoff = Date.now() - 60_000; + const index = this.tokenEvents.findIndex((e) => e.timestamp >= cutoff); + if (index > 0) { + this.tokenEvents = this.tokenEvents.slice(index); + } else if (index === -1 && this.tokenEvents.length > 0) { + this.tokenEvents = []; + } + } +} diff --git a/src/server/modules/proxy/proxy-replay.service.ts b/src/server/modules/proxy/proxy-replay.service.ts new file mode 100644 index 0000000..81741ac --- /dev/null +++ b/src/server/modules/proxy/proxy-replay.service.ts @@ -0,0 +1,299 @@ +import { Injectable, Logger, NotFoundException, BadRequestException, Inject } from '@nestjs/common'; +import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry'; +import { ProxyService } from './proxy.service'; +import { v4 as uuidv4 } from 'uuid'; +import { + OpenAIChatRequest, + AnthropicChatRequest, + GeminiRequest, +} from './interfaces/request-interfaces'; + +export interface ReplayEntry { + requestId: string; + timestamp: number; + endpoint: string; + method: string; + body: unknown; + headers: Record; + accountId?: string; + model?: string; + responseSnapshot?: unknown; +} + +@Injectable() +export class ProxyReplayService { + private readonly logger = new Logger(ProxyReplayService.name); + private readonly buffer: ReplayEntry[] = []; + private readonly maxSize = 20; + + constructor( + @Inject(ProxyService) private readonly proxyService: ProxyService, + ) { + registerProxyAdvancedService('proxyReplay', this); + } + + recordRequest(entry: ReplayEntry): void { + if (this.buffer.length >= this.maxSize) { + this.buffer.shift(); + } + this.buffer.push(entry); + this.logger.debug(`Recorded request ${entry.requestId} for ${entry.endpoint}`); + } + + getRecentRequests(limit?: number): ReplayEntry[] { + const recent = [...this.buffer].reverse(); + if (limit && limit > 0) { + return recent.slice(0, limit); + } + return recent; + } + + async replayRequest(requestId: string): Promise<{ original: ReplayEntry; newResponse: unknown }> { + const entry = this.buffer.find((e) => e.requestId === requestId); + if (!entry) { + throw new NotFoundException(`Request ${requestId} not found in replay buffer`); + } + + this.logger.log(`Replaying request ${requestId} for ${entry.endpoint}`); + const newResponse = await this.executeReplay(entry); + return { original: entry, newResponse }; + } + + private async executeReplay(entry: ReplayEntry): Promise { + switch (entry.endpoint) { + case '/v1/chat/completions': + case '/v1/messages/chat/completions': { + const body = entry.body as OpenAIChatRequest; + return this.proxyService.handleChatCompletions(body, uuidv4()); + } + case '/v1/completions': { + const originalBody = entry.body as { + model?: string; + prompt?: string | string[]; + max_tokens?: number; + temperature?: number; + top_p?: number; + stream?: boolean; + }; + const request: OpenAIChatRequest = { + model: originalBody.model ?? 'gemini-3-flash', + messages: [ + { + role: 'user', + content: Array.isArray(originalBody.prompt) + ? originalBody.prompt.join('\n') + : (originalBody.prompt ?? ''), + }, + ], + max_tokens: originalBody.max_tokens, + temperature: originalBody.temperature, + top_p: originalBody.top_p, + stream: originalBody.stream, + }; + return this.proxyService.handleChatCompletions(request, uuidv4()); + } + case '/v1/messages': { + const body = entry.body as AnthropicChatRequest; + return this.proxyService.handleAnthropicMessages(body); + } + case '/v1/responses': { + const originalBody = entry.body as { + model?: string; + instructions?: string; + input?: unknown; + tools?: OpenAIChatRequest['tools']; + max_output_tokens?: number; + temperature?: number; + top_p?: number; + stream?: boolean; + }; + const messages: OpenAIChatRequest['messages'] = []; + if ( + typeof originalBody.instructions === 'string' && + originalBody.instructions.trim().length > 0 + ) { + messages.push({ role: 'system', content: originalBody.instructions }); + } + + const input = originalBody.input; + if (typeof input === 'string') { + messages.push({ role: 'user', content: input }); + } else if (Array.isArray(input)) { + for (const item of input) { + if (typeof item === 'string') { + messages.push({ role: 'user', content: item }); + } else if (item && typeof item === 'object') { + const obj = item as Record; + if (obj.type === 'message' && typeof obj.content === 'string') { + messages.push({ + role: typeof obj.role === 'string' ? obj.role : 'user', + content: obj.content, + }); + } else if (obj.type === 'message') { + messages.push({ + role: typeof obj.role === 'string' ? obj.role : 'user', + content: JSON.stringify(obj.content), + }); + } + } + } + } + + if (messages.length === 0) { + messages.push({ role: 'user', content: '' }); + } + + const request: OpenAIChatRequest = { + model: originalBody.model ?? 'gemini-3-flash', + messages, + tools: originalBody.tools, + max_tokens: originalBody.max_output_tokens, + temperature: originalBody.temperature, + top_p: originalBody.top_p, + stream: originalBody.stream, + }; + return this.proxyService.handleChatCompletions(request, uuidv4()); + } + case '/v1/images/generations': { + const originalBody = entry.body as { + model?: string; + prompt?: string; + size?: string; + quality?: string; + }; + const request: OpenAIChatRequest = { + model: originalBody.model ?? 'gemini-3-pro-image', + messages: [{ role: 'user', content: originalBody.prompt ?? '' }], + stream: false, + size: originalBody.size, + quality: originalBody.quality, + }; + return this.proxyService.handleChatCompletions(request, uuidv4()); + } + case '/v1/images/edits': { + const originalBody = entry.body as { + model?: string; + prompt?: string; + size?: string; + quality?: string; + image?: string | { data?: string; mimeType?: string }; + reference_images?: Array; + mask?: string | { data?: string; mimeType?: string }; + }; + const imageParts = this.collectImageContentParts([ + originalBody.image, + originalBody.mask, + ...(originalBody.reference_images ?? []), + ]); + const request: OpenAIChatRequest = { + model: originalBody.model ?? 'gemini-3-pro-image', + messages: [ + { + role: 'user', + content: + imageParts.length > 0 + ? [ + { + type: 'text' as const, + text: + originalBody.prompt ?? + 'Please edit this image based on the provided instruction.', + }, + ...imageParts, + ] + : (originalBody.prompt ?? + 'Please edit this image based on the provided instruction.'), + }, + ], + stream: false, + size: originalBody.size, + quality: originalBody.quality, + }; + return this.proxyService.handleChatCompletions(request, uuidv4()); + } + case '/v1/audio/transcriptions': { + const originalBody = entry.body as { + model?: string; + prompt?: string; + file?: string | { data?: string; mimeType?: string }; + audio?: string | { data?: string; mimeType?: string }; + }; + const inlineAudio = this.resolveInlineData(originalBody.file ?? originalBody.audio); + if (!inlineAudio) { + throw new BadRequestException("Missing 'file' or 'audio' input for replay"); + } + return this.proxyService.handleGeminiGenerateContent( + originalBody.model ?? 'gemini-3-flash', + { + contents: [ + { + role: 'user', + parts: [ + { + text: + originalBody.prompt ?? + 'Please transcribe the provided speech audio accurately.', + }, + { inlineData: inlineAudio }, + ], + }, + ], + } as GeminiRequest, + ); + } + default: + throw new BadRequestException(`Replay not supported for endpoint ${entry.endpoint}`); + } + } + + private collectImageContentParts( + entries: Array, + ): Array<{ type: 'image_url'; image_url: { url: string } }> { + const parts: Array<{ type: 'image_url'; image_url: { url: string } }> = []; + for (const entry of entries) { + const inlineData = this.resolveInlineData(entry); + if (!inlineData) { + continue; + } + parts.push({ + type: 'image_url', + image_url: { + url: `data:${inlineData.mimeType};base64,${inlineData.data}`, + }, + }); + } + return parts; + } + + private resolveInlineData(input: unknown): { mimeType: string; data: string } | null { + if (!input) { + return null; + } + if (typeof input === 'string') { + const dataUri = input.match(/^data:(?[^;]+);base64,(?[A-Za-z0-9+/=]+)$/); + if (dataUri?.groups?.mime && dataUri.groups.data) { + return { + mimeType: dataUri.groups.mime, + data: dataUri.groups.data, + }; + } + const cleaned = input.replace(/\s+/g, ''); + if (cleaned.length > 0) { + return { + mimeType: 'audio/mpeg', + data: cleaned, + }; + } + return null; + } + const inputRecord = input as Record; + const data = typeof inputRecord.data === 'string' ? inputRecord.data : undefined; + if (!data) { + return null; + } + return { + mimeType: typeof inputRecord.mimeType === 'string' ? inputRecord.mimeType : 'audio/mpeg', + data, + }; + } +} diff --git a/src/server/modules/proxy/proxy.controller.ts b/src/server/modules/proxy/proxy.controller.ts index dc710d6..74cde91 100644 --- a/src/server/modules/proxy/proxy.controller.ts +++ b/src/server/modules/proxy/proxy.controller.ts @@ -15,6 +15,7 @@ import { FastifyReply, FastifyRequest } from 'fastify'; import { isEmpty, isFunction, isNil, isObjectLike, isPlainObject, isString } from 'lodash-es'; import { v4 as uuidv4 } from 'uuid'; import { ProxyService } from './proxy.service'; +import { ProxyReplayService } from './proxy-replay.service'; import { Observable } from 'rxjs'; import { trafficLogger } from '../../../utils/traffic-logger'; import { @@ -33,6 +34,7 @@ import { } from '../../../lib/geminiNexus/ModelMapping'; import { getServerConfig } from '../../server-config'; import { TokenManagerService } from './token-manager.service'; +import { ProxyMetricsService } from './proxy-metrics.service'; @Controller('v1') @UseGuards(ProxyGuard) @@ -41,11 +43,15 @@ export class ProxyController { constructor( @Inject(ProxyService) private readonly proxyService: ProxyService, + @Optional() @Inject(ProxyReplayService) private readonly proxyReplayService?: ProxyReplayService, @Optional() @Inject(TokenManagerService) private readonly tokenManager?: TokenManagerService, + @Optional() @Inject(ProxyMetricsService) private readonly proxyMetrics?: ProxyMetricsService, ) {} @Get('models') listModels(@Res() res: FastifyReply) { + this.proxyMetrics?.incrementActiveConnections(); + const startTime = Date.now(); try { const config = getServerConfig(); const customMapping = config?.custom_mapping ?? {}; @@ -65,6 +71,12 @@ export class ProxyController { object: 'list', data, }); + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration: Date.now() - startTime, + status: HttpStatus.OK, + endpoint: '/v1/models', + }); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to list models'; this.logger.error(message, error instanceof Error ? error.stack : undefined); @@ -80,6 +92,14 @@ export class ProxyController { res.status(status).send({ error: errorPayload, }); + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration: Date.now() - startTime, + status, + endpoint: '/v1/models', + }); + } finally { + this.proxyMetrics?.decrementActiveConnections(); } } @@ -89,6 +109,15 @@ export class ProxyController { @Req() req: FastifyRequest, @Res() res: FastifyReply, ) { + this.proxyReplayService?.recordRequest({ + requestId: uuidv4(), + timestamp: Date.now(), + endpoint: '/v1/chat/completions', + method: 'POST', + body, + headers: req.headers as Record, + model: body.model, + }); await this.respondOpenAIChatCompletions(body, res, req); } @@ -103,6 +132,15 @@ export class ProxyController { @Req() req: FastifyRequest, @Res() res: FastifyReply, ) { + this.proxyReplayService?.recordRequest({ + requestId: uuidv4(), + timestamp: Date.now(), + endpoint: '/v1/messages/chat/completions', + method: 'POST', + body, + headers: req.headers as Record, + model: body.model, + }); await this.respondOpenAIChatCompletions(body, res, req); } @@ -117,8 +155,20 @@ export class ProxyController { top_p?: number; stream?: boolean; }, + @Req() req: FastifyRequest, @Res() res: FastifyReply, ) { + this.proxyReplayService?.recordRequest({ + requestId: uuidv4(), + timestamp: Date.now(), + endpoint: '/v1/completions', + method: 'POST', + body, + headers: req.headers as Record, + model: body.model, + }); + this.proxyMetrics?.incrementActiveConnections(); + const startTime = Date.now(); const request: OpenAIChatRequest = { model: body.model ?? 'gemini-3-flash', messages: [ @@ -132,17 +182,41 @@ export class ProxyController { top_p: body.top_p, stream: body.stream, }; + let isStream = false; try { const result = await this.proxyService.handleChatCompletions(request); - if (body.stream && this.isObservableLike(result)) { - this.writeSseResponse(res, result); + isStream = !!(body.stream && this.isObservableLike(result)); + if (isStream) { + this.writeSseResponse(res, result as Observable, undefined, '/v1/completions', startTime); return; } const response = result as OpenAIChatResponse; + const duration = Date.now() - startTime; + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration, + status: HttpStatus.OK, + endpoint: '/v1/completions', + }); + if (response.usage?.total_tokens) { + this.proxyMetrics?.recordTokens(response.usage.total_tokens); + } res.status(HttpStatus.OK).send(this.toLegacyTextCompletionsResponse(response)); } catch (error) { + const duration = Date.now() - startTime; + const status = this.resolveErrorHttpStatus(error instanceof Error ? error.message : ''); + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration, + status, + endpoint: '/v1/completions', + }); this.sendOpenAIErrorResponse(res, '/v1/completions', error); + } finally { + if (!isStream) { + this.proxyMetrics?.decrementActiveConnections(); + } } } @@ -159,21 +233,57 @@ export class ProxyController { top_p?: number; stream?: boolean; }, + @Req() req: FastifyRequest, @Res() res: FastifyReply, ) { + this.proxyReplayService?.recordRequest({ + requestId: uuidv4(), + timestamp: Date.now(), + endpoint: '/v1/responses', + method: 'POST', + body, + headers: req.headers as Record, + model: body.model, + }); + this.proxyMetrics?.incrementActiveConnections(); + const startTime = Date.now(); const request = this.buildResponsesChatRequest(body); + let isStream = false; try { const result = await this.proxyService.handleChatCompletions(request); - if (body.stream && this.isObservableLike(result)) { - this.writeSseResponse(res, result); + isStream = !!(body.stream && this.isObservableLike(result)); + if (isStream) { + this.writeSseResponse(res, result as Observable, undefined, '/v1/responses', startTime); return; } const response = result as OpenAIChatResponse; + const duration = Date.now() - startTime; + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration, + status: HttpStatus.OK, + endpoint: '/v1/responses', + }); + if (response.usage?.total_tokens) { + this.proxyMetrics?.recordTokens(response.usage.total_tokens); + } res.status(HttpStatus.OK).send(this.toLegacyTextCompletionsResponse(response)); } catch (error) { + const duration = Date.now() - startTime; + const status = this.resolveErrorHttpStatus(error instanceof Error ? error.message : ''); + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration, + status, + endpoint: '/v1/responses', + }); this.sendOpenAIErrorResponse(res, '/v1/responses', error); + } finally { + if (!isStream) { + this.proxyMetrics?.decrementActiveConnections(); + } } } @@ -186,8 +296,18 @@ export class ProxyController { size?: string; quality?: string; }, + @Req() req: FastifyRequest, @Res() res: FastifyReply, ) { + this.proxyReplayService?.recordRequest({ + requestId: uuidv4(), + timestamp: Date.now(), + endpoint: '/v1/images/generations', + method: 'POST', + body, + headers: req.headers as Record, + model: body.model, + }); const request: OpenAIChatRequest = { model: body.model ?? 'gemini-3-pro-image', messages: [ @@ -219,6 +339,15 @@ export class ProxyController { @Req() req: FastifyRequest, @Res() res: FastifyReply, ) { + this.proxyReplayService?.recordRequest({ + requestId: uuidv4(), + timestamp: Date.now(), + endpoint: '/v1/images/edits', + method: 'POST', + body, + headers: req.headers as Record, + model: body.model, + }); if (!this.hasMultipartBoundary(req)) { res .status(HttpStatus.BAD_REQUEST) @@ -270,7 +399,26 @@ export class ProxyController { @Req() req: FastifyRequest, @Res() res: FastifyReply, ) { + this.proxyMetrics?.incrementActiveConnections(); + const startTime = Date.now(); + const endpoint = '/v1/audio/transcriptions'; + this.proxyReplayService?.recordRequest({ + requestId: uuidv4(), + timestamp: Date.now(), + endpoint: '/v1/audio/transcriptions', + method: 'POST', + body, + headers: req.headers as Record, + model: body.model, + }); if (!this.hasMultipartBoundary(req)) { + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration: Date.now() - startTime, + status: HttpStatus.BAD_REQUEST, + endpoint, + }); + this.proxyMetrics?.decrementActiveConnections(); res .status(HttpStatus.BAD_REQUEST) .send('Invalid `boundary` for `multipart/form-data` request'); @@ -279,6 +427,13 @@ export class ProxyController { const inlineAudio = this.resolveInlineData(body.file ?? body.audio); if (!inlineAudio) { + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration: Date.now() - startTime, + status: HttpStatus.BAD_REQUEST, + endpoint, + }); + this.proxyMetrics?.decrementActiveConnections(); res.status(HttpStatus.BAD_REQUEST).send({ error: { message: "Missing 'file' or 'audio' input. Provide base64 content or a data URL.", @@ -313,11 +468,31 @@ export class ProxyController { .join('') .trim(); + const duration = Date.now() - startTime; + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration, + status: HttpStatus.OK, + endpoint, + }); + if (result.usageMetadata?.totalTokenCount) { + this.proxyMetrics?.recordTokens(result.usageMetadata.totalTokenCount); + } res.status(HttpStatus.OK).send({ text: text ?? '', }); } catch (error) { - this.sendOpenAIErrorResponse(res, '/v1/audio/transcriptions', error); + const duration = Date.now() - startTime; + const status = this.resolveErrorHttpStatus(error instanceof Error ? error.message : ''); + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration, + status, + endpoint, + }); + this.sendOpenAIErrorResponse(res, endpoint, error); + } finally { + this.proxyMetrics?.decrementActiveConnections(); } } @@ -326,9 +501,12 @@ export class ProxyController { res: FastifyReply, req?: FastifyRequest, ) { + this.proxyMetrics?.incrementActiveConnections(); const requestId = uuidv4(); const startTime = Date.now(); - trafficLogger.logInbound(requestId, '/v1/chat/completions', body, undefined, { stream: body.stream }); + trafficLogger.logInbound(requestId, '/v1/chat/completions', body, undefined, { + stream: body.stream, + }); // Extract x-session-affinity header so OpenCode session stickiness works even when // the request body does not carry an explicit session_id field. @@ -336,36 +514,106 @@ export class ProxyController { ? (req.headers['x-session-affinity'] as string | undefined) : undefined; + let isStream = false; try { - const result = await this.proxyService.handleChatCompletions(body, requestId, headerSessionKey); + const result = await this.proxyService.handleChatCompletions( + body, + requestId, + headerSessionKey, + ); - if (body.stream && this.isObservableLike(result)) { - this.writeSseResponse(res, result, requestId, '/v1/chat/completions', startTime); + isStream = !!(body.stream && this.isObservableLike(result)); + if (isStream) { + this.writeSseResponse(res, result as Observable, requestId, '/v1/chat/completions', startTime); return; } else { const duration = Date.now() - startTime; - trafficLogger.logOutbound(requestId, '/v1/chat/completions', HttpStatus.OK, result, duration); + trafficLogger.logOutbound( + requestId, + '/v1/chat/completions', + HttpStatus.OK, + result, + duration, + ); + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration, + status: HttpStatus.OK, + endpoint: '/v1/chat/completions', + }); + const response = result as OpenAIChatResponse; + if (response.usage?.total_tokens) { + this.proxyMetrics?.recordTokens(response.usage.total_tokens); + } res.status(HttpStatus.OK).send(result); } } catch (error) { trafficLogger.logError(requestId, '/v1/chat/completions', error); + const duration = Date.now() - startTime; + const status = this.resolveErrorHttpStatus(error instanceof Error ? error.message : ''); + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration, + status, + endpoint: '/v1/chat/completions', + }); this.sendOpenAIErrorResponse(res, '/v1/chat/completions', error); + } finally { + if (!isStream) { + this.proxyMetrics?.decrementActiveConnections(); + } } } @Post('messages') - async anthropicMessages(@Body() body: AnthropicChatRequest, @Res() res: FastifyReply) { + async anthropicMessages( + @Body() body: AnthropicChatRequest, + @Req() req: FastifyRequest, + @Res() res: FastifyReply, + ) { + this.proxyReplayService?.recordRequest({ + requestId: uuidv4(), + timestamp: Date.now(), + endpoint: '/v1/messages', + method: 'POST', + body, + headers: req.headers as Record, + model: body.model, + }); + this.proxyMetrics?.incrementActiveConnections(); + const startTime = Date.now(); + let isStream = false; try { const result = await this.proxyService.handleAnthropicMessages(body); + isStream = !!(body.stream && this.isObservableLike(result)); - if (body.stream && this.isObservableLike(result)) { - this.writeSseResponse(res, result); + if (isStream) { + this.writeSseResponse(res, result as Observable, undefined, '/v1/messages', startTime); return; } else { + const duration = Date.now() - startTime; + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration, + status: HttpStatus.OK, + endpoint: '/v1/messages', + }); res.status(HttpStatus.OK).send(result); } } catch (error) { + const duration = Date.now() - startTime; + const status = this.resolveErrorHttpStatus(error instanceof Error ? error.message : ''); + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration, + status, + endpoint: '/v1/messages', + }); this.sendAnthropicErrorResponse(res, '/v1/messages', error); + } finally { + if (!isStream) { + this.proxyMetrics?.decrementActiveConnections(); + } } } @@ -762,6 +1010,35 @@ export class ProxyController { return isObjectLike(value) && isFunction((value as { subscribe?: unknown }).subscribe); } + private extractAndRecordTokens(payload: string): void { + try { + const lines = payload.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('data: ')) { + continue; + } + const jsonStr = trimmed.slice(6).trim(); + if (jsonStr === '[DONE]') { + continue; + } + const parsed = JSON.parse(jsonStr); + const usageMetadata = parsed?.usageMetadata; + if (usageMetadata && typeof usageMetadata.totalTokenCount === 'number') { + this.proxyMetrics?.recordTokens(usageMetadata.totalTokenCount); + return; + } + const usage = parsed?.usage; + if (usage && typeof usage.total_tokens === 'number') { + this.proxyMetrics?.recordTokens(usage.total_tokens); + return; + } + } + } catch { + // Ignore parse errors for token extraction + } + } + private writeSseResponse( res: FastifyReply, stream: Observable, @@ -774,6 +1051,7 @@ export class ProxyController { res.header('Cache-Control', 'no-cache'); res.header('Connection', 'keep-alive'); res.send(stream); + this.proxyMetrics?.decrementActiveConnections(); return; } @@ -791,6 +1069,23 @@ export class ProxyController { res.raw.flushHeaders(); } + let finished = false; + const finish = (status: number) => { + if (finished) { + return; + } + finished = true; + if (startTime && endpoint) { + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration: Date.now() - startTime, + status, + endpoint, + }); + } + this.proxyMetrics?.decrementActiveConnections(); + }; + const subscription = stream.subscribe({ next: (chunk) => { if (res.raw.writableEnded) { @@ -801,6 +1096,7 @@ export class ProxyController { if (requestId && endpoint) { trafficLogger.logSseChunk(requestId, endpoint, chunk); } + this.extractAndRecordTokens(payload); if (isFunction((res.raw as any).flush)) { (res.raw as any).flush(); } @@ -826,16 +1122,19 @@ export class ProxyController { \n\n`, ); res.raw.end(); + finish(status); }, complete: () => { if (!res.raw.writableEnded) { res.raw.end(); } + finish(HttpStatus.OK); }, }); res.raw.on('close', () => { subscription.unsubscribe(); + finish(HttpStatus.OK); }); } @@ -844,14 +1143,23 @@ export class ProxyController { prompt: string, res: FastifyReply, ): Promise { + this.proxyMetrics?.incrementActiveConnections(); + const startTime = Date.now(); + const endpoint = '/v1/images/generations'; try { const result = await this.proxyService.handleChatCompletions(request); if (result instanceof Observable) { this.logProxyEndpointError( - '/v1/images/generations', + endpoint, HttpStatus.INTERNAL_SERVER_ERROR, 'Streaming image generation is not supported by this endpoint', ); + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration: Date.now() - startTime, + status: HttpStatus.INTERNAL_SERVER_ERROR, + endpoint, + }); res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ error: { message: 'Streaming image generation is not supported by this endpoint', @@ -865,10 +1173,16 @@ export class ProxyController { const image = this.extractInlineBase64Image(isString(content) ? content : ''); if (!image) { this.logProxyEndpointError( - '/v1/images/generations', + endpoint, HttpStatus.BAD_GATEWAY, 'Upstream did not return inline image data', ); + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration: Date.now() - startTime, + status: HttpStatus.BAD_GATEWAY, + endpoint, + }); res.status(HttpStatus.BAD_GATEWAY).send({ error: { message: 'Upstream did not return inline image data', @@ -878,6 +1192,15 @@ export class ProxyController { return; } + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration: Date.now() - startTime, + status: HttpStatus.OK, + endpoint, + }); + if (result.usage?.total_tokens) { + this.proxyMetrics?.recordTokens(result.usage.total_tokens); + } res.status(HttpStatus.OK).send({ created: Math.floor(Date.now() / 1000), data: [ @@ -898,6 +1221,15 @@ export class ProxyController { ); const fallbackImage = this.extractInlineBase64ImageFromGeminiResponse(geminiResult); if (fallbackImage) { + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration: Date.now() - startTime, + status: HttpStatus.OK, + endpoint, + }); + if (geminiResult.usageMetadata?.totalTokenCount) { + this.proxyMetrics?.recordTokens(geminiResult.usageMetadata.totalTokenCount); + } res.status(HttpStatus.OK).send({ created: Math.floor(Date.now() / 1000), data: [ @@ -914,7 +1246,16 @@ export class ProxyController { } } - this.sendOpenAIErrorResponse(res, '/v1/images/generations', error, message); + const status = this.resolveErrorHttpStatus(message); + this.proxyMetrics?.recordRequest({ + timestamp: startTime, + duration: Date.now() - startTime, + status, + endpoint, + }); + this.sendOpenAIErrorResponse(res, endpoint, error, message); + } finally { + this.proxyMetrics?.decrementActiveConnections(); } } @@ -1132,7 +1473,11 @@ export class ProxyController { if (status === HttpStatus.SERVICE_UNAVAILABLE) { return { type: 'server_error', code: 'temporarily_unavailable' }; } - if (lowered.includes('invalid') || lowered.includes('malformed') || lowered.includes('bad request')) { + if ( + lowered.includes('invalid') || + lowered.includes('malformed') || + lowered.includes('bad request') + ) { return { type: 'invalid_request_error', code: 'invalid_request_error' }; } return { type: 'server_error', code: 'internal_error' }; diff --git a/src/server/modules/proxy/proxy.module.ts b/src/server/modules/proxy/proxy.module.ts index 86d7439..cc85fbe 100644 --- a/src/server/modules/proxy/proxy.module.ts +++ b/src/server/modules/proxy/proxy.module.ts @@ -6,11 +6,14 @@ import { GeminiClient } from './clients/gemini.client'; import { GeminiController } from './gemini.controller'; import { ProxyGuard } from './proxy.guard'; import { TokenUsageService } from '../usage/token-usage.service'; +import { ProxyReplayService } from './proxy-replay.service'; +import { ProxyMetricsService } from './proxy-metrics.service'; +import { ProxyIdeConfigService } from './proxy-ide-config.service'; @Module({ imports: [], controllers: [ProxyController, GeminiController], - providers: [ProxyService, TokenManagerService, GeminiClient, ProxyGuard, TokenUsageService], + providers: [ProxyService, TokenManagerService, GeminiClient, ProxyGuard, TokenUsageService, ProxyReplayService, ProxyMetricsService, ProxyIdeConfigService], exports: [TokenManagerService], }) export class ProxyModule {} diff --git a/src/server/modules/proxy/proxy.service.ts b/src/server/modules/proxy/proxy.service.ts index e08bff7..e7b2d36 100644 --- a/src/server/modules/proxy/proxy.service.ts +++ b/src/server/modules/proxy/proxy.service.ts @@ -50,7 +50,10 @@ export class ProxyService { private async recordUsage( accountId: string, model: string, - usage: { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number } | undefined | null, + usage: + | { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number } + | undefined + | null, requestType: string, isEstimated?: boolean, ): Promise { @@ -79,12 +82,15 @@ export class ProxyService { private extractUsageFromMetadata( meta: unknown, - ): { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number } | undefined { + ): + | { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number } + | undefined { if (!meta || typeof meta !== 'object') return undefined; const m = meta as Record; return { promptTokenCount: typeof m.promptTokenCount === 'number' ? m.promptTokenCount : undefined, - candidatesTokenCount: typeof m.candidatesTokenCount === 'number' ? m.candidatesTokenCount : undefined, + candidatesTokenCount: + typeof m.candidatesTokenCount === 'number' ? m.candidatesTokenCount : undefined, totalTokenCount: typeof m.totalTokenCount === 'number' ? m.totalTokenCount : undefined, }; } @@ -332,13 +338,17 @@ export class ProxyService { ); } else if (accumulatedText) { const estimated = this.estimateTokens(accumulatedText); - this.recordUsage(accountId, _model, { - promptTokenCount: 0, - candidatesTokenCount: estimated, - totalTokenCount: estimated, - }, 'anthropic', true).catch((err) => - this.logger.error('Usage recording failed', err), - ); + this.recordUsage( + accountId, + _model, + { + promptTokenCount: 0, + candidatesTokenCount: estimated, + totalTokenCount: estimated, + }, + 'anthropic', + true, + ).catch((err) => this.logger.error('Usage recording failed', err)); } subscriber.complete(); }); @@ -504,7 +514,7 @@ export class ProxyService { token.token.upstream_proxy_url, extraHeaders, ); - return this.passthroughSseStream(stream, token.id, effectiveTargetModel); + return this.passthroughSseStream(stream, token.id, effectiveTargetModel); } catch (err) { if (err instanceof Error && this.isProjectContextError(err.message)) { this.logger.warn( @@ -526,7 +536,7 @@ export class ProxyService { token.token.upstream_proxy_url, extraHeaders, ); - return this.passthroughSseStream(stream, token.id, effectiveTargetModel); + return this.passthroughSseStream(stream, token.id, effectiveTargetModel); } catch (fallbackErr) { lastError = fallbackErr; } @@ -579,7 +589,9 @@ export class ProxyService { if (typeof text === 'string') { accumulatedText += text; } - } catch { /* ignore */ } + } catch { + /* ignore */ + } } subscriber.next(chunk.toString()); @@ -597,13 +609,17 @@ export class ProxyService { ); } else if (accumulatedText) { const estimated = this.estimateTokens(accumulatedText); - this.recordUsage(accountId, model, { - promptTokenCount: 0, - candidatesTokenCount: estimated, - totalTokenCount: estimated, - }, 'gemini', true).catch((err) => - this.logger.error('Usage recording failed', err), - ); + this.recordUsage( + accountId, + model, + { + promptTokenCount: 0, + candidatesTokenCount: estimated, + totalTokenCount: estimated, + }, + 'gemini', + true, + ).catch((err) => this.logger.error('Usage recording failed', err)); } subscriber.complete(); }); @@ -859,14 +875,26 @@ export class ProxyService { // Use v1internal API (same as Anthropic handler) if (request.stream) { try { - trafficLogger.logUpstream(requestId || 'unknown', '/v1/chat/completions', geminiBody, extraHeaders, { model: effectiveTargetModel }); + trafficLogger.logUpstream( + requestId || 'unknown', + '/v1/chat/completions', + geminiBody, + extraHeaders, + { model: effectiveTargetModel }, + ); const stream = await this.geminiClient.streamGenerateInternal( geminiBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); - return this.processStreamResponse(stream, request.model, requestId, token.id, effectiveTargetModel); + return this.processStreamResponse( + stream, + request.model, + requestId, + token.id, + effectiveTargetModel, + ); } catch (streamError) { this.logger.warn( `Stream path failed for model=${request.model}; falling back to non-stream generation: ${ @@ -874,14 +902,26 @@ export class ProxyService { }`, ); - trafficLogger.logUpstream(requestId || 'unknown', '/v1/chat/completions', geminiBody, extraHeaders, { model: effectiveTargetModel }); + trafficLogger.logUpstream( + requestId || 'unknown', + '/v1/chat/completions', + geminiBody, + extraHeaders, + { model: effectiveTargetModel }, + ); const response = await this.generateInternalWithStreamFallback( geminiBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); - trafficLogger.logUpstreamResponse(requestId || 'unknown', '/v1/chat/completions', 200, response, 0); + trafficLogger.logUpstreamResponse( + requestId || 'unknown', + '/v1/chat/completions', + 200, + response, + 0, + ); this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai'); this.logger.log( `Upstream response snippet after stream fallback: ${JSON.stringify(response).substring(0, 500)}`, @@ -923,24 +963,48 @@ export class ProxyService { fallbackBody.model = effectiveTargetModel; this.applyInternalGenerationConstraints(fallbackBody, effectiveTargetModel, token.id); if (request.stream) { - trafficLogger.logUpstream(requestId || 'unknown', '/v1/chat/completions', fallbackBody, extraHeaders, { model: effectiveTargetModel }); + trafficLogger.logUpstream( + requestId || 'unknown', + '/v1/chat/completions', + fallbackBody, + extraHeaders, + { model: effectiveTargetModel }, + ); const stream = await this.geminiClient.streamGenerateInternal( fallbackBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); - return this.processStreamResponse(stream, request.model, requestId, token.id, effectiveTargetModel); + return this.processStreamResponse( + stream, + request.model, + requestId, + token.id, + effectiveTargetModel, + ); } - trafficLogger.logUpstream(requestId || 'unknown', '/v1/chat/completions', fallbackBody, extraHeaders, { model: effectiveTargetModel }); + trafficLogger.logUpstream( + requestId || 'unknown', + '/v1/chat/completions', + fallbackBody, + extraHeaders, + { model: effectiveTargetModel }, + ); const response = await this.generateInternalWithStreamFallback( fallbackBody, token.token.access_token, token.token.upstream_proxy_url, extraHeaders, ); - trafficLogger.logUpstreamResponse(requestId || 'unknown', '/v1/chat/completions', 200, response, 0); + trafficLogger.logUpstreamResponse( + requestId || 'unknown', + '/v1/chat/completions', + 200, + response, + 0, + ); this.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai'); const claudeResponse = transformResponse(response); return this.convertClaudeToOpenAIResponse(claudeResponse, request.model); @@ -1135,7 +1199,14 @@ export class ProxyService { try { const json = JSON.parse(dataStr); - trafficLogger.logUpstreamResponse(requestId || 'unknown', '/v1/chat/completions', 200, json, 0, { rawLine: trimmed }); + trafficLogger.logUpstreamResponse( + requestId || 'unknown', + '/v1/chat/completions', + 200, + json, + 0, + { rawLine: trimmed }, + ); // v1internal API wraps response in {"response": {"candidates": [...]}} const responsePayload = json.response ?? json; if (responsePayload.usageMetadata) { @@ -1300,13 +1371,17 @@ export class ProxyService { ); } else if (accumulatedText) { const estimated = this.estimateTokens(accumulatedText); - this.recordUsage(accountId, effectiveTargetModel, { - promptTokenCount: 0, - candidatesTokenCount: estimated, - totalTokenCount: estimated, - }, 'openai', true).catch((err) => - this.logger.error('Usage recording failed', err), - ); + this.recordUsage( + accountId, + effectiveTargetModel, + { + promptTokenCount: 0, + candidatesTokenCount: estimated, + totalTokenCount: estimated, + }, + 'openai', + true, + ).catch((err) => this.logger.error('Usage recording failed', err)); } subscriber.complete(); }); @@ -1776,7 +1851,7 @@ export class ProxyService { message: { role: 'assistant', // When tool_calls are present, content must be null per OpenAI spec. - content: toolCalls.length > 0 ? null : (finalContent || ''), + content: toolCalls.length > 0 ? null : finalContent || '', tool_calls: toolCalls.length > 0 ? toolCalls : undefined, }, finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason( @@ -1844,7 +1919,9 @@ export class ProxyService { if (error instanceof UpstreamRequestError) { const status = error.status; if (status === 401) { - this.logger.warn(`Upstream returned 401 for account ${accountId}; attempting token refresh`); + this.logger.warn( + `Upstream returned 401 for account ${accountId}; attempting token refresh`, + ); const refreshed = await this.tokenManager.forceRefreshToken(accountId); if (refreshed) { this.logger.log(`Token refreshed successfully for account ${accountId}; retry permitted`); diff --git a/src/server/modules/proxy/token-manager.service.ts b/src/server/modules/proxy/token-manager.service.ts index b2d6f57..4805e6a 100644 --- a/src/server/modules/proxy/token-manager.service.ts +++ b/src/server/modules/proxy/token-manager.service.ts @@ -6,6 +6,7 @@ import { GoogleAPIService } from '../../../services/GoogleAPIService'; import { getServerConfig } from '../../server-config'; import { RateLimitReason, RateLimitTracker } from './rate-limit-tracker'; import { updateDynamicForwardingRules } from '../../../lib/geminiNexus/ModelMapping'; +import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry'; interface TokenData { email: string; @@ -36,6 +37,14 @@ interface GetNextTokenOptions { type TokenEntry = [string, TokenData]; +interface CircuitBreakerEntry { + failureCount: number; + lastFailureTime: number | null; + state: 'healthy' | 'degraded' | 'unhealthy'; + failureRate: number; + requestLog: Array<{ timestamp: number; success: boolean }>; +} + function normalizeProjectId(projectId: string | null | undefined): string | undefined { if (!isString(projectId)) { return undefined; @@ -89,6 +98,11 @@ export class TokenManagerService implements OnModuleInit { private accountCooldowns: Map = new Map(); private sessionBindings: Map = new Map(); private rateLimitTracker = new RateLimitTracker(); + private circuitBreakerStates = new Map(); + private readonly circuitBreakerWindowMs = 5 * 60 * 1000; + private readonly circuitBreakerUnhealthyThreshold = 5; + private readonly circuitBreakerDegradedThreshold = 2; + private readonly circuitBreakerFailureRateThreshold = 0.5; private shadowComparisonCount = 0; private shadowMismatchCount = 0; @@ -96,6 +110,10 @@ export class TokenManagerService implements OnModuleInit { private parityErrorCount = 0; private noGoBlocked = false; + constructor() { + registerProxyAdvancedService('tokenManager', this); + } + async onModuleInit() { await this.loadAccounts(); } @@ -115,6 +133,13 @@ export class TokenManagerService implements OnModuleInit { } } + // Prune circuit breaker states for removed accounts + for (const accountId of this.circuitBreakerStates.keys()) { + if (!this.tokens.has(accountId)) { + this.circuitBreakerStates.delete(accountId); + } + } + this.logger.log(`Token manager loaded ${count} cloud accounts into cache`); return count; } catch (e) { @@ -331,18 +356,24 @@ export class TokenManagerService implements OnModuleInit { ); } - if (candidateAccountPool.length === 0) { + // Circuit breaker: skip unhealthy accounts unless all are unhealthy + const healthyPool = candidateAccountPool.filter( + ([accountId]) => this.getOrCreateCircuitBreakerEntry(accountId).state !== 'unhealthy', + ); + const finalCandidatePool = healthyPool.length > 0 ? healthyPool : candidateAccountPool; + + if (finalCandidatePool.length === 0) { this.logger.warn('No eligible account found after exclusion filtering'); return null; } if (this.shouldExecuteShadowComparison()) { - this.executeShadowComparison(candidateAccountPool, sessionKey, model); + this.executeShadowComparison(finalCandidatePool, sessionKey, model); } const selectedTokenEntry = this.isParitySchedulingEnabled() - ? await this.selectParityTokenCandidate(candidateAccountPool, sessionKey, model, now) - : this.selectLegacyTokenCandidate(candidateAccountPool, sessionKey, now); + ? await this.selectParityTokenCandidate(finalCandidatePool, sessionKey, model, now) + : this.selectLegacyTokenCandidate(finalCandidatePool, sessionKey, now); if (!selectedTokenEntry) { return null; @@ -978,6 +1009,95 @@ export class TokenManagerService implements OnModuleInit { return this.tokens.size; } + recordSuccess(accountId: string): void { + const resolvedId = this.resolveAccountId(accountId) ?? accountId; + const entry = this.getOrCreateCircuitBreakerEntry(resolvedId); + entry.failureCount = 0; + entry.requestLog.push({ timestamp: Date.now(), success: true }); + this.computeCircuitBreakerState(entry); + } + + recordFailure(accountId: string, error?: string): void { + const resolvedId = this.resolveAccountId(accountId) ?? accountId; + const entry = this.getOrCreateCircuitBreakerEntry(resolvedId); + entry.failureCount++; + entry.lastFailureTime = Date.now(); + entry.requestLog.push({ timestamp: Date.now(), success: false }); + this.computeCircuitBreakerState(entry); + + if (entry.state === 'unhealthy') { + this.logger.warn( + `Account ${resolvedId} circuit breaker opened: failureCount=${entry.failureCount}, failureRate=${entry.failureRate.toFixed(2)}${error ? `, error=${error}` : ''}`, + ); + } else if (entry.state === 'degraded') { + this.logger.warn( + `Account ${resolvedId} circuit breaker degraded: failureCount=${entry.failureCount}, failureRate=${entry.failureRate.toFixed(2)}${error ? `, error=${error}` : ''}`, + ); + } + } + + getCircuitBreakerStatus(): Record { + const result: Record = {}; + for (const accountId of this.tokens.keys()) { + const entry = this.getOrCreateCircuitBreakerEntry(accountId); + this.computeCircuitBreakerState(entry); + result[accountId] = { + state: entry.state, + failureCount: entry.failureCount, + lastFailureTime: entry.lastFailureTime, + failureRate: entry.failureRate, + }; + } + return result; + } + + getParityCounters(): { totalRequests: number; matchedRequests: number; parityViolations: number; lastUpdated: string } { + return { + totalRequests: this.parityRequestCount, + matchedRequests: this.shadowComparisonCount, + parityViolations: this.parityErrorCount, + lastUpdated: new Date().toISOString(), + }; + } + + private getOrCreateCircuitBreakerEntry(accountId: string): CircuitBreakerEntry { + let entry = this.circuitBreakerStates.get(accountId); + if (!entry) { + entry = { + failureCount: 0, + lastFailureTime: null, + state: 'healthy', + failureRate: 0, + requestLog: [], + }; + this.circuitBreakerStates.set(accountId, entry); + } + return entry; + } + + private pruneCircuitBreakerLog(entry: CircuitBreakerEntry): void { + const cutoff = Date.now() - this.circuitBreakerWindowMs; + entry.requestLog = entry.requestLog.filter((r) => r.timestamp > cutoff); + } + + private computeCircuitBreakerState(entry: CircuitBreakerEntry): void { + this.pruneCircuitBreakerLog(entry); + const total = entry.requestLog.length; + const failures = entry.requestLog.filter((r) => !r.success).length; + entry.failureRate = total > 0 ? failures / total : 0; + + if (entry.failureCount >= this.circuitBreakerUnhealthyThreshold) { + entry.state = 'unhealthy'; + } else if ( + entry.failureCount >= this.circuitBreakerDegradedThreshold && + entry.failureRate >= this.circuitBreakerFailureRateThreshold + ) { + entry.state = 'degraded'; + } else { + entry.state = 'healthy'; + } + } + private normalizeRefreshedOauthClientKey( currentToken: { oauth_client_key?: string; project_id?: string }, refreshedClientKey?: string, diff --git a/src/tests/scripts/project-id-request-validation.ts b/src/tests/scripts/project-id-request-validation.ts index 1af97ac..9cd8904 100644 --- a/src/tests/scripts/project-id-request-validation.ts +++ b/src/tests/scripts/project-id-request-validation.ts @@ -259,7 +259,11 @@ async function validateRuntimeAnthropicRequestFromRealTokenManager(): Promise { handleChatCompletions: vi.fn().mockResolvedValue({ ok: true }), handleAnthropicMessages: vi.fn(), }; - const controller = new ProxyController(proxyService as any); + const controller = new ProxyController(proxyService as any, undefined, undefined, undefined); const reply = createReplyMock(); const req = createReqMock(); @@ -46,7 +46,7 @@ describe('ProxyController Integration', () => { handleChatCompletions: vi.fn().mockResolvedValue(stream), handleAnthropicMessages: vi.fn(), }; - const controller = new ProxyController(proxyService as any); + const controller = new ProxyController(proxyService as any, undefined, undefined, undefined); const reply = createReplyMock(); const req = createReqMock(); @@ -91,8 +91,9 @@ describe('ProxyController Integration', () => { }), handleAnthropicMessages: vi.fn(), }; - const controller = new ProxyController(proxyService as any); + const controller = new ProxyController(proxyService as any, undefined, undefined, undefined); const reply = createReplyMock(); + const req = createReqMock(); await controller.completions( { @@ -100,6 +101,7 @@ describe('ProxyController Integration', () => { prompt: 'hello world', stream: false, }, + req, reply as any, ); @@ -148,8 +150,9 @@ describe('ProxyController Integration', () => { }), }; - const controller = new ProxyController(proxyService as any); + const controller = new ProxyController(proxyService as any, undefined, undefined, undefined); const reply = createReplyMock(); + const req = createReqMock(); await controller.responses( { @@ -174,6 +177,7 @@ describe('ProxyController Integration', () => { }, ], }, + req, reply as any, ); @@ -214,8 +218,9 @@ describe('ProxyController Integration', () => { handleChatCompletions: vi.fn().mockResolvedValue(stream), }; - const controller = new ProxyController(proxyService as any); + const controller = new ProxyController(proxyService as any, undefined, undefined, undefined); const reply = createReplyMock(); + const req = createReqMock(); await controller.responses( { @@ -224,6 +229,7 @@ describe('ProxyController Integration', () => { input: 'hello', stream: true, }, + req, reply as any, ); @@ -245,8 +251,9 @@ describe('ProxyController Integration', () => { }), }; - const controller = new ProxyController(proxyService as any); + const controller = new ProxyController(proxyService as any, undefined, undefined, undefined); const reply = createReplyMock(); + const req = createReqMock(); await controller.responses( { @@ -264,6 +271,7 @@ describe('ProxyController Integration', () => { }, ], }, + req, reply as any, ); @@ -291,14 +299,16 @@ describe('ProxyController Integration', () => { ], }), }; - const controller = new ProxyController(proxyService as any); + const controller = new ProxyController(proxyService as any, undefined, undefined, undefined); const reply = createReplyMock(); + const req = createReqMock(); await controller.imageGenerations( { model: 'gemini-3-pro-image', prompt: 'draw a cat', }, + req, reply as any, ); @@ -318,14 +328,16 @@ describe('ProxyController Integration', () => { const proxyService = { handleChatCompletions: vi.fn().mockRejectedValue(new Error('429 quota exceeded')), }; - const controller = new ProxyController(proxyService as any); + const controller = new ProxyController(proxyService as any, undefined, undefined, undefined); const reply = createReplyMock(); + const req = createReqMock(); await controller.imageGenerations( { model: 'gemini-3-pro-image', prompt: 'draw a dog', }, + req, reply as any, ); @@ -358,14 +370,16 @@ describe('ProxyController Integration', () => { ], }), }; - const controller = new ProxyController(proxyService as any); + const controller = new ProxyController(proxyService as any, undefined, undefined, undefined); const reply = createReplyMock(); + const req = createReqMock(); await controller.imageGenerations( { model: 'gemini-3-pro-image', prompt: 'draw a fox', }, + req, reply as any, ); @@ -395,7 +409,7 @@ describe('ProxyController Integration', () => { ], }), }; - const controller = new ProxyController(proxyService as any); + const controller = new ProxyController(proxyService as any, undefined, undefined, undefined); const reply = createReplyMock(); await controller.imageEdits( @@ -422,7 +436,7 @@ describe('ProxyController Integration', () => { const proxyService = { handleChatCompletions: vi.fn(), }; - const controller = new ProxyController(proxyService as any); + const controller = new ProxyController(proxyService as any, undefined, undefined, undefined); const reply = createReplyMock(); await controller.imageEdits( @@ -456,7 +470,7 @@ describe('ProxyController Integration', () => { ], }), }; - const controller = new ProxyController(proxyService as any); + const controller = new ProxyController(proxyService as any, undefined, undefined, undefined); const reply = createReplyMock(); await controller.audioTranscriptions( @@ -481,7 +495,7 @@ describe('ProxyController Integration', () => { const proxyService = { handleGeminiGenerateContent: vi.fn(), }; - const controller = new ProxyController(proxyService as any); + const controller = new ProxyController(proxyService as any, undefined, undefined, undefined); const reply = createReplyMock(); await controller.audioTranscriptions( @@ -509,8 +523,9 @@ describe('ProxyController Integration', () => { type: 'message', }), }; - const controller = new ProxyController(proxyService as any); + const controller = new ProxyController(proxyService as any, undefined, undefined, undefined); const reply = createReplyMock(); + const req = createReqMock(); await controller.anthropicMessages( { @@ -518,6 +533,7 @@ describe('ProxyController Integration', () => { stream: false, messages: [{ role: 'user', content: 'hello' }], } as any, + req, reply as any, ); diff --git a/src/utils/traffic-logger.ts b/src/utils/traffic-logger.ts index c45f1db..fc01529 100644 --- a/src/utils/traffic-logger.ts +++ b/src/utils/traffic-logger.ts @@ -4,8 +4,15 @@ import { isObjectLike } from 'lodash-es'; import winston from 'winston'; import DailyRotateFile from 'winston-daily-rotate-file'; import { getAgentDir } from './paths'; +import { TrafficLogsRepo } from '../ipc/database/proxyMetricsHandler'; -export type TrafficDirection = 'inbound' | 'outbound' | 'upstream' | 'upstream_response' | 'sse_chunk' | 'error'; +export type TrafficDirection = + | 'inbound' + | 'outbound' + | 'upstream' + | 'upstream_response' + | 'sse_chunk' + | 'error'; export interface TrafficLogEntry { timestamp: string; @@ -113,9 +120,34 @@ class TrafficLogger { level: 'debug', message: line, }); + + try { + TrafficLogsRepo.insert({ + timestamp: new Date(entry.timestamp).getTime(), + direction: entry.direction, + requestId: entry.requestId, + endpoint: entry.endpoint, + method: entry.method, + status: entry.status, + headers: entry.headers, + body: entry.body, + chunk: entry.chunk, + durationMs: entry.durationMs, + error: entry.error, + metadata: entry.metadata, + }); + } catch (dbError) { + console.error('Failed to persist traffic log to DB', dbError); + } } - logInbound(requestId: string, endpoint: string, body: unknown, headers?: Record, metadata?: Record): void { + logInbound( + requestId: string, + endpoint: string, + body: unknown, + headers?: Record, + metadata?: Record, + ): void { this.log({ timestamp: new Date().toISOString(), direction: 'inbound', @@ -128,7 +160,14 @@ class TrafficLogger { }); } - logOutbound(requestId: string, endpoint: string, status: number, body: unknown, durationMs: number, headers?: Record): void { + logOutbound( + requestId: string, + endpoint: string, + status: number, + body: unknown, + durationMs: number, + headers?: Record, + ): void { this.log({ timestamp: new Date().toISOString(), direction: 'outbound', @@ -141,7 +180,13 @@ class TrafficLogger { }); } - logUpstream(requestId: string, endpoint: string, body: unknown, headers?: Record, metadata?: Record): void { + logUpstream( + requestId: string, + endpoint: string, + body: unknown, + headers?: Record, + metadata?: Record, + ): void { this.log({ timestamp: new Date().toISOString(), direction: 'upstream', @@ -153,7 +198,14 @@ class TrafficLogger { }); } - logUpstreamResponse(requestId: string, endpoint: string, status: number, body: unknown, durationMs: number, headers?: Record): void { + logUpstreamResponse( + requestId: string, + endpoint: string, + status: number, + body: unknown, + durationMs: number, + headers?: Record, + ): void { this.log({ timestamp: new Date().toISOString(), direction: 'upstream_response', @@ -166,7 +218,12 @@ class TrafficLogger { }); } - logSseChunk(requestId: string, endpoint: string, chunk: unknown, metadata?: Record): void { + logSseChunk( + requestId: string, + endpoint: string, + chunk: unknown, + metadata?: Record, + ): void { this.log({ timestamp: new Date().toISOString(), direction: 'sse_chunk', @@ -177,7 +234,14 @@ class TrafficLogger { }); } - logError(requestId: string, endpoint: string, error: Error | unknown, status?: number, body?: unknown, metadata?: Record): void { + logError( + requestId: string, + endpoint: string, + error: Error | unknown, + status?: number, + body?: unknown, + metadata?: Record, + ): void { const err = error instanceof Error ? error : new Error(String(error)); this.log({ timestamp: new Date().toISOString(),