From 492afc4ff19fec44a69e70a67d3249ea45bfdb7b Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 14 Feb 2026 18:53:14 -0300 Subject: [PATCH] refactor: decompose usageDb, handleSingleModelChat, UI components (T-15, T-28, T-29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/FASE-01-security-hardening.md | 177 ++++ docs/FASE-02-cicd-test-infrastructure.md | 155 +++ docs/FASE-03-architecture-refactoring.md | 185 ++++ docs/FASE-04-error-handling-observability.md | 168 ++++ docs/FASE-05-code-quality-standards.md | 171 ++++ docs/FASE-06-documentation-governance.md | 137 +++ docs/FASE-07-ux-microinteractions.md | 209 ++++ docs/FASE-08-llm-proxy-advanced.md | 158 +++ docs/FASE-09-e2e-flow-hardening.md | 173 ++++ docs/PLANO-IMPLANTACAO.md | 118 +++ docs/TASKS.md | 143 +++ src/lib/usage/callLogs.js | 339 +++++++ src/lib/usage/costCalculator.js | 56 ++ src/lib/usage/migrations.js | 186 ++++ src/lib/usage/usageHistory.js | 249 +++++ src/lib/usage/usageStats.js | 196 ++++ src/lib/usageDb.js | 999 +------------------ src/shared/components/ColumnToggle.js | 100 ++ src/shared/components/DataTable.js | 157 +++ src/shared/components/FilterBar.js | 205 ++++ src/sse/handlers/chat.js | 161 ++- src/sse/handlers/chatHelpers.js | 168 ++++ 22 files changed, 3552 insertions(+), 1058 deletions(-) create mode 100644 docs/FASE-01-security-hardening.md create mode 100644 docs/FASE-02-cicd-test-infrastructure.md create mode 100644 docs/FASE-03-architecture-refactoring.md create mode 100644 docs/FASE-04-error-handling-observability.md create mode 100644 docs/FASE-05-code-quality-standards.md create mode 100644 docs/FASE-06-documentation-governance.md create mode 100644 docs/FASE-07-ux-microinteractions.md create mode 100644 docs/FASE-08-llm-proxy-advanced.md create mode 100644 docs/FASE-09-e2e-flow-hardening.md create mode 100644 docs/PLANO-IMPLANTACAO.md create mode 100644 docs/TASKS.md create mode 100644 src/lib/usage/callLogs.js create mode 100644 src/lib/usage/costCalculator.js create mode 100644 src/lib/usage/migrations.js create mode 100644 src/lib/usage/usageHistory.js create mode 100644 src/lib/usage/usageStats.js create mode 100644 src/shared/components/ColumnToggle.js create mode 100644 src/shared/components/DataTable.js create mode 100644 src/shared/components/FilterBar.js create mode 100644 src/sse/handlers/chatHelpers.js diff --git a/docs/FASE-01-security-hardening.md b/docs/FASE-01-security-hardening.md new file mode 100644 index 0000000000..6cf2d52e4d --- /dev/null +++ b/docs/FASE-01-security-hardening.md @@ -0,0 +1,177 @@ +# FASE 01 — Security Hardening + +> **Prioridade:** 🔴 Crítica +> **Estimativa de Complexidade:** Média (3–5 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 | diff --git a/docs/FASE-02-cicd-test-infrastructure.md b/docs/FASE-02-cicd-test-infrastructure.md new file mode 100644 index 0000000000..0a19eedfbf --- /dev/null +++ b/docs/FASE-02-cicd-test-infrastructure.md @@ -0,0 +1,155 @@ +# FASE 02 — CI/CD & Infraestrutura de Testes + +> **Prioridade:** 🔴 Crítica +> **Estimativa de Complexidade:** Média (3–5 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 | diff --git a/docs/FASE-03-architecture-refactoring.md b/docs/FASE-03-architecture-refactoring.md new file mode 100644 index 0000000000..299bfcab57 --- /dev/null +++ b/docs/FASE-03-architecture-refactoring.md @@ -0,0 +1,185 @@ +# FASE 03 — Refatoração Arquitetural + +> **Prioridade:** 🟠 Importante +> **Estimativa de Complexidade:** Alta (5–8 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 | diff --git a/docs/FASE-04-error-handling-observability.md b/docs/FASE-04-error-handling-observability.md new file mode 100644 index 0000000000..eef764667c --- /dev/null +++ b/docs/FASE-04-error-handling-observability.md @@ -0,0 +1,168 @@ +# FASE 04 — Error Handling & Observabilidade + +> **Prioridade:** 🟠 Importante +> **Estimativa de Complexidade:** Média (4–6 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 | diff --git a/docs/FASE-05-code-quality-standards.md b/docs/FASE-05-code-quality-standards.md new file mode 100644 index 0000000000..40f2f38ba8 --- /dev/null +++ b/docs/FASE-05-code-quality-standards.md @@ -0,0 +1,171 @@ +# FASE 05 — Qualidade do Código e Padronização + +> **Prioridade:** 🟠 Importante / 🟡 Moderado +> **Estimativa de Complexidade:** Média (4–6 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 | diff --git a/docs/FASE-06-documentation-governance.md b/docs/FASE-06-documentation-governance.md new file mode 100644 index 0000000000..dc7ee5fd88 --- /dev/null +++ b/docs/FASE-06-documentation-governance.md @@ -0,0 +1,137 @@ +# FASE 06 — Documentação e Governança + +> **Prioridade:** 🟡 Moderado +> **Estimativa de Complexidade:** Baixa-Média (2–4 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 | diff --git a/docs/FASE-07-ux-microinteractions.md b/docs/FASE-07-ux-microinteractions.md new file mode 100644 index 0000000000..703876efd3 --- /dev/null +++ b/docs/FASE-07-ux-microinteractions.md @@ -0,0 +1,209 @@ +# FASE 07 — UX e Microinterações + +> **Prioridade:** 🟡 Moderado +> **Estimativa de Complexidade:** Média (4–6 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) | diff --git a/docs/FASE-08-llm-proxy-advanced.md b/docs/FASE-08-llm-proxy-advanced.md new file mode 100644 index 0000000000..3c27f6ff0a --- /dev/null +++ b/docs/FASE-08-llm-proxy-advanced.md @@ -0,0 +1,158 @@ +# FASE 08 — LLM Proxy: Recursos Avançados + +> **Prioridade:** 🟡 Moderado +> **Estimativa de Complexidade:** Alta (6–10 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 | diff --git a/docs/FASE-09-e2e-flow-hardening.md b/docs/FASE-09-e2e-flow-hardening.md new file mode 100644 index 0000000000..7cb9f583cc --- /dev/null +++ b/docs/FASE-09-e2e-flow-hardening.md @@ -0,0 +1,173 @@ +# FASE 09 — Hardening de Fluxo Ponta a Ponta + +> **Prioridade:** 🟡 Moderado / 🟢 Menor +> **Estimativa de Complexidade:** Média (3–5 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 | diff --git a/docs/PLANO-IMPLANTACAO.md b/docs/PLANO-IMPLANTACAO.md new file mode 100644 index 0000000000..57c93067e0 --- /dev/null +++ b/docs/PLANO-IMPLANTACAO.md @@ -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:** 34–59 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 (3–5d) | +| 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 (3–5d) | +| 03 | [FASE-03-architecture-refactoring.md](file:///home/diegosouzapw/dev/proxys/9router/docs/FASE-03-architecture-refactoring.md) | 🟠 Importante | 5 | Alta (5–8d) | +| 04 | [FASE-04-error-handling-observability.md](file:///home/diegosouzapw/dev/proxys/9router/docs/FASE-04-error-handling-observability.md) | 🟠 Importante | 5 | Média (4–6d) | +| 05 | [FASE-05-code-quality-standards.md](file:///home/diegosouzapw/dev/proxys/9router/docs/FASE-05-code-quality-standards.md) | 🟠/🟡 Importante | 4 | Média (4–6d) | +| 06 | [FASE-06-documentation-governance.md](file:///home/diegosouzapw/dev/proxys/9router/docs/FASE-06-documentation-governance.md) | 🟡 Moderado | 4 | Baixa (2–4d) | +| 07 | [FASE-07-ux-microinteractions.md](file:///home/diegosouzapw/dev/proxys/9router/docs/FASE-07-ux-microinteractions.md) | 🟡 Moderado | 6 | Média (4–6d) | +| 08 | [FASE-08-llm-proxy-advanced.md](file:///home/diegosouzapw/dev/proxys/9router/docs/FASE-08-llm-proxy-advanced.md) | 🟡 Moderado | 4 | Alta (6–10d) | +| 09 | [FASE-09-e2e-flow-hardening.md](file:///home/diegosouzapw/dev/proxys/9router/docs/FASE-09-e2e-flow-hardening.md) | 🟡/🟢 Moderado | 3 | Média (3–5d) | + +--- + +## Ordem de Execução e Dependências + +```mermaid +graph TD + F1[FASE 01
Security Hardening] --> F2[FASE 02
CI/CD & Testes] + F2 --> F3[FASE 03
Refatoração Arquitetural] + F3 --> F4[FASE 04
Error Handling & Observabilidade] + F3 --> F5[FASE 05
Qualidade do Código] + F5 --> F6[FASE 06
Documentação & Governança] + F5 --> F7[FASE 07
UX & Microinterações] + F4 --> F8[FASE 08
LLM Proxy Avançado] + F8 --> F9[FASE 09
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-01–03 | Semana 4 | Monólitos decompostos, domain layer criado | +| **M4 — Produção-Ready** | FASE-01–05 | Semana 6 | Error handling, logging, tipagem padronizados | +| **M5 — Documentado** | FASE-01–06 | Semana 7 | ADRs, CONTRIBUTING, SECURITY completos | +| **M6 — UX Polish** | FASE-01–07 | Semana 9 | Toasts, a11y, breadcrumbs, empty states | +| **M7 — Gateway Completo** | FASE-01–09 | 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) diff --git a/docs/TASKS.md b/docs/TASKS.md new file mode 100644 index 0000000000..54e71586a5 --- /dev/null +++ b/docs/TASKS.md @@ -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 diff --git a/src/lib/usage/callLogs.js b/src/lib/usage/callLogs.js new file mode 100644 index 0000000000..887b672e2c --- /dev/null +++ b/src/lib/usage/callLogs.js @@ -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; +} diff --git a/src/lib/usage/costCalculator.js b/src/lib/usage/costCalculator.js new file mode 100644 index 0000000000..8fbb821a2c --- /dev/null +++ b/src/lib/usage/costCalculator.js @@ -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} 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; + } +} diff --git a/src/lib/usage/migrations.js b/src/lib/usage/migrations.js new file mode 100644 index 0000000000..a882dcb9e2 --- /dev/null +++ b/src/lib/usage/migrations.js @@ -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 */ + } +} diff --git a/src/lib/usage/usageHistory.js b/src/lib/usage/usageHistory.js new file mode 100644 index 0000000000..54b44eaf11 --- /dev/null +++ b/src/lib/usage/usageHistory.js @@ -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 []; + } +} diff --git a/src/lib/usage/usageStats.js b/src/lib/usage/usageStats.js new file mode 100644 index 0000000000..562d0d44b5 --- /dev/null +++ b/src/lib/usage/usageStats.js @@ -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; +} diff --git a/src/lib/usageDb.js b/src/lib/usageDb.js index 437064efd6..6b415844a9 100644 --- a/src/lib/usageDb.js +++ b/src/lib/usageDb.js @@ -1,968 +1,39 @@ /** - * usageDb.js — Usage tracking, request logging, and call logs. + * usageDb.js — Facade (T-15 decomposition) * - * P1.2: Migrated from LowDB/JSON to SQLite. - * - usage_history table replaces usage.json - * - call_logs table replaces call_logs.json - * - log.txt and call_logs/ disk files remain file-based + * This file is now a thin re-export layer. All logic has been + * extracted into focused modules under `./usage/`: + * + * migrations.js — Legacy file + JSON→SQLite migration + * usageHistory.js — Usage tracking, request log, pending requests + * costCalculator.js — Cost calculation (pure function) + * usageStats.js — Aggregated stats for dashboard + * callLogs.js — Structured call log management + * + * Existing imports like `import { getUsageStats } from "@/lib/usageDb"` + * continue to work unchanged. */ -import path from "path"; -import fs from "fs"; -import { getDbInstance, isCloud, isBuildPhase, DATA_DIR } from "./db/core.js"; -import { resolveDataDir, getLegacyDotDataDir, isSamePath } from "./dataPaths.js"; - -const shouldPersistToDisk = !isCloud && !isBuildPhase; - -// ──────────────── File Paths (log.txt + call_logs/) ──────────────── - -const LEGACY_DATA_DIR = isCloud ? null : getLegacyDotDataDir(); -const LOG_FILE = isCloud ? null : path.join(DATA_DIR, "log.txt"); -const CALL_LOGS_DIR = isCloud ? null : path.join(DATA_DIR, "call_logs"); - -// Legacy paths for migration -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}`); -} - -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); - } -} - -migrateLegacyUsageFiles(); - -// ──────────────── JSON → SQLite Migration ──────────────── - -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 migration on module load -if (shouldPersistToDisk) { - try { - migrateUsageJsonToSqlite(); - } catch { - /* ok */ - } -} - -// ──────────────── 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) - ); - } -} - -// ──────────────── 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 — file-based) ──────────────── - -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 []; - } -} - -// ──────────────── Calculate Cost ──────────────── - -/** - * Calculate cost for a usage entry (pure function, no DB interaction) - */ -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; - } -} - -// ──────────────── Usage Stats ──────────────── - -/** - * 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 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 tenMinutesAgo = new Date(currentMinuteStart.getTime() - 9 * 60 * 1000); - - 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]); - } - - 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; -} - -// ============================================================================ -// Call Logs — Structured logs for the Logger UI -// ============================================================================ - -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; -} +// Trigger migrations on module load (side-effect) +import "./usage/migrations.js"; + +// Re-export everything for backward compatibility +export { + trackPendingRequest, + getUsageDb, + saveRequestUsage, + getUsageHistory, + appendRequestLog, + getRecentLogs, +} from "./usage/usageHistory.js"; + +export { calculateCost } from "./usage/costCalculator.js"; + +export { getUsageStats } from "./usage/usageStats.js"; + +export { + saveCallLog, + rotateCallLogs, + getCallLogs, + getCallLogById, +} from "./usage/callLogs.js"; diff --git a/src/shared/components/ColumnToggle.js b/src/shared/components/ColumnToggle.js new file mode 100644 index 0000000000..071a19cd4b --- /dev/null +++ b/src/shared/components/ColumnToggle.js @@ -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: + * 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 ( +
+ + + {open && ( +
+ {columns.map((col) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/shared/components/DataTable.js b/src/shared/components/DataTable.js new file mode 100644 index 0000000000..5e11d5d015 --- /dev/null +++ b/src/shared/components/DataTable.js @@ -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: + * {row[column.key]}} + * 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 ( +
+ + Loading... + +
+ ); + } + + if (data.length === 0) { + return ( +
+ {emptyIcon} + {emptyMessage} +
+ ); + } + + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {data.map((row, idx) => ( + 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) => ( + + ))} + + ))} + +
+ {renderHeader ? renderHeader(col) : col.label} +
+ {renderCell(row, col)} +
+
+ ); +} diff --git a/src/shared/components/FilterBar.js b/src/shared/components/FilterBar.js new file mode 100644 index 0000000000..4ee4d9ae31 --- /dev/null +++ b/src/shared/components/FilterBar.js @@ -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: + * 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 ( +
+ {/* Search input */} +
+ 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", + }} + /> + + 🔍 + +
+ + {/* Filter chips */} + {filters.map((filter) => ( +
+ + {expandedFilter === filter.key && ( +
+ + {(filter.options || []).map((opt) => ( + + ))} +
+ )} +
+ ))} + + {/* Clear all */} + {hasActiveFilters && ( + + )} + + {/* Extra controls (e.g. refresh button) */} + {children} +
+ ); +} diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 2e81b5433d..efb549a121 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -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 {} +} + diff --git a/src/sse/handlers/chatHelpers.js b/src/sse/handlers/chatHelpers.js new file mode 100644 index 0000000000..93284132f7 --- /dev/null +++ b/src/sse/handlers/chatHelpers.js @@ -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); + }, + }; +}