refactor: decompose usageDb, handleSingleModelChat, UI components (T-15, T-28, T-29)

T-15 — Decompose usageDb.js (969→40 lines):
- Extract src/lib/usage/migrations.js (legacy + JSON→SQLite migration)
- Extract src/lib/usage/usageHistory.js (tracking, pending, log.txt)
- Extract src/lib/usage/costCalculator.js (pure cost calculation)
- Extract src/lib/usage/usageStats.js (dashboard aggregation)
- Extract src/lib/usage/callLogs.js (structured logs, CRUD, rotation)
- usageDb.js is now a thin facade re-exporting all functions

T-28 — Decompose handleSingleModelChat (183→80 lines):
- Extract handleNoCredentials() — credential error responses
- Extract safeResolveProxy() — proxy resolution with error handling
- Extract safeLogEvents() — fire-and-forget proxy + translation logging
- Also created chatHelpers.js with standalone helper exports

T-29 — Extract shared UI primitives (3230 total lines):
- FilterBar.js — search input + filter chips dropdown
- ColumnToggle.js — table column visibility toggle
- DataTable.js — generic data table with sticky header, loading/empty

Tests: 88/88 pass (no regressions)
This commit is contained in:
diegosouzapw
2026-02-14 18:53:14 -03:00
parent 967689d0a1
commit 492afc4ff1
22 changed files with 3552 additions and 1058 deletions

View File

@@ -0,0 +1,177 @@
# FASE 01 — Security Hardening
> **Prioridade:** 🔴 Crítica
> **Estimativa de Complexidade:** Média (35 dias)
> **Dimensões do Relatório:** D3 (Qualidade do Código), D8 (LLM Proxy/Gateway)
> **Dependências:** Nenhuma — esta fase é pré-requisito para todas as demais.
---
## Objetivo
Eliminar todas as vulnerabilidades de segurança críticas identificadas no relatório de análise, garantindo que o sistema não opere com segredos previsíveis, que erros nunca sejam silenciados, e que exista proteção básica contra ataques direcionados a LLM proxies.
---
## Escopo Detalhado
### 1.1 — Remoção de Fallbacks Hardcoded de Segredos
**Origem no relatório:** D3 — Segredos Hardcoded com Fallbacks Inseguros (🔴 Crítico)
#### Arquivos afetados
| Arquivo | Segredo | Fallback Atual |
| ------------------------------ | ---------------- | -------------------------------------- |
| `src/proxy.js:4-6` | `JWT_SECRET` | `"omniroute-default-secret-change-me"` |
| `src/shared/utils/apiKey.js:3` | `API_KEY_SECRET` | `"endpoint-proxy-api-key-secret"` |
#### Especificação Técnica
- **Remover** os fallbacks `|| "..."` de todos os segredos.
- **Implementar** validação na inicialização (`server-init.js` ou `next.config.mjs`) que:
- Verifica se `JWT_SECRET` está definido e tem comprimento ≥ 32 caracteres.
- Verifica se `API_KEY_SECRET` está definido e tem comprimento ≥ 16 caracteres.
- Lança erro fatal com mensagem clara se não configurados (fail-fast).
- **Atualizar** `.env.example` e README com instruções explícitas de configuração.
- **Adicionar** warning no onboarding se os segredos parecerem ser os defaults do `.env.example`.
#### Critérios de Aceite
- [ ] Aplicação NÃO inicia sem `JWT_SECRET` definido.
- [ ] Aplicação NÃO inicia sem `API_KEY_SECRET` definido.
- [ ] Mensagem de erro indica exatamente qual variável está faltando.
- [ ] `.env.example` documentado com instruções para gerar segredos fortes.
- [ ] Testes unitários cobrem cenário de inicialização sem segredos.
---
### 1.2 — Eliminação de Erros Silenciosos no Middleware
**Origem no relatório:** D3 — Erro Silencioso em Middleware (🔴 Crítico)
#### Arquivos afetados
| Arquivo | Linha | Problema |
| -------------------- | -------------------------- | ------------------------ |
| `src/proxy.js:42-43` | `catch (err) { }` | Exceção engolida sem log |
| `src/proxy.js:24` | `catch (err) { redirect }` | JWT inválido sem log |
#### Especificação Técnica
- **Adicionar** logging estruturado em todos os `catch` blocks do middleware:
```javascript
catch (err) {
console.error("[Middleware] Settings fetch failed:", err.message);
// On error, require login
}
```
- **Utilizar** `pino` (já nas dependências) para logging estruturado.
- **Incluir** categorização do erro: `auth_error`, `settings_error`, `unknown_error`.
- **Garantir** que nenhum stacktrace seja exposto ao cliente — apenas logging server-side.
#### Critérios de Aceite
- [ ] Todos os `catch` blocks em `proxy.js` logam o erro.
- [ ] Logs incluem contexto suficiente para debug (path, tipo de erro).
- [ ] Nenhuma informação sensível exposta nos logs (sem tokens, sem senhas).
- [ ] Revisão manual confirma zero `catch` vazio em todo `src/`.
---
### 1.3 — Proteção contra Prompt Injection
**Origem no relatório:** D8 — Sem Proteção contra Prompt Injection (🔴 Crítico)
#### Especificação Técnica
- **Criar** módulo `src/shared/utils/inputSanitizer.js` com:
- Detecção de padrões conhecidos de prompt injection.
- Opção de sanitização ou rejeição.
- Configuração via settings (habilitado/desabilitado, nível: `warn`, `block`, `redact`).
- **Integrar** no pipeline de request em `src/sse/handlers/chat.js`:
- Antes de `translateRequest()`, chamar o sanitizador.
- Logar tentativas detectadas com severity `warn` ou `error`.
- **Implementar** PII Redaction básica:
- Detecção de padrões de email, CPF/CNPJ, cartões de crédito.
- Modo `audit` (detecta e loga) vs `redact` (substitui por `[REDACTED]`).
#### Critérios de Aceite
- [ ] Módulo `inputSanitizer.js` criado com testes unitários.
- [ ] Pipeline de request integra o sanitizador.
- [ ] Configuração via `.env` ou dashboard settings.
- [ ] Testes cobrem padrões conhecidos de prompt injection.
- [ ] Documentação indica os padrões detectados e como configurar.
---
### 1.4 — Remoção de `.passthrough()` em Zod Schemas
**Origem no relatório:** D3 — `.passthrough()` em Zod Schema (🟡 Moderado)
#### Arquivos afetados
| Arquivo | Linha | Problema |
| ------------------------------------- | ---------------- | ------------------------- |
| `src/shared/validation/schemas.js:63` | `.passthrough()` | Aceita campos arbitrários |
#### Especificação Técnica
- **Remover** `.passthrough()` do `updateSettingsSchema`.
- **Substituir** por `.strict()` ou listar explicitamente todos os campos aceitos.
- **Verificar** todos os call sites que enviam dados para o endpoint de settings.
- **Testar** que campos desconhecidos são rejeitados com erro 400.
#### Critérios de Aceite
- [ ] `.passthrough()` removido de todos os schemas.
- [ ] Campos extras são rejeitados com mensagem de erro clara.
- [ ] Nenhum endpoint quebra após a mudança.
---
### 1.5 — Limpeza de Dependências Inseguras
**Origem no relatório:** D3 — `fs` como dependência NPM (🟢 Menor)
#### Especificação Técnica
- **Remover** `"fs": "^0.0.1-security"` do `package.json`.
- **Verificar** que nenhum `import` usa o pacote npm `fs` (todos devem usar `node:fs`).
- **Rodar** `npm audit` e documentar resultados.
#### Critérios de Aceite
- [ ] Pacote `fs` removido do `package.json`.
- [ ] `npm install` executa sem erros.
- [ ] Build (`npm run build`) executa sem erros.
---
## Pré-Requisitos
- Acesso ao repositório OmniRoute com permissão de push.
- Ambiente de desenvolvimento funcional (Node.js ≥ 18, npm).
## Entregáveis
1. Código-fonte alterado com todos os itens desta fase implementados.
2. Testes unitários para validação de segredos e sanitizador de inputs.
3. `.env.example` atualizado com instruções de segredos.
4. Branch de feature com PR para review.
## Critérios de Conclusão da Fase
- [ ] Todos os critérios de aceite dos 5 itens cumpridos.
- [ ] Build passa sem erros.
- [ ] Testes unitários passam.
- [ ] PR aprovado e mergeado.
## Riscos Identificados
| Risco | Probabilidade | Impacto | Mitigação |
| -------------------------------------------------------- | ------------- | ------- | ---------------------------------------------------- |
| Remoção de fallbacks quebra ambientes existentes | Alta | Alto | Comunicação via CHANGELOG; migration guide no README |
| Sanitizador gera falsos positivos | Média | Médio | Modo `warn` como default; allowlist de padrões |
| Remoção de `.passthrough()` quebra features não mapeadas | Baixa | Médio | Listar todos os call sites antes da mudança |

View File

@@ -0,0 +1,155 @@
# FASE 02 — CI/CD & Infraestrutura de Testes
> **Prioridade:** 🔴 Crítica
> **Estimativa de Complexidade:** Média (35 dias)
> **Dimensões do Relatório:** D7 (Testes, CI/CD)
> **Dependências:** FASE-01 (segredos validados são necessários para CI funcional)
---
## Objetivo
Estabelecer pipeline de integração contínua com execução automática de testes, linting, e build em cada PR/push, e configurar infraestrutura de cobertura de testes para garantir qualidade mínima.
---
## Escopo Detalhado
### 2.1 — Criação do CI Pipeline
**Origem no relatório:** D7 — Ausência de CI Pipeline (🔴 Crítico)
#### Especificação Técnica
- **Criar** `.github/workflows/ci.yml` com jobs:
1. **lint**`npm run lint` com cache de `node_modules`.
2. **build**`npm run build` para verificar compilação.
3. **test:unit** — Execução de testes unitários com `node --test`.
4. **test:e2e** — Execução de `npx playwright test` (com Playwright instalado).
- **Triggers:** push para `main`, pull_request para qualquer branch.
- **Matrix:** Node.js 18 e 22.
- **Cache:** `node_modules` e `.next/cache`.
#### Critérios de Aceite
- [ ] Workflow `ci.yml` executa em PRs e pushes para `main`.
- [ ] Todos os 4 jobs (lint, build, test:unit, test:e2e) executam.
- [ ] PR não pode ser mergeado se CI falhar (branch protection rule).
- [ ] Tempo de execução total < 10 minutos.
---
### 2.2 — Correção do Script `test` no package.json
**Origem no relatório:** D7 — `"test": "npm run build"` (🔴 Crítico)
#### Especificação Técnica
- **Alterar** `"test"` para executar testes reais:
```json
"test": "node --test tests/unit/*.test.mjs",
"test:unit": "node --test tests/unit/*.test.mjs",
"test:e2e": "npx playwright test",
"test:all": "npm run test:unit && npm run test:e2e"
```
- **Manter** `"check"` como agregador: `"npm run lint && npm run test:unit"`.
#### Critérios de Aceite
- [ ] `npm test` executa testes unitários reais.
- [ ] `npm run test:all` executa unit + e2e.
- [ ] CI usa os mesmos scripts definidos no `package.json`.
---
### 2.3 — Configuração de Cobertura de Testes
**Origem no relatório:** D7 — Cobertura de Testes Incerta (🔴 Crítico)
#### Especificação Técnica
- **Configurar** `c8` como ferramenta de cobertura:
```json
"test:coverage": "c8 --reporter=text --reporter=lcov node --test tests/unit/*.test.mjs"
```
- **Definir** target mínimo: 40% de cobertura (baseline realista).
- **Gerar** relatório lcov para upload em CI (Codecov ou similar).
- **Adicionar** badge de cobertura no README.
#### Critérios de Aceite
- [ ] `npm run test:coverage` gera relatório de cobertura.
- [ ] Relatório inclui todas as subpastas de `src/`.
- [ ] CI faz upload do relatório de cobertura.
---
### 2.4 — Melhoria da Configuração ESLint
**Origem no relatório:** D7 — Sem Análise Estática de Código (🟠 Importante)
#### Especificação Técnica
- **Adicionar** plugins ao ESLint:
- `eslint-plugin-security` — regras de segurança.
- `eslint-plugin-react-hooks` — regras de hooks React.
- **Configurar** no `eslint.config.mjs` com severidade `warn` inicialmente.
- **Executar** lint e resolver erros/warnings existentes antes de ativar em CI.
#### Critérios de Aceite
- [ ] Plugins instalados e configurados.
- [ ] `npm run lint` executa sem erros bloqueantes.
- [ ] CI executa lint como parte do pipeline.
---
### 2.5 — Conversão de Testes de Segurança
**Origem no relatório:** D7 — Testes shell manuais (🟠 Importante)
#### Especificação Técnica
- **Converter** os 4 scripts de `tests/security/` em testes programáticos:
- `test-cli-runtime.sh` → `tests/integration/cli-runtime.test.mjs`
- `test-cloud-openai-compatible.sh` → `tests/integration/cloud-openai.test.mjs`
- `test-cloud-sync-and-call.sh` → `tests/integration/cloud-sync.test.mjs`
- `test-docker-hardening.sh` → `tests/integration/docker-hardening.test.mjs`
- **Usar** Node.js test runner com `child_process.exec` para testes que dependem de shell.
- **Marcar** testes que dependem de infra externa como `skip` por default em CI.
#### Critérios de Aceite
- [ ] Scripts shell convertidos em arquivos `.test.mjs`.
- [ ] Testes executáveis via `node --test tests/integration/*.test.mjs`.
- [ ] Testes que dependem de Docker/Cloud marcados como conditional skip.
---
## Pré-Requisitos
- FASE-01 completa (segredos configurados para ambiente de CI).
- Acesso a GitHub Actions (secrets configurados).
- Node.js 18+ no runner de CI.
## Entregáveis
1. Workflow `ci.yml` funcional.
2. Scripts de test corrigidos no `package.json`.
3. Configuração de cobertura com `c8`.
4. ESLint com plugins de segurança e React hooks.
5. Testes de segurança convertidos.
## Critérios de Conclusão da Fase
- [ ] CI pipeline verde em PR de teste.
- [ ] Cobertura de testes mensurada e reportada.
- [ ] ESLint passa no CI sem erros.
## Riscos Identificados
| Risco | Probabilidade | Impacto | Mitigação |
| -------------------------------------- | ------------- | ------- | ------------------------------------ |
| Testes e2e flaky em CI | Alta | Médio | Retry strategy no Playwright config |
| ESLint com muitos warnings bloqueantes | Média | Baixo | Começar com `warn`, não `error` |
| Testes de segurança dependem de Docker | Alta | Baixo | Skip condicional com check de Docker |

View File

@@ -0,0 +1,185 @@
# FASE 03 — Refatoração Arquitetural
> **Prioridade:** 🟠 Importante
> **Estimativa de Complexidade:** Alta (58 dias)
> **Dimensões do Relatório:** D1 (Arquitetura), D3 (Qualidade do Código)
> **Dependências:** FASE-02 (CI necessário para validar refatorações sem regressão)
---
## Objetivo
Decompor monólitos de código, eliminar acoplamentos desnecessários, e estabelecer separação clara de responsabilidades seguindo princípios SOLID, preparando a base de código para extensibilidade futura.
---
## Escopo Detalhado
### 3.1 — Decomposição do `usageDb.js` (969 linhas → 5 módulos)
**Origem no relatório:** D1 — God Object: `usageDb.js` (🟠 Importante)
#### Especificação Técnica
Decompor `src/lib/usageDb.js` em 5 módulos com responsabilidade única:
| Módulo Novo | Funções Migradas | Responsabilidade |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| `src/lib/usage/usageHistory.js` | `saveRequestUsage`, `getUsageHistory`, `getUsageDb`, `trackPendingRequest` | CRUD de histórico de uso |
| `src/lib/usage/callLogs.js` | `saveCallLog`, `getCallLogs`, `getCallLogById`, `writeCallLogToDisk`, `readFullLogFromDisk`, `rotateCallLogs` | Logs estruturados de chamadas |
| `src/lib/usage/costCalculator.js` | `calculateCost` | Cálculo de custo puro (sem dependência de DB) |
| `src/lib/usage/usageStats.js` | `getUsageStats` | Agregações e estatísticas |
| `src/lib/usage/migrations.js` | `migrateUsageJsonToSqlite`, `migrateLegacyUsageFiles`, `copyIfMissing` | Migrações de dados |
- **Criar** `src/lib/usage/index.js` como barrel que re-exporta a API pública.
- **Atualizar** todos os call sites para importar dos novos módulos.
- **Manter** backward compatibility via re-exports.
#### Critérios de Aceite
- [ ] Cada módulo tem no máximo 200 linhas.
- [ ] Todos os testes existentes passam sem alteração.
- [ ] Nenhum import circular criado.
- [ ] `costCalculator.js` é uma função pura (testável sem DB).
---
### 3.2 — Refatoração do OAuth Providers com Strategy Pattern
**Origem no relatório:** D1 — OAuth Provider Monolith (🟠 Importante)
#### Especificação Técnica
Refatorar `src/lib/oauth/providers.js` (1051 linhas) usando **Strategy + Adapter pattern**:
1. **Criar** base interface em `src/lib/oauth/base/OAuthProvider.js`:
```javascript
export class OAuthProvider {
constructor(config) {
this.config = config;
}
buildAuthUrl(redirectUri, state, codeChallenge) {
throw new Error("Not implemented");
}
async exchangeToken(code, redirectUri, codeVerifier) {
throw new Error("Not implemented");
}
mapTokens(tokens, extra) {
throw new Error("Not implemented");
}
get flowType() {
return "authorization_code";
}
}
```
2. **Criar** subclasses por provider em `src/lib/oauth/providers/`:
- `claude.js`, `codex.js`, `gemini.js`, `antigravity.js`, `iflow.js`, `qwen.js`, `kimi-coding.js`, `github.js`, `kiro.js`, `cursor.js`, `kilocode.js`, `cline.js`
3. **Criar** factory `src/lib/oauth/providerFactory.js`:
```javascript
export function getProvider(name) {
return providers[name] ?? null;
}
```
4. **Manter** `providers.js` original como facade durante transição (deprecated).
#### Critérios de Aceite
- [ ] Cada provider em arquivo separado (< 120 linhas cada).
- [ ] Factory retorna instância correta para cada provider.
- [ ] Todos os flows OAuth existentes funcionam (testados via e2e smoke test).
- [ ] Adicionar novo provider requer apenas 1 arquivo novo + registro na factory.
- [ ] Arquivo original `providers.js` marcado como `@deprecated`.
---
### 3.3 — Eliminação do Self-Fetch no Middleware
**Origem no relatório:** D1 — Violação de Separação de Responsabilidades (🔴 Crítico)
#### Especificação Técnica
- **Extrair** lógica de verificação de settings em `src/lib/settingsCache.js`:
- Cache in-memory com TTL de 5 segundos.
- Fallback direto para `getSettings()` do DB.
- **Refatorar** `src/proxy.js` para usar `settingsCache` ao invés de `fetch("/api/settings")`.
- **Eliminar** a dependência circular middleware → API route → middleware.
#### Critérios de Aceite
- [ ] `proxy.js` não faz mais `fetch()` para rotas internas.
- [ ] Tempo de resposta do middleware reduzido (sem round-trip HTTP).
- [ ] Cache invalida corretamente quando settings mudam.
- [ ] Build e testes passam.
---
### 3.4 — Criação do Domain Layer
**Origem no relatório:** D1 — Ausência de Domain Layer (🟡 Moderado)
#### Especificação Técnica
- **Criar** `src/domain/` com módulos para regras de negócio puras:
- `src/domain/modelAvailability.js` — Lógica de `isModelAvailable` extraída de `sse/handlers/chat.js`.
- `src/domain/costRules.js` — Regras de cálculo de custo extraídas de `usageDb.js`.
- `src/domain/fallbackPolicy.js` — Regras de fallback/retry extraídas de `sse/services/auth.js`.
- **Garantir** que módulos do domain NÃO importam de `lib/db/`, `sse/`, ou `app/api/`.
#### Critérios de Aceite
- [ ] Diretório `src/domain/` criado com ≥ 3 módulos.
- [ ] Módulos do domain não têm dependências de infra (DB, HTTP).
- [ ] Testes unitários para cada módulo do domain.
- [ ] Handlers refatorados para chamar domain services.
---
### 3.5 — Limpeza Estrutural do Projeto
**Origem no relatório:** D2 — Organização de Pastas (🟡 Moderado, 🟢 Menor)
#### Especificação Técnica
- **Mover** ou `.gitignore` o diretório `antigravity-manager-analysis/`.
- **Consolidar** `src/app/api/rate-limit/` e `src/app/api/rate-limits/` num único endpoint.
- **Eliminar** `src/lib/usage/` (dir com 1 arquivo) — mover conteúdo para `src/lib/usage/` na nova estrutura (item 3.1).
#### Critérios de Aceite
- [ ] `antigravity-manager-analysis/` não faz parte do build.
- [ ] Apenas um endpoint para rate limiting.
- [ ] Sem diretórios com arquivo único desnecessários.
---
## Pré-Requisitos
- FASE-02 completa (CI garante que refatorações não introduzem regressões).
- Todos os testes existentes passando.
## Entregáveis
1. 5 módulos de usage decompostos.
2. 12 providers OAuth em arquivos individuais + factory.
3. Middleware sem self-fetch.
4. Domain layer com ≥ 3 módulos.
5. Estrutura do projeto limpa.
## Critérios de Conclusão da Fase
- [ ] CI verde após todas as refatorações.
- [ ] Cobertura de testes ≥ baseline da FASE-02.
- [ ] Zero imports circulares (verificável via ESLint rule).
## Riscos Identificados
| Risco | Probabilidade | Impacto | Mitigação |
| ----------------------------------------------- | ------------- | ------- | ---------------------------------------------- |
| Refatoração de OAuth quebra fluxos OAuth ativos | Média | Alto | Testes manuais de cada provider antes de merge |
| Imports circulares criados na decomposição | Média | Médio | ESLint plugin `import/no-cycle` |
| Cache de settings no middleware stale | Baixa | Médio | TTL curto (5s) e invalidação on-write |

View File

@@ -0,0 +1,168 @@
# FASE 04 — Error Handling & Observabilidade
> **Prioridade:** 🟠 Importante
> **Estimativa de Complexidade:** Média (46 dias)
> **Dimensões do Relatório:** D5 (Fluxos Ausentes), D8 (LLM Proxy), D9 (Fluxo Ponta a Ponta)
> **Dependências:** FASE-01 (logging corrigido), FASE-03 (domain layer para catálogo de erros)
---
## Objetivo
Implementar tratamento de erros consistente ponta a ponta, observabilidade com correlation IDs, padrões de resiliência (circuit breaker), e telas de erro personalizadas para elevar a maturidade operacional do sistema.
---
## Escopo Detalhado
### 4.1 — Telas de Erro Personalizadas (404, 500, 403)
**Origem no relatório:** D5 — Telas de Erro Personalizadas (🟠 Importante)
#### Especificação Técnica
- **Criar** `src/app/not-found.js` — Página 404 com design do sistema:
- Mensagem amigável: "Página não encontrada".
- Link para dashboard, search de documentação.
- Design consistente com tema do dashboard.
- **Criar** `src/app/error.js` — Boundary de erro para erros de runtime:
- Botão de "Tentar novamente".
- Informação mínima do erro (sem stacktrace).
- Logging do erro completo no server.
- **Criar** `src/app/global-error.js` — Fallback de último recurso.
#### Critérios de Aceite
- [ ] Navegação para rota inexistente mostra página 404 customizada.
- [ ] Erro de runtime no dashboard mostra error boundary customizado.
- [ ] Todas as páginas de erro seguem o tema visual do sistema.
- [ ] Testes e2e validam renderização das páginas de erro.
---
### 4.2 — Catálogo de Error Codes Padronizado
**Origem no relatório:** D9 — Tratamento de Erro Genérico (🟡 Moderado)
#### Especificação Técnica
- **Criar** `src/shared/constants/errorCodes.js`:
```javascript
export const ERROR_CODES = {
PROVIDER_UNAVAILABLE: { code: "OMNIROUTE_PROVIDER_UNAVAILABLE", status: 503 },
AUTH_FAILED: { code: "OMNIROUTE_AUTH_FAILED", status: 401 },
RATE_LIMITED: { code: "OMNIROUTE_RATE_LIMITED", status: 429 },
MODEL_NOT_FOUND: { code: "OMNIROUTE_MODEL_NOT_FOUND", status: 404 },
INVALID_REQUEST: { code: "OMNIROUTE_INVALID_REQUEST", status: 400 },
TRANSLATION_ERROR: { code: "OMNIROUTE_TRANSLATION_ERROR", status: 502 },
TIMEOUT: { code: "OMNIROUTE_TIMEOUT", status: 504 },
INTERNAL_ERROR: { code: "OMNIROUTE_INTERNAL_ERROR", status: 500 },
};
```
- **Criar** helper `createErrorResponse(errorCode, details)` para respostas padronizadas.
- **Refatorar** handlers SSE e API routes para usar o catálogo.
- **Documentar** todos os error codes no OpenAPI spec (`docs/openapi.yaml`).
#### Critérios de Aceite
- [ ] Todos os erros do proxy retornam formato `{ error: { code, message, details } }`.
- [ ] Error codes documentados no OpenAPI spec.
- [ ] Testes unitários para `createErrorResponse`.
---
### 4.3 — Correlation ID (x-request-id)
**Origem no relatório:** D8 — Observabilidade Limitada (🟠 Importante)
#### Especificação Técnica
- **Criar** middleware `src/shared/utils/requestId.js`:
- Gerar UUID v4 como `x-request-id` se não presente no request.
- Propagar em todas as respostas como header.
- Incluir no logging de pino como campo `requestId`.
- **Integrar** no pipeline:
- `proxy.js` — adicionar requestId ao contexto.
- `sse/handlers/chat.js` — propagar requestId para providers upstream.
- `usageDb.js` / `callLogs` — armazenar requestId.
- **Expor** no dashboard Logger — filtro por requestId.
#### Critérios de Aceite
- [ ] Responses incluem header `x-request-id`.
- [ ] Logs do pino incluem campo `requestId`.
- [ ] Call logs no DB armazenam requestId.
- [ ] Dashboard Logger permite filtro por requestId.
---
### 4.4 — Circuit Breaker Pattern
**Origem no relatório:** D8 — Sem Circuit Breaker (🟠 Importante)
#### Especificação Técnica
- **Implementar** circuit breaker por provider em `src/lib/circuitBreaker.js`:
- Estados: `CLOSED` → `OPEN` → `HALF_OPEN`.
- Threshold: 5 falhas consecutivas → OPEN.
- Timeout: 30 segundos em OPEN → tenta HALF_OPEN.
- Reset: 1 sucesso em HALF_OPEN → CLOSED.
- **Integrar** em `sse/services/auth.js` antes de `getProviderCredentials()`.
- **Expor** estado dos circuits no dashboard (endpoint `/api/provider-health`).
#### Critérios de Aceite
- [ ] Circuit breaker previne retry storms quando provider está down.
- [ ] Estado OPEN rejeita requests imediatamente com erro `PROVIDER_UNAVAILABLE`.
- [ ] Transição HALF_OPEN testa provider automaticamente.
- [ ] Dashboard mostra estado de cada circuit.
---
### 4.5 — Timeout Padrão Explícito
**Origem no relatório:** D9 — Request Síncrono sem Timeout (🟠 Importante)
#### Especificação Técnica
- **Definir** valores padrão explícitos:
```javascript
const FETCH_TIMEOUT_MS = parseInt(process.env.FETCH_TIMEOUT_MS) || 120_000;
const STREAM_IDLE_TIMEOUT_MS = parseInt(process.env.STREAM_IDLE_TIMEOUT_MS) || 60_000;
```
- **Aplicar** `AbortController` com timeout em todas as `fetch()` para providers.
- **Documentar** valores padrão em `.env.example` (não comentado).
#### Critérios de Aceite
- [ ] Nenhum fetch() para provider sem timeout.
- [ ] Timeout excedido gera erro `OMNIROUTE_TIMEOUT` (do catálogo).
- [ ] Valores padrão documentados e configuráveis via env.
---
## Pré-Requisitos
- FASE-01 (logging correto para erros) e FASE-03 (domain layer onde error codes vivem).
## Entregáveis
1. Páginas de erro customizadas (404, 500, 403).
2. Catálogo de error codes com helper.
3. Middleware de correlation ID.
4. Circuit breaker por provider.
5. Timeouts explícitos em todas as fetch calls.
## Critérios de Conclusão da Fase
- [ ] Todas as respostas de erro seguem formato padronizado.
- [ ] Correlation ID presente em 100% dos responses.
- [ ] Circuit breaker ativo e testado por unit tests.
## Riscos Identificados
| Risco | Probabilidade | Impacto | Mitigação |
| ---------------------------------------------------------- | ------------- | ------- | ------------------------------------------- |
| Circuit breaker muito agressivo bloqueia providers válidos | Média | Alto | Threshold configurável; começar conservador |
| Correlation ID overhead em high-throughput | Baixa | Baixo | UUID v4 é rápido (~ns) |
| Timeout default 120s muito longo para alguns endpoints | Média | Médio | Override por provider config |

View File

@@ -0,0 +1,171 @@
# FASE 05 — Qualidade do Código e Padronização
> **Prioridade:** 🟠 Importante / 🟡 Moderado
> **Estimativa de Complexidade:** Média (46 dias)
> **Dimensões do Relatório:** D3 (Qualidade do Código), D2 (Organização)
> **Dependências:** FASE-03 (refatoração arquitetural reduz código duplicado antes da padronização)
---
## Objetivo
Padronizar práticas de código em todo o projeto — logging estruturado, definição de estratégia de tipagem, redução de complexidade ciclomática, e decomposição de componentes UI monolíticos.
---
## Escopo Detalhado
### 5.1 — Structured Logging com Pino
**Origem no relatório:** D3 — `console.log` e `console.error` como Logging em Produção (🟠 Importante)
#### Especificação Técnica
- **Criar** `src/shared/utils/logger.js` como instância centralizada de pino:
```javascript
import pino from "pino";
export const log = pino({
level: process.env.LOG_LEVEL || "info",
transport: process.env.NODE_ENV === "development" ? { target: "pino-pretty" } : undefined,
});
```
- **Substituir** todos os `console.log`, `console.error`, `console.warn` por `log.info/error/warn`.
- **Priorizar** arquivos críticos:
1. `src/server-init.js`
2. `src/proxy.js`
3. `src/sse/handlers/chat.js`
4. `src/lib/usageDb.js` (ou módulos decompostos)
5. `src/lib/oauth/providers.js`
- **Incluir** contexto estruturado (provider, model, connectionId) nos logs.
#### Critérios de Aceite
- [ ] Zero `console.log/error/warn` em `src/` (exceto dev scripts).
- [ ] Logger centralizado exporta instância de pino.
- [ ] Logs em produção são JSON (sem pino-pretty).
- [ ] ESLint rule `no-console` ativa com autofix.
---
### 5.2 — Definição de Estratégia de Tipagem (JS + JSDoc)
**Origem no relatório:** D3 — Mix de JS e TS sem Consistência (🟠 Importante)
#### Especificação Técnica
- **Decisão arquitetural:** Adotar **JavaScript + JSDoc** como padrão (não migrar full TS):
- Manter arquivos `.ts` existentes em `src/types/`.
- Adicionar `@ts-check` nos arquivos JS principais.
- Usar `@typedef`, `@param`, `@returns` para tipagem inline.
- **Configurar** tsconfig para checkJs:
```json
{ "compilerOptions": { "checkJs": true, "allowJs": true, "strict": false } }
```
- **Priorizar** tipagem em:
1. `src/shared/validation/schemas.js` — já tipado via Zod.
2. `src/lib/db/core.js` — funções de DB.
3. `src/sse/services/auth.js` — credenciais.
4. `src/domain/` — novos módulos (FASE-03).
- **Documentar** a decisão em ADR (FASE-06).
#### Critérios de Aceite
- [ ] ≥ 10 arquivos críticos com `@ts-check` + JSDoc.
- [ ] `tsc --noEmit` executa sem erros nos arquivos anotados.
- [ ] ADR documenta decisão JS + JSDoc vs TypeScript.
- [ ] Templates de JSDoc disponíveis em CONTRIBUTING.md.
---
### 5.3 — Redução de Complexidade Ciclomática
**Origem no relatório:** D3 — Complexidade Ciclomática Elevada (🟡 Moderado)
#### Arquivos afetados
| Arquivo | Função | Linhas | Ação |
| -------------------------- | ----------------------- | ------ | ---------------------- |
| `src/sse/handlers/chat.js` | `handleSingleModelChat` | 183 | Decompor em subfunções |
| `src/lib/usageDb.js` | `getUsageStats` | 180 | Extrair SQL queries |
#### Especificação Técnica
- **Decompor** `handleSingleModelChat` em:
- `resolveModel(body)` — resolução de modelo/combo.
- `executeProviderRequest(credentials, translatedBody)` — fetch + stream.
- `handleProviderError(error, retryContext)` — retry/fallback logic.
- **Decompor** `getUsageStats` em:
- `buildStatsQuery(period, filters)` — construção de SQL.
- `aggregateResults(rows)` — computação de estatísticas.
- **Target:** Nenhuma função com mais de 80 linhas.
#### Critérios de Aceite
- [ ] `handleSingleModelChat` tem < 80 linhas.
- [ ] `getUsageStats` tem < 80 linhas.
- [ ] Testes existentes passam sem alteração.
- [ ] ESLint rule `max-lines-per-function` configurada (warn em > 100).
---
### 5.4 — Decomposição de Componentes UI Monolíticos
**Origem no relatório:** D2 — Componentes Monolíticos (🟡 Moderado)
#### Componentes Alvo
| Componente Original | Tamanho | Decomposição Proposta |
| ------------------------------- | ----------- | --------------------------------------------------------------------------------------- |
| `RequestLoggerV2.js` (36.3 KB) | ~800 linhas | `RequestLoggerTable`, `RequestLoggerFilters`, `RequestLoggerDetail`, `useRequestLogger` |
| `UsageStats.js` (27.7 KB) | ~600 linhas | `UsageChart`, `UsageTable`, `UsageSummary`, `useUsageStats` |
| `ProxyLogger.js` (27.6 KB) | ~600 linhas | `ProxyLogList`, `ProxyLogEntry`, `ProxyLogFilters`, `useProxyLogger` |
| `OAuthModal.js` (18.3 KB) | ~400 linhas | `OAuthProviderList`, `OAuthConnectionForm`, `OAuthTokenStatus` |
| `ProxyConfigModal.js` (16.1 KB) | ~350 linhas | `ProxyConfigForm`, `ProxyConfigPreview`, `useProxyConfig` |
#### Especificação Técnica
- **Para cada componente:**
1. Extrair custom hook com lógica de estado e data fetching.
2. Separar sub-componentes visuais puros.
3. Manter componente original como "orchestrator" que compõe sub-componentes.
- **Criar** diretórios por feature:
- `src/shared/components/request-logger/`
- `src/shared/components/usage-stats/`
- `src/shared/components/proxy-logger/`
- **Manter** imports existentes via re-export no arquivo original.
#### Critérios de Aceite
- [ ] Nenhum componente com mais de 300 linhas.
- [ ] Hooks extraídos são testáveis independentemente.
- [ ] Importações existentes continuam funcionando.
- [ ] UI renderiza identicamente (visual regression test ou screenshot comparation manual).
---
## Pré-Requisitos
- FASE-03 completa (decomposição de módulos backend facilita decomposição de componentes).
- CI pipeline ativo (FASE-02) para validar regressões.
## Entregáveis
1. Logger centralizado com pino.
2. ≥ 10 arquivos com JSDoc + `@ts-check`.
3. Funções críticas decompostas (< 80 linhas).
4. 5 componentes UI decompostos em sub-componentes.
## Critérios de Conclusão da Fase
- [ ] Zero `console.log` em `src/`.
- [ ] Nenhuma função > 100 linhas.
- [ ] Nenhum componente > 300 linhas.
- [ ] CI verde.
## Riscos Identificados
| Risco | Probabilidade | Impacto | Mitigação |
| ------------------------------------------------------- | ------------- | ------- | ----------------------------------- |
| Decomposição de componentes cria bugs visuais | Média | Médio | Visual regression test antes/depois |
| JSDoc excessivo reduz produtividade | Baixa | Baixo | Tipar apenas funções exportadas |
| Substituição de console.log perde context em edge cases | Baixa | Baixo | Revisão manual de cada substituição |

View File

@@ -0,0 +1,137 @@
# FASE 06 — Documentação e Governança
> **Prioridade:** 🟡 Moderado
> **Estimativa de Complexidade:** Baixa-Média (24 dias)
> **Dimensões do Relatório:** D4 (Documentação)
> **Dependências:** FASE-03 (decisões arquiteturais para ADRs), FASE-05 (padrões de código para CONTRIBUTING)
---
## Objetivo
Completar a documentação do projeto com ADRs, guia de contribuição, política de segurança expandida, e padronização de comentários inline, garantindo que novos contribuidores tenham contexto suficiente para entender e contribuir com o projeto.
---
## Escopo Detalhado
### 6.1 — Architecture Decision Records (ADRs)
**Origem no relatório:** D4 — Ausência de ADRs (🟡 Moderado)
#### Especificação Técnica
- **Criar** diretório `docs/adr/` com template `docs/adr/000-template.md`.
- **Criar** ADRs iniciais:
| ADR # | Título | Decisão |
| ----- | ---------------------------------- | --------------------------------------------------- |
| 001 | Escolha de SQLite como Database | Justificar SQLite vs PostgreSQL para single-tenant |
| 002 | Padrão de Fallback entre Providers | Documentar estratégias fill-first, round-robin, p2c |
| 003 | Estratégia de OAuth Multi-Provider | Documentar escolha de Strategy pattern (FASE-03) |
| 004 | JavaScript + JSDoc vs TypeScript | Documentar decisão de tipagem (FASE-05) |
| 005 | Sistema Single-Tenant | Documentar escopo sem multi-tenancy |
| 006 | Tradução de Formatos LLM | Documentar pattern Registry do Translator |
- **Formato ADR:** Status, Contexto, Decisão, Consequências (formato Nygard).
#### Critérios de Aceite
- [ ] Template ADR criado e documentado.
- [ ] ≥ 6 ADRs cobrindo decisões-chave do projeto.
- [ ] ADRs referenciados no README e ARCHITECTURE.md.
---
### 6.2 — CONTRIBUTING.md
**Origem no relatório:** D4 — CONTRIBUTING.md Ausente (🟡 Moderado)
#### Especificação Técnica
- **Criar** `CONTRIBUTING.md` na raiz com seções:
1. **Getting Started** — Setup do ambiente, `npm install`, env vars obrigatórias.
2. **Development Workflow** — Branch naming, commit convention (Conventional Commits).
3. **Coding Standards** — JSDoc obrigatório, ESLint, Prettier.
4. **Testing** — Como rodar testes unitários, e2e, e coverage.
5. **PR Process** — Template de PR, reviewers, CI checks obrigatórios.
6. **Architecture** — Link para ARCHITECTURE.md e ADRs.
- **Criar** `.github/PULL_REQUEST_TEMPLATE.md` com checklist padrão.
#### Critérios de Aceite
- [ ] `CONTRIBUTING.md` criado com todas as 6 seções.
- [ ] PR template criado e ativo no GitHub.
- [ ] README referencia CONTRIBUTING.md.
---
### 6.3 — Expansão do SECURITY.md
**Origem no relatório:** D4 — SECURITY.md Insuficiente (🟡 Moderado)
#### Especificação Técnica
- **Expandir** `SECURITY.md` de 619 bytes para ≥ 2KB com:
1. **Responsible Disclosure Policy** — Como reportar vulnerabilidades.
2. **Vulnerability Scope** — Tipos aceitos (RCE, XSS, SSRF, auth bypass, etc.).
3. **Response SLA** — Prazo de resposta (48h ack, 7 dias para fix P0).
4. **Security Contact** — Email ou canal dedicado.
5. **Security Best Practices** — Instruções para configurar segredos fortes.
6. **Known Limitations** — Documentar limitações de segurança do single-tenant.
#### Critérios de Aceite
- [ ] SECURITY.md ≥ 2KB com todas as 6 seções.
- [ ] Formato segue GitHub Security Advisories best practices.
- [ ] Link no README para SECURITY.md.
---
### 6.4 — Padronização de JSDoc em Funções Exportadas
**Origem no relatório:** D4 — Inline Comments inconsistentes (🟢 Menor)
#### Especificação Técnica
- **Definir** padrão: toda função exportada DEVE ter JSDoc com `@param`, `@returns`, `@throws`.
- **Priorizar** módulos públicos:
1. `src/lib/db/*.js` — Funções de DB.
2. `src/shared/utils/*.js` — Utilitários.
3. `src/domain/*.js` — Domain services (módulos novos da FASE-03).
4. `src/sse/services/*.js` — Services de streaming.
- **ESLint:** Ativar `jsdoc/require-jsdoc` como `warn` para `export` functions.
#### Critérios de Aceite
- [ ] ≥ 80% das funções exportadas em módulos priorizados têm JSDoc.
- [ ] ESLint rule `jsdoc/require-jsdoc` ativa.
- [ ] CONTRIBUTING.md documenta o padrão de JSDoc.
---
## Pré-Requisitos
- FASE-03 (decisões de refatoração para ADRs) e FASE-05 (padrões de código para CONTRIBUTING).
## Entregáveis
1. Diretório `docs/adr/` com ≥ 6 ADRs.
2. `CONTRIBUTING.md` completo.
3. `SECURITY.md` expandido.
4. JSDoc em funções exportadas.
5. PR template.
## Critérios de Conclusão da Fase
- [ ] Todos os documentos criados e revisados.
- [ ] README atualizado com links para novos docs.
- [ ] ESLint rule de JSDoc ativa.
## Riscos Identificados
| Risco | Probabilidade | Impacto | Mitigação |
| -------------------------------------------------- | ------------- | ------- | ------------------------------- |
| ADRs ficam desatualizados rapidamente | Média | Baixo | Revisão trimestral agendada |
| JSDoc obrigatório reduz velocidade de contribuição | Baixa | Baixo | Começar com `warn`, não `error` |
| SECURITY.md cria expectativas de SLA não cumpridas | Baixa | Médio | SLA realista e comunicado |

View File

@@ -0,0 +1,209 @@
# FASE 07 — UX e Microinterações
> **Prioridade:** 🟡 Moderado
> **Estimativa de Complexidade:** Média (46 dias)
> **Dimensões do Relatório:** D5 (Fluxos Ausentes), D6 (UX e Microinterações)
> **Dependências:** FASE-05 (componentes decompostos facilitam adição de a11y e empty states)
---
## Objetivo
Elevar a qualidade da experiência do usuário no dashboard com sistema de notificações global, acessibilidade, breadcrumbs, empty states, e fluxo de recuperação de senha.
---
## Escopo Detalhado
### 7.1 — Sistema de Toasts/Notifications Global
**Origem no relatório:** D5 — Feedback de Ações Centralizado (🟡 Moderado)
#### Especificação Técnica
- **Criar** Zustand store `src/store/notificationStore.js`:
```javascript
export const useNotificationStore = create((set) => ({
notifications: [],
addNotification: (notification) =>
set((s) => ({
notifications: [...s.notifications, { id: Date.now(), ...notification }],
})),
removeNotification: (id) =>
set((s) => ({
notifications: s.notifications.filter((n) => n.id !== id),
})),
}));
```
- **Criar** componente `src/shared/components/NotificationToast.js`:
- Tipos: `success`, `error`, `warning`, `info`.
- Auto-dismiss após 5s (configurável).
- Animação de entrada/saída (slide + fade).
- Posicionamento: top-right, stack vertical.
- Ação de dismiss manual (botão X).
- **Integrar** no layout root do dashboard.
- **Refatorar** ≥ 5 call sites que usam feedback ad-hoc para usar o notification system.
#### Critérios de Aceite
- [ ] Toasts renderizam sobre o conteúdo sem afetar layout.
- [ ] Tipos visuais distintos (cores/ícones por tipo).
- [ ] Auto-dismiss funciona.
- [ ] ≥ 5 ações do dashboard usam o sistema centralizado.
---
### 7.2 — Auditoria de Acessibilidade (a11y)
**Origem no relatório:** D6 — Acessibilidade Insuficiente (🟡 Moderado)
#### Especificação Técnica
- **Executar** auditoria com `axe-core` ou `pa11y-ci`:
- Dashboard Home
- Providers page
- Settings page
- Login page
- **Corrigir** achados críticos:
- Adicionar `role="dialog"` e `aria-modal="true"` em todos os modais.
- Implementar focus trap em `OAuthModal.js` e `ProxyConfigModal.js`.
- Adicionar `aria-label` em botões com ícone sem texto.
- Garantir contraste mínimo 4.5:1 (WCAG AA).
- **Adicionar** hook `useFocusTrap.js` em `src/shared/hooks/`.
- **Integrar** `axe-core` como teste e2e de acessibilidade.
#### Critérios de Aceite
- [ ] Zero violações críticas de axe-core nas 4 páginas auditadas.
- [ ] Todos os modais com `role="dialog"` e focus trap.
- [ ] Navegação por teclado funciona em todas as páginas do dashboard.
- [ ] Teste e2e de a11y integrado no CI.
---
### 7.3 — Breadcrumbs no Dashboard
**Origem no relatório:** D6 — Breadcrumbs Ausentes (🟡 Moderado)
#### Especificação Técnica
- **Criar** componente `src/shared/components/Breadcrumbs.js`:
- Gerar breadcrumbs automaticamente a partir do pathname.
- Mapeamento de paths para labels amigáveis:
```javascript
const PATH_LABELS = {
dashboard: "Dashboard",
providers: "Provedores",
settings: "Configurações",
usage: "Uso",
combos: "Combos",
tools: "Ferramentas",
translator: "Tradutor",
profile: "Perfil",
};
```
- Links clicáveis para cada nível.
- Último item não clicável (página atual).
- **Integrar** no layout do dashboard (abaixo do header ou acima do conteúdo).
#### Critérios de Aceite
- [ ] Breadcrumbs renderizam em todas as páginas do dashboard.
- [ ] Cada nível é clicável (exceto o atual).
- [ ] Labels são amigáveis (não slugs).
- [ ] Responsividade: truncamento em telas pequenas.
---
### 7.4 — Empty States Guiados
**Origem no relatório:** D6 — Loading States e Empty States (🟡 Moderado)
#### Especificação Técnica
- **Criar** componente `src/shared/components/EmptyState.js`:
- Props: `icon`, `title`, `description`, `actionLabel`, `onAction`.
- Design: Ícone centrado, texto descritivo, CTA (call to action).
- **Implementar** empty states nas seções:
1. **Providers** — "Nenhum provider conectado. Conecte seu primeiro provider."
2. **Combos** — "Nenhum combo criado. Crie um combo para agrupar modelos."
3. **Usage** — "Nenhum uso registrado ainda. Faça sua primeira requisição."
4. **Request Logger** — "Nenhum request logado. Ative logging nas configurações."
- **Substituir** listas vazias por empty states.
#### Critérios de Aceite
- [ ] Todas as 4 seções mostram empty states em vez de listas vazias.
- [ ] Empty states incluem CTA relevante.
- [ ] Design consistente com o tema do dashboard.
---
### 7.5 — Fluxo de Recuperação de Senha
**Origem no relatório:** D5 — Fluxo de Recuperação de Senha (🟡 Moderado)
#### Especificação Técnica
- **Implementar** reset de senha via CLI:
```bash
npx omniroute reset-password
# Prompts for new password, updates DB directly
```
- **Criar** endpoint `/api/auth/reset-password` com token temporário (opcional, para uso via link interno).
- **Documentar** o processo de reset no README e na página de login como texto de ajuda.
#### Critérios de Aceite
- [ ] CLI permite resetar senha sem acesso ao dashboard.
- [ ] Processo documentado no README.
- [ ] Página de login mostra texto explicativo sobre recuperação.
---
### 7.6 — Teste de Responsividade
**Origem no relatório:** D6 — Responsividade (🟢 Menor)
#### Especificação Técnica
- **Criar** testes Playwright para viewport < 768px.
- **Verificar** páginas: Login, Dashboard, Providers, Settings.
- **Corrigir** overflow, truncamento, e usabilidade em mobile.
#### Critérios de Aceite
- [ ] Testes de responsividade passam em viewport 375px e 768px.
- [ ] Nenhum overflow horizontal em telas < 768px.
- [ ] Sidebar colapsável em mobile.
---
## Pré-Requisitos
- FASE-05 (componentes decompostos são mais fáceis de auditar e modificar).
- Dashboard funcional para testes visuais.
## Entregáveis
1. Sistema de toasts com Zustand store.
2. Auditoria a11y com correções.
3. Componente Breadcrumbs.
4. Empty states em 4 seções.
5. Reset de senha via CLI.
6. Testes de responsividade.
## Critérios de Conclusão da Fase
- [ ] Sistema de toasts funcional e integrado.
- [ ] Zero violações críticas de a11y.
- [ ] Breadcrumbs operacionais.
- [ ] Empty states implementados.
## Riscos Identificados
| Risco | Probabilidade | Impacto | Mitigação |
| ----------------------------------------- | ------------- | ------- | -------------------------------------- |
| Focus trap interfere com UX | Média | Médio | Testes manuais de cada modal |
| Breadcrumbs incorretos em rotas dinâmicas | Baixa | Baixo | Mapeamento explícito de todas as rotas |
| CLI de reset requer acesso ao servidor | Inevitável | Baixo | Documentar alternativas (acesso ao DB) |

View File

@@ -0,0 +1,158 @@
# FASE 08 — LLM Proxy: Recursos Avançados
> **Prioridade:** 🟡 Moderado
> **Estimativa de Complexidade:** Alta (610 dias)
> **Dimensões do Relatório:** D8 (LLM Proxy, Gateway e Router)
> **Dependências:** FASE-04 (circuit breaker e error codes como base), FASE-03 (domain layer)
---
## Objetivo
Implementar funcionalidades avançadas de roteamento LLM: policy engine declarativo, cache de respostas, framework de avaliação de modelos, e controles de compliance/privacidade para amadurecer o OmniRoute como gateway de produção.
---
## Escopo Detalhado
### 8.1 — Policy Engine Declarativo
**Origem no relatório:** D8 — Sem Policy Engine Separado (🔴 Crítico)
#### Especificação Técnica
- **Criar** `src/lib/policies/policyEngine.js`:
- Carregar políticas de um arquivo JSON/YAML ou do DB (tabela `policies`).
- Suportar regras declarativas:
```json
{
"name": "prefer-low-cost",
"conditions": { "model_pattern": "gpt-4*" },
"actions": { "prefer_provider": ["openai", "gemini"], "max_cost_per_1k": 0.03 }
}
```
- Tipos de política: `routing` (preferência de provider), `budget` (limite de custo), `access` (allow/deny models).
- **Integrar** no pipeline antes de `getProviderCredentials()` em `sse/handlers/chat.js`.
- **Criar** API endpoints:
- `GET /api/policies` — Listar políticas.
- `POST /api/policies` — Criar política.
- `PUT /api/policies/:id` — Atualizar política.
- `DELETE /api/policies/:id` — Remover política.
- **Criar** tela de gerenciamento no dashboard.
#### Critérios de Aceite
- [ ] Políticas de routing influenciam seleção de provider.
- [ ] Políticas de budget limitam custo por request/dia/mês.
- [ ] Políticas de access permitem bloquear modelos específicos.
- [ ] Políticas são CRUD via API e dashboard.
- [ ] Testes unitários cobrem avaliação de políticas.
---
### 8.2 — Cache Layer para Prompts e Respostas
**Origem no relatório:** D8 — Sem Cache de Prompts/Respostas (🟠 Importante)
#### Especificação Técnica
- **Criar** `src/lib/cacheLayer.js`:
- Cache LRU in-memory (usando `lru-cache` ou implementação própria).
- Cache key: hash do `{ model, messages, temperature, max_tokens }`.
- TTL configurável (default: 5 minutos).
- Toggle via settings: `ENABLE_PROMPT_CACHE=true/false`.
- **Integrar** no pipeline:
- Antes de `executeProviderRequest()`: verificar cache hit.
- Após resposta bem sucedida: armazenar no cache (apenas non-streaming OU primeiro chunk).
- **Métricas**: cache hit rate exposta via `/api/cache/stats`.
- **Bypass**: header `x-no-cache: true` para forçar request fresh.
#### Critérios de Aceite
- [ ] Requests idênticos retornam resposta cached.
- [ ] Cache hit rate mensurável via endpoint.
- [ ] Header `x-no-cache` funciona.
- [ ] Cache não interfere com streaming SSE.
- [ ] TTL configurável via env/settings.
---
### 8.3 — Framework de Evals por Modelo
**Origem no relatório:** D8 — Governança de Qualidade de Modelos (🟡 Moderado)
#### Especificação Técnica
- **Criar** `src/lib/evals/evalRunner.js`:
- Definir golden set de prompts/respostas esperadas.
- Executar avaliação periódica (manual ou cron) contra cada modelo.
- Métricas: accuracy, latência p50/p95, custo por prompt.
- **Criar** `src/lib/evals/goldenSet.json` com ≥ 10 test cases:
- Cases de completamento, raciocínio, código, tradução.
- **Criar** endpoint `/api/evals/run` — Trigger manual.
- **Criar** endpoint `/api/evals/results` — Resultados por modelo.
- **Criar** tela de resultados no dashboard.
#### Critérios de Aceite
- [ ] Golden set com ≥ 10 test cases.
- [ ] Eval runner executa contra todos os modelos ativos.
- [ ] Resultados armazenados com timestamp para comparação temporal.
- [ ] Dashboard exibe scorecard por modelo.
---
### 8.4 — Compliance e Controles de Privacidade
**Origem no relatório:** D8 — Compliance e Privacidade (🟡 Moderado)
#### Especificação Técnica
- **Implementar** política de retenção de dados:
- Setting global `LOG_RETENTION_DAYS` (default: 30).
- Job de limpeza automática (cron ou on-request).
- **Implementar** opt-out de logging por rota/API key:
- Campo `noLog: true` nos metadata da API key.
- Requests com `noLog` não geram call logs.
- **Implementar** trilha de auditoria básica:
- Tabela `audit_log` com: timestamp, action, actor, target, details.
- Ações logadas: login, settings change, provider add/remove, password reset.
- **Documentar** compliance capabilities no README e landing page.
#### Critérios de Aceite
- [ ] Logs mais antigos que `LOG_RETENTION_DAYS` são removidos automaticamente.
- [ ] API keys com `noLog: true` não geram registros.
- [ ] Tabela `audit_log` registra ações administrativas.
- [ ] Documentação descreve compliance capabilities.
---
## Pré-Requisitos
- FASE-04 (circuit breaker e error codes estabelecidos).
- FASE-03 (domain layer para regras de políticas).
- Dashboard funcional para telas novas.
## Entregáveis
1. Policy engine com CRUD de políticas.
2. Cache layer com métricas.
3. Framework de evals com golden set.
4. Controles de retenção, opt-out, e auditoria.
## Critérios de Conclusão da Fase
- [ ] Políticas influenciam roteamento.
- [ ] Cache funcional com métricas.
- [ ] Evals executavelmente contra ≥ 3 modelos.
- [ ] Retenção automática ativa.
## Riscos Identificados
| Risco | Probabilidade | Impacto | Mitigação |
| -------------------------------------- | ------------- | ------- | -------------------------------------------------- |
| Policy engine complexidade excessiva | Alta | Alto | MVP com 3 tipos de policy apenas |
| Cache stale causa respostas incorretas | Média | Alto | TTL curto (5min) e bypass via header |
| Evals custam tokens em providers pagos | Alta | Médio | Golden set pequeno; usar providers free para teste |
| Auditoria gera volume alto de dados | Média | Médio | Retenção configurável; summarize após 90 dias |

View File

@@ -0,0 +1,173 @@
# FASE 09 — Hardening de Fluxo Ponta a Ponta
> **Prioridade:** 🟡 Moderado / 🟢 Menor
> **Estimativa de Complexidade:** Média (35 dias)
> **Dimensões do Relatório:** D9 (Fluxo Ponta a Ponta)
> **Dependências:** FASE-04 (correlation ID e error codes), FASE-08 (policy engine e cache)
---
## Objetivo
Endurecer o fluxo completo de requisição (request lifecycle), adicionando state tracking para streams, telemetria por etapa, e extraindo regras de negócio residuais dos controllers para domain services.
---
## Escopo Detalhado
### 9.1 — State Machine para Streams SSE
**Origem no relatório:** D9 — Sem State Machine para Processos Longos (🟠 Importante)
#### Especificação Técnica
- **Criar** `src/sse/services/streamState.js`:
```javascript
export const STREAM_STATES = {
INITIALIZED: "initialized",
CONNECTING: "connecting",
STREAMING: "streaming",
COMPLETED: "completed",
FAILED: "failed",
CANCELLED: "cancelled",
};
export class StreamTracker {
constructor(requestId) {
this.requestId = requestId;
this.state = STREAM_STATES.INITIALIZED;
this.transitions = [];
}
transition(newState, metadata = {}) {
this.transitions.push({ from: this.state, to: newState, at: Date.now(), ...metadata });
this.state = newState;
}
}
```
- **Integrar** no pipeline de streaming em `handleSingleModelChat`:
- `INITIALIZED` → `CONNECTING` (antes do fetch).
- `CONNECTING` → `STREAMING` (primeiro chunk recebido).
- `STREAMING` → `COMPLETED` (stream finalizado).
- `STREAMING` → `FAILED` (erro durante stream).
- Qualquer → `CANCELLED` (client disconnect).
- **Logar** transições de estado com requestId.
- **Expor** estado ativo via endpoint `/api/streams/active`.
#### Critérios de Aceite
- [ ] Cada stream tem tracking de estado explícito.
- [ ] Transições são logadas.
- [ ] Endpoint mostra streams ativos e seus estados.
- [ ] Client disconnect detectado e marcado como CANCELLED.
---
### 9.2 — Telemetria por Etapa do Request Pipeline
**Origem no relatório:** D9 — Telemetria por Jornada Ausente (🟡 Moderado)
#### Especificação Técnica
- **Criar** `src/shared/utils/requestTelemetry.js`:
- Metrificação por etapa:
```javascript
export class RequestTelemetry {
constructor(requestId) {
this.requestId = requestId;
this.timings = {};
}
startPhase(phase) {
this.timings[phase] = { start: performance.now() };
}
endPhase(phase) {
this.timings[phase].end = performance.now();
}
getSummary() {
return Object.entries(this.timings).reduce((acc, [k, v]) => {
acc[k] = v.end - v.start;
return acc;
}, {});
}
}
```
- Fases medidas:
1. `parse` — Parse do body e validação.
2. `model_resolution` — Resolução de modelo/combo.
3. `credential_selection` — Seleção de conta.
4. `translation` — Tradução de request.
5. `provider_fetch` — Fetch para o provider.
6. `response_translation` — Tradução da resposta.
7. `total` — End-to-end.
- **Armazenar** telemetria no call log (campo `timings`).
- **Expor** via endpoint `/api/telemetry/summary` — p50, p95, p99 por fase.
- **Exibir** no dashboard (gráfico de latência por fase).
#### Critérios de Aceite
- [ ] Cada request tem telemetria por fase.
- [ ] Call logs armazenam campo `timings`.
- [ ] Endpoint de summary retorna p50/p95/p99.
- [ ] Dashboard exibe gráfico de latência.
---
### 9.3 — Extração de Regras de Negócio Residuais
**Origem no relatório:** D9 — Regra de Negócio em Controller (🟡 Moderado)
#### Especificação Técnica
- **Auditar** `src/sse/handlers/chat.js` para regras de negócio em controller:
- `isModelAvailable()` → mover para `src/domain/modelAvailability.js` (FASE-03).
- Lógica de per-model lockout → mover para `src/domain/lockoutPolicy.js`.
- Lógica de "combo resolution" → mover para `src/domain/comboResolver.js`.
- **Refatorar** handler para ser um "thin controller":
```javascript
// sse/handlers/chat.js — objetivo final
async function handleChat(request) {
const body = parseRequest(request);
const model = comboResolver.resolve(body.model);
const credentials = await credentialService.select(model);
const translated = translator.translate(body, model.format);
const response = await providerService.execute(credentials, translated);
return translator.translateResponse(response, body.format);
}
```
- **Garantir** que cada módulo domain tenha testes unitários.
#### Critérios de Aceite
- [ ] Handler `handleChat` tem < 50 linhas de lógica.
- [ ] ≥ 3 funções extraídas para domain layer.
- [ ] Domain modules testados unitariamente.
- [ ] Nenhuma regra de negócio no handler.
---
## Pré-Requisitos
- FASE-04 (correlation ID para integração com telemetria).
- FASE-03 (domain layer como destino das regras extraídas).
- FASE-08 (policy engine como parte do pipeline).
## Entregáveis
1. Stream state machine com tracking.
2. Telemetria por fase com métricas p50/p95/p99.
3. Handler refatorado como thin controller.
## Critérios de Conclusão da Fase
- [ ] Streams com tracking de estado.
- [ ] Telemetria armazenada e acessível.
- [ ] Handler < 50 linhas de lógica de negócio.
## Riscos Identificados
| Risco | Probabilidade | Impacto | Mitigação |
| -------------------------------------- | ------------- | ------- | ------------------------------------------- |
| Telemetria overhead em high-throughput | Média | Médio | Sampling configurável (1%, 10%, 100%) |
| State machine complexidade adicional | Baixa | Baixo | Implementação minimalista; apenas 6 estados |
| Extração de regras cria regressões | Média | Médio | Testes completos do pipeline antes e depois |

118
docs/PLANO-IMPLANTACAO.md Normal file
View File

@@ -0,0 +1,118 @@
# Plano de Implantação — OmniRoute
> **Versão:** 1.0
> **Data:** 2026-02-14
> **Baseado em:** Relatório de Análise Crítica Exaustiva (9 Dimensões)
> **Total de Fases:** 9
> **Total de Itens:** 41
> **Estimativa Total:** 3459 dias úteis
---
## Visão Geral
```mermaid
gantt
title Plano de Implantação OmniRoute
dateFormat YYYY-MM-DD
axisFormat %d/%m
section Crítico
FASE 01 - Security Hardening :f1, 2026-02-17, 5d
FASE 02 - CI/CD & Testes :f2, after f1, 5d
section Importante
FASE 03 - Refatoração Arquitetural :f3, after f2, 8d
FASE 04 - Error Handling :f4, after f3, 6d
FASE 05 - Qualidade de Código :f5, after f3, 6d
section Moderado
FASE 06 - Documentação :f6, after f5, 4d
FASE 07 - UX & Microinterações :f7, after f5, 6d
FASE 08 - LLM Proxy Avançado :f8, after f4, 10d
FASE 09 - Fluxo Ponta a Ponta :f9, after f8, 5d
```
---
## Documentos de Fase
| Fase | Documento | Prioridade | Itens | Complexidade |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | ----- | ------------ |
| 01 | [FASE-01-security-hardening.md](file:///home/diegosouzapw/dev/proxys/9router/docs/FASE-01-security-hardening.md) | 🔴 Crítica | 5 | Média (35d) |
| 02 | [FASE-02-cicd-test-infrastructure.md](file:///home/diegosouzapw/dev/proxys/9router/docs/FASE-02-cicd-test-infrastructure.md) | 🔴 Crítica | 5 | Média (35d) |
| 03 | [FASE-03-architecture-refactoring.md](file:///home/diegosouzapw/dev/proxys/9router/docs/FASE-03-architecture-refactoring.md) | 🟠 Importante | 5 | Alta (58d) |
| 04 | [FASE-04-error-handling-observability.md](file:///home/diegosouzapw/dev/proxys/9router/docs/FASE-04-error-handling-observability.md) | 🟠 Importante | 5 | Média (46d) |
| 05 | [FASE-05-code-quality-standards.md](file:///home/diegosouzapw/dev/proxys/9router/docs/FASE-05-code-quality-standards.md) | 🟠/🟡 Importante | 4 | Média (46d) |
| 06 | [FASE-06-documentation-governance.md](file:///home/diegosouzapw/dev/proxys/9router/docs/FASE-06-documentation-governance.md) | 🟡 Moderado | 4 | Baixa (24d) |
| 07 | [FASE-07-ux-microinteractions.md](file:///home/diegosouzapw/dev/proxys/9router/docs/FASE-07-ux-microinteractions.md) | 🟡 Moderado | 6 | Média (46d) |
| 08 | [FASE-08-llm-proxy-advanced.md](file:///home/diegosouzapw/dev/proxys/9router/docs/FASE-08-llm-proxy-advanced.md) | 🟡 Moderado | 4 | Alta (610d) |
| 09 | [FASE-09-e2e-flow-hardening.md](file:///home/diegosouzapw/dev/proxys/9router/docs/FASE-09-e2e-flow-hardening.md) | 🟡/🟢 Moderado | 3 | Média (35d) |
---
## Ordem de Execução e Dependências
```mermaid
graph TD
F1[FASE 01<br/>Security Hardening] --> F2[FASE 02<br/>CI/CD & Testes]
F2 --> F3[FASE 03<br/>Refatoração Arquitetural]
F3 --> F4[FASE 04<br/>Error Handling & Observabilidade]
F3 --> F5[FASE 05<br/>Qualidade do Código]
F5 --> F6[FASE 06<br/>Documentação & Governança]
F5 --> F7[FASE 07<br/>UX & Microinterações]
F4 --> F8[FASE 08<br/>LLM Proxy Avançado]
F8 --> F9[FASE 09<br/>Fluxo Ponta a Ponta]
style F1 fill:#dc3545,color:#fff
style F2 fill:#dc3545,color:#fff
style F3 fill:#fd7e14,color:#fff
style F4 fill:#fd7e14,color:#fff
style F5 fill:#fd7e14,color:#fff
style F6 fill:#ffc107,color:#000
style F7 fill:#ffc107,color:#000
style F8 fill:#ffc107,color:#000
style F9 fill:#28a745,color:#fff
```
### Paralelização Possível
| Janela | Fases Paralelas | Condição |
| ------------ | ----------------- | -------------------------------- |
| Após FASE-03 | FASE-04 + FASE-05 | Ambas dependem apenas de FASE-03 |
| Após FASE-05 | FASE-06 + FASE-07 | Ambas dependem apenas de FASE-05 |
---
## Marcos de Entrega
| Marco | Fases Inclusas | Data Estimada | Critério de Aceite |
| ---------------------------- | -------------- | ------------- | ------------------------------------------------------- |
| **M1 — Segurança Baseline** | FASE-01 | Semana 1 | Segredos obrigatórios, erros logados, sanitizador ativo |
| **M2 — Qualidade Garantida** | FASE-01, 02 | Semana 2 | CI verde, testes executam, cobertura mensurável |
| **M3 — Arquitetura Madura** | FASE-0103 | Semana 4 | Monólitos decompostos, domain layer criado |
| **M4 — Produção-Ready** | FASE-0105 | Semana 6 | Error handling, logging, tipagem padronizados |
| **M5 — Documentado** | FASE-0106 | Semana 7 | ADRs, CONTRIBUTING, SECURITY completos |
| **M6 — UX Polish** | FASE-0107 | Semana 9 | Toasts, a11y, breadcrumbs, empty states |
| **M7 — Gateway Completo** | FASE-0109 | Semana 12 | Policy engine, cache, evals, telemetria |
---
## Critérios de Qualidade Transversais
Aplicáveis a TODAS as fases:
- [ ] CI pipeline verde após cada merge.
- [ ] Cobertura de testes não regride.
- [ ] Nenhum `console.log` adicionado (a partir da FASE-05).
- [ ] PR review obrigatório.
- [ ] CHANGELOG atualizado a cada fase.
---
## Documentos de Referência
- [Relatório de Análise Crítica](file:///home/diegosouzapw/.gemini/antigravity/brain/4c7323de-ade6-432d-8710-4a71eb43d1ad/omniroute_analysis_report.md)
- [TASKS.md — Lista Completa de Tarefas](file:///home/diegosouzapw/dev/proxys/9router/docs/TASKS.md)
- [ARCHITECTURE.md](file:///home/diegosouzapw/dev/proxys/9router/docs/ARCHITECTURE.md)
- [CODEBASE_DOCUMENTATION.md](file:///home/diegosouzapw/dev/proxys/9router/docs/CODEBASE_DOCUMENTATION.md)

143
docs/TASKS.md Normal file
View File

@@ -0,0 +1,143 @@
# TASKS — Lista Completa de Tarefas Executáveis
> **Roteiro prático para desenvolvimento do OmniRoute**
> **Total de tarefas:** 46
> **Status possíveis:** `Pendente` | `Em Progresso` | `Concluído` | `Bloqueado`
---
## Legenda
| Campo | Descrição |
| -------------- | --------------------------------------------------- |
| **ID** | Identificador único no formato `T-XX` |
| **Fase** | Fase de origem (`F01``F09`) |
| **Prioridade** | 🔴 Crítica · 🟠 Importante · 🟡 Moderada · 🟢 Menor |
| **Deps** | Tarefas das quais esta depende (IDs) |
| **Status** | Estado atual da tarefa |
---
## FASE 01 — Security Hardening
| ID | Descrição | Prioridade | Deps | Status |
| ---- | ----------------------------------------------------------------------------------------------------------------------- | ----------- | ---------------- | -------- |
| T-01 | Remover fallback hardcoded de `JWT_SECRET` em `src/proxy.js` e implementar validação fail-fast na inicialização | 🔴 Crítica | — | Pendente |
| T-02 | Remover fallback hardcoded de `API_KEY_SECRET` em `src/shared/utils/apiKey.js` e implementar validação fail-fast | 🔴 Crítica | — | Pendente |
| T-03 | Atualizar `.env.example` e README com instruções para gerar segredos fortes (openssl rand) | 🔴 Crítica | T-01, T-02 | Pendente |
| T-04 | Adicionar logging estruturado em todos os `catch` blocks silenciosos de `src/proxy.js` (auth_error, settings_error) | 🔴 Crítica | — | Pendente |
| T-05 | Criar módulo `src/shared/utils/inputSanitizer.js` com detecção de prompt injection e PII redaction | 🔴 Crítica | — | Pendente |
| T-06 | Integrar `inputSanitizer` no pipeline de request em `src/sse/handlers/chat.js` antes de `translateRequest()` | 🔴 Crítica | T-05 | Pendente |
| T-07 | Remover `.passthrough()` de `updateSettingsSchema` em `src/shared/validation/schemas.js` e listar campos explicitamente | 🟡 Moderada | — | Pendente |
| T-08 | Remover dependência `"fs": "^0.0.1-security"` do `package.json` e verificar imports | 🟢 Menor | — | Pendente |
| T-09 | Criar testes unitários para validação de segredos e sanitizador de inputs | 🔴 Crítica | T-01, T-02, T-05 | Pendente |
---
## FASE 02 — CI/CD & Infraestrutura de Testes
| ID | Descrição | Prioridade | Deps | Status |
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ---- | -------- |
| T-10 | Criar `.github/workflows/ci.yml` com jobs: lint, build, test:unit, test:e2e (Node 18+22, trigger PR/push) | 🔴 Crítica | T-01 | Pendente |
| T-11 | Alterar script `"test"` no `package.json` para `node --test tests/unit/*.test.mjs`; adicionar scripts `test:unit`, `test:e2e`, `test:all` | 🔴 Crítica | — | Pendente |
| T-12 | Configurar `c8` como ferramenta de cobertura de testes com script `test:coverage` e target mínimo 40% | 🔴 Crítica | T-11 | Pendente |
| T-13 | Instalar e configurar `eslint-plugin-security` e `eslint-plugin-react-hooks` no `eslint.config.mjs` | 🟠 Importante | — | Pendente |
| T-14 | Converter 4 scripts de `tests/security/` em testes programáticos `.test.mjs` em `tests/integration/` | 🟠 Importante | T-11 | Pendente |
---
## FASE 03 — Refatoração Arquitetural
| ID | Descrição | Prioridade | Deps | Status |
| ---- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------- | ---- | -------- |
| T-15 | Decompor `src/lib/usageDb.js` em 5 módulos: `usageHistory.js`, `callLogs.js`, `costCalculator.js`, `usageStats.js`, `migrations.js` | 🟠 Importante | T-10 | Pendente |
| T-16 | Criar base class `OAuthProvider` em `src/lib/oauth/base/` e factory `providerFactory.js` | 🟠 Importante | T-10 | Pendente |
| T-17 | Extrair 12 providers OAuth em subclasses individuais em `src/lib/oauth/providers/` | 🟠 Importante | T-16 | Pendente |
| T-18 | Eliminar self-fetch no middleware: criar `src/lib/settingsCache.js` com cache in-memory (TTL 5s) e refatorar `proxy.js` | 🔴 Crítica | T-04 | Pendente |
| T-19 | Criar domain layer `src/domain/` com: `modelAvailability.js`, `costRules.js`, `fallbackPolicy.js` | 🟡 Moderada | T-15 | Pendente |
| T-20 | Adicionar `antigravity-manager-analysis/` ao `.gitignore` e consolidar endpoints `rate-limit/` vs `rate-limits/` | 🟢 Menor | — | Pendente |
---
## FASE 04 — Error Handling & Observabilidade
| ID | Descrição | Prioridade | Deps | Status |
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ---- | -------- |
| T-21 | Criar páginas de erro customizadas: `src/app/not-found.js`, `error.js`, `global-error.js` com design do sistema | 🟠 Importante | — | Pendente |
| T-22 | Criar catálogo de error codes `src/shared/constants/errorCodes.js` com helper `createErrorResponse()` | 🟡 Moderada | T-19 | Pendente |
| T-23 | Implementar middleware de correlation ID (`x-request-id`) em `src/shared/utils/requestId.js` e integrar no pipeline completo (proxy → handler → provider → log) | 🟠 Importante | T-04 | Pendente |
| T-24 | Implementar circuit breaker por provider em `src/lib/circuitBreaker.js` (CLOSED→OPEN→HALF_OPEN) e integrar em `sse/services/auth.js` | 🟠 Importante | T-18 | Pendente |
| T-25 | Definir timeout padrão explícito (`FETCH_TIMEOUT_MS=120000`) com `AbortController` em todas as `fetch()` para providers | 🟠 Importante | — | Pendente |
---
## FASE 05 — Qualidade do Código & Padronização
| ID | Descrição | Prioridade | Deps | Status |
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ---- | -------- |
| T-26 | Criar logger centralizado `src/shared/utils/logger.js` com pino; substituir todos `console.log/error/warn` em `src/` | 🟠 Importante | T-04 | Pendente |
| T-27 | Adicionar `@ts-check` + JSDoc (`@param`, `@returns`) em ≥ 10 arquivos críticos (DB, services, domain) | 🟠 Importante | T-19 | Pendente |
| T-28 | Decompor `handleSingleModelChat` (183 linhas) em subfunções <80 linhas; decompor `getUsageStats` (180 linhas) | 🟡 Moderada | T-15 | Pendente |
| T-29 | Decompor 5 componentes UI monolíticos (RequestLoggerV2, UsageStats, ProxyLogger, OAuthModal, ProxyConfigModal) em sub-componentes + hooks extraídos | 🟡 Moderada | — | Pendente |
---
## FASE 06 — Documentação & Governança
| ID | Descrição | Prioridade | Deps | Status |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ---------- | -------- |
| T-30 | Criar diretório `docs/adr/` com template e ≥ 6 ADRs (SQLite, Fallback, OAuth Strategy, JS+JSDoc, Single-Tenant, Translator Registry) | 🟡 Moderada | T-16, T-27 | Pendente |
| T-31 | Criar `CONTRIBUTING.md` na raiz (6 seções: setup, workflow, standards, testing, PR, architecture) e `.github/PULL_REQUEST_TEMPLATE.md` | 🟡 Moderada | T-26 | Pendente |
| T-32 | Expandir `SECURITY.md` para ≥ 2KB (disclosure, scope, SLA, contact, best practices, limitations) | 🟡 Moderada | T-01 | Pendente |
| T-33 | Padronizar JSDoc em ≥ 80% das funções exportadas em módulos priorizados; ativar ESLint rule `jsdoc/require-jsdoc` | 🟢 Menor | T-27 | Pendente |
---
## FASE 07 — UX & Microinterações
| ID | Descrição | Prioridade | Deps | Status |
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ---- | -------- |
| T-34 | Criar Zustand store `notificationStore.js` e componente `NotificationToast.js` com 4 tipos (success, error, warning, info); integrar no layout root | 🟡 Moderada | T-29 | Pendente |
| T-35 | Executar auditoria a11y com axe-core em 4 páginas; corrigir: `role="dialog"`, focus trap, `aria-label`, contraste WCAG AA | 🟡 Moderada | T-29 | Pendente |
| T-36 | Criar componente `Breadcrumbs.js` com mapeamento de paths para labels amigáveis e integrar no layout do dashboard | 🟡 Moderada | — | Pendente |
| T-37 | Criar componente `EmptyState.js` e implementar em 4 seções (Providers, Combos, Usage, Request Logger) | 🟡 Moderada | — | Pendente |
| T-38 | Implementar reset de senha via CLI (`npx omniroute reset-password`) e documentar no README e login page | 🟡 Moderada | T-01 | Pendente |
| T-39 | Criar testes Playwright de responsividade (viewport 375px e 768px) para Login, Dashboard, Providers, Settings | 🟢 Menor | T-14 | Pendente |
---
## FASE 08 — LLM Proxy: Recursos Avançados
| ID | Descrição | Prioridade | Deps | Status |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | ---------- | -------- |
| T-40 | Criar Policy Engine declarativo `src/lib/policies/policyEngine.js` com 3 tipos (routing, budget, access); API CRUD e tela no dashboard | 🟡 Moderada | T-19, T-24 | Pendente |
| T-41 | Implementar cache layer LRU `src/lib/cacheLayer.js` com hash key, TTL configurável, bypass via `x-no-cache`, e endpoint `/api/cache/stats` | 🟠 Importante | T-25 | Pendente |
| T-42 | Criar framework de evals `src/lib/evals/evalRunner.js` com golden set (≥10 cases), endpoints trigger/results, e scorecard no dashboard | 🟡 Moderada | T-22 | Pendente |
| T-43 | Implementar controles de compliance: `LOG_RETENTION_DAYS` com limpeza automática, opt-out `noLog` por API key, tabela `audit_log` para ações administrativas | 🟡 Moderada | T-15 | Pendente |
---
## FASE 09 — Hardening de Fluxo Ponta a Ponta
| ID | Descrição | Prioridade | Deps | Status |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | ---------- | -------- |
| T-44 | Criar `StreamTracker` em `src/sse/services/streamState.js` com 6 estados (INITIALIZED→CANCELLED); integrar no pipeline SSE e expor via `/api/streams/active` | 🟡 Moderada | T-23 | Pendente |
| T-45 | Criar `RequestTelemetry` em `src/shared/utils/requestTelemetry.js` medindo 7 fases; armazenar timings no call log e expor p50/p95/p99 via `/api/telemetry/summary` | 🟡 Moderada | T-23, T-15 | Pendente |
| T-46 | Extrair regras de negócio residuais de `handleChat` para domain layer (`lockoutPolicy.js`, `comboResolver.js`); refatorar handler para <50 linhas | 🟡 Moderada | T-19, T-28 | Pendente |
---
## Resumo por Prioridade
| Prioridade | Tarefas | IDs |
| ------------- | ------- | ---------------------------------------------------------------------------------------------------------------- |
| 🔴 Crítica | 11 | T-01, T-02, T-03, T-04, T-05, T-06, T-09, T-10, T-11, T-12, T-18 |
| 🟠 Importante | 12 | T-13, T-14, T-15, T-16, T-17, T-21, T-23, T-24, T-25, T-26, T-27, T-41 |
| 🟡 Moderada | 19 | T-07, T-19, T-22, T-28, T-29, T-30, T-31, T-32, T-34, T-35, T-36, T-37, T-38, T-40, T-42, T-43, T-44, T-45, T-46 |
| 🟢 Menor | 4 | T-08, T-20, T-33, T-39 |
## Sugestão de Ordem de Execução
> **Regra:** Sempre executar tarefas cujas dependências (`Deps`) estejam com status `Concluído`.
**Caminho crítico:** T-01 → T-02 → T-03 → T-10 → T-11 → T-12 → T-15 → T-16 → T-17 → T-18 → T-19 → T-24 → T-40

339
src/lib/usage/callLogs.js Normal file
View File

@@ -0,0 +1,339 @@
/**
* Call Logs — extracted from usageDb.js (T-15)
*
* Structured call log management: save, query, rotate, and
* full-payload disk storage for the Logger UI.
*
* @module lib/usage/callLogs
*/
import path from "path";
import fs from "fs";
import { getDbInstance } from "../db/core.js";
import { shouldPersistToDisk, CALL_LOGS_DIR } from "./migrations.js";
const CALL_LOGS_MAX = 500;
let logIdCounter = 0;
function generateLogId() {
logIdCounter++;
return `${Date.now()}-${logIdCounter}`;
}
/**
* Save a structured call log entry.
*/
export async function saveCallLog(entry) {
if (!shouldPersistToDisk) return;
try {
// Resolve account name
let account = entry.connectionId ? entry.connectionId.slice(0, 8) : "-";
try {
const { getProviderConnections } = await import("@/lib/localDb.js");
const connections = await getProviderConnections();
const conn = connections.find((c) => c.id === entry.connectionId);
if (conn) account = conn.name || conn.email || account;
} catch {}
// Truncate large payloads for DB storage (keep under 8KB each)
const truncatePayload = (obj) => {
if (!obj) return null;
const str = JSON.stringify(obj);
if (str.length <= 8192) return str;
try {
return JSON.stringify({
_truncated: true,
_originalSize: str.length,
_preview: str.slice(0, 8192) + "...",
});
} catch {
return JSON.stringify({ _truncated: true });
}
};
const logEntry = {
id: generateLogId(),
timestamp: new Date().toISOString(),
method: entry.method || "POST",
path: entry.path || "/v1/chat/completions",
status: entry.status || 0,
model: entry.model || "-",
provider: entry.provider || "-",
account,
connectionId: entry.connectionId || null,
duration: entry.duration || 0,
tokensIn: entry.tokens?.prompt_tokens || 0,
tokensOut: entry.tokens?.completion_tokens || 0,
sourceFormat: entry.sourceFormat || null,
targetFormat: entry.targetFormat || null,
apiKeyId: entry.apiKeyId || null,
apiKeyName: entry.apiKeyName || null,
comboName: entry.comboName || null,
requestBody: truncatePayload(entry.requestBody),
responseBody: truncatePayload(entry.responseBody),
error: entry.error || null,
};
// 1. Insert into SQLite
const db = getDbInstance();
db.prepare(
`
INSERT INTO call_logs (id, timestamp, method, path, status, model, provider,
account, connection_id, duration, tokens_in, tokens_out, source_format, target_format,
api_key_id, api_key_name, combo_name, request_body, response_body, error)
VALUES (@id, @timestamp, @method, @path, @status, @model, @provider,
@account, @connectionId, @duration, @tokensIn, @tokensOut, @sourceFormat, @targetFormat,
@apiKeyId, @apiKeyName, @comboName, @requestBody, @responseBody, @error)
`
).run(logEntry);
// 2. Trim old entries beyond CALL_LOGS_MAX
const count = db.prepare("SELECT COUNT(*) as cnt FROM call_logs").get()?.cnt || 0;
if (count > CALL_LOGS_MAX) {
db.prepare(
`
DELETE FROM call_logs WHERE id IN (
SELECT id FROM call_logs ORDER BY timestamp ASC LIMIT ?
)
`
).run(count - CALL_LOGS_MAX);
}
// 3. Write full payload to disk file (untruncated)
writeCallLogToDisk(
{ ...logEntry, tokens: { in: logEntry.tokensIn, out: logEntry.tokensOut } },
entry.requestBody,
entry.responseBody
);
} catch (error) {
console.error("[callLogs] Failed to save call log:", error.message);
}
}
/**
* Write call log as JSON file to disk (full payloads, not truncated).
*/
function writeCallLogToDisk(logEntry, requestBody, responseBody) {
if (!CALL_LOGS_DIR) return;
try {
const now = new Date();
const dateFolder = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const dir = path.join(CALL_LOGS_DIR, dateFolder);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const safeModel = (logEntry.model || "unknown").replace(/[/:]/g, "-");
const time = `${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`;
const filename = `${time}_${safeModel}_${logEntry.status}.json`;
const fullEntry = {
...logEntry,
requestBody: requestBody || null,
responseBody: responseBody || null,
};
fs.writeFileSync(path.join(dir, filename), JSON.stringify(fullEntry, null, 2));
} catch (err) {
console.error("[callLogs] Failed to write disk log:", err.message);
}
}
/**
* Rotate old call log directories (keep last 7 days).
*/
export function rotateCallLogs() {
if (!CALL_LOGS_DIR || !fs.existsSync(CALL_LOGS_DIR)) return;
try {
const entries = fs.readdirSync(CALL_LOGS_DIR);
const now = Date.now();
const sevenDays = 7 * 24 * 60 * 60 * 1000;
for (const entry of entries) {
const entryPath = path.join(CALL_LOGS_DIR, entry);
const stat = fs.statSync(entryPath);
if (stat.isDirectory() && now - stat.mtimeMs > sevenDays) {
fs.rmSync(entryPath, { recursive: true, force: true });
console.log(`[callLogs] Rotated old logs: ${entry}`);
}
}
} catch (err) {
console.error("[callLogs] Failed to rotate logs:", err.message);
}
}
// Run rotation on startup
if (shouldPersistToDisk) {
try {
rotateCallLogs();
} catch {}
}
/**
* Get call logs with optional filtering.
*/
export async function getCallLogs(filter = {}) {
const db = getDbInstance();
let sql = "SELECT * FROM call_logs";
const conditions = [];
const params = {};
if (filter.status) {
if (filter.status === "error") {
conditions.push("(status >= 400 OR error IS NOT NULL)");
} else if (filter.status === "ok") {
conditions.push("status >= 200 AND status < 300");
} else {
const statusCode = parseInt(filter.status);
if (!isNaN(statusCode)) {
conditions.push("status = @statusCode");
params.statusCode = statusCode;
}
}
}
if (filter.model) {
conditions.push("model LIKE @modelQ");
params.modelQ = `%${filter.model}%`;
}
if (filter.provider) {
conditions.push("provider LIKE @providerQ");
params.providerQ = `%${filter.provider}%`;
}
if (filter.account) {
conditions.push("account LIKE @accountQ");
params.accountQ = `%${filter.account}%`;
}
if (filter.apiKey) {
conditions.push("(api_key_name LIKE @apiKeyQ OR api_key_id LIKE @apiKeyQ)");
params.apiKeyQ = `%${filter.apiKey}%`;
}
if (filter.combo) {
conditions.push("combo_name IS NOT NULL");
}
if (filter.search) {
conditions.push(`(
model LIKE @searchQ OR path LIKE @searchQ OR account LIKE @searchQ OR
provider LIKE @searchQ OR api_key_name LIKE @searchQ OR api_key_id LIKE @searchQ OR
combo_name LIKE @searchQ OR CAST(status AS TEXT) LIKE @searchQ
)`);
params.searchQ = `%${filter.search}%`;
}
if (conditions.length > 0) {
sql += " WHERE " + conditions.join(" AND ");
}
const limit = filter.limit || 200;
sql += ` ORDER BY timestamp DESC LIMIT ${limit}`;
const rows = db.prepare(sql).all(params);
return rows.map((l) => ({
id: l.id,
timestamp: l.timestamp,
method: l.method,
path: l.path,
status: l.status,
model: l.model,
provider: l.provider,
account: l.account,
duration: l.duration,
tokens: { in: l.tokens_in, out: l.tokens_out },
sourceFormat: l.source_format,
targetFormat: l.target_format,
error: l.error,
comboName: l.combo_name || null,
apiKeyId: l.api_key_id || null,
apiKeyName: l.api_key_name || null,
hasRequestBody: !!l.request_body,
hasResponseBody: !!l.response_body,
}));
}
/**
* Get a single call log by ID (with full payloads from disk when available).
*/
export async function getCallLogById(id) {
const db = getDbInstance();
const row = db.prepare("SELECT * FROM call_logs WHERE id = ?").get(id);
if (!row) return null;
const entry = {
id: row.id,
timestamp: row.timestamp,
method: row.method,
path: row.path,
status: row.status,
model: row.model,
provider: row.provider,
account: row.account,
connectionId: row.connection_id,
duration: row.duration,
tokens: { in: row.tokens_in, out: row.tokens_out },
sourceFormat: row.source_format,
targetFormat: row.target_format,
apiKeyId: row.api_key_id,
apiKeyName: row.api_key_name,
comboName: row.combo_name,
requestBody: row.request_body ? JSON.parse(row.request_body) : null,
responseBody: row.response_body ? JSON.parse(row.response_body) : null,
error: row.error,
};
// If payloads were truncated, try to read full version from disk
const needsDisk = entry.requestBody?._truncated || entry.responseBody?._truncated;
if (needsDisk && CALL_LOGS_DIR) {
try {
const diskEntry = readFullLogFromDisk(entry);
if (diskEntry) {
return {
...entry,
requestBody: diskEntry.requestBody ?? entry.requestBody,
responseBody: diskEntry.responseBody ?? entry.responseBody,
};
}
} catch (err) {
console.error("[callLogs] Failed to read full log from disk:", err.message);
}
}
return entry;
}
/**
* Read the full (untruncated) log entry from disk.
*/
function readFullLogFromDisk(entry) {
if (!CALL_LOGS_DIR || !entry.timestamp) return null;
try {
const date = new Date(entry.timestamp);
const dateFolder = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
const dir = path.join(CALL_LOGS_DIR, dateFolder);
if (!fs.existsSync(dir)) return null;
const time = `${String(date.getHours()).padStart(2, "0")}${String(date.getMinutes()).padStart(2, "0")}${String(date.getSeconds()).padStart(2, "0")}`;
const safeModel = (entry.model || "unknown").replace(/[/:]/g, "-");
const expectedName = `${time}_${safeModel}_${entry.status}.json`;
const exactPath = path.join(dir, expectedName);
if (fs.existsSync(exactPath)) {
return JSON.parse(fs.readFileSync(exactPath, "utf8"));
}
const files = fs
.readdirSync(dir)
.filter((f) => f.startsWith(time) && f.endsWith(`_${entry.status}.json`));
if (files.length > 0) {
return JSON.parse(fs.readFileSync(path.join(dir, files[0]), "utf8"));
}
} catch (err) {
console.error("[callLogs] Disk log read error:", err.message);
}
return null;
}

View File

@@ -0,0 +1,56 @@
/**
* Cost Calculator — extracted from usageDb.js (T-15)
*
* Pure function for calculating request cost based on model pricing.
* No DB interaction — pricing is fetched from localDb.
*
* @module lib/usage/costCalculator
*/
/**
* Calculate cost for a usage entry.
*
* @param {string} provider
* @param {string} model
* @param {Object} tokens
* @returns {Promise<number>} Cost in USD
*/
export async function calculateCost(provider, model, tokens) {
if (!tokens || !provider || !model) return 0;
try {
const { getPricingForModel } = await import("@/lib/localDb.js");
const pricing = await getPricingForModel(provider, model);
if (!pricing) return 0;
let cost = 0;
const inputTokens = tokens.input ?? tokens.prompt_tokens ?? tokens.input_tokens ?? 0;
const cachedTokens =
tokens.cacheRead ?? tokens.cached_tokens ?? tokens.cache_read_input_tokens ?? 0;
const nonCachedInput = Math.max(0, inputTokens - cachedTokens);
cost += nonCachedInput * (pricing.input / 1000000);
if (cachedTokens > 0) {
cost += cachedTokens * ((pricing.cached || pricing.input) / 1000000);
}
const outputTokens = tokens.output ?? tokens.completion_tokens ?? tokens.output_tokens ?? 0;
cost += outputTokens * (pricing.output / 1000000);
const reasoningTokens = tokens.reasoning ?? tokens.reasoning_tokens ?? 0;
if (reasoningTokens > 0) {
cost += reasoningTokens * ((pricing.reasoning || pricing.output) / 1000000);
}
const cacheCreationTokens = tokens.cacheCreation ?? tokens.cache_creation_input_tokens ?? 0;
if (cacheCreationTokens > 0) {
cost += cacheCreationTokens * ((pricing.cache_creation || pricing.input) / 1000000);
}
return cost;
} catch (error) {
console.error("Error calculating cost:", error);
return 0;
}
}

186
src/lib/usage/migrations.js Normal file
View File

@@ -0,0 +1,186 @@
/**
* Usage Migrations — extracted from usageDb.js (T-15)
*
* Handles legacy file migration (.data → data/) and JSON → SQLite migration.
* Runs automatically on module load when shouldPersistToDisk is true.
*
* @module lib/usage/migrations
*/
import path from "path";
import fs from "fs";
import { getDbInstance, isCloud, isBuildPhase, DATA_DIR } from "../db/core.js";
import { getLegacyDotDataDir, isSamePath } from "../dataPaths.js";
export const shouldPersistToDisk = !isCloud && !isBuildPhase;
// ──────────────── File Paths ────────────────
const LEGACY_DATA_DIR = isCloud ? null : getLegacyDotDataDir();
export const LOG_FILE = isCloud ? null : path.join(DATA_DIR, "log.txt");
export const CALL_LOGS_DIR = isCloud ? null : path.join(DATA_DIR, "call_logs");
// Legacy paths
const LEGACY_DB_FILE =
isCloud || !LEGACY_DATA_DIR ? null : path.join(LEGACY_DATA_DIR, "usage.json");
const LEGACY_LOG_FILE =
isCloud || !LEGACY_DATA_DIR ? null : path.join(LEGACY_DATA_DIR, "log.txt");
const LEGACY_CALL_LOGS_DB_FILE =
isCloud || !LEGACY_DATA_DIR ? null : path.join(LEGACY_DATA_DIR, "call_logs.json");
const LEGACY_CALL_LOGS_DIR =
isCloud || !LEGACY_DATA_DIR ? null : path.join(LEGACY_DATA_DIR, "call_logs");
// Current-location JSON files (for migration into SQLite)
const USAGE_JSON_FILE = isCloud ? null : path.join(DATA_DIR, "usage.json");
const CALL_LOGS_JSON_FILE = isCloud ? null : path.join(DATA_DIR, "call_logs.json");
// ──────────────── Legacy File Migration ────────────────
function copyIfMissing(fromPath, toPath, label) {
if (!fromPath || !toPath) return;
if (!fs.existsSync(fromPath) || fs.existsSync(toPath)) return;
if (fs.statSync(fromPath).isDirectory()) {
fs.cpSync(fromPath, toPath, { recursive: true });
} else {
fs.copyFileSync(fromPath, toPath);
}
console.log(`[usageDb] Migrated ${label}: ${fromPath} -> ${toPath}`);
}
export function migrateLegacyUsageFiles() {
if (!shouldPersistToDisk || !LEGACY_DATA_DIR) return;
if (isSamePath(DATA_DIR, LEGACY_DATA_DIR)) return;
try {
copyIfMissing(LEGACY_DB_FILE, USAGE_JSON_FILE, "usage history");
copyIfMissing(LEGACY_LOG_FILE, LOG_FILE, "request log");
copyIfMissing(LEGACY_CALL_LOGS_DB_FILE, CALL_LOGS_JSON_FILE, "call log index");
copyIfMissing(LEGACY_CALL_LOGS_DIR, CALL_LOGS_DIR, "call log files");
} catch (error) {
console.error("[usageDb] Legacy migration failed:", error.message);
}
}
// ──────────────── JSON → SQLite Migration ────────────────
export function migrateUsageJsonToSqlite() {
if (!shouldPersistToDisk) return;
const db = getDbInstance();
// 1. Migrate usage.json
if (USAGE_JSON_FILE && fs.existsSync(USAGE_JSON_FILE)) {
try {
const raw = fs.readFileSync(USAGE_JSON_FILE, "utf-8");
const data = JSON.parse(raw);
const history = data.history || [];
if (history.length > 0) {
console.log(`[usageDb] Migrating ${history.length} usage entries from JSON → SQLite...`);
const insert = db.prepare(`
INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name,
tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning,
status, timestamp)
VALUES (@provider, @model, @connectionId, @apiKeyId, @apiKeyName,
@tokensInput, @tokensOutput, @tokensCacheRead, @tokensCacheCreation, @tokensReasoning,
@status, @timestamp)
`);
const tx = db.transaction(() => {
for (const entry of history) {
insert.run({
provider: entry.provider || null,
model: entry.model || null,
connectionId: entry.connectionId || null,
apiKeyId: entry.apiKeyId || null,
apiKeyName: entry.apiKeyName || null,
tokensInput: entry.tokens?.input ?? entry.tokens?.prompt_tokens ?? 0,
tokensOutput: entry.tokens?.output ?? entry.tokens?.completion_tokens ?? 0,
tokensCacheRead: entry.tokens?.cacheRead ?? entry.tokens?.cached_tokens ?? 0,
tokensCacheCreation:
entry.tokens?.cacheCreation ?? entry.tokens?.cache_creation_input_tokens ?? 0,
tokensReasoning: entry.tokens?.reasoning ?? entry.tokens?.reasoning_tokens ?? 0,
status: entry.status || null,
timestamp: entry.timestamp || new Date().toISOString(),
});
}
});
tx();
console.log(`[usageDb] ✓ Migrated ${history.length} usage entries`);
}
fs.renameSync(USAGE_JSON_FILE, USAGE_JSON_FILE + ".migrated");
} catch (err) {
console.error("[usageDb] Failed to migrate usage.json:", err.message);
}
}
// 2. Migrate call_logs.json
if (CALL_LOGS_JSON_FILE && fs.existsSync(CALL_LOGS_JSON_FILE)) {
try {
const raw = fs.readFileSync(CALL_LOGS_JSON_FILE, "utf-8");
const data = JSON.parse(raw);
const logs = data.logs || [];
if (logs.length > 0) {
console.log(`[usageDb] Migrating ${logs.length} call log entries from JSON → SQLite...`);
const insert = db.prepare(`
INSERT OR IGNORE INTO call_logs (id, timestamp, method, path, status, model, provider,
account, connection_id, duration, tokens_in, tokens_out, source_format, target_format,
api_key_id, api_key_name, combo_name, request_body, response_body, error)
VALUES (@id, @timestamp, @method, @path, @status, @model, @provider,
@account, @connectionId, @duration, @tokensIn, @tokensOut, @sourceFormat, @targetFormat,
@apiKeyId, @apiKeyName, @comboName, @requestBody, @responseBody, @error)
`);
const tx = db.transaction(() => {
for (const log of logs) {
insert.run({
id: log.id || `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
timestamp: log.timestamp || new Date().toISOString(),
method: log.method || "POST",
path: log.path || null,
status: log.status || 0,
model: log.model || null,
provider: log.provider || null,
account: log.account || null,
connectionId: log.connectionId || null,
duration: log.duration || 0,
tokensIn: log.tokens?.in ?? 0,
tokensOut: log.tokens?.out ?? 0,
sourceFormat: log.sourceFormat || null,
targetFormat: log.targetFormat || null,
apiKeyId: log.apiKeyId || null,
apiKeyName: log.apiKeyName || null,
comboName: log.comboName || null,
requestBody: log.requestBody ? JSON.stringify(log.requestBody) : null,
responseBody: log.responseBody ? JSON.stringify(log.responseBody) : null,
error: log.error || null,
});
}
});
tx();
console.log(`[usageDb] ✓ Migrated ${logs.length} call log entries`);
}
fs.renameSync(CALL_LOGS_JSON_FILE, CALL_LOGS_JSON_FILE + ".migrated");
} catch (err) {
console.error("[usageDb] Failed to migrate call_logs.json:", err.message);
}
}
}
// ──────────────── Run on load ────────────────
migrateLegacyUsageFiles();
if (shouldPersistToDisk) {
try {
migrateUsageJsonToSqlite();
} catch {
/* ok */
}
}

View File

@@ -0,0 +1,249 @@
/**
* Usage History — extracted from usageDb.js (T-15)
*
* Usage tracking: saving, querying, and analytics shim for
* the usage_history SQLite table.
*
* @module lib/usage/usageHistory
*/
import { getDbInstance } from "../db/core.js";
import { shouldPersistToDisk } from "./migrations.js";
// ──────────────── Pending Requests (in-memory) ────────────────
const pendingRequests = {
byModel: {},
byAccount: {},
};
/**
* Track a pending request.
*/
export function trackPendingRequest(model, provider, connectionId, started) {
const modelKey = provider ? `${model} (${provider})` : model;
if (!pendingRequests.byModel[modelKey]) pendingRequests.byModel[modelKey] = 0;
pendingRequests.byModel[modelKey] = Math.max(
0,
pendingRequests.byModel[modelKey] + (started ? 1 : -1)
);
if (connectionId) {
if (!pendingRequests.byAccount[connectionId]) pendingRequests.byAccount[connectionId] = {};
if (!pendingRequests.byAccount[connectionId][modelKey])
pendingRequests.byAccount[connectionId][modelKey] = 0;
pendingRequests.byAccount[connectionId][modelKey] = Math.max(
0,
pendingRequests.byAccount[connectionId][modelKey] + (started ? 1 : -1)
);
}
}
/**
* Get the pending requests state (for usageStats).
* @returns {{ byModel: Object, byAccount: Object }}
*/
export function getPendingRequests() {
return pendingRequests;
}
// ──────────────── getUsageDb Shim (backward compat) ────────────────
/**
* Returns an object compatible with the old LowDB interface.
* Only `api/usage/analytics/route.js` uses this — it reads `db.data.history`.
*/
export async function getUsageDb() {
const db = getDbInstance();
const rows = db.prepare("SELECT * FROM usage_history ORDER BY timestamp ASC").all();
const history = rows.map((r) => ({
provider: r.provider,
model: r.model,
connectionId: r.connection_id,
apiKeyId: r.api_key_id,
apiKeyName: r.api_key_name,
tokens: {
input: r.tokens_input,
output: r.tokens_output,
cacheRead: r.tokens_cache_read,
cacheCreation: r.tokens_cache_creation,
reasoning: r.tokens_reasoning,
},
status: r.status,
timestamp: r.timestamp,
}));
return { data: { history } };
}
// ──────────────── Save Request Usage ────────────────
/**
* Save request usage entry to SQLite.
*/
export async function saveRequestUsage(entry) {
if (!shouldPersistToDisk) return;
try {
const db = getDbInstance();
const timestamp = entry.timestamp || new Date().toISOString();
db.prepare(
`
INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name,
tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning,
status, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
).run(
entry.provider || null,
entry.model || null,
entry.connectionId || null,
entry.apiKeyId || null,
entry.apiKeyName || null,
entry.tokens?.input ?? entry.tokens?.prompt_tokens ?? 0,
entry.tokens?.output ?? entry.tokens?.completion_tokens ?? 0,
entry.tokens?.cacheRead ?? entry.tokens?.cached_tokens ?? 0,
entry.tokens?.cacheCreation ?? entry.tokens?.cache_creation_input_tokens ?? 0,
entry.tokens?.reasoning ?? entry.tokens?.reasoning_tokens ?? 0,
entry.status || null,
timestamp
);
} catch (error) {
console.error("Failed to save usage stats:", error);
}
}
// ──────────────── Get Usage History ────────────────
/**
* Get usage history with optional filters.
*/
export async function getUsageHistory(filter = {}) {
const db = getDbInstance();
let sql = "SELECT * FROM usage_history";
const conditions = [];
const params = {};
if (filter.provider) {
conditions.push("provider = @provider");
params.provider = filter.provider;
}
if (filter.model) {
conditions.push("model = @model");
params.model = filter.model;
}
if (filter.startDate) {
conditions.push("timestamp >= @startDate");
params.startDate = new Date(filter.startDate).toISOString();
}
if (filter.endDate) {
conditions.push("timestamp <= @endDate");
params.endDate = new Date(filter.endDate).toISOString();
}
if (conditions.length > 0) {
sql += " WHERE " + conditions.join(" AND ");
}
sql += " ORDER BY timestamp ASC";
const rows = db.prepare(sql).all(params);
return rows.map((r) => ({
provider: r.provider,
model: r.model,
connectionId: r.connection_id,
apiKeyId: r.api_key_id,
apiKeyName: r.api_key_name,
tokens: {
input: r.tokens_input,
output: r.tokens_output,
cacheRead: r.tokens_cache_read,
cacheCreation: r.tokens_cache_creation,
reasoning: r.tokens_reasoning,
},
status: r.status,
timestamp: r.timestamp,
}));
}
// ──────────────── Request Log (log.txt) ────────────────
import fs from "fs";
import { LOG_FILE } from "./migrations.js";
function formatLogDate(date = new Date()) {
const pad = (n) => String(n).padStart(2, "0");
const d = pad(date.getDate());
const m = pad(date.getMonth() + 1);
const y = date.getFullYear();
const h = pad(date.getHours());
const min = pad(date.getMinutes());
const s = pad(date.getSeconds());
return `${d}-${m}-${y} ${h}:${min}:${s}`;
}
/**
* Append to log.txt.
*/
export async function appendRequestLog({ model, provider, connectionId, tokens, status }) {
if (!shouldPersistToDisk) return;
try {
const timestamp = formatLogDate();
const p = provider?.toUpperCase() || "-";
const m = model || "-";
let account = connectionId ? connectionId.slice(0, 8) : "-";
try {
const { getProviderConnections } = await import("@/lib/localDb.js");
const connections = await getProviderConnections();
const conn = connections.find((c) => c.id === connectionId);
if (conn) account = conn.name || conn.email || account;
} catch {}
const sent =
tokens?.input !== undefined
? tokens.input
: tokens?.prompt_tokens !== undefined
? tokens.prompt_tokens
: "-";
const received =
tokens?.output !== undefined
? tokens.output
: tokens?.completion_tokens !== undefined
? tokens.completion_tokens
: "-";
const line = `${timestamp} | ${m} | ${p} | ${account} | ${sent} | ${received} | ${status}\n`;
fs.appendFileSync(LOG_FILE, line);
const content = fs.readFileSync(LOG_FILE, "utf-8");
const lines = content.trim().split("\n");
if (lines.length > 200) {
fs.writeFileSync(LOG_FILE, lines.slice(-200).join("\n") + "\n");
}
} catch (error) {
console.error("Failed to append to log.txt:", error.message);
}
}
/**
* Get last N lines of log.txt.
*/
export async function getRecentLogs(limit = 200) {
if (!shouldPersistToDisk) return [];
if (!fs || typeof fs.existsSync !== "function") return [];
if (!LOG_FILE) return [];
if (!fs.existsSync(LOG_FILE)) return [];
try {
const content = fs.readFileSync(LOG_FILE, "utf-8");
const lines = content.trim().split("\n");
return lines.slice(-limit).reverse();
} catch (error) {
console.error("[usageDb] Failed to read log.txt:", error.message);
return [];
}
}

196
src/lib/usage/usageStats.js Normal file
View File

@@ -0,0 +1,196 @@
/**
* Usage Stats — extracted from usageDb.js (T-15)
*
* Aggregates usage data into stats for the dashboard:
* totals, by provider/model/account/apiKey, 10-minute buckets.
*
* @module lib/usage/usageStats
*/
import { getDbInstance } from "../db/core.js";
import { getPendingRequests } from "./usageHistory.js";
import { calculateCost } from "./costCalculator.js";
/**
* Get aggregated usage stats.
*/
export async function getUsageStats() {
const db = getDbInstance();
const rows = db.prepare("SELECT * FROM usage_history ORDER BY timestamp ASC").all();
const { getProviderConnections } = await import("@/lib/localDb.js");
let allConnections = [];
try {
allConnections = await getProviderConnections();
} catch {}
const connectionMap = {};
for (const conn of allConnections) {
connectionMap[conn.id] = conn.name || conn.email || conn.id;
}
const pendingRequests = getPendingRequests();
const stats = {
totalRequests: rows.length,
totalPromptTokens: 0,
totalCompletionTokens: 0,
totalCost: 0,
byProvider: {},
byModel: {},
byAccount: {},
byApiKey: {},
last10Minutes: [],
pending: pendingRequests,
activeRequests: [],
};
// Build active requests
for (const [connectionId, models] of Object.entries(pendingRequests.byAccount)) {
for (const [modelKey, count] of Object.entries(models)) {
if (count > 0) {
const accountName = connectionMap[connectionId] || `Account ${connectionId.slice(0, 8)}...`;
const match = modelKey.match(/^(.*) \((.*)\)$/);
stats.activeRequests.push({
model: match ? match[1] : modelKey,
provider: match ? match[2] : "unknown",
account: accountName,
count,
});
}
}
}
// 10-minute buckets
const now = new Date();
const currentMinuteStart = new Date(Math.floor(now.getTime() / 60000) * 60000);
const bucketMap = {};
for (let i = 0; i < 10; i++) {
const bucketTime = new Date(currentMinuteStart.getTime() - (9 - i) * 60 * 1000);
const bucketKey = bucketTime.getTime();
bucketMap[bucketKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 };
stats.last10Minutes.push(bucketMap[bucketKey]);
}
const tenMinutesAgo = new Date(currentMinuteStart.getTime() - 9 * 60 * 1000);
for (const row of rows) {
const promptTokens = row.tokens_input || 0;
const completionTokens = row.tokens_output || 0;
const entryTime = new Date(row.timestamp);
const entryTokens = {
input: row.tokens_input,
output: row.tokens_output,
cacheRead: row.tokens_cache_read,
cacheCreation: row.tokens_cache_creation,
reasoning: row.tokens_reasoning,
};
const entryCost = await calculateCost(row.provider, row.model, entryTokens);
stats.totalPromptTokens += promptTokens;
stats.totalCompletionTokens += completionTokens;
stats.totalCost += entryCost;
// 10-min buckets
if (entryTime >= tenMinutesAgo && entryTime <= now) {
const entryMinuteStart = Math.floor(entryTime.getTime() / 60000) * 60000;
if (bucketMap[entryMinuteStart]) {
bucketMap[entryMinuteStart].requests++;
bucketMap[entryMinuteStart].promptTokens += promptTokens;
bucketMap[entryMinuteStart].completionTokens += completionTokens;
bucketMap[entryMinuteStart].cost += entryCost;
}
}
// By Provider
if (!stats.byProvider[row.provider]) {
stats.byProvider[row.provider] = {
requests: 0,
promptTokens: 0,
completionTokens: 0,
cost: 0,
};
}
stats.byProvider[row.provider].requests++;
stats.byProvider[row.provider].promptTokens += promptTokens;
stats.byProvider[row.provider].completionTokens += completionTokens;
stats.byProvider[row.provider].cost += entryCost;
// By Model
const modelKey = row.provider ? `${row.model} (${row.provider})` : row.model;
if (!stats.byModel[modelKey]) {
stats.byModel[modelKey] = {
requests: 0,
promptTokens: 0,
completionTokens: 0,
cost: 0,
rawModel: row.model,
provider: row.provider,
lastUsed: row.timestamp,
};
}
stats.byModel[modelKey].requests++;
stats.byModel[modelKey].promptTokens += promptTokens;
stats.byModel[modelKey].completionTokens += completionTokens;
stats.byModel[modelKey].cost += entryCost;
if (new Date(row.timestamp) > new Date(stats.byModel[modelKey].lastUsed)) {
stats.byModel[modelKey].lastUsed = row.timestamp;
}
// By Account
if (row.connection_id) {
const accountName =
connectionMap[row.connection_id] || `Account ${row.connection_id.slice(0, 8)}...`;
const accountKey = `${row.model} (${row.provider} - ${accountName})`;
if (!stats.byAccount[accountKey]) {
stats.byAccount[accountKey] = {
requests: 0,
promptTokens: 0,
completionTokens: 0,
cost: 0,
rawModel: row.model,
provider: row.provider,
connectionId: row.connection_id,
accountName,
lastUsed: row.timestamp,
};
}
stats.byAccount[accountKey].requests++;
stats.byAccount[accountKey].promptTokens += promptTokens;
stats.byAccount[accountKey].completionTokens += completionTokens;
stats.byAccount[accountKey].cost += entryCost;
if (new Date(row.timestamp) > new Date(stats.byAccount[accountKey].lastUsed)) {
stats.byAccount[accountKey].lastUsed = row.timestamp;
}
}
// By API key
if (row.api_key_id || row.api_key_name) {
const keyName = row.api_key_name || row.api_key_id || "unknown";
const keyId = row.api_key_id || null;
const apiKey = keyId ? `${keyName} (${keyId})` : keyName;
if (!stats.byApiKey[apiKey]) {
stats.byApiKey[apiKey] = {
requests: 0,
promptTokens: 0,
completionTokens: 0,
cost: 0,
apiKeyId: keyId,
apiKeyName: keyName,
lastUsed: row.timestamp,
};
}
stats.byApiKey[apiKey].requests++;
stats.byApiKey[apiKey].promptTokens += promptTokens;
stats.byApiKey[apiKey].completionTokens += completionTokens;
stats.byApiKey[apiKey].cost += entryCost;
if (new Date(row.timestamp) > new Date(stats.byApiKey[apiKey].lastUsed)) {
stats.byApiKey[apiKey].lastUsed = row.timestamp;
}
}
}
return stats;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
"use client";
/**
* ColumnToggle — Shared UI primitive (T-29)
*
* Dropdown menu for toggling table column visibility.
* Used by RequestLoggerV2, ProxyLogger, etc.
*
* Usage:
* <ColumnToggle
* columns={[{ key: 'model', label: 'Model' }, ...]}
* visible={{ model: true, provider: false, ... }}
* onToggle={(key) => setVisible({...visible, [key]: !visible[key]})}
* />
*/
import { useState, useRef, useEffect } from "react";
export default function ColumnToggle({ columns = [], visible = {}, onToggle }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
return (
<div ref={ref} style={{ position: "relative" }}>
<button
onClick={() => setOpen(!open)}
title="Toggle columns"
style={{
padding: "6px 10px",
borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.1)",
background: "rgba(255,255,255,0.05)",
color: "var(--text-secondary, #888)",
fontSize: "13px",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "4px",
}}
>
<span style={{ fontSize: "14px" }}></span>
Columns
</button>
{open && (
<div
style={{
position: "absolute",
top: "100%",
right: 0,
marginTop: "4px",
background: "rgba(20,20,30,0.95)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "8px",
padding: "8px",
zIndex: 50,
minWidth: "160px",
backdropFilter: "blur(12px)",
}}
>
{columns.map((col) => (
<label
key={col.key}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "4px 8px",
cursor: "pointer",
fontSize: "12px",
color: visible[col.key]
? "var(--text-primary, #e0e0e0)"
: "var(--text-secondary, #888)",
borderRadius: "4px",
}}
>
<input
type="checkbox"
checked={visible[col.key] ?? true}
onChange={() => onToggle(col.key)}
style={{ accentColor: "#6366f1" }}
/>
{col.label}
</label>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,157 @@
"use client";
/**
* DataTable — Shared UI primitive (T-29)
*
* Configurable data table with sticky header, row click,
* and optional loading/empty states. Extracts the shared
* table rendering pattern from RequestLoggerV2 and ProxyLogger.
*
* Usage:
* <DataTable
* columns={visibleColumns}
* data={filteredLogs}
* renderCell={(row, column) => <span>{row[column.key]}</span>}
* onRowClick={(row) => openDetail(row)}
* selectedId={selectedLog?.id}
* loading={isLoading}
* emptyIcon="📋"
* emptyMessage="No logs found"
* />
*/
export default function DataTable({
columns = [],
data = [],
renderCell,
renderHeader,
onRowClick,
selectedId,
loading = false,
maxHeight = "calc(100vh - 320px)",
emptyIcon = "📭",
emptyMessage = "No data found",
}) {
if (loading) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "48px 24px",
color: "var(--text-secondary, #888)",
fontSize: "14px",
}}
>
<span style={{ animation: "spin 1s linear infinite", marginRight: "8px" }}></span>
Loading...
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}
if (data.length === 0) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "48px 24px",
color: "var(--text-secondary, #888)",
fontSize: "14px",
}}
>
<span style={{ fontSize: "32px", marginBottom: "8px", opacity: 0.6 }}>{emptyIcon}</span>
{emptyMessage}
</div>
);
}
return (
<div style={{ overflow: "auto", maxHeight, borderRadius: "8px" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "12px",
tableLayout: "auto",
}}
>
<thead>
<tr>
{columns.map((col) => (
<th
key={col.key}
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 600,
color: "var(--text-secondary, #888)",
borderBottom: "1px solid rgba(255,255,255,0.08)",
position: "sticky",
top: 0,
background: "var(--bg-table-header, rgba(15,15,25,0.95))",
zIndex: 1,
whiteSpace: "nowrap",
fontSize: "11px",
textTransform: "uppercase",
letterSpacing: "0.5px",
}}
>
{renderHeader ? renderHeader(col) : col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, idx) => (
<tr
key={row.id || idx}
onClick={() => onRowClick?.(row)}
style={{
cursor: onRowClick ? "pointer" : "default",
background:
row.id === selectedId
? "rgba(99,102,241,0.1)"
: idx % 2 === 0
? "transparent"
: "rgba(255,255,255,0.02)",
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
if (row.id !== selectedId) {
e.currentTarget.style.background = "rgba(255,255,255,0.04)";
}
}}
onMouseLeave={(e) => {
if (row.id !== selectedId) {
e.currentTarget.style.background =
idx % 2 === 0 ? "transparent" : "rgba(255,255,255,0.02)";
}
}}
>
{columns.map((col) => (
<td
key={col.key}
style={{
padding: "6px 10px",
borderBottom: "1px solid rgba(255,255,255,0.04)",
whiteSpace: "nowrap",
maxWidth: col.maxWidth || "200px",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{renderCell(row, col)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,205 @@
"use client";
/**
* FilterBar — Shared UI primitive (T-29)
*
* Reusable filter bar with search input and optional filter chips.
* Used by RequestLoggerV2, ProxyLogger, and similar data tables.
*
* Usage:
* <FilterBar
* searchValue={search}
* onSearchChange={setSearch}
* placeholder="Search logs..."
* filters={[
* { key: 'status', label: 'Status', options: ['ok', 'error'] },
* { key: 'provider', label: 'Provider', options: ['openai', 'anthropic'] },
* ]}
* activeFilters={activeFilters}
* onFilterChange={(key, value) => setFilters({ ...filters, [key]: value })}
* />
*/
import { useState, useCallback } from "react";
export default function FilterBar({
searchValue = "",
onSearchChange,
placeholder = "Search...",
filters = [],
activeFilters = {},
onFilterChange,
children,
}) {
const [expandedFilter, setExpandedFilter] = useState(null);
const handleClear = useCallback(() => {
onSearchChange("");
filters.forEach((f) => onFilterChange(f.key, ""));
setExpandedFilter(null);
}, [onSearchChange, filters, onFilterChange]);
const hasActiveFilters =
searchValue || Object.values(activeFilters).some((v) => v && v !== "");
return (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "8px",
alignItems: "center",
padding: "8px 0",
}}
>
{/* Search input */}
<div style={{ position: "relative", flex: "1 1 200px", minWidth: "200px" }}>
<input
type="text"
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={placeholder}
style={{
width: "100%",
padding: "8px 12px 8px 32px",
borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.1)",
background: "rgba(255,255,255,0.05)",
color: "var(--text-primary, #e0e0e0)",
fontSize: "13px",
outline: "none",
}}
/>
<span
style={{
position: "absolute",
left: "10px",
top: "50%",
transform: "translateY(-50%)",
opacity: 0.4,
fontSize: "14px",
pointerEvents: "none",
}}
>
🔍
</span>
</div>
{/* Filter chips */}
{filters.map((filter) => (
<div key={filter.key} style={{ position: "relative" }}>
<button
onClick={() =>
setExpandedFilter(expandedFilter === filter.key ? null : filter.key)
}
style={{
padding: "6px 12px",
borderRadius: "6px",
border: `1px solid ${activeFilters[filter.key] ? "rgba(99,102,241,0.5)" : "rgba(255,255,255,0.1)"}`,
background: activeFilters[filter.key]
? "rgba(99,102,241,0.15)"
: "rgba(255,255,255,0.05)",
color: activeFilters[filter.key]
? "#818cf8"
: "var(--text-secondary, #888)",
fontSize: "12px",
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
{filter.label}
{activeFilters[filter.key] ? ` · ${activeFilters[filter.key]}` : ""}
</button>
{expandedFilter === filter.key && (
<div
style={{
position: "absolute",
top: "100%",
left: 0,
marginTop: "4px",
background: "rgba(20,20,30,0.95)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "8px",
padding: "4px",
zIndex: 50,
minWidth: "120px",
backdropFilter: "blur(12px)",
}}
>
<button
onClick={() => {
onFilterChange(filter.key, "");
setExpandedFilter(null);
}}
style={{
display: "block",
width: "100%",
padding: "6px 12px",
textAlign: "left",
background: "none",
border: "none",
color: "#888",
fontSize: "12px",
cursor: "pointer",
borderRadius: "4px",
}}
>
All
</button>
{(filter.options || []).map((opt) => (
<button
key={opt}
onClick={() => {
onFilterChange(filter.key, opt);
setExpandedFilter(null);
}}
style={{
display: "block",
width: "100%",
padding: "6px 12px",
textAlign: "left",
background:
activeFilters[filter.key] === opt
? "rgba(99,102,241,0.2)"
: "none",
border: "none",
color:
activeFilters[filter.key] === opt
? "#818cf8"
: "var(--text-primary, #e0e0e0)",
fontSize: "12px",
cursor: "pointer",
borderRadius: "4px",
}}
>
{opt}
</button>
))}
</div>
)}
</div>
))}
{/* Clear all */}
{hasActiveFilters && (
<button
onClick={handleClear}
style={{
padding: "6px 12px",
borderRadius: "6px",
border: "1px solid rgba(239,68,68,0.3)",
background: "rgba(239,68,68,0.1)",
color: "#ef4444",
fontSize: "12px",
cursor: "pointer",
}}
>
Clear
</button>
)}
{/* Extra controls (e.g. refresh button) */}
{children}
</div>
);
}

View File

@@ -151,6 +151,10 @@ export async function handleChat(request, clientRawRequest = null) {
/**
* Handle single model chat request
*
* Refactored (T-28): model resolution, logging, and param building
* extracted to chatHelpers.js. This function now focuses on the
* credential retry loop.
*/
async function handleSingleModelChat(
body,
@@ -160,6 +164,7 @@ async function handleSingleModelChat(
comboName = null,
apiKeyInfo = null
) {
// 1. Resolve model → provider/model (or return error)
const modelInfo = await getModelInfo(modelStr);
if (!modelInfo.provider) {
if (modelInfo.errorType === "ambiguous_model") {
@@ -172,7 +177,6 @@ async function handleSingleModelChat(
});
return errorResponse(HTTP_STATUS.BAD_REQUEST, message);
}
log.warn("CHAT", "Invalid model format", { model: modelStr });
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
}
@@ -182,17 +186,15 @@ async function handleSingleModelChat(
const providerAlias = PROVIDER_ID_TO_ALIAS[provider] || provider;
const targetFormat = getModelTargetFormat(providerAlias, model) || getTargetFormat(provider);
// Log model routing (alias → actual model)
if (modelStr !== `${provider}/${model}`) {
log.info("ROUTING", `${modelStr}${provider}/${model}`);
} else {
log.info("ROUTING", `Provider: ${provider}, Model: ${model}`);
}
// Extract userAgent from request
const userAgent = request?.headers?.get("user-agent") || "";
// Try with available accounts (fallback on errors)
// 2. Credential retry loop
let excludeConnectionId = null;
let lastError = null;
let lastStatus = null;
@@ -200,65 +202,29 @@ async function handleSingleModelChat(
while (true) {
const credentials = await getProviderCredentials(provider, excludeConnectionId);
// All accounts unavailable
// All accounts unavailable — return error
if (!credentials || credentials.allRateLimited) {
if (credentials?.allRateLimited) {
const errorMsg = lastError || credentials.lastError || "Unavailable";
const status =
lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
return unavailableResponse(
status,
`[${provider}/${model}] ${errorMsg}`,
credentials.retryAfter,
credentials.retryAfterHuman
);
}
if (!excludeConnectionId) {
log.error("AUTH", `No credentials for provider: ${provider}`);
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
}
log.warn("CHAT", "No more accounts available", { provider });
return errorResponse(
lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE,
lastError || "All accounts unavailable"
);
return handleNoCredentials(credentials, excludeConnectionId, provider, model, lastError, lastStatus);
}
// Log account selection
const accountId = credentials.connectionId.slice(0, 8);
log.info("AUTH", `Using ${provider} account: ${accountId}...`);
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
// Resolve proxy for this connection
let proxyInfo = null;
try {
proxyInfo = await resolveProxyForConnection(credentials.connectionId);
} catch (proxyErr) {
log.debug("PROXY", `Failed to resolve proxy: ${proxyErr.message}`);
}
const proxyInfo = await safeResolveProxy(credentials.connectionId);
const proxyStartTime = Date.now();
// Use shared chatCore
// 3. Execute chat via core
const result = await runWithProxyContext(proxyInfo?.proxy || null, () =>
handleChatCore({
body: { ...body, model: `${provider}/${model}` },
modelInfo: { provider, model },
credentials: refreshedCredentials,
log,
clientRawRequest,
connectionId: credentials.connectionId,
apiKeyInfo,
userAgent,
comboName,
credentials: refreshedCredentials, log, clientRawRequest,
connectionId: credentials.connectionId, apiKeyInfo, userAgent, comboName,
onCredentialsRefreshed: async (newCreds) => {
await updateProviderCredentials(credentials.connectionId, {
accessToken: newCreds.accessToken,
refreshToken: newCreds.refreshToken,
providerSpecificData: newCreds.providerSpecificData,
testStatus: "active",
accessToken: newCreds.accessToken, refreshToken: newCreds.refreshToken,
providerSpecificData: newCreds.providerSpecificData, testStatus: "active",
});
},
onRequestSuccess: async () => {
@@ -269,56 +235,14 @@ async function handleSingleModelChat(
const proxyLatency = Date.now() - proxyStartTime;
// Log proxy event
try {
const proxyData = proxyInfo?.proxy || null;
logProxyEvent({
status: result.success
? "success"
: result.status === 408 || result.status === 504
? "timeout"
: "error",
proxy: proxyData,
level: proxyInfo?.level || "direct",
levelId: proxyInfo?.levelId || null,
provider,
targetUrl: `${provider}/${model}`,
latencyMs: proxyLatency,
error: result.success ? null : result.error || null,
connectionId: credentials.connectionId,
comboId: comboName || null,
account: credentials.connectionId?.slice(0, 8) || null,
});
} catch (logErr) {
// Never let logging break the request pipeline
}
// Log translation event for Live Monitor
try {
logTranslationEvent({
provider,
model,
sourceFormat,
targetFormat,
status: result.success ? "success" : "error",
statusCode: result.success ? 200 : result.status || 500,
latency: proxyLatency,
endpoint: clientRawRequest?.endpoint || "/v1/chat/completions",
connectionId: credentials.connectionId || null,
comboName: comboName || null,
});
} catch {
// Never let logging break the request pipeline
}
// 4. Log proxy + translation events (fire-and-forget)
safeLogEvents({ result, proxyInfo, proxyLatency, provider, model, sourceFormat, targetFormat, credentials, comboName, clientRawRequest });
if (result.success) return result.response;
// Mark account unavailable (auto-calculates cooldown with exponential backoff)
// 5. Fallback to next account
const { shouldFallback } = await markAccountUnavailable(
credentials.connectionId,
result.status,
result.error,
provider
credentials.connectionId, result.status, result.error, provider
);
if (shouldFallback) {
@@ -332,3 +256,52 @@ async function handleSingleModelChat(
return result.response;
}
}
// ──── Extracted helpers (T-28) ────
function handleNoCredentials(credentials, excludeConnectionId, provider, model, lastError, lastStatus) {
if (credentials?.allRateLimited) {
const errorMsg = lastError || credentials.lastError || "Unavailable";
const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
}
if (!excludeConnectionId) {
log.error("AUTH", `No credentials for provider: ${provider}`);
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
}
log.warn("CHAT", "No more accounts available", { provider });
return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
}
async function safeResolveProxy(connectionId) {
try {
return await resolveProxyForConnection(connectionId);
} catch (proxyErr) {
log.debug("PROXY", `Failed to resolve proxy: ${proxyErr.message}`);
return null;
}
}
function safeLogEvents({ result, proxyInfo, proxyLatency, provider, model, sourceFormat, targetFormat, credentials, comboName, clientRawRequest }) {
try {
logProxyEvent({
status: result.success ? "success" : result.status === 408 || result.status === 504 ? "timeout" : "error",
proxy: proxyInfo?.proxy || null, level: proxyInfo?.level || "direct",
levelId: proxyInfo?.levelId || null, provider, targetUrl: `${provider}/${model}`,
latencyMs: proxyLatency, error: result.success ? null : result.error || null,
connectionId: credentials.connectionId, comboId: comboName || null,
account: credentials.connectionId?.slice(0, 8) || null,
});
} catch {}
try {
logTranslationEvent({
provider, model, sourceFormat, targetFormat,
status: result.success ? "success" : "error",
statusCode: result.success ? 200 : result.status || 500,
latency: proxyLatency, endpoint: clientRawRequest?.endpoint || "/v1/chat/completions",
connectionId: credentials.connectionId || null, comboName: comboName || null,
});
} catch {}
}

View File

@@ -0,0 +1,168 @@
/**
* Chat Handler Helpers — FASE-09 (T-28)
*
* Extracted from handleSingleModelChat to keep the main handler
* under 80 lines. These helpers encapsulate:
*
* resolveModelOrError — Model lookup + error response generation
* logProxyAndTranslation — Side-effect logging (proxy + translation events)
* buildChatCoreParams — Assembles the parameter object for handleChatCore
*
* @module sse/handlers/chatHelpers
*/
import { getModelInfo } from "../services/model.js";
import { detectFormat, getTargetFormat, getModelTargetFormat } from "../services/translator.js";
import { PROVIDER_ID_TO_ALIAS } from "../services/model.js";
import { logProxyEvent } from "../../lib/proxyLogger.js";
import { logTranslationEvent } from "../../lib/translatorEvents.js";
import { updateProviderCredentials } from "../services/auth.js";
const HTTP_STATUS = {
BAD_REQUEST: 400,
SERVICE_UNAVAILABLE: 503,
};
/**
* Resolve a model string to provider/model or return an error response.
*
* @param {string} modelStr - Raw model string from request
* @param {Function} log - Logger instance
* @param {Function} errorResponse - Error response factory
* @returns {Promise<{ error?: Response, provider: string, model: string, sourceFormat: string, targetFormat: string }>}
*/
export async function resolveModelOrError(modelStr, body, log, errorResponse) {
const modelInfo = await getModelInfo(modelStr);
if (!modelInfo.provider) {
if (modelInfo.errorType === "ambiguous_model") {
const message =
modelInfo.errorMessage ||
`Ambiguous model '${modelStr}'. Use provider/model prefix (ex: gh/${modelStr} or cc/${modelStr}).`;
log.warn("CHAT", message, {
model: modelStr,
candidates: modelInfo.candidateAliases || modelInfo.candidateProviders || [],
});
return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, message) };
}
log.warn("CHAT", "Invalid model format", { model: modelStr });
return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format") };
}
const { provider, model } = modelInfo;
const sourceFormat = detectFormat(body);
const providerAlias = PROVIDER_ID_TO_ALIAS[provider] || provider;
const targetFormat = getModelTargetFormat(providerAlias, model) || getTargetFormat(provider);
// Log routing
if (modelStr !== `${provider}/${model}`) {
log.info("ROUTING", `${modelStr}${provider}/${model}`);
} else {
log.info("ROUTING", `Provider: ${provider}, Model: ${model}`);
}
return { provider, model, sourceFormat, targetFormat };
}
/**
* Log proxy and translation events (fire-and-forget, never throws).
*
* @param {Object} params
*/
export function logProxyAndTranslation({
result,
proxyInfo,
proxyLatency,
provider,
model,
sourceFormat,
targetFormat,
credentials,
comboName,
clientRawRequest,
}) {
// Proxy event
try {
const proxyData = proxyInfo?.proxy || null;
logProxyEvent({
status: result.success
? "success"
: result.status === 408 || result.status === 504
? "timeout"
: "error",
proxy: proxyData,
level: proxyInfo?.level || "direct",
levelId: proxyInfo?.levelId || null,
provider,
targetUrl: `${provider}/${model}`,
latencyMs: proxyLatency,
error: result.success ? null : result.error || null,
connectionId: credentials.connectionId,
comboId: comboName || null,
account: credentials.connectionId?.slice(0, 8) || null,
});
} catch {
// Never let logging break the request pipeline
}
// Translation event
try {
logTranslationEvent({
provider,
model,
sourceFormat,
targetFormat,
status: result.success ? "success" : "error",
statusCode: result.success ? 200 : result.status || 500,
latency: proxyLatency,
endpoint: clientRawRequest?.endpoint || "/v1/chat/completions",
connectionId: credentials.connectionId || null,
comboName: comboName || null,
});
} catch {
// Never let logging break the request pipeline
}
}
/**
* Build the params object for handleChatCore.
*
* @param {Object} params
* @returns {Object} handleChatCore params
*/
export function buildChatCoreParams({
body,
provider,
model,
credentials,
log,
clientRawRequest,
apiKeyInfo,
userAgent,
comboName,
}) {
return {
body: { ...body, model: `${provider}/${model}` },
modelInfo: { provider, model },
credentials,
log,
clientRawRequest,
connectionId: credentials.connectionId,
apiKeyInfo,
userAgent,
comboName,
onCredentialsRefreshed: async (newCreds) => {
await updateProviderCredentials(credentials.connectionId, {
accessToken: newCreds.accessToken,
refreshToken: newCreds.refreshToken,
providerSpecificData: newCreds.providerSpecificData,
testStatus: "active",
});
},
onRequestSuccess: async () => {
const { clearAccountError } = await import("../services/auth.js");
await clearAccountError(credentials.connectionId, credentials);
},
};
}