Merge pull request #24 from diegosouzapw/feature/batch-a-domain-infra

feat: domain layer, error codes, request ID, fetch timeout (Batch A)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-02-14 19:04:15 -03:00
committed by GitHub
16 changed files with 1058 additions and 74 deletions

View File

@@ -20,124 +20,131 @@
## 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 |
| 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 | — | Concluído |
| T-02 | Remover fallback hardcoded de `API_KEY_SECRET` em `src/shared/utils/apiKey.js` e implementar validação fail-fast | 🔴 Crítica | — | Concluído |
| T-03 | Atualizar `.env.example` e README com instruções para gerar segredos fortes (openssl rand) | 🔴 Crítica | T-01, T-02 | Concluído |
| T-04 | Adicionar logging estruturado em todos os `catch` blocks silenciosos de `src/proxy.js` (auth_error, settings_error) | 🔴 Crítica | — | Concluído |
| T-05 | Criar módulo `src/shared/utils/inputSanitizer.js` com detecção de prompt injection e PII redaction | 🔴 Crítica | — | Concluído |
| T-06 | Integrar `inputSanitizer` no pipeline de request em `src/sse/handlers/chat.js` antes de `translateRequest()` | 🔴 Crítica | T-05 | Concluído |
| T-07 | Remover `.passthrough()` de `updateSettingsSchema` em `src/shared/validation/schemas.js` e listar campos explicitamente | 🟡 Moderada | — | Concluído |
| T-08 | Remover dependência `"fs": "^0.0.1-security"` do `package.json` e verificar imports | 🟢 Menor | — | Concluído |
| T-09 | Criar testes unitários para validação de segredos e sanitizador de inputs | 🔴 Crítica | T-01, T-02, T-05 | Concluído |
---
## 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 |
| 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 | Concluído |
| 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 | — | Concluído |
| T-12 | Configurar `c8` como ferramenta de cobertura de testes com script `test:coverage` e target mínimo 40% | 🔴 Crítica | T-11 | Concluído |
| T-13 | Instalar e configurar `eslint-plugin-security` e `eslint-plugin-react-hooks` no `eslint.config.mjs` | 🟠 Importante | — | Concluído |
| T-14 | Converter 4 scripts de `tests/security/` em testes programáticos `.test.mjs` em `tests/integration/` | 🟠 Importante | T-11 | Concluído |
---
## 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 |
| 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 | Concluído |
| T-16 | Criar base class `OAuthProvider` em `src/lib/oauth/base/` e factory `providerFactory.js` | 🟠 Importante | T-10 | Concluído |
| T-17 | Extrair 12 providers OAuth em subclasses individuais em `src/lib/oauth/providers/` | 🟠 Importante | T-16 | Concluído |
| 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 | Concluído |
| T-19 | Criar domain layer `src/domain/` com: `modelAvailability.js`, `costRules.js`, `fallbackPolicy.js` | 🟡 Moderada | T-15 | Concluído |
| T-20 | Adicionar `antigravity-manager-analysis/` ao `.gitignore` e consolidar endpoints `rate-limit/` vs `rate-limits/` | 🟢 Menor | — | Concluído |
---
## 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 |
| 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 | — | Concluído |
| T-22 | Criar catálogo de error codes `src/shared/constants/errorCodes.js` com helper `createErrorResponse()` | 🟡 Moderada | T-19 | Concluído |
| 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 | Concluído |
| 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 | Concluído |
| T-25 | Definir timeout padrão explícito (`FETCH_TIMEOUT_MS=120000`) com `AbortController` em todas as `fetch()` para providers | 🟠 Importante | — | Concluído |
---
## 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 |
| 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 | Concluído |
| T-27 | Adicionar `@ts-check` + JSDoc (`@param`, `@returns`) em ≥ 10 arquivos críticos (DB, services, domain) | 🟠 Importante | T-19 | Concluído |
| T-28 | Decompor `handleSingleModelChat` (183 linhas) em subfunções <80 linhas; decompor `getUsageStats` (180 linhas) | 🟡 Moderada | T-15 | Concluído |
| T-29 | Decompor 5 componentes UI monolíticos (RequestLoggerV2, UsageStats, ProxyLogger, OAuthModal, ProxyConfigModal) em sub-componentes + hooks extraídos | 🟡 Moderada | — | Concluído |
---
## 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 |
| 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 | Concluído |
| T-32 | Expandir `SECURITY.md` para ≥ 2KB (disclosure, scope, SLA, contact, best practices, limitations) | 🟡 Moderada | T-01 | Concluído |
| 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 |
| 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 | Concluído |
| 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 | — | Concluído |
| T-37 | Criar componente `EmptyState.js` e implementar em 4 seções (Providers, Combos, Usage, Request Logger) | 🟡 Moderada | — | Concluído |
| 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 |
| 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 | Concluído |
| 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 | Concluído |
| 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 |
| 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 | Concluído |
| 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 | Concluído |
| 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 | Concluído |
---
## 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 |
| Prioridade | Total | Concluídas | Pendentes |
| ------------- | ------ | ---------- | --------- |
| 🔴 Crítica | 11 | 11 | 0 |
| 🟠 Importante | 12 | 12 | 0 |
| 🟡 Moderada | 19 | 12 | 7 |
| 🟢 Menor | 4 | 2 | 2 |
| **Total** | **46** | **37** | **9** |
## Sugestão de Ordem de Execução
## Tarefas Pendentes
> **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
| ID | Fase | Descrição | Prioridade |
| ---- | ---- | --------------------------------- | ----------- |
| T-30 | F06 | ADRs (6+ decisões arquiteturais) | 🟡 Moderada |
| T-33 | F06 | JSDoc coverage ≥80% + ESLint rule | 🟢 Menor |
| T-35 | F07 | Auditoria a11y com axe-core | 🟡 Moderada |
| T-38 | F07 | Password reset CLI | 🟡 Moderada |
| T-39 | F07 | Playwright responsive tests | 🟢 Menor |
| T-42 | F08 | Eval framework (golden set) | 🟡 Moderada |
| T-43 | F08 | Compliance (retention, audit log) | 🟡 Moderada |

View File

@@ -1,3 +1,4 @@
// @ts-check
/**
* Combo Resolver — FASE-09 Domain Extraction (T-46)
*

157
src/domain/costRules.js Normal file
View File

@@ -0,0 +1,157 @@
/**
* Cost Rules — Domain Layer (T-19)
*
* Business rules for cost management: budget thresholds,
* quota checking, and cost summaries per API key.
*
* @module domain/costRules
*/
// @ts-check
/**
* @typedef {Object} BudgetConfig
* @property {number} dailyLimitUsd - Max daily spend in USD
* @property {number} [monthlyLimitUsd] - Max monthly spend in USD
* @property {number} [warningThreshold=0.8] - Alert when usage reaches this fraction
*/
/**
* @typedef {Object} CostEntry
* @property {number} cost - Cost in USD
* @property {number} timestamp - Unix timestamp
*/
/** @type {Map<string, BudgetConfig>} API key ID → budget config */
const budgets = new Map();
/** @type {Map<string, CostEntry[]>} API key ID → cost entries */
const costHistory = new Map();
/**
* Set budget for an API key.
*
* @param {string} apiKeyId
* @param {BudgetConfig} config
*/
export function setBudget(apiKeyId, config) {
budgets.set(apiKeyId, {
dailyLimitUsd: config.dailyLimitUsd,
monthlyLimitUsd: config.monthlyLimitUsd || 0,
warningThreshold: config.warningThreshold ?? 0.8,
});
}
/**
* Get budget config for an API key.
*
* @param {string} apiKeyId
* @returns {BudgetConfig | null}
*/
export function getBudget(apiKeyId) {
return budgets.get(apiKeyId) || null;
}
/**
* Record a cost for an API key.
*
* @param {string} apiKeyId
* @param {number} cost - Cost in USD
*/
export function recordCost(apiKeyId, cost) {
if (!costHistory.has(apiKeyId)) {
costHistory.set(apiKeyId, []);
}
costHistory.get(apiKeyId).push({ cost, timestamp: Date.now() });
}
/**
* Check if an API key has remaining budget.
*
* @param {string} apiKeyId
* @param {number} [additionalCost=0] - Projected cost to check
* @returns {{ allowed: boolean, reason?: string, dailyUsed: number, dailyLimit: number, warningReached: boolean }}
*/
export function checkBudget(apiKeyId, additionalCost = 0) {
const budget = budgets.get(apiKeyId);
if (!budget) {
return { allowed: true, dailyUsed: 0, dailyLimit: 0, warningReached: false };
}
const dailyUsed = getDailyTotal(apiKeyId);
const projectedTotal = dailyUsed + additionalCost;
const warningReached = projectedTotal >= budget.dailyLimitUsd * budget.warningThreshold;
if (projectedTotal > budget.dailyLimitUsd) {
return {
allowed: false,
reason: `Daily budget exceeded: $${projectedTotal.toFixed(4)} / $${budget.dailyLimitUsd.toFixed(2)}`,
dailyUsed,
dailyLimit: budget.dailyLimitUsd,
warningReached: true,
};
}
return {
allowed: true,
dailyUsed,
dailyLimit: budget.dailyLimitUsd,
warningReached,
};
}
/**
* Get daily total cost for an API key.
*
* @param {string} apiKeyId
* @returns {number} Total cost today in USD
*/
export function getDailyTotal(apiKeyId) {
const entries = costHistory.get(apiKeyId) || [];
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const startMs = todayStart.getTime();
return entries
.filter((e) => e.timestamp >= startMs)
.reduce((sum, e) => sum + e.cost, 0);
}
/**
* Get cost summary for an API key.
*
* @param {string} apiKeyId
* @returns {{ dailyTotal: number, monthlyTotal: number, totalEntries: number, budget: BudgetConfig | null }}
*/
export function getCostSummary(apiKeyId) {
const entries = costHistory.get(apiKeyId) || [];
const now = new Date();
const todayStart = new Date(now);
todayStart.setHours(0, 0, 0, 0);
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const dailyTotal = entries
.filter((e) => e.timestamp >= todayStart.getTime())
.reduce((sum, e) => sum + e.cost, 0);
const monthlyTotal = entries
.filter((e) => e.timestamp >= monthStart.getTime())
.reduce((sum, e) => sum + e.cost, 0);
return {
dailyTotal,
monthlyTotal,
totalEntries: entries.length,
budget: budgets.get(apiKeyId) || null,
};
}
/**
* Clear all cost data (for testing).
*/
export function resetCostData() {
budgets.clear();
costHistory.clear();
}

View File

@@ -0,0 +1,108 @@
/**
* Fallback Policy — Domain Layer (T-19)
*
* Declarative fallback chain for model routing.
* When a primary provider is unavailable, the policy engine
* resolves to alternative providers in priority order.
*
* @module domain/fallbackPolicy
*/
// @ts-check
/**
* @typedef {Object} FallbackEntry
* @property {string} provider - Provider ID
* @property {number} [priority=0] - Lower = higher priority
* @property {boolean} [enabled=true] - Whether this fallback is active
*/
/** @type {Map<string, FallbackEntry[]>} model → fallback chain */
const fallbackChains = new Map();
/**
* Register a fallback chain for a model.
*
* @param {string} model - Model identifier (e.g. "gpt-4o")
* @param {FallbackEntry[]} chain - Ordered list of fallback providers
*/
export function registerFallback(model, chain) {
const sorted = [...chain]
.map((e) => ({
provider: e.provider,
priority: e.priority ?? 0,
enabled: e.enabled ?? true,
}))
.sort((a, b) => a.priority - b.priority);
fallbackChains.set(model, sorted);
}
/**
* Resolve the fallback chain for a model.
* Returns only enabled providers, sorted by priority.
*
* @param {string} model
* @param {string[]} [excludeProviders=[]] - Providers to skip (e.g. already tried)
* @returns {FallbackEntry[]} Ordered list of fallback providers
*/
export function resolveFallbackChain(model, excludeProviders = []) {
const chain = fallbackChains.get(model);
if (!chain) return [];
const excludeSet = new Set(excludeProviders);
return chain.filter((e) => e.enabled && !excludeSet.has(e.provider));
}
/**
* Get the next provider in the fallback chain.
*
* @param {string} model
* @param {string[]} [excludeProviders=[]]
* @returns {string | null} Next provider ID or null if chain exhausted
*/
export function getNextFallback(model, excludeProviders = []) {
const chain = resolveFallbackChain(model, excludeProviders);
return chain.length > 0 ? chain[0].provider : null;
}
/**
* Check if a model has any fallback providers configured.
*
* @param {string} model
* @returns {boolean}
*/
export function hasFallback(model) {
const chain = fallbackChains.get(model);
return !!chain && chain.some((e) => e.enabled);
}
/**
* Remove a fallback chain for a model.
*
* @param {string} model
* @returns {boolean} true if removed
*/
export function removeFallback(model) {
return fallbackChains.delete(model);
}
/**
* Get all registered fallback chains (for dashboard).
*
* @returns {Record<string, FallbackEntry[]>}
*/
export function getAllFallbackChains() {
const result = {};
for (const [model, chain] of fallbackChains.entries()) {
result[model] = chain;
}
return result;
}
/**
* Reset all fallback chains (for testing).
*/
export function resetAllFallbacks() {
fallbackChains.clear();
}

View File

@@ -1,3 +1,4 @@
// @ts-check
/**
* Lockout Policy — FASE-09 Domain Extraction (T-46)
*

View File

@@ -0,0 +1,130 @@
/**
* Model Availability — Domain Layer (T-19)
*
* Tracks model availability per provider with TTL-based cooldowns.
* When a model becomes unavailable (rate-limited, erroring), it is
* marked with a cooldown period. The availability report powers
* the dashboard health view.
*
* @module domain/modelAvailability
*/
// @ts-check
/**
* @typedef {Object} UnavailableEntry
* @property {string} provider
* @property {string} model
* @property {number} unavailableSince - timestamp
* @property {number} cooldownMs
* @property {string} [reason]
*/
/** @type {Map<string, UnavailableEntry>} */
const unavailable = new Map();
/**
* Build a composite key for provider+model.
* @param {string} provider
* @param {string} model
* @returns {string}
*/
function makeKey(provider, model) {
return `${provider}::${model}`;
}
/**
* Check if a model is currently available.
*
* @param {string} provider - Provider ID (e.g. "openai", "anthropic")
* @param {string} model - Model ID (e.g. "gpt-4o", "claude-sonnet-4-20250514")
* @returns {boolean} true if model is available (not in cooldown)
*/
export function isModelAvailable(provider, model) {
const key = makeKey(provider, model);
const entry = unavailable.get(key);
if (!entry) return true;
// Check if cooldown has expired
if (Date.now() - entry.unavailableSince >= entry.cooldownMs) {
unavailable.delete(key);
return true;
}
return false;
}
/**
* Mark a model as temporarily unavailable.
*
* @param {string} provider
* @param {string} model
* @param {number} [cooldownMs=60000] - Cooldown in milliseconds (default 60s)
* @param {string} [reason] - Optional reason for unavailability
*/
export function setModelUnavailable(provider, model, cooldownMs = 60000, reason) {
const key = makeKey(provider, model);
unavailable.set(key, {
provider,
model,
unavailableSince: Date.now(),
cooldownMs,
reason: reason || "unknown",
});
}
/**
* Clear unavailability for a model (e.g. after manual reset).
*
* @param {string} provider
* @param {string} model
* @returns {boolean} true if entry existed and was removed
*/
export function clearModelUnavailability(provider, model) {
return unavailable.delete(makeKey(provider, model));
}
/**
* Get a report of all currently unavailable models.
*
* @returns {Array<{ provider: string, model: string, reason: string, remainingMs: number, unavailableSince: string }>}
*/
export function getAvailabilityReport() {
const now = Date.now();
const report = [];
for (const [key, entry] of unavailable.entries()) {
const elapsed = now - entry.unavailableSince;
if (elapsed >= entry.cooldownMs) {
unavailable.delete(key);
continue;
}
report.push({
provider: entry.provider,
model: entry.model,
reason: entry.reason || "unknown",
remainingMs: entry.cooldownMs - elapsed,
unavailableSince: new Date(entry.unavailableSince).toISOString(),
});
}
return report;
}
/**
* Get total count of unavailable models.
* @returns {number}
*/
export function getUnavailableCount() {
// Prune expired entries first
getAvailabilityReport();
return unavailable.size;
}
/**
* Reset all availability states (for testing or admin).
*/
export function resetAllAvailability() {
unavailable.clear();
}

View File

@@ -1,3 +1,4 @@
// @ts-check
/**
* Policy Engine — FASE-08 LLM Proxy Advanced
*

View File

@@ -1,3 +1,4 @@
// @ts-check
/**
* Call Logs — extracted from usageDb.js (T-15)
*

View File

@@ -1,3 +1,4 @@
// @ts-check
/**
* Cost Calculator — extracted from usageDb.js (T-15)
*

View File

@@ -1,3 +1,4 @@
// @ts-check
/**
* Usage History — extracted from usageDb.js (T-15)
*

View File

@@ -0,0 +1,114 @@
/**
* Error Codes Catalog — T-22
*
* Centralized error code registry for consistent API error responses.
* Each code has a category prefix, numeric ID, message, and HTTP status.
*
* Usage:
* import { ERROR_CODES, createErrorResponse } from "@/shared/constants/errorCodes";
* return createErrorResponse("AUTH_001", { detail: "Token expired" });
*
* @module shared/constants/errorCodes
*/
// @ts-check
/**
* @typedef {Object} ErrorCodeDef
* @property {string} code - Error code (e.g. "AUTH_001")
* @property {string} message - Human-readable message
* @property {number} httpStatus - HTTP status code
* @property {string} category - Category (AUTH, PROXY, RATE_LIMIT, etc.)
*/
/** @type {Record<string, ErrorCodeDef>} */
export const ERROR_CODES = {
// ── Auth ──
AUTH_001: { code: "AUTH_001", message: "Authentication required", httpStatus: 401, category: "AUTH" },
AUTH_002: { code: "AUTH_002", message: "Invalid API key", httpStatus: 401, category: "AUTH" },
AUTH_003: { code: "AUTH_003", message: "API key expired", httpStatus: 401, category: "AUTH" },
AUTH_004: { code: "AUTH_004", message: "Insufficient permissions", httpStatus: 403, category: "AUTH" },
AUTH_005: { code: "AUTH_005", message: "Account locked", httpStatus: 423, category: "AUTH" },
AUTH_006: { code: "AUTH_006", message: "No credentials for provider", httpStatus: 400, category: "AUTH" },
// ── Proxy ──
PROXY_001: { code: "PROXY_001", message: "Proxy connection failed", httpStatus: 502, category: "PROXY" },
PROXY_002: { code: "PROXY_002", message: "Proxy timeout", httpStatus: 504, category: "PROXY" },
PROXY_003: { code: "PROXY_003", message: "All proxies exhausted", httpStatus: 503, category: "PROXY" },
// ── Rate Limiting ──
RATE_001: { code: "RATE_001", message: "Rate limit exceeded", httpStatus: 429, category: "RATE_LIMIT" },
RATE_002: { code: "RATE_002", message: "Daily budget exceeded", httpStatus: 429, category: "RATE_LIMIT" },
RATE_003: { code: "RATE_003", message: "All accounts rate-limited", httpStatus: 503, category: "RATE_LIMIT" },
// ── Model / Routing ──
MODEL_001: { code: "MODEL_001", message: "Model not found", httpStatus: 404, category: "MODEL" },
MODEL_002: { code: "MODEL_002", message: "Ambiguous model identifier", httpStatus: 400, category: "MODEL" },
MODEL_003: { code: "MODEL_003", message: "Model temporarily unavailable", httpStatus: 503, category: "MODEL" },
// ── Provider ──
PROVIDER_001: { code: "PROVIDER_001", message: "Provider error", httpStatus: 502, category: "PROVIDER" },
PROVIDER_002: { code: "PROVIDER_002", message: "Provider timeout", httpStatus: 504, category: "PROVIDER" },
PROVIDER_003: { code: "PROVIDER_003", message: "Provider not configured", httpStatus: 400, category: "PROVIDER" },
// ── Validation ──
VALID_001: { code: "VALID_001", message: "Invalid request body", httpStatus: 400, category: "VALIDATION" },
VALID_002: { code: "VALID_002", message: "Missing required field", httpStatus: 400, category: "VALIDATION" },
VALID_003: { code: "VALID_003", message: "Input sanitization blocked", httpStatus: 400, category: "VALIDATION" },
// ── Internal ──
INTERNAL_001: { code: "INTERNAL_001", message: "Internal server error", httpStatus: 500, category: "INTERNAL" },
INTERNAL_002: { code: "INTERNAL_002", message: "Database error", httpStatus: 500, category: "INTERNAL" },
INTERNAL_003: { code: "INTERNAL_003", message: "Circuit breaker open", httpStatus: 503, category: "INTERNAL" },
};
/**
* Create a standardized error response.
*
* @param {string} code - Error code from ERROR_CODES
* @param {Object} [details] - Additional error details
* @param {string} [details.detail] - Extra detail message
* @param {string} [details.requestId] - Correlation request ID
* @param {number} [details.retryAfter] - Retry-After seconds
* @returns {{ error: { code: string, message: string, category: string, detail?: string, requestId?: string }, status: number, retryAfter?: number }}
*/
export function createErrorResponse(code, details = {}) {
const def = ERROR_CODES[code];
if (!def) {
return {
error: {
code: "INTERNAL_001",
message: `Unknown error code: ${code}`,
category: "INTERNAL",
},
status: 500,
};
}
const response = {
error: {
code: def.code,
message: def.message,
category: def.category,
...(details.detail ? { detail: details.detail } : {}),
...(details.requestId ? { requestId: details.requestId } : {}),
},
status: def.httpStatus,
};
if (details.retryAfter) {
response.retryAfter = details.retryAfter;
}
return response;
}
/**
* Get all error codes for a category.
*
* @param {string} category
* @returns {ErrorCodeDef[]}
*/
export function getErrorsByCategory(category) {
return Object.values(ERROR_CODES).filter((e) => e.category === category);
}

View File

@@ -1,3 +1,4 @@
// @ts-check
/**
* Circuit Breaker — FASE-04 Observability & Resilience
*

View File

@@ -0,0 +1,81 @@
/**
* Fetch Timeout — T-25
*
* Wraps fetch() with an AbortController-based timeout.
* Default timeout is 120 seconds (FETCH_TIMEOUT_MS env var).
*
* @module shared/utils/fetchTimeout
*/
// @ts-check
const DEFAULT_TIMEOUT_MS = 120000; // 2 minutes
const FETCH_TIMEOUT_MS = parseInt(process.env.FETCH_TIMEOUT_MS || "", 10) || DEFAULT_TIMEOUT_MS;
/**
* Fetch with automatic timeout via AbortController.
*
* @param {string | URL} url - URL to fetch
* @param {RequestInit & { timeoutMs?: number }} [options] - Fetch options + optional timeoutMs
* @returns {Promise<Response>}
* @throws {Error} With name "AbortError" on timeout
*/
export async function fetchWithTimeout(url, options = {}) {
const { timeoutMs = FETCH_TIMEOUT_MS, signal: externalSignal, ...fetchOptions } = options;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
// If an external signal was provided, wire it to abort our controller too
if (externalSignal) {
if (externalSignal.aborted) {
controller.abort();
} else {
externalSignal.addEventListener("abort", () => controller.abort(), { once: true });
}
}
try {
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal,
});
return response;
} catch (error) {
if (error.name === "AbortError") {
throw new FetchTimeoutError(
`Request to ${url} timed out after ${timeoutMs}ms`,
timeoutMs,
String(url)
);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Error thrown on fetch timeout.
*/
export class FetchTimeoutError extends Error {
/**
* @param {string} message
* @param {number} timeoutMs
* @param {string} url
*/
constructor(message, timeoutMs, url) {
super(message);
this.name = "FetchTimeoutError";
this.timeoutMs = timeoutMs;
this.url = url;
}
}
/**
* Get the configured timeout value.
* @returns {number} Timeout in milliseconds
*/
export function getConfiguredTimeout() {
return FETCH_TIMEOUT_MS;
}

View File

@@ -1,3 +1,4 @@
// @ts-check
/**
* Input Sanitizer — FASE-01 Security Hardening
*

View File

@@ -0,0 +1,91 @@
/**
* Request ID — Correlation ID Middleware (T-23)
*
* Generates and propagates `x-request-id` headers for
* request tracing across the proxy pipeline.
*
* Uses AsyncLocalStorage to make request ID available
* anywhere in the call stack without explicit passing.
*
* @module shared/utils/requestId
*/
// @ts-check
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
const requestIdStore = new AsyncLocalStorage();
/**
* Get the current request ID from the async context.
* Returns null if not inside a request context.
*
* @returns {string | null}
*/
export function getRequestId() {
return requestIdStore.getStore() || null;
}
/**
* Run a handler with a request ID in the async context.
* If the incoming request has an `x-request-id` header, it is reused;
* otherwise a new UUID v4 is generated.
*
* @template T
* @param {Request} request - Incoming request
* @param {() => T | Promise<T>} handler - Handler to execute
* @returns {Promise<T>}
*/
export async function withRequestId(request, handler) {
const existingId = request?.headers?.get?.("x-request-id");
const requestId = existingId || randomUUID();
return requestIdStore.run(requestId, handler);
}
/**
* Create headers object with the current request ID.
* Useful for outgoing provider requests.
*
* @param {Record<string, string>} [headers={}] - Existing headers
* @returns {Record<string, string>} Headers with x-request-id added
*/
export function addRequestIdHeader(headers = {}) {
const requestId = getRequestId();
if (requestId) {
return { ...headers, "x-request-id": requestId };
}
return headers;
}
/**
* Next.js middleware-compatible wrapper.
* Attaches `x-request-id` to response headers.
*
* @param {Request} request
* @param {Response} response
* @returns {Response} Response with request ID header
*/
export function attachRequestIdToResponse(request, response) {
const requestId =
getRequestId() ||
request?.headers?.get?.("x-request-id") ||
randomUUID();
const headers = new Headers(response.headers);
headers.set("x-request-id", requestId);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
/**
* Generate a new request ID (UUID v4).
* @returns {string}
*/
export function generateRequestId() {
return randomUUID();
}

View File

@@ -0,0 +1,288 @@
/**
* Batch A — Domain Layer + Infrastructure Tests
*
* Tests for: modelAvailability, costRules, fallbackPolicy,
* errorCodes, requestId, fetchTimeout
*/
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
// ──────────────── T-19: Model Availability ────────────────
import {
isModelAvailable,
setModelUnavailable,
clearModelUnavailability,
getAvailabilityReport,
getUnavailableCount,
resetAllAvailability,
} from "../../src/domain/modelAvailability.js";
describe("modelAvailability", () => {
before(() => resetAllAvailability());
after(() => resetAllAvailability());
it("should report model as available by default", () => {
assert.equal(isModelAvailable("openai", "gpt-4o"), true);
});
it("should mark model as unavailable", () => {
setModelUnavailable("openai", "gpt-4o", 60000, "rate limited");
assert.equal(isModelAvailable("openai", "gpt-4o"), false);
});
it("should report unavailable models", () => {
const report = getAvailabilityReport();
assert.equal(report.length, 1);
assert.equal(report[0].provider, "openai");
assert.equal(report[0].model, "gpt-4o");
assert.equal(report[0].reason, "rate limited");
assert.ok(report[0].remainingMs > 0);
});
it("should count unavailable models", () => {
assert.equal(getUnavailableCount(), 1);
});
it("should clear model unavailability", () => {
clearModelUnavailability("openai", "gpt-4o");
assert.equal(isModelAvailable("openai", "gpt-4o"), true);
assert.equal(getUnavailableCount(), 0);
});
it("should auto-expire after cooldown", () => {
setModelUnavailable("anthropic", "claude-sonnet-4-20250514", 1, "test");
// Wait 2ms for expiry
const start = Date.now();
while (Date.now() - start < 5) {} // spin wait
assert.equal(isModelAvailable("anthropic", "claude-sonnet-4-20250514"), true);
});
});
// ──────────────── T-19: Cost Rules ────────────────
import {
setBudget,
getBudget,
recordCost,
checkBudget,
getDailyTotal,
getCostSummary,
resetCostData,
} from "../../src/domain/costRules.js";
describe("costRules", () => {
before(() => resetCostData());
after(() => resetCostData());
it("should allow when no budget set", () => {
const result = checkBudget("key-1");
assert.equal(result.allowed, true);
});
it("should set and get budget", () => {
setBudget("key-1", { dailyLimitUsd: 10.0, warningThreshold: 0.8 });
const budget = getBudget("key-1");
assert.equal(budget.dailyLimitUsd, 10.0);
assert.equal(budget.warningThreshold, 0.8);
});
it("should record costs and check budget", () => {
recordCost("key-1", 5.0);
const result = checkBudget("key-1");
assert.equal(result.allowed, true);
assert.equal(result.dailyUsed, 5.0);
});
it("should detect warning threshold", () => {
recordCost("key-1", 4.0); // total = 9.0 / 10.0 = 90% > 80%
const result = checkBudget("key-1");
assert.equal(result.allowed, true);
assert.equal(result.warningReached, true);
});
it("should block when budget exceeded", () => {
const result = checkBudget("key-1", 2.0); // 9.0 + 2.0 = 11.0 > 10.0
assert.equal(result.allowed, false);
assert.ok(result.reason.includes("exceeded"));
});
it("should get cost summary", () => {
const summary = getCostSummary("key-1");
assert.ok(summary.dailyTotal >= 9.0);
assert.equal(summary.totalEntries, 2);
});
});
// ──────────────── T-19: Fallback Policy ────────────────
import {
registerFallback,
resolveFallbackChain,
getNextFallback,
hasFallback,
removeFallback,
getAllFallbackChains,
resetAllFallbacks,
} from "../../src/domain/fallbackPolicy.js";
describe("fallbackPolicy", () => {
before(() => resetAllFallbacks());
after(() => resetAllFallbacks());
it("should return empty chain for unknown model", () => {
assert.deepEqual(resolveFallbackChain("unknown"), []);
assert.equal(hasFallback("unknown"), false);
});
it("should register fallback chain sorted by priority", () => {
registerFallback("gpt-4o", [
{ provider: "azure", priority: 2 },
{ provider: "openai", priority: 0 },
{ provider: "github", priority: 1 },
]);
const chain = resolveFallbackChain("gpt-4o");
assert.equal(chain[0].provider, "openai");
assert.equal(chain[1].provider, "github");
assert.equal(chain[2].provider, "azure");
});
it("should exclude specified providers", () => {
const chain = resolveFallbackChain("gpt-4o", ["openai"]);
assert.equal(chain.length, 2);
assert.equal(chain[0].provider, "github");
});
it("should get next fallback", () => {
assert.equal(getNextFallback("gpt-4o"), "openai");
assert.equal(getNextFallback("gpt-4o", ["openai"]), "github");
assert.equal(getNextFallback("gpt-4o", ["openai", "github", "azure"]), null);
});
it("should respect enabled flag", () => {
registerFallback("test-model", [
{ provider: "a", enabled: false },
{ provider: "b", enabled: true },
]);
const chain = resolveFallbackChain("test-model");
assert.equal(chain.length, 1);
assert.equal(chain[0].provider, "b");
});
it("should remove fallback chain", () => {
removeFallback("test-model");
assert.equal(hasFallback("test-model"), false);
});
it("should list all chains", () => {
const all = getAllFallbackChains();
assert.ok("gpt-4o" in all);
});
});
// ──────────────── T-22: Error Codes ────────────────
import {
ERROR_CODES,
createErrorResponse,
getErrorsByCategory,
} from "../../src/shared/constants/errorCodes.js";
describe("errorCodes", () => {
it("should have at least 20 error codes", () => {
assert.ok(Object.keys(ERROR_CODES).length >= 20);
});
it("should create error response for known code", () => {
const res = createErrorResponse("AUTH_001", { detail: "missing token" });
assert.equal(res.error.code, "AUTH_001");
assert.equal(res.error.message, "Authentication required");
assert.equal(res.status, 401);
assert.equal(res.error.detail, "missing token");
});
it("should create error response with requestId", () => {
const res = createErrorResponse("PROXY_002", { requestId: "abc-123" });
assert.equal(res.error.requestId, "abc-123");
assert.equal(res.status, 504);
});
it("should handle unknown code gracefully", () => {
const res = createErrorResponse("UNKNOWN_999");
assert.equal(res.status, 500);
assert.ok(res.error.message.includes("Unknown"));
});
it("should filter by category", () => {
const authErrors = getErrorsByCategory("AUTH");
assert.ok(authErrors.length >= 4);
assert.ok(authErrors.every((e) => e.category === "AUTH"));
});
});
// ──────────────── T-23: Request ID ────────────────
import {
getRequestId,
withRequestId,
addRequestIdHeader,
generateRequestId,
} from "../../src/shared/utils/requestId.js";
describe("requestId", () => {
it("should return null outside context", () => {
assert.equal(getRequestId(), null);
});
it("should generate UUID format", () => {
const id = generateRequestId();
assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
});
it("should propagate request ID through context", async () => {
const mockRequest = { headers: { get: (h) => (h === "x-request-id" ? "test-id-123" : null) } };
let captured = null;
await withRequestId(mockRequest, () => {
captured = getRequestId();
});
assert.equal(captured, "test-id-123");
});
it("should generate new ID when none provided", async () => {
const mockRequest = { headers: { get: () => null } };
let captured = null;
await withRequestId(mockRequest, () => {
captured = getRequestId();
});
assert.ok(captured);
assert.match(captured, /^[0-9a-f-]{36}$/);
});
it("should add request ID to headers", async () => {
const mockRequest = { headers: { get: (h) => (h === "x-request-id" ? "header-id" : null) } };
await withRequestId(mockRequest, () => {
const headers = addRequestIdHeader({ "content-type": "application/json" });
assert.equal(headers["x-request-id"], "header-id");
assert.equal(headers["content-type"], "application/json");
});
});
});
// ──────────────── T-25: Fetch Timeout ────────────────
import { getConfiguredTimeout, FetchTimeoutError } from "../../src/shared/utils/fetchTimeout.js";
describe("fetchTimeout", () => {
it("should have default timeout of 120000ms", () => {
assert.equal(getConfiguredTimeout(), 120000);
});
it("should export FetchTimeoutError", () => {
const err = new FetchTimeoutError("test", 5000, "http://test.com");
assert.equal(err.name, "FetchTimeoutError");
assert.equal(err.timeoutMs, 5000);
assert.equal(err.url, "http://test.com");
assert.ok(err instanceof Error);
});
});