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
This commit is contained in:
Gemini Nexus Bot committed 2026-04-23 00:57:01 -03:00
1 parent c73537d44c
commit 0be7d7fea2
36 files changed
+4551 -882

No files matched your search

+33 -23
View File
@@ -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. Depois de salvar, reinicie o OpenCode ou use `/models` para selecionar.
**Comando rápido para adicionar chave:** **Comando rápido para adicionar chave:**
```bash ```bash
opencode /connect opencode /connect
# Escolha "Other" → digite o provider_id (ex: minha-ia-custom) → cole a API key # Escolha "Other" → digite o provider_id (ex: minha-ia-custom) → cole a API key
@@ -73,6 +74,7 @@ POST {baseURL}/chat/completions
``` ```
**Headers enviados:** **Headers enviados:**
- `Authorization: Bearer <sua-api-key>` - `Authorization: Bearer <sua-api-key>`
- `Content-Type: application/json` - `Content-Type: application/json`
- Headers customizados que você definir em `options.headers` - Headers customizados que você definir em `options.headers`
@@ -95,12 +97,15 @@ POST {baseURL}/chat/completions
"stream": true, "stream": true,
"temperature": 0.7, "temperature": 0.7,
"max_tokens": 8192, "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" "tool_choice": "auto"
} }
``` ```
**Parâmetros comuns que o OpenCode envia:** **Parâmetros comuns que o OpenCode envia:**
- `messages` (array completo do histórico) - `messages` (array completo do histórico)
- `stream: true` (quase sempre, para UX em tempo real) - `stream: true` (quase sempre, para UX em tempo real)
- `temperature`, `max_tokens`, `top_p` - `temperature`, `max_tokens`, `top_p`
@@ -165,19 +170,23 @@ Se sua API suportar `tools`, o OpenCode pode:
```json ```json
{ {
"choices": [{ "choices": [
"message": { {
"role": "assistant", "message": {
"tool_calls": [{ "role": "assistant",
"id": "call_123", "tool_calls": [
"type": "function", {
"function": { "id": "call_123",
"name": "read_file", "type": "function",
"arguments": "{\"path\": \"src/app.ts\"}" "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 ### 5. Requisitos Mínimos da Sua API para Funcionar Perfeitamente
| Recurso | Obrigatório? | Recomendado? | Observação | | Recurso | Obrigatório? | Recomendado? | Observação |
|----------------------|--------------|--------------|----------| | ---------------------- | ------------ | ------------ | ---------------------------------- |
| `/v1/chat/completions` | Sim | - | Endpoint principal | | `/v1/chat/completions` | Sim | - | Endpoint principal |
| Streaming (SSE) | Sim | - | Essencial para UX | | Streaming (SSE) | Sim | - | Essencial para UX |
| Tool Calling | Não | Sim | Para agentes completos | | Tool Calling | Não | Sim | Para agentes completos |
| System messages | Sim | - | - | | System messages | Sim | - | - |
| Multi-turn history | Sim | - | - | | Multi-turn history | Sim | - | - |
| Usage stats | Não | Sim | Mostra tokens gastos | | Usage stats | Não | Sim | Mostra tokens gastos |
| Error handling | Sim | - | Retorne JSON de erro padrão OpenAI | | Error handling | Sim | - | Retorne JSON de erro padrão OpenAI |
**Endpoint exato esperado:** **Endpoint exato esperado:**
`POST https://sua-api.com/v1/chat/completions` `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). Sua API **precisa ser 100% compatível** com o endpoint OpenAI `/v1/chat/completions` (streaming + tool calling recomendado).
O OpenCode vai: O OpenCode vai:
1. Configurar via `opencode.json` (baseURL + apiKey) 1. Configurar via `opencode.json` (baseURL + apiKey)
2. Enviar requisições padrão OpenAI 2. Enviar requisições padrão OpenAI
3. Receber streaming ou resposta completa 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. 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ê. 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ê.
+1 -2
View File
@@ -449,8 +449,7 @@ const config: ForgeConfig = {
makers: [ makers: [
new MakerSquirrel({ new MakerSquirrel({
setupIcon: 'images/icon.ico', setupIcon: 'images/icon.ico',
iconUrl: iconUrl: 'https://raw.githubusercontent.com/evandrodevbr/GeminiNexus/main/images/icon.ico',
'https://raw.githubusercontent.com/evandrodevbr/GeminiNexus/main/images/icon.ico',
}), }),
...(process.platform === 'win32' && process.arch === 'x64' ...(process.platform === 'win32' && process.arch === 'x64'
? [ ? [
+450 -193
View File
@@ -17,6 +17,9 @@
"@nestjs/microservices": "^11.1.18", "@nestjs/microservices": "^11.1.18",
"@nestjs/platform-express": "^11.1.18", "@nestjs/platform-express": "^11.1.18",
"@nestjs/platform-fastify": "^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/client": "^1.13.14",
"@orpc/server": "^1.13.14", "@orpc/server": "^1.13.14",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
@@ -61,7 +64,6 @@
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-error-boundary": "^6.1.0", "react-error-boundary": "^6.1.0",
"react-i18next": "^16.2.4", "react-i18next": "^16.2.4",
"recharts": "^3.8.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"tailwind-merge": "^3.4.0", "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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -7415,40 +7752,76 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@reduxjs/toolkit": { "node_modules/@react-spring/animated": {
"version": "2.11.2", "version": "10.0.3",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.3.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", "integrity": "sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@react-spring/shared": "~10.0.3",
"@standard-schema/utils": "^0.3.0", "@react-spring/types": "~10.0.3"
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
} }
}, },
"node_modules/@reduxjs/toolkit/node_modules/immer": { "node_modules/@react-spring/core": {
"version": "11.1.4", "version": "10.0.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.3.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", "integrity": "sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==",
"license": "MIT", "license": "MIT",
"dependencies": {
"@react-spring/animated": "~10.0.3",
"@react-spring/shared": "~10.0.3",
"@react-spring/types": "~10.0.3"
},
"funding": { "funding": {
"type": "opencollective", "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": { "node_modules/@rolldown/pluginutils": {
@@ -9412,12 +9785,6 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT" "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": { "node_modules/@swc/core": {
"version": "1.15.8", "version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.8.tgz", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.8.tgz",
@@ -10700,22 +11067,16 @@
"@types/node": "*" "@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": { "node_modules/@types/d3-color": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-ease": { "node_modules/@types/d3-format": {
"version": "3.0.2", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.4.5.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "integrity": "sha512-mLxrC1MSWupOSncXN/HOlWUAAIffAEBaI4+PKy2uMPsKe4FNZlk7qrbTjmzJXITQQqBHivaks4Td18azgqnotA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-interpolate": { "node_modules/@types/d3-interpolate": {
@@ -10742,6 +11103,12 @@
"@types/d3-time": "*" "@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": { "node_modules/@types/d3-shape": {
"version": "3.1.8", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
@@ -10757,10 +11124,10 @@
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-timer": { "node_modules/@types/d3-time-format": {
"version": "3.0.2", "version": "2.3.4",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.3.4.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "integrity": "sha512-xdDXbpVO74EvadI3UDxjxTdR6QIxm1FKzEA/+F8tL4GWWUg/hgvBqf6chql64U5A9ZUGWo7pEu4eNlyLwbKdhg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/debug": { "node_modules/@types/debug": {
@@ -11010,12 +11377,6 @@
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
"license": "MIT" "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": { "node_modules/@types/uuid": {
"version": "11.0.0", "version": "11.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz",
@@ -14590,15 +14951,6 @@
"node": ">=12" "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": { "node_modules/d3-format": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
@@ -14645,6 +14997,19 @@
"node": ">=12" "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": { "node_modules/d3-shape": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@@ -14681,15 +15046,6 @@
"node": ">=12" "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": { "node_modules/data-urls": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz",
@@ -14802,12 +15158,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/decompress-response": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -16551,16 +16901,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/es6-error": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
@@ -16986,6 +17326,7 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/events": { "node_modules/events": {
@@ -19516,16 +19857,6 @@
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT" "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": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -21363,7 +21694,6 @@
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash-es": { "node_modules/lodash-es": {
@@ -26420,32 +26750,10 @@
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "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": { "node_modules/read-binary-file-arch": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", "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": ">=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": { "node_modules/rechoir": {
"version": "0.8.0", "version": "0.8.0",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
@@ -26921,21 +27209,6 @@
"node": ">=8" "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": { "node_modules/reflect-metadata": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -27059,12 +27332,6 @@
"url": "https://github.com/sponsors/jet2jet" "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": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "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": { "node_modules/use-sidecar": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
@@ -30731,28 +31010,6 @@
"optional": true, "optional": true,
"peer": 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": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+3 -1
View File
@@ -102,6 +102,9 @@
"@nestjs/microservices": "^11.1.18", "@nestjs/microservices": "^11.1.18",
"@nestjs/platform-express": "^11.1.18", "@nestjs/platform-express": "^11.1.18",
"@nestjs/platform-fastify": "^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/client": "^1.13.14",
"@orpc/server": "^1.13.14", "@orpc/server": "^1.13.14",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
@@ -146,7 +149,6 @@
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-error-boundary": "^6.1.0", "react-error-boundary": "^6.1.0",
"react-i18next": "^16.2.4", "react-i18next": "^16.2.4",
"recharts": "^3.8.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
+29
View File
@@ -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<ProxyAdvancedTabProps> = () => {
const { t } = useTranslation();
return (
<Tabs defaultValue="monitor" className="space-y-5">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="monitor">{t('proxy.advanced.monitor')}</TabsTrigger>
<TabsTrigger value="tools">{t('proxy.advanced.tabs.tools')}</TabsTrigger>
</TabsList>
<TabsContent value="monitor">
<MonitorTab />
</TabsContent>
<TabsContent value="tools">
<ToolsTab />
</TabsContent>
</Tabs>
);
};
@@ -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<string>('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<typeof Badge>['variant'] => {
if (state === 'closed') return 'default';
if (state === 'open') return 'destructive';
return 'secondary';
};
return (
<div className="space-y-5">
{/* Metrics */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label={t('proxy.advanced.metrics.requestsPerMinute')}
value={metrics ? Math.round(metrics.requestsPerMinute) : 0}
icon={Zap}
isLoading={metricsLoading}
accent="amber"
/>
<StatCard
label={t('proxy.advanced.metrics.avgLatency')}
value={metrics ? Math.round(metrics.avgLatency) : 0}
icon={Clock}
isLoading={metricsLoading}
accent="blue"
/>
<StatCard
label={t('proxy.advanced.metrics.errorRate')}
value={metrics ? Math.round(metrics.errorRate * 100) : 0}
icon={AlertTriangle}
isLoading={metricsLoading}
accent="amber"
/>
<StatCard
label={t('proxy.advanced.metrics.activeConnections')}
value={metrics?.activeConnections ?? 0}
icon={Network}
isLoading={metricsLoading}
accent="green"
/>
</div>
{/* Circuit Breakers */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-semibold">
{t('proxy.advanced.circuitBreaker.title')}
</CardTitle>
</CardHeader>
<CardContent>
{cbLoading ? (
<div className="flex h-20 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : !circuitBreakers ? (
<p className="text-sm text-muted-foreground">No data</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Object.entries(circuitBreakers.states).map(([api, state]) => (
<Card key={api} className="bg-muted/30">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{api}</span>
<Badge variant={getCircuitBadgeVariant(state.state)}>
{state.state}
</Badge>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{t('proxy.advanced.circuitBreaker.failures')}: {state.failures}
</div>
{state.lastFailure && (
<div className="text-xs text-muted-foreground">
{t('proxy.advanced.circuitBreaker.lastFailure')}:{' '}
{new Date(state.lastFailure).toLocaleString()}
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
{/* Traffic Logs */}
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h3 className="text-sm font-semibold">
{t('proxy.advanced.trafficLog.title')}
</h3>
{stats && (
<span className="text-xs text-muted-foreground">
Total: {stats.total}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Select value={direction} onValueChange={setDirection}>
<SelectTrigger className="h-8 w-[140px] text-xs">
<SelectValue placeholder={t('proxy.advanced.trafficLog.direction')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('proxy.advanced.trafficLog.all')}
</SelectItem>
<SelectItem value="inbound">
{t('proxy.advanced.trafficLog.inbound')}
</SelectItem>
<SelectItem value="outbound">
{t('proxy.advanced.trafficLog.outbound')}
</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => handleExport('json')}
>
{t('proxy.advanced.trafficLog.exportJson')}
</Button>
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => handleExport('csv')}
>
{t('proxy.advanced.trafficLog.exportCsv')}
</Button>
</div>
</div>
<TrafficLogTable logs={logs || []} isLoading={logsLoading} />
</div>
</div>
);
};
+150
View File
@@ -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 (
<div className="space-y-5">
{/* Parity Counters */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard
label={t('proxy.advanced.tools.totalRequests')}
value={parity?.totalRequests ?? 0}
icon={CheckCircle}
isLoading={parityLoading}
accent="blue"
/>
<StatCard
label={t('proxy.advanced.tools.matchedRequests')}
value={parity?.matchedRequests ?? 0}
icon={ShieldAlert}
isLoading={parityLoading}
accent="green"
/>
<StatCard
label={t('proxy.advanced.tools.parityViolations')}
value={parity?.parityViolations ?? 0}
icon={XCircle}
isLoading={parityLoading}
accent="amber"
/>
</div>
{/* IDE Quick Setup */}
<Card>
<CardHeader>
<CardTitle>{t('proxy.advanced.tools.ideSetup')}</CardTitle>
<CardDescription>Quick configuration for popular IDEs</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{isIdeLoading ? (
<div className="flex h-20 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : (
IDE_CONFIGS.map(({ key, label, icon: Icon }) => {
const instructions = getIdeInstructions(key);
return (
<div key={key} className="rounded-lg border">
<div className="flex items-center justify-between p-3">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{label}</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-8 text-xs"
onClick={() => copyToClipboard(instructions)}
>
<Copy className="mr-1.5 h-3.5 w-3.5" />
{t('proxy.advanced.tools.copyConfig')}
</Button>
</div>
{instructions && (
<pre className="overflow-x-auto border-t bg-gray-900 p-3 font-mono text-xs whitespace-pre-wrap text-gray-100">
{instructions}
</pre>
)}
</div>
);
})
)}
</CardContent>
</Card>
{/* Request Replay Placeholder */}
<Card>
<CardHeader>
<CardTitle>{t('proxy.advanced.tools.requestReplay')}</CardTitle>
<CardDescription>Replay recent requests for debugging</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-32 flex-col items-center justify-center gap-2 rounded-lg border border-dashed">
<Badge variant="outline">{t('proxy.advanced.tools.comingSoon')}</Badge>
<p className="text-xs text-muted-foreground">
Request replay will be available in a future update
</p>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -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<TrafficLogTableProps> = ({ logs, isLoading }) => {
const { t } = useTranslation();
const [sortKey, setSortKey] = useState<SortKey>('timestamp');
const [sortDir, setSortDir] = useState<SortDir>('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 <span className="ml-1 inline-block w-3" />;
return sortDir === 'asc' ? (
<ArrowUp className="ml-1 inline h-3 w-3" />
) : (
<ArrowDown className="ml-1 inline h-3 w-3" />
);
};
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-semibold">
{t('proxy.advanced.trafficLog.title')}
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : logs.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t('proxy.advanced.trafficLog.noLogs')}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('timestamp')}
>
{t('proxy.advanced.trafficLog.timestamp')}
<SortIcon column="timestamp" />
</th>
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('direction')}
>
{t('proxy.advanced.trafficLog.direction')}
<SortIcon column="direction" />
</th>
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('endpoint')}
>
{t('proxy.advanced.trafficLog.endpoint')}
<SortIcon column="endpoint" />
</th>
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('method')}
>
{t('proxy.advanced.trafficLog.method')}
<SortIcon column="method" />
</th>
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('status')}
>
{t('proxy.advanced.trafficLog.status')}
<SortIcon column="status" />
</th>
<th
className="cursor-pointer pb-2 font-medium select-none"
onClick={() => toggleSort('duration')}
>
{t('proxy.advanced.trafficLog.duration')}
<SortIcon column="duration" />
</th>
</tr>
</thead>
<tbody>
{sortedLogs.map((log, i) => (
<tr
key={`${log.requestId}-${i}`}
className="border-b border-border/40 last:border-0"
>
<td className="py-2 pr-4 font-mono text-xs text-muted-foreground">
{new Date(log.timestamp).toLocaleTimeString()}
</td>
<td className="py-2 pr-4">
<Badge
variant="outline"
className={
log.direction === 'inbound'
? 'border-blue-500/30 text-blue-400'
: log.direction === 'outbound'
? 'border-emerald-500/30 text-emerald-400'
: 'border-slate-500/30 text-slate-400'
}
>
{log.direction}
</Badge>
</td>
<td className="py-2 pr-4 font-mono text-xs">{log.endpoint}</td>
<td className="py-2 pr-4 text-xs">{log.method || '—'}</td>
<td className="py-2 pr-4 text-xs">
{log.status != null ? (
<Badge
variant={log.status >= 400 ? 'destructive' : 'secondary'}
className="text-xs"
>
{log.status}
</Badge>
) : (
'—'
)}
</td>
<td className="py-2 text-xs">
{log.durationMs != null ? `${log.durationMs}ms` : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
};
-36
View File
@@ -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<CustomTooltipProps> = ({ active, payload, label }) => {
if (!active || !payload || payload.length === 0) {
return null;
}
return (
<div className="bg-popover border-border rounded-md border px-3 py-2 shadow-md">
<p className="text-foreground mb-1.5 text-xs font-medium">{label}</p>
<div className="flex flex-col gap-1">
{payload.map((entry, index) => (
<div key={index} className="flex items-center gap-2 text-xs">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="text-muted-foreground">{entry.name}:</span>
<span className="text-foreground font-mono font-medium">
{typeof entry.value === 'number' ? entry.value.toLocaleString() : entry.value}
</span>
</div>
))}
</div>
</div>
);
};
+60
View File
@@ -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<string, string> = {
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 (
<div className="bg-popover border-border rounded-md border px-3 py-2 shadow-md">
<p className="text-foreground mb-1.5 text-xs font-medium">{indexValue}</p>
<div className="flex flex-col gap-1">
{keys.map((key: string) => (
<div key={key} className="flex items-center gap-2 text-xs">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: colorMap[key] }}
/>
<span className="text-muted-foreground">{key}:</span>
<span className="text-foreground font-mono font-medium">
{typeof data[key] === 'number' ? data[key].toLocaleString() : data[key]}
</span>
</div>
))}
</div>
</div>
);
}
export function PieTooltip({ datum }: any) {
return (
<div className="bg-popover border-border rounded-md border px-3 py-2 shadow-md">
<div className="flex items-center gap-2 text-xs">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: datum.color }}
/>
<span className="text-muted-foreground">{datum.id}:</span>
<span className="text-foreground font-mono font-medium">
{typeof datum.value === 'number' ? datum.value.toLocaleString() : datum.value}
</span>
</div>
</div>
);
}
+9 -5
View File
@@ -144,7 +144,11 @@ export function ensureDatabaseInitialized(dbPath: string): void {
// Migration: Check if request_type column exists on token_usage // Migration: Check if request_type column exists on token_usage
const tokenUsageTableInfoRaw = db.pragma('table_info(token_usage)') as any[]; 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'); const hasRequestType = tokenUsageTableInfo.some((col) => col.name === 'request_type');
if (!hasRequestType) { if (!hasRequestType) {
db.exec('ALTER TABLE token_usage ADD COLUMN request_type TEXT'); 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_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_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_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) { } catch (error) {
logger.error('Failed to initialize cloud database schema', error); logger.error('Failed to initialize cloud database schema', error);
throw error; throw error;
@@ -551,9 +557,7 @@ export class CloudAccountRepo {
const rows = orm.select().from(accounts).orderBy(desc(accounts.lastUsed)).all(); const rows = orm.select().from(accounts).orderBy(desc(accounts.lastUsed)).all();
const activeRows = rows.filter((r) => r.isActive); const activeRows = rows.filter((r) => r.isActive);
logger.debug( logger.debug(`getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`);
`getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`,
);
// Auto-activate the single account if none are active // Auto-activate the single account if none are active
if (rows.length > 0 && activeRows.length === 0) { if (rows.length > 0 && activeRows.length === 0) {
+452
View File
@@ -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<typeof import('./schema')>;
} | 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<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: { message: string; stack?: string; status?: number; body?: unknown };
metadata?: Record<string, unknown>;
}
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<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: { message: string; stack?: string; status?: number; body?: unknown };
metadata?: Record<string, unknown>;
}> {
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<string, number>; byEndpoint: Record<string, number>; 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<string, number> = {};
const byEndpoint: Record<string, number> = {};
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<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: { message: string; stack?: string; status?: number; body?: unknown };
metadata?: Record<string, unknown>;
}> {
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;
}
}
}
+36
View File
@@ -39,3 +39,39 @@ export const tokenUsage = sqliteTable('token_usage', {
requestType: text('request_type'), requestType: text('request_type'),
isEstimated: integer('is_estimated'), 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'),
});
+5 -4
View File
@@ -24,7 +24,10 @@ export interface RecordUsageParams {
isEstimated?: boolean; isEstimated?: boolean;
} }
let usageDbConnection: { raw: Database.Database; orm: BetterSQLite3Database<typeof import('./schema')> } | null = null; let usageDbConnection: {
raw: Database.Database;
orm: BetterSQLite3Database<typeof import('./schema')>;
} | null = null;
function getUsageDb() { function getUsageDb() {
if (!usageDbConnection) { if (!usageDbConnection) {
@@ -57,9 +60,7 @@ function buildDateFilter(start?: number, end?: number): { sql: string; params: n
} }
function buildAccountFilter(accountId?: string): { sql: string; params: unknown[] } { function buildAccountFilter(accountId?: string): { sql: string; params: unknown[] } {
return accountId return accountId ? { sql: 'AND account_id = ?', params: [accountId] } : { sql: '', params: [] };
? { sql: 'AND account_id = ?', params: [accountId] }
: { sql: '', params: [] };
} }
export class TokenUsageRepo { export class TokenUsageRepo {
+325
View File
@@ -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<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: {
message: string;
stack?: string;
status?: number;
body?: unknown;
};
metadata?: Record<string, unknown>;
}
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<string, number>; byEndpoint: Record<string, number>; 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<string, { state: string; failures: number; lastFailure: string | null }>; 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<string, { state: string; failures: number; lastFailure: string | null }> = {};
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) };
}
}
+172
View File
@@ -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);
}),
});
@@ -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<K extends keyof ProxyAdvancedRegistry>(
key: K,
instance: ProxyAdvancedRegistry[K],
): void {
proxyAdvancedRegistry[key] = instance;
}
+2
View File
@@ -7,6 +7,7 @@ import { cloudRouter } from './cloud/router';
import { configRouter } from './config/router'; import { configRouter } from './config/router';
import { gatewayRouter } from './gateway/router'; import { gatewayRouter } from './gateway/router';
import { usageRouter } from './usage/router'; import { usageRouter } from './usage/router';
import { proxyAdvancedRouter } from './proxy-advanced/router';
import { os } from '@orpc/server'; import { os } from '@orpc/server';
import { z } from 'zod'; import { z } from 'zod';
@@ -55,5 +56,6 @@ export const router = os.use(logMiddleware).router({
config: configRouter, config: configRouter,
gateway: gatewayRouter, gateway: gatewayRouter,
usage: usageRouter, usage: usageRouter,
proxyAdvanced: proxyAdvancedRouter,
system: systemHandler, system: systemHandler,
}); });
+11 -2
View File
@@ -694,7 +694,13 @@ function buildGenerationConfig(
if (maxTokens !== undefined) { if (maxTokens !== undefined) {
config.maxOutputTokens = maxTokens; 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) { if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
config.stopSequences = [...new Set([...claudeReq.stop_sequences, ...defaultStopSequences])]; config.stopSequences = [...new Set([...claudeReq.stop_sequences, ...defaultStopSequences])];
} else { } else {
@@ -705,7 +711,10 @@ function buildGenerationConfig(
if (claudeReq.response_format) { if (claudeReq.response_format) {
if (claudeReq.response_format.type === 'json_object') { if (claudeReq.response_format.type === 'json_object') {
config.responseMimeType = 'application/json'; 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.responseMimeType = 'application/json';
config.responseSchema = cleanJsonSchema(claudeReq.response_format.json_schema); config.responseSchema = cleanJsonSchema(claudeReq.response_format.json_schema);
} }
+4 -1
View File
@@ -254,7 +254,10 @@ class NonStreamingProcessor {
/** /**
* Public API: Transform Gemini Response to Claude Response * 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(); const processor = new NonStreamingProcessor();
return processor.process(geminiResponse, modelName); return processor.process(geminiResponse, modelName);
} }
+61
View File
@@ -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',
},
},
};
+255
View File
@@ -489,10 +489,95 @@ i18n
maps_to: 'Maps to', maps_to: 'Maps to',
restore: 'Restore Defaults', 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: { examples: {
title: 'Usage Examples', title: 'Usage Examples',
description: 'Example commands to call the local API proxy.', 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: { usage: {
title: 'Token Usage', title: 'Token Usage',
@@ -990,10 +1075,95 @@ i18n
maps_to: '映射到', maps_to: '映射到',
restore: '恢复默认', restore: '恢复默认',
}, },
routing: {
currentMappings: '当前模型映射',
modelMapping: 'Anthropic 模型路由配置',
noMapping: '未配置模型映射。',
upstreamProxy: '出站代理配置',
status: '状态',
enabled: '已启用',
disabled: '已禁用',
url: 'URL',
},
examples: { examples: {
title: '使用示例', title: '使用示例',
description: '调用本地 API 代理的示例命令。', 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: { usage: {
title: 'Token 用量', title: 'Token 用量',
@@ -1496,10 +1666,95 @@ i18n
maps_to: '->', maps_to: '->',
restore: 'Сброс', restore: 'Сброс',
}, },
routing: {
currentMappings: 'Текущие сопоставления моделей',
modelMapping: 'Конфигурация маршрутизации моделей Anthropic',
noMapping: 'Сопоставления моделей не настроены.',
upstreamProxy: 'Конфигурация исходящего прокси',
status: 'Статус',
enabled: 'Включено',
disabled: 'Отключено',
url: 'URL',
},
examples: { examples: {
title: 'Примеры', title: 'Примеры',
description: 'Примеры использования локального прокси.', 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: { usage: {
title: 'Использование токенов', title: 'Использование токенов',
+49
View File
@@ -472,6 +472,55 @@ const vi = {
title: 'Ví dụ sử dụng', title: 'Ví dụ sử dụng',
description: 'Ví dụ lệnh để gọi local API proxy.', 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: { usage: {
title: 'Token Usage', title: 'Token Usage',
+623 -415
View File
File diff suppressed because it is too large. Load diff
+144 -116
View File
@@ -2,19 +2,8 @@ import { useMemo, useState } from 'react';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import { ResponsiveBar } from '@nivo/bar';
BarChart, import { ResponsivePie } from '@nivo/pie';
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
Legend,
} from 'recharts';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -27,7 +16,15 @@ import { ipc } from '@/ipc/manager';
import { useCloudAccounts } from '@/hooks/useCloudAccounts'; import { useCloudAccounts } from '@/hooks/useCloudAccounts';
import { StatCard } from '@/components/usage/StatCard'; import { StatCard } from '@/components/usage/StatCard';
import { ChartCard } from '@/components/usage/ChartCard'; 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 { import {
Activity, Activity,
ArrowUpRight, ArrowUpRight,
@@ -38,23 +35,6 @@ import {
Zap, Zap,
} from 'lucide-react'; } 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 */ /* Types */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -108,6 +88,13 @@ function formatBucketLabel(bucket: string, range: TimeRange): string {
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); 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 */ /* Main Component */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -201,7 +188,7 @@ function UsagePage() {
const pieData = useMemo(() => { const pieData = useMemo(() => {
return (modelData || []).map((d) => ({ return (modelData || []).map((d) => ({
name: d.model, id: d.model,
value: d.totalTokens, value: d.totalTokens,
})); }));
}, [modelData]); }, [modelData]);
@@ -215,6 +202,10 @@ function UsagePage() {
})); }));
}, [hourlyData]); }, [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 isDailyEmpty = !dailyLoading && !dailyError && (!dailyData || dailyData.length === 0);
const isModelEmpty = !modelLoading && !modelError && (!modelData || modelData.length === 0); const isModelEmpty = !modelLoading && !modelError && (!modelData || modelData.length === 0);
const isHourlyEmpty = !hourlyLoading && !hourlyError && (!hourlyData || hourlyData.length === 0); const isHourlyEmpty = !hourlyLoading && !hourlyError && (!hourlyData || hourlyData.length === 0);
@@ -343,35 +334,52 @@ function UsagePage() {
errorMessage={t('usage.loadFailed')} errorMessage={t('usage.loadFailed')}
retryLabel={t('usage.retry')} retryLabel={t('usage.retry')}
> >
<ResponsiveContainer width="100%" height={280}> <div className="h-[280px]">
<BarChart data={chartData} barCategoryGap="20%"> <ResponsiveBar
<CartesianGrid data={chartData}
strokeDasharray="3 3" keys={['Prompt', 'Completion']}
stroke="rgba(255,255,255,0.06)" indexBy="name"
vertical={false} groupMode="grouped"
/> theme={nivoTheme}
<XAxis colors={barColor}
dataKey="name" margin={{ top: 10, right: 10, bottom: 40, left: 50 }}
tick={{ fill: '#a1a1aa', fontSize: 11 }} padding={0.2}
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }} innerPadding={2}
tickLine={false} borderRadius={2}
/> enableGridY={true}
<YAxis enableGridX={false}
tick={{ fill: '#a1a1aa', fontSize: 11 }} gridYValues={5}
axisLine={false} axisBottom={{
tickLine={false} tickSize: 0,
tickFormatter={(v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`)} tickPadding: 5,
/> tickRotation: 0,
<Tooltip content={<CustomTooltip />} /> }}
<Legend axisLeft={{
wrapperStyle={{ fontSize: 12, paddingTop: 8 }} tickSize: 0,
iconType="circle" tickPadding: 5,
iconSize={8} tickRotation: 0,
/> format: (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`),
<Bar dataKey="Prompt" fill={COLOR_PROMPT} radius={[2, 2, 0, 0]} /> }}
<Bar dataKey="Completion" fill={COLOR_COMPLETION} radius={[2, 2, 0, 0]} /> tooltip={BarTooltip}
</BarChart> legends={[
</ResponsiveContainer> {
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}
/>
</div>
</ChartCard> </ChartCard>
{/* Model Usage Donut Chart */} {/* Model Usage Donut Chart */}
@@ -386,30 +394,34 @@ function UsagePage() {
errorMessage={t('usage.loadFailed')} errorMessage={t('usage.loadFailed')}
retryLabel={t('usage.retry')} retryLabel={t('usage.retry')}
> >
<ResponsiveContainer width="100%" height={280}> <div className="h-[280px]">
<PieChart> <ResponsivePie
<Pie data={pieData}
data={pieData} innerRadius={0.55}
cx="50%" padAngle={2}
cy="50%" colors={PIE_COLORS}
innerRadius={70} borderWidth={0}
outerRadius={100} theme={nivoTheme}
paddingAngle={3} margin={{ top: 10, right: 10, bottom: 40, left: 10 }}
dataKey="value" tooltip={PieTooltip}
stroke="none" legends={[
> {
{pieData.map((_, index) => ( anchor: 'bottom',
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} /> direction: 'row',
))} justify: false,
</Pie> translateX: 0,
<Tooltip content={<CustomTooltip />} /> translateY: 30,
<Legend itemsSpacing: 16,
wrapperStyle={{ fontSize: 12, paddingTop: 8 }} itemWidth: 80,
iconType="circle" itemHeight: 16,
iconSize={8} itemDirection: 'left-to-right',
/> symbolSize: 8,
</PieChart> symbolShape: 'circle',
</ResponsiveContainer> },
]}
animate={true}
/>
</div>
</ChartCard> </ChartCard>
</div> </div>
@@ -426,37 +438,53 @@ function UsagePage() {
errorMessage={t('usage.loadFailed')} errorMessage={t('usage.loadFailed')}
retryLabel={t('usage.retry')} retryLabel={t('usage.retry')}
> >
<ResponsiveContainer width="100%" height={280}> <div className="h-[280px]">
<BarChart data={hourlyChartData} barCategoryGap="20%"> <ResponsiveBar
<CartesianGrid data={hourlyChartData}
strokeDasharray="3 3" keys={['Prompt', 'Completion', 'Total']}
stroke="rgba(255,255,255,0.06)" indexBy="name"
vertical={false} groupMode="grouped"
/> theme={nivoTheme}
<XAxis colors={barColor}
dataKey="name" margin={{ top: 10, right: 10, bottom: 40, left: 50 }}
tick={{ fill: '#a1a1aa', fontSize: 11 }} padding={0.2}
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }} innerPadding={2}
tickLine={false} borderRadius={2}
interval={2} enableGridY={true}
/> enableGridX={false}
<YAxis gridYValues={5}
tick={{ fill: '#a1a1aa', fontSize: 11 }} axisBottom={{
axisLine={false} tickSize: 0,
tickLine={false} tickPadding: 5,
tickFormatter={(v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`)} tickRotation: 0,
/> tickValues: hourlyTickValues,
<Tooltip content={<CustomTooltip />} /> }}
<Legend axisLeft={{
wrapperStyle={{ fontSize: 12, paddingTop: 8 }} tickSize: 0,
iconType="circle" tickPadding: 5,
iconSize={8} tickRotation: 0,
/> format: (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`),
<Bar dataKey="Prompt" fill={COLOR_PROMPT} radius={[2, 2, 0, 0]} /> }}
<Bar dataKey="Completion" fill={COLOR_COMPLETION} radius={[2, 2, 0, 0]} /> tooltip={BarTooltip}
<Bar dataKey="Total" fill={COLOR_TOTAL} radius={[2, 2, 0, 0]} /> legends={[
</BarChart> {
</ResponsiveContainer> 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}
/>
</div>
</ChartCard> </ChartCard>
)} )}
</div> </div>
+7 -2
View File
@@ -82,12 +82,17 @@ export function safeApiLogValue(value: unknown, maxLength = 50_000): unknown {
const sanitized = sanitizeObject(value); const sanitized = sanitizeObject(value);
if (typeof sanitized === 'string' && sanitized.length > maxLength) { 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); const stringified = JSON.stringify(sanitized);
if (stringified.length > maxLength) { 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; return sanitized;
@@ -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, string> | 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);
}
}
@@ -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 = [];
}
}
}
@@ -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<string, unknown>;
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<unknown> {
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<string, unknown>;
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<string | { data?: string; mimeType?: string }>;
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<string | { data?: string; mimeType?: string } | undefined>,
): 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:(?<mime>[^;]+);base64,(?<data>[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<string, unknown>;
const data = typeof inputRecord.data === 'string' ? inputRecord.data : undefined;
if (!data) {
return null;
}
return {
mimeType: typeof inputRecord.mimeType === 'string' ? inputRecord.mimeType : 'audio/mpeg',
data,
};
}
}
+362 -17
View File
@@ -15,6 +15,7 @@ import { FastifyReply, FastifyRequest } from 'fastify';
import { isEmpty, isFunction, isNil, isObjectLike, isPlainObject, isString } from 'lodash-es'; import { isEmpty, isFunction, isNil, isObjectLike, isPlainObject, isString } from 'lodash-es';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { ProxyService } from './proxy.service'; import { ProxyService } from './proxy.service';
import { ProxyReplayService } from './proxy-replay.service';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { trafficLogger } from '../../../utils/traffic-logger'; import { trafficLogger } from '../../../utils/traffic-logger';
import { import {
@@ -33,6 +34,7 @@ import {
} from '../../../lib/geminiNexus/ModelMapping'; } from '../../../lib/geminiNexus/ModelMapping';
import { getServerConfig } from '../../server-config'; import { getServerConfig } from '../../server-config';
import { TokenManagerService } from './token-manager.service'; import { TokenManagerService } from './token-manager.service';
import { ProxyMetricsService } from './proxy-metrics.service';
@Controller('v1') @Controller('v1')
@UseGuards(ProxyGuard) @UseGuards(ProxyGuard)
@@ -41,11 +43,15 @@ export class ProxyController {
constructor( constructor(
@Inject(ProxyService) private readonly proxyService: ProxyService, @Inject(ProxyService) private readonly proxyService: ProxyService,
@Optional() @Inject(ProxyReplayService) private readonly proxyReplayService?: ProxyReplayService,
@Optional() @Inject(TokenManagerService) private readonly tokenManager?: TokenManagerService, @Optional() @Inject(TokenManagerService) private readonly tokenManager?: TokenManagerService,
@Optional() @Inject(ProxyMetricsService) private readonly proxyMetrics?: ProxyMetricsService,
) {} ) {}
@Get('models') @Get('models')
listModels(@Res() res: FastifyReply) { listModels(@Res() res: FastifyReply) {
this.proxyMetrics?.incrementActiveConnections();
const startTime = Date.now();
try { try {
const config = getServerConfig(); const config = getServerConfig();
const customMapping = config?.custom_mapping ?? {}; const customMapping = config?.custom_mapping ?? {};
@@ -65,6 +71,12 @@ export class ProxyController {
object: 'list', object: 'list',
data, data,
}); });
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.OK,
endpoint: '/v1/models',
});
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to list models'; const message = error instanceof Error ? error.message : 'Failed to list models';
this.logger.error(message, error instanceof Error ? error.stack : undefined); this.logger.error(message, error instanceof Error ? error.stack : undefined);
@@ -80,6 +92,14 @@ export class ProxyController {
res.status(status).send({ res.status(status).send({
error: errorPayload, 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, @Req() req: FastifyRequest,
@Res() res: FastifyReply, @Res() res: FastifyReply,
) { ) {
this.proxyReplayService?.recordRequest({
requestId: uuidv4(),
timestamp: Date.now(),
endpoint: '/v1/chat/completions',
method: 'POST',
body,
headers: req.headers as Record<string, unknown>,
model: body.model,
});
await this.respondOpenAIChatCompletions(body, res, req); await this.respondOpenAIChatCompletions(body, res, req);
} }
@@ -103,6 +132,15 @@ export class ProxyController {
@Req() req: FastifyRequest, @Req() req: FastifyRequest,
@Res() res: FastifyReply, @Res() res: FastifyReply,
) { ) {
this.proxyReplayService?.recordRequest({
requestId: uuidv4(),
timestamp: Date.now(),
endpoint: '/v1/messages/chat/completions',
method: 'POST',
body,
headers: req.headers as Record<string, unknown>,
model: body.model,
});
await this.respondOpenAIChatCompletions(body, res, req); await this.respondOpenAIChatCompletions(body, res, req);
} }
@@ -117,8 +155,20 @@ export class ProxyController {
top_p?: number; top_p?: number;
stream?: boolean; stream?: boolean;
}, },
@Req() req: FastifyRequest,
@Res() res: FastifyReply, @Res() res: FastifyReply,
) { ) {
this.proxyReplayService?.recordRequest({
requestId: uuidv4(),
timestamp: Date.now(),
endpoint: '/v1/completions',
method: 'POST',
body,
headers: req.headers as Record<string, unknown>,
model: body.model,
});
this.proxyMetrics?.incrementActiveConnections();
const startTime = Date.now();
const request: OpenAIChatRequest = { const request: OpenAIChatRequest = {
model: body.model ?? 'gemini-3-flash', model: body.model ?? 'gemini-3-flash',
messages: [ messages: [
@@ -132,17 +182,41 @@ export class ProxyController {
top_p: body.top_p, top_p: body.top_p,
stream: body.stream, stream: body.stream,
}; };
let isStream = false;
try { try {
const result = await this.proxyService.handleChatCompletions(request); const result = await this.proxyService.handleChatCompletions(request);
if (body.stream && this.isObservableLike(result)) { isStream = !!(body.stream && this.isObservableLike(result));
this.writeSseResponse(res, result); if (isStream) {
this.writeSseResponse(res, result as Observable<unknown>, undefined, '/v1/completions', startTime);
return; return;
} }
const response = result as OpenAIChatResponse; 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)); res.status(HttpStatus.OK).send(this.toLegacyTextCompletionsResponse(response));
} catch (error) { } 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); this.sendOpenAIErrorResponse(res, '/v1/completions', error);
} finally {
if (!isStream) {
this.proxyMetrics?.decrementActiveConnections();
}
} }
} }
@@ -159,21 +233,57 @@ export class ProxyController {
top_p?: number; top_p?: number;
stream?: boolean; stream?: boolean;
}, },
@Req() req: FastifyRequest,
@Res() res: FastifyReply, @Res() res: FastifyReply,
) { ) {
this.proxyReplayService?.recordRequest({
requestId: uuidv4(),
timestamp: Date.now(),
endpoint: '/v1/responses',
method: 'POST',
body,
headers: req.headers as Record<string, unknown>,
model: body.model,
});
this.proxyMetrics?.incrementActiveConnections();
const startTime = Date.now();
const request = this.buildResponsesChatRequest(body); const request = this.buildResponsesChatRequest(body);
let isStream = false;
try { try {
const result = await this.proxyService.handleChatCompletions(request); const result = await this.proxyService.handleChatCompletions(request);
if (body.stream && this.isObservableLike(result)) { isStream = !!(body.stream && this.isObservableLike(result));
this.writeSseResponse(res, result); if (isStream) {
this.writeSseResponse(res, result as Observable<unknown>, undefined, '/v1/responses', startTime);
return; return;
} }
const response = result as OpenAIChatResponse; 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)); res.status(HttpStatus.OK).send(this.toLegacyTextCompletionsResponse(response));
} catch (error) { } 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); this.sendOpenAIErrorResponse(res, '/v1/responses', error);
} finally {
if (!isStream) {
this.proxyMetrics?.decrementActiveConnections();
}
} }
} }
@@ -186,8 +296,18 @@ export class ProxyController {
size?: string; size?: string;
quality?: string; quality?: string;
}, },
@Req() req: FastifyRequest,
@Res() res: FastifyReply, @Res() res: FastifyReply,
) { ) {
this.proxyReplayService?.recordRequest({
requestId: uuidv4(),
timestamp: Date.now(),
endpoint: '/v1/images/generations',
method: 'POST',
body,
headers: req.headers as Record<string, unknown>,
model: body.model,
});
const request: OpenAIChatRequest = { const request: OpenAIChatRequest = {
model: body.model ?? 'gemini-3-pro-image', model: body.model ?? 'gemini-3-pro-image',
messages: [ messages: [
@@ -219,6 +339,15 @@ export class ProxyController {
@Req() req: FastifyRequest, @Req() req: FastifyRequest,
@Res() res: FastifyReply, @Res() res: FastifyReply,
) { ) {
this.proxyReplayService?.recordRequest({
requestId: uuidv4(),
timestamp: Date.now(),
endpoint: '/v1/images/edits',
method: 'POST',
body,
headers: req.headers as Record<string, unknown>,
model: body.model,
});
if (!this.hasMultipartBoundary(req)) { if (!this.hasMultipartBoundary(req)) {
res res
.status(HttpStatus.BAD_REQUEST) .status(HttpStatus.BAD_REQUEST)
@@ -270,7 +399,26 @@ export class ProxyController {
@Req() req: FastifyRequest, @Req() req: FastifyRequest,
@Res() res: FastifyReply, @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<string, unknown>,
model: body.model,
});
if (!this.hasMultipartBoundary(req)) { if (!this.hasMultipartBoundary(req)) {
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration: Date.now() - startTime,
status: HttpStatus.BAD_REQUEST,
endpoint,
});
this.proxyMetrics?.decrementActiveConnections();
res res
.status(HttpStatus.BAD_REQUEST) .status(HttpStatus.BAD_REQUEST)
.send('Invalid `boundary` for `multipart/form-data` request'); .send('Invalid `boundary` for `multipart/form-data` request');
@@ -279,6 +427,13 @@ export class ProxyController {
const inlineAudio = this.resolveInlineData(body.file ?? body.audio); const inlineAudio = this.resolveInlineData(body.file ?? body.audio);
if (!inlineAudio) { 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({ res.status(HttpStatus.BAD_REQUEST).send({
error: { error: {
message: "Missing 'file' or 'audio' input. Provide base64 content or a data URL.", message: "Missing 'file' or 'audio' input. Provide base64 content or a data URL.",
@@ -313,11 +468,31 @@ export class ProxyController {
.join('') .join('')
.trim(); .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({ res.status(HttpStatus.OK).send({
text: text ?? '', text: text ?? '',
}); });
} catch (error) { } 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, res: FastifyReply,
req?: FastifyRequest, req?: FastifyRequest,
) { ) {
this.proxyMetrics?.incrementActiveConnections();
const requestId = uuidv4(); const requestId = uuidv4();
const startTime = Date.now(); 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 // Extract x-session-affinity header so OpenCode session stickiness works even when
// the request body does not carry an explicit session_id field. // 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) ? (req.headers['x-session-affinity'] as string | undefined)
: undefined; : undefined;
let isStream = false;
try { 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)) { isStream = !!(body.stream && this.isObservableLike(result));
this.writeSseResponse(res, result, requestId, '/v1/chat/completions', startTime); if (isStream) {
this.writeSseResponse(res, result as Observable<unknown>, requestId, '/v1/chat/completions', startTime);
return; return;
} else { } else {
const duration = Date.now() - startTime; 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); res.status(HttpStatus.OK).send(result);
} }
} catch (error) { } catch (error) {
trafficLogger.logError(requestId, '/v1/chat/completions', 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); this.sendOpenAIErrorResponse(res, '/v1/chat/completions', error);
} finally {
if (!isStream) {
this.proxyMetrics?.decrementActiveConnections();
}
} }
} }
@Post('messages') @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<string, unknown>,
model: body.model,
});
this.proxyMetrics?.incrementActiveConnections();
const startTime = Date.now();
let isStream = false;
try { try {
const result = await this.proxyService.handleAnthropicMessages(body); const result = await this.proxyService.handleAnthropicMessages(body);
isStream = !!(body.stream && this.isObservableLike(result));
if (body.stream && this.isObservableLike(result)) { if (isStream) {
this.writeSseResponse(res, result); this.writeSseResponse(res, result as Observable<unknown>, undefined, '/v1/messages', startTime);
return; return;
} else { } else {
const duration = Date.now() - startTime;
this.proxyMetrics?.recordRequest({
timestamp: startTime,
duration,
status: HttpStatus.OK,
endpoint: '/v1/messages',
});
res.status(HttpStatus.OK).send(result); res.status(HttpStatus.OK).send(result);
} }
} catch (error) { } 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); 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); 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( private writeSseResponse(
res: FastifyReply, res: FastifyReply,
stream: Observable<unknown>, stream: Observable<unknown>,
@@ -774,6 +1051,7 @@ export class ProxyController {
res.header('Cache-Control', 'no-cache'); res.header('Cache-Control', 'no-cache');
res.header('Connection', 'keep-alive'); res.header('Connection', 'keep-alive');
res.send(stream); res.send(stream);
this.proxyMetrics?.decrementActiveConnections();
return; return;
} }
@@ -791,6 +1069,23 @@ export class ProxyController {
res.raw.flushHeaders(); 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({ const subscription = stream.subscribe({
next: (chunk) => { next: (chunk) => {
if (res.raw.writableEnded) { if (res.raw.writableEnded) {
@@ -801,6 +1096,7 @@ export class ProxyController {
if (requestId && endpoint) { if (requestId && endpoint) {
trafficLogger.logSseChunk(requestId, endpoint, chunk); trafficLogger.logSseChunk(requestId, endpoint, chunk);
} }
this.extractAndRecordTokens(payload);
if (isFunction((res.raw as any).flush)) { if (isFunction((res.raw as any).flush)) {
(res.raw as any).flush(); (res.raw as any).flush();
} }
@@ -826,16 +1122,19 @@ export class ProxyController {
\n\n`, \n\n`,
); );
res.raw.end(); res.raw.end();
finish(status);
}, },
complete: () => { complete: () => {
if (!res.raw.writableEnded) { if (!res.raw.writableEnded) {
res.raw.end(); res.raw.end();
} }
finish(HttpStatus.OK);
}, },
}); });
res.raw.on('close', () => { res.raw.on('close', () => {
subscription.unsubscribe(); subscription.unsubscribe();
finish(HttpStatus.OK);
}); });
} }
@@ -844,14 +1143,23 @@ export class ProxyController {
prompt: string, prompt: string,
res: FastifyReply, res: FastifyReply,
): Promise<void> { ): Promise<void> {
this.proxyMetrics?.incrementActiveConnections();
const startTime = Date.now();
const endpoint = '/v1/images/generations';
try { try {
const result = await this.proxyService.handleChatCompletions(request); const result = await this.proxyService.handleChatCompletions(request);
if (result instanceof Observable) { if (result instanceof Observable) {
this.logProxyEndpointError( this.logProxyEndpointError(
'/v1/images/generations', endpoint,
HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR,
'Streaming image generation is not supported by this endpoint', '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({ res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({
error: { error: {
message: 'Streaming image generation is not supported by this endpoint', message: 'Streaming image generation is not supported by this endpoint',
@@ -865,10 +1173,16 @@ export class ProxyController {
const image = this.extractInlineBase64Image(isString(content) ? content : ''); const image = this.extractInlineBase64Image(isString(content) ? content : '');
if (!image) { if (!image) {
this.logProxyEndpointError( this.logProxyEndpointError(
'/v1/images/generations', endpoint,
HttpStatus.BAD_GATEWAY, HttpStatus.BAD_GATEWAY,
'Upstream did not return inline image data', '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({ res.status(HttpStatus.BAD_GATEWAY).send({
error: { error: {
message: 'Upstream did not return inline image data', message: 'Upstream did not return inline image data',
@@ -878,6 +1192,15 @@ export class ProxyController {
return; 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({ res.status(HttpStatus.OK).send({
created: Math.floor(Date.now() / 1000), created: Math.floor(Date.now() / 1000),
data: [ data: [
@@ -898,6 +1221,15 @@ export class ProxyController {
); );
const fallbackImage = this.extractInlineBase64ImageFromGeminiResponse(geminiResult); const fallbackImage = this.extractInlineBase64ImageFromGeminiResponse(geminiResult);
if (fallbackImage) { 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({ res.status(HttpStatus.OK).send({
created: Math.floor(Date.now() / 1000), created: Math.floor(Date.now() / 1000),
data: [ 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) { if (status === HttpStatus.SERVICE_UNAVAILABLE) {
return { type: 'server_error', code: 'temporarily_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: 'invalid_request_error', code: 'invalid_request_error' };
} }
return { type: 'server_error', code: 'internal_error' }; return { type: 'server_error', code: 'internal_error' };
+4 -1
View File
@@ -6,11 +6,14 @@ import { GeminiClient } from './clients/gemini.client';
import { GeminiController } from './gemini.controller'; import { GeminiController } from './gemini.controller';
import { ProxyGuard } from './proxy.guard'; import { ProxyGuard } from './proxy.guard';
import { TokenUsageService } from '../usage/token-usage.service'; 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({ @Module({
imports: [], imports: [],
controllers: [ProxyController, GeminiController], controllers: [ProxyController, GeminiController],
providers: [ProxyService, TokenManagerService, GeminiClient, ProxyGuard, TokenUsageService], providers: [ProxyService, TokenManagerService, GeminiClient, ProxyGuard, TokenUsageService, ProxyReplayService, ProxyMetricsService, ProxyIdeConfigService],
exports: [TokenManagerService], exports: [TokenManagerService],
}) })
export class ProxyModule {} export class ProxyModule {}
+115 -38
View File
@@ -50,7 +50,10 @@ export class ProxyService {
private async recordUsage( private async recordUsage(
accountId: string, accountId: string,
model: string, model: string,
usage: { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number } | undefined | null, usage:
| { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number }
| undefined
| null,
requestType: string, requestType: string,
isEstimated?: boolean, isEstimated?: boolean,
): Promise<void> { ): Promise<void> {
@@ -79,12 +82,15 @@ export class ProxyService {
private extractUsageFromMetadata( private extractUsageFromMetadata(
meta: unknown, meta: unknown,
): { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number } | undefined { ):
| { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number }
| undefined {
if (!meta || typeof meta !== 'object') return undefined; if (!meta || typeof meta !== 'object') return undefined;
const m = meta as Record<string, unknown>; const m = meta as Record<string, unknown>;
return { return {
promptTokenCount: typeof m.promptTokenCount === 'number' ? m.promptTokenCount : undefined, 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, totalTokenCount: typeof m.totalTokenCount === 'number' ? m.totalTokenCount : undefined,
}; };
} }
@@ -332,13 +338,17 @@ export class ProxyService {
); );
} else if (accumulatedText) { } else if (accumulatedText) {
const estimated = this.estimateTokens(accumulatedText); const estimated = this.estimateTokens(accumulatedText);
this.recordUsage(accountId, _model, { this.recordUsage(
promptTokenCount: 0, accountId,
candidatesTokenCount: estimated, _model,
totalTokenCount: estimated, {
}, 'anthropic', true).catch((err) => promptTokenCount: 0,
this.logger.error('Usage recording failed', err), candidatesTokenCount: estimated,
); totalTokenCount: estimated,
},
'anthropic',
true,
).catch((err) => this.logger.error('Usage recording failed', err));
} }
subscriber.complete(); subscriber.complete();
}); });
@@ -504,7 +514,7 @@ export class ProxyService {
token.token.upstream_proxy_url, token.token.upstream_proxy_url,
extraHeaders, extraHeaders,
); );
return this.passthroughSseStream(stream, token.id, effectiveTargetModel); return this.passthroughSseStream(stream, token.id, effectiveTargetModel);
} catch (err) { } catch (err) {
if (err instanceof Error && this.isProjectContextError(err.message)) { if (err instanceof Error && this.isProjectContextError(err.message)) {
this.logger.warn( this.logger.warn(
@@ -526,7 +536,7 @@ export class ProxyService {
token.token.upstream_proxy_url, token.token.upstream_proxy_url,
extraHeaders, extraHeaders,
); );
return this.passthroughSseStream(stream, token.id, effectiveTargetModel); return this.passthroughSseStream(stream, token.id, effectiveTargetModel);
} catch (fallbackErr) { } catch (fallbackErr) {
lastError = fallbackErr; lastError = fallbackErr;
} }
@@ -579,7 +589,9 @@ export class ProxyService {
if (typeof text === 'string') { if (typeof text === 'string') {
accumulatedText += text; accumulatedText += text;
} }
} catch { /* ignore */ } } catch {
/* ignore */
}
} }
subscriber.next(chunk.toString()); subscriber.next(chunk.toString());
@@ -597,13 +609,17 @@ export class ProxyService {
); );
} else if (accumulatedText) { } else if (accumulatedText) {
const estimated = this.estimateTokens(accumulatedText); const estimated = this.estimateTokens(accumulatedText);
this.recordUsage(accountId, model, { this.recordUsage(
promptTokenCount: 0, accountId,
candidatesTokenCount: estimated, model,
totalTokenCount: estimated, {
}, 'gemini', true).catch((err) => promptTokenCount: 0,
this.logger.error('Usage recording failed', err), candidatesTokenCount: estimated,
); totalTokenCount: estimated,
},
'gemini',
true,
).catch((err) => this.logger.error('Usage recording failed', err));
} }
subscriber.complete(); subscriber.complete();
}); });
@@ -859,14 +875,26 @@ export class ProxyService {
// Use v1internal API (same as Anthropic handler) // Use v1internal API (same as Anthropic handler)
if (request.stream) { if (request.stream) {
try { 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( const stream = await this.geminiClient.streamGenerateInternal(
geminiBody, geminiBody,
token.token.access_token, token.token.access_token,
token.token.upstream_proxy_url, token.token.upstream_proxy_url,
extraHeaders, extraHeaders,
); );
return this.processStreamResponse(stream, request.model, requestId, token.id, effectiveTargetModel); return this.processStreamResponse(
stream,
request.model,
requestId,
token.id,
effectiveTargetModel,
);
} catch (streamError) { } catch (streamError) {
this.logger.warn( this.logger.warn(
`Stream path failed for model=${request.model}; falling back to non-stream generation: ${ `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( const response = await this.generateInternalWithStreamFallback(
geminiBody, geminiBody,
token.token.access_token, token.token.access_token,
token.token.upstream_proxy_url, token.token.upstream_proxy_url,
extraHeaders, 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.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai');
this.logger.log( this.logger.log(
`Upstream response snippet after stream fallback: ${JSON.stringify(response).substring(0, 500)}`, `Upstream response snippet after stream fallback: ${JSON.stringify(response).substring(0, 500)}`,
@@ -923,24 +963,48 @@ export class ProxyService {
fallbackBody.model = effectiveTargetModel; fallbackBody.model = effectiveTargetModel;
this.applyInternalGenerationConstraints(fallbackBody, effectiveTargetModel, token.id); this.applyInternalGenerationConstraints(fallbackBody, effectiveTargetModel, token.id);
if (request.stream) { 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( const stream = await this.geminiClient.streamGenerateInternal(
fallbackBody, fallbackBody,
token.token.access_token, token.token.access_token,
token.token.upstream_proxy_url, token.token.upstream_proxy_url,
extraHeaders, 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( const response = await this.generateInternalWithStreamFallback(
fallbackBody, fallbackBody,
token.token.access_token, token.token.access_token,
token.token.upstream_proxy_url, token.token.upstream_proxy_url,
extraHeaders, 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.recordUsage(token.id, effectiveTargetModel, response.usageMetadata, 'openai');
const claudeResponse = transformResponse(response); const claudeResponse = transformResponse(response);
return this.convertClaudeToOpenAIResponse(claudeResponse, request.model); return this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
@@ -1135,7 +1199,14 @@ export class ProxyService {
try { try {
const json = JSON.parse(dataStr); 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": [...]}} // v1internal API wraps response in {"response": {"candidates": [...]}}
const responsePayload = json.response ?? json; const responsePayload = json.response ?? json;
if (responsePayload.usageMetadata) { if (responsePayload.usageMetadata) {
@@ -1300,13 +1371,17 @@ export class ProxyService {
); );
} else if (accumulatedText) { } else if (accumulatedText) {
const estimated = this.estimateTokens(accumulatedText); const estimated = this.estimateTokens(accumulatedText);
this.recordUsage(accountId, effectiveTargetModel, { this.recordUsage(
promptTokenCount: 0, accountId,
candidatesTokenCount: estimated, effectiveTargetModel,
totalTokenCount: estimated, {
}, 'openai', true).catch((err) => promptTokenCount: 0,
this.logger.error('Usage recording failed', err), candidatesTokenCount: estimated,
); totalTokenCount: estimated,
},
'openai',
true,
).catch((err) => this.logger.error('Usage recording failed', err));
} }
subscriber.complete(); subscriber.complete();
}); });
@@ -1776,7 +1851,7 @@ export class ProxyService {
message: { message: {
role: 'assistant', role: 'assistant',
// When tool_calls are present, content must be null per OpenAI spec. // 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, tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
}, },
finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason( finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason(
@@ -1844,7 +1919,9 @@ export class ProxyService {
if (error instanceof UpstreamRequestError) { if (error instanceof UpstreamRequestError) {
const status = error.status; const status = error.status;
if (status === 401) { 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); const refreshed = await this.tokenManager.forceRefreshToken(accountId);
if (refreshed) { if (refreshed) {
this.logger.log(`Token refreshed successfully for account ${accountId}; retry permitted`); this.logger.log(`Token refreshed successfully for account ${accountId}; retry permitted`);
@@ -6,6 +6,7 @@ import { GoogleAPIService } from '../../../services/GoogleAPIService';
import { getServerConfig } from '../../server-config'; import { getServerConfig } from '../../server-config';
import { RateLimitReason, RateLimitTracker } from './rate-limit-tracker'; import { RateLimitReason, RateLimitTracker } from './rate-limit-tracker';
import { updateDynamicForwardingRules } from '../../../lib/geminiNexus/ModelMapping'; import { updateDynamicForwardingRules } from '../../../lib/geminiNexus/ModelMapping';
import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry';
interface TokenData { interface TokenData {
email: string; email: string;
@@ -36,6 +37,14 @@ interface GetNextTokenOptions {
type TokenEntry = [string, TokenData]; 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 { function normalizeProjectId(projectId: string | null | undefined): string | undefined {
if (!isString(projectId)) { if (!isString(projectId)) {
return undefined; return undefined;
@@ -89,6 +98,11 @@ export class TokenManagerService implements OnModuleInit {
private accountCooldowns: Map<string, number> = new Map(); private accountCooldowns: Map<string, number> = new Map();
private sessionBindings: Map<string, { accountId: string; expiresAt: number }> = new Map(); private sessionBindings: Map<string, { accountId: string; expiresAt: number }> = new Map();
private rateLimitTracker = new RateLimitTracker(); private rateLimitTracker = new RateLimitTracker();
private circuitBreakerStates = new Map<string, CircuitBreakerEntry>();
private readonly circuitBreakerWindowMs = 5 * 60 * 1000;
private readonly circuitBreakerUnhealthyThreshold = 5;
private readonly circuitBreakerDegradedThreshold = 2;
private readonly circuitBreakerFailureRateThreshold = 0.5;
private shadowComparisonCount = 0; private shadowComparisonCount = 0;
private shadowMismatchCount = 0; private shadowMismatchCount = 0;
@@ -96,6 +110,10 @@ export class TokenManagerService implements OnModuleInit {
private parityErrorCount = 0; private parityErrorCount = 0;
private noGoBlocked = false; private noGoBlocked = false;
constructor() {
registerProxyAdvancedService('tokenManager', this);
}
async onModuleInit() { async onModuleInit() {
await this.loadAccounts(); 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`); this.logger.log(`Token manager loaded ${count} cloud accounts into cache`);
return count; return count;
} catch (e) { } 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'); this.logger.warn('No eligible account found after exclusion filtering');
return null; return null;
} }
if (this.shouldExecuteShadowComparison()) { if (this.shouldExecuteShadowComparison()) {
this.executeShadowComparison(candidateAccountPool, sessionKey, model); this.executeShadowComparison(finalCandidatePool, sessionKey, model);
} }
const selectedTokenEntry = this.isParitySchedulingEnabled() const selectedTokenEntry = this.isParitySchedulingEnabled()
? await this.selectParityTokenCandidate(candidateAccountPool, sessionKey, model, now) ? await this.selectParityTokenCandidate(finalCandidatePool, sessionKey, model, now)
: this.selectLegacyTokenCandidate(candidateAccountPool, sessionKey, now); : this.selectLegacyTokenCandidate(finalCandidatePool, sessionKey, now);
if (!selectedTokenEntry) { if (!selectedTokenEntry) {
return null; return null;
@@ -978,6 +1009,95 @@ export class TokenManagerService implements OnModuleInit {
return this.tokens.size; 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<string, { state: 'healthy' | 'degraded' | 'unhealthy'; failureCount: number; lastFailureTime: number | null; failureRate: number }> {
const result: Record<string, { state: 'healthy' | 'degraded' | 'unhealthy'; failureCount: number; lastFailureTime: number | null; failureRate: number }> = {};
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( private normalizeRefreshedOauthClientKey(
currentToken: { oauth_client_key?: string; project_id?: string }, currentToken: { oauth_client_key?: string; project_id?: string },
refreshedClientKey?: string, refreshedClientKey?: string,
@@ -259,7 +259,11 @@ async function validateRuntimeAnthropicRequestFromRealTokenManager(): Promise<vo
}; };
try { try {
const service = new ProxyService(tokenManagerProxy as any, mockGeminiClient as any, mockTokenUsageService as any); const service = new ProxyService(
tokenManagerProxy as any,
mockGeminiClient as any,
mockTokenUsageService as any,
);
await service.handleAnthropicMessages({ await service.handleAnthropicMessages({
model: 'claude-sonnet-4-5', model: 'claude-sonnet-4-5',
stream: false, stream: false,
@@ -21,7 +21,7 @@ describe('ProxyController Integration', () => {
handleChatCompletions: vi.fn().mockResolvedValue({ ok: true }), handleChatCompletions: vi.fn().mockResolvedValue({ ok: true }),
handleAnthropicMessages: vi.fn(), handleAnthropicMessages: vi.fn(),
}; };
const controller = new ProxyController(proxyService as any); const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock(); const reply = createReplyMock();
const req = createReqMock(); const req = createReqMock();
@@ -46,7 +46,7 @@ describe('ProxyController Integration', () => {
handleChatCompletions: vi.fn().mockResolvedValue(stream), handleChatCompletions: vi.fn().mockResolvedValue(stream),
handleAnthropicMessages: vi.fn(), handleAnthropicMessages: vi.fn(),
}; };
const controller = new ProxyController(proxyService as any); const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock(); const reply = createReplyMock();
const req = createReqMock(); const req = createReqMock();
@@ -91,8 +91,9 @@ describe('ProxyController Integration', () => {
}), }),
handleAnthropicMessages: vi.fn(), handleAnthropicMessages: vi.fn(),
}; };
const controller = new ProxyController(proxyService as any); const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock(); const reply = createReplyMock();
const req = createReqMock();
await controller.completions( await controller.completions(
{ {
@@ -100,6 +101,7 @@ describe('ProxyController Integration', () => {
prompt: 'hello world', prompt: 'hello world',
stream: false, stream: false,
}, },
req,
reply as any, 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 reply = createReplyMock();
const req = createReqMock();
await controller.responses( await controller.responses(
{ {
@@ -174,6 +177,7 @@ describe('ProxyController Integration', () => {
}, },
], ],
}, },
req,
reply as any, reply as any,
); );
@@ -214,8 +218,9 @@ describe('ProxyController Integration', () => {
handleChatCompletions: vi.fn().mockResolvedValue(stream), 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 reply = createReplyMock();
const req = createReqMock();
await controller.responses( await controller.responses(
{ {
@@ -224,6 +229,7 @@ describe('ProxyController Integration', () => {
input: 'hello', input: 'hello',
stream: true, stream: true,
}, },
req,
reply as any, 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 reply = createReplyMock();
const req = createReqMock();
await controller.responses( await controller.responses(
{ {
@@ -264,6 +271,7 @@ describe('ProxyController Integration', () => {
}, },
], ],
}, },
req,
reply as any, 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 reply = createReplyMock();
const req = createReqMock();
await controller.imageGenerations( await controller.imageGenerations(
{ {
model: 'gemini-3-pro-image', model: 'gemini-3-pro-image',
prompt: 'draw a cat', prompt: 'draw a cat',
}, },
req,
reply as any, reply as any,
); );
@@ -318,14 +328,16 @@ describe('ProxyController Integration', () => {
const proxyService = { const proxyService = {
handleChatCompletions: vi.fn().mockRejectedValue(new Error('429 quota exceeded')), 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 reply = createReplyMock();
const req = createReqMock();
await controller.imageGenerations( await controller.imageGenerations(
{ {
model: 'gemini-3-pro-image', model: 'gemini-3-pro-image',
prompt: 'draw a dog', prompt: 'draw a dog',
}, },
req,
reply as any, 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 reply = createReplyMock();
const req = createReqMock();
await controller.imageGenerations( await controller.imageGenerations(
{ {
model: 'gemini-3-pro-image', model: 'gemini-3-pro-image',
prompt: 'draw a fox', prompt: 'draw a fox',
}, },
req,
reply as any, 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(); const reply = createReplyMock();
await controller.imageEdits( await controller.imageEdits(
@@ -422,7 +436,7 @@ describe('ProxyController Integration', () => {
const proxyService = { const proxyService = {
handleChatCompletions: vi.fn(), handleChatCompletions: vi.fn(),
}; };
const controller = new ProxyController(proxyService as any); const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock(); const reply = createReplyMock();
await controller.imageEdits( 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(); const reply = createReplyMock();
await controller.audioTranscriptions( await controller.audioTranscriptions(
@@ -481,7 +495,7 @@ describe('ProxyController Integration', () => {
const proxyService = { const proxyService = {
handleGeminiGenerateContent: vi.fn(), handleGeminiGenerateContent: vi.fn(),
}; };
const controller = new ProxyController(proxyService as any); const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock(); const reply = createReplyMock();
await controller.audioTranscriptions( await controller.audioTranscriptions(
@@ -509,8 +523,9 @@ describe('ProxyController Integration', () => {
type: 'message', type: 'message',
}), }),
}; };
const controller = new ProxyController(proxyService as any); const controller = new ProxyController(proxyService as any, undefined, undefined, undefined);
const reply = createReplyMock(); const reply = createReplyMock();
const req = createReqMock();
await controller.anthropicMessages( await controller.anthropicMessages(
{ {
@@ -518,6 +533,7 @@ describe('ProxyController Integration', () => {
stream: false, stream: false,
messages: [{ role: 'user', content: 'hello' }], messages: [{ role: 'user', content: 'hello' }],
} as any, } as any,
req,
reply as any, reply as any,
); );
+71 -7
View File
@@ -4,8 +4,15 @@ import { isObjectLike } from 'lodash-es';
import winston from 'winston'; import winston from 'winston';
import DailyRotateFile from 'winston-daily-rotate-file'; import DailyRotateFile from 'winston-daily-rotate-file';
import { getAgentDir } from './paths'; 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 { export interface TrafficLogEntry {
timestamp: string; timestamp: string;
@@ -113,9 +120,34 @@ class TrafficLogger {
level: 'debug', level: 'debug',
message: line, 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<string, unknown>, metadata?: Record<string, unknown>): void { logInbound(
requestId: string,
endpoint: string,
body: unknown,
headers?: Record<string, unknown>,
metadata?: Record<string, unknown>,
): void {
this.log({ this.log({
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
direction: 'inbound', direction: 'inbound',
@@ -128,7 +160,14 @@ class TrafficLogger {
}); });
} }
logOutbound(requestId: string, endpoint: string, status: number, body: unknown, durationMs: number, headers?: Record<string, unknown>): void { logOutbound(
requestId: string,
endpoint: string,
status: number,
body: unknown,
durationMs: number,
headers?: Record<string, unknown>,
): void {
this.log({ this.log({
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
direction: 'outbound', direction: 'outbound',
@@ -141,7 +180,13 @@ class TrafficLogger {
}); });
} }
logUpstream(requestId: string, endpoint: string, body: unknown, headers?: Record<string, unknown>, metadata?: Record<string, unknown>): void { logUpstream(
requestId: string,
endpoint: string,
body: unknown,
headers?: Record<string, unknown>,
metadata?: Record<string, unknown>,
): void {
this.log({ this.log({
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
direction: 'upstream', direction: 'upstream',
@@ -153,7 +198,14 @@ class TrafficLogger {
}); });
} }
logUpstreamResponse(requestId: string, endpoint: string, status: number, body: unknown, durationMs: number, headers?: Record<string, unknown>): void { logUpstreamResponse(
requestId: string,
endpoint: string,
status: number,
body: unknown,
durationMs: number,
headers?: Record<string, unknown>,
): void {
this.log({ this.log({
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
direction: 'upstream_response', direction: 'upstream_response',
@@ -166,7 +218,12 @@ class TrafficLogger {
}); });
} }
logSseChunk(requestId: string, endpoint: string, chunk: unknown, metadata?: Record<string, unknown>): void { logSseChunk(
requestId: string,
endpoint: string,
chunk: unknown,
metadata?: Record<string, unknown>,
): void {
this.log({ this.log({
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
direction: 'sse_chunk', direction: 'sse_chunk',
@@ -177,7 +234,14 @@ class TrafficLogger {
}); });
} }
logError(requestId: string, endpoint: string, error: Error | unknown, status?: number, body?: unknown, metadata?: Record<string, unknown>): void { logError(
requestId: string,
endpoint: string,
error: Error | unknown,
status?: number,
body?: unknown,
metadata?: Record<string, unknown>,
): void {
const err = error instanceof Error ? error : new Error(String(error)); const err = error instanceof Error ? error : new Error(String(error));
this.log({ this.log({
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),