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:
1 parent
c73537d44c
commit
0be7d7fea2
36 files changed
+4551
-882
No files matched your search
+33
-23
@@ -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
@@ -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'
|
||||||
? [
|
? [
|
||||||
|
|||||||
Generated
+450
-193
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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: 'Использование токенов',
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
+144
-116
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' };
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in new issue
Block a user