Compare commits

...

76 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza a1137c1fa1 Merge pull request #48 from diegosouzapw/style/add-website-url
v0.8.0 — Website launch, SECURITY.md audit, docs improvements
2026-02-16 14:33:46 -03:00
diegosouzapw e962e4f558 chore: bump version to 0.8.0
- Website launch (omniroute.online)
- Comprehensive SECURITY.md
- Documentation improvements
- Updated provider count to 36+
2026-02-16 14:33:13 -03:00
diegosouzapw 6d3b4166df docs: remove all Architecture Decision Record (ADR) documents. 2026-02-16 14:31:35 -03:00
diegosouzapw 45eced0b3e docs: comprehensive SECURITY.md based on full codebase audit
Documented all security features actually implemented:
- AES-256-GCM encryption at rest (API keys, tokens)
- Prompt injection guard (5 pattern types)
- PII redaction (email, CPF, CNPJ, CC, phone, SSN)
- Secrets validator (fail-fast on weak values)
- Circuit breaker (3-state, SQLite-persisted)
- OAuth 2.0 + PKCE, CORS, IP filtering
- Rate limiting, anti-thundering herd
- Compliance (log retention, audit log, no-log)
2026-02-16 14:29:50 -03:00
diegosouzapw 1b00a6b70b docs: update SECURITY.md and add to README
- Bump supported versions: 0.7.x (active), 0.6.x (security)
- Replace fake email with GitHub Security Advisories link
- Add Security Policy to README documentation table
2026-02-16 14:27:17 -03:00
diegosouzapw 03d05e0490 docs: track USER_GUIDE, API_REFERENCE, and TROUBLESHOOTING in git
These files were referenced in README but ignored by .gitignore.
Added them to the exception list so they appear in the repository.
2026-02-16 14:25:52 -03:00
diegosouzapw 4cac582cb0 docs: add omniroute.online website URL to README, npm, and Docker
- Add Website badge to README header
- Add website link to nav, tech stack, support, and footer
- Update provider count from 28 to 36+
- Set package.json homepage to https://omniroute.online
- Add OCI image URL label to Dockerfile
2026-02-16 14:22:29 -03:00
Diego Rodrigues de Sa e Souza eb0fbccd05 Merge pull request #47 from diegosouzapw/fix/vm-onboarding-auth
fix: auto-skip onboarding and enforce login on VM deployments
2026-02-16 11:25:06 -03:00
diegosouzapw 32c0f40021 fix: auto-skip onboarding and enforce login on VM deployments
- getSettings() auto-sets setupComplete=true when INITIAL_PASSWORD env is set
- proxy.js middleware enforces login when INITIAL_PASSWORD exists (even without DB hash)
- Settings API reports hasPassword=true when INITIAL_PASSWORD is configured
- Login page hides '123456' default hint when a custom password is set

Fixes fresh Docker/VM deployments getting stuck on onboarding wizard
and being accessible without authentication.
2026-02-16 11:24:15 -03:00
Diego Rodrigues de Sa e Souza baae44f825 Merge pull request #46 from diegosouzapw/feature/release-0.7.0
chore: release v0.7.0 — Docker Hub, changelog overhaul
2026-02-16 09:49:07 -03:00
diegosouzapw 5fe53168b2 chore: bump version to 0.7.0, reorganize changelog, add Docker Hub CI/CD and README
- Bump version from 0.6.0 to 0.7.0 in package.json
- Rewrite CHANGELOG.md with comprehensive entries from v0.1.0 to v0.7.0
- Add docker-publish.yml GitHub Actions workflow for auto Docker Hub push
- Add Docker Hub badge, Docker section, and Docker Hub link to README
- Update release command example to v0.7.0
2026-02-16 09:48:35 -03:00
diegosouzapw ebdc1c214d refactor: extract CliStatusBadge component and refactor CLI tool cards
- Create shared CliStatusBadge component with support for configured,
  not_configured, not_installed, other, and unknown statuses
- Replace inline status badge markup in ClaudeToolCard and ClineToolCard
  with the new reusable component
- Add colored status dot indicator alongside badge text
- Support batch status fallback so badges render even when cards are collapsed
- Refactor model/provider selection logic in tool card configuration
2026-02-16 01:35:57 -03:00
Diego Rodrigues de Sa e Souza 467e998650 Merge pull request #45 from diegosouzapw/fix/cloud-connection-ux
fix(cloud): improve cloud connection UX
2026-02-16 01:03:23 -03:00
diegosouzapw 9a9f592f54 fix(cloud): improve cloud connection UX with GET status, toast feedback, and sidebar indicator
- Add GET handler to /api/sync/cloud for real-time status polling
- Rewrite CloudSyncStatus: clickable, event-driven updates, clearer labels
- Add inline status toast with auto-dismiss after enable/disable
- Show success animation in modal before auto-closing
- Dispatch cloud-status-changed event for instant sidebar updates
2026-02-16 01:02:52 -03:00
Diego Rodrigues de Sa e Souza c3fe96e221 Merge pull request #44 from diegosouzapw/feature/release-0.6.0
chore: release v0.6.0
2026-02-16 00:23:19 -03:00
diegosouzapw 1c6541f25d chore: bump version to 0.6.0 and update changelog 2026-02-16 00:22:55 -03:00
diegosouzapw 9f8dfa1398 feat: add costs page and enhance health provider status display
Add dedicated Costs page combining Budget and Pricing tabs with a
sidebar navigation layout. Improve Health page provider section with
status legend (Healthy/Recovering/Down) and import AI_PROVIDERS
constants for richer provider display.
2026-02-15 23:01:27 -03:00
diegosouzapw e4db9bff0e feat: add provider metrics display and model import for passthrough providers
- Fetch and display request metrics (success rate, latency, total requests) on provider overview cards
- Create /api/provider-metrics endpoint to aggregate stats from request logs
- Add /api/providers/[id]/import-models endpoint to import models from provider's /models API
- Add "Import from /models" button on passthrough provider detail pages
- Include proper PropTypes for new metrics and alias fields
2026-02-15 21:53:27 -03:00
diegosouzapw 54aba4c087 feat: redesign app icons with network node graph and new color scheme
Replace text-based "9" favicon with a network/router node graph design
across all icon sizes. Add apple-touch-icon and icon-192 SVG assets.
Update brand gradient from orange (#f97815) to red (#E54D5E).
2026-02-15 20:56:48 -03:00
Diego Rodrigues de Sa e Souza c2542b75f6 Merge pull request #43 from diegosouzapw/feature/release-0.5.0
chore: release v0.5.0
2026-02-15 19:03:37 -03:00
diegosouzapw 6572f2a1b6 chore: bump version to 0.5.0 and update changelog 2026-02-15 19:03:18 -03:00
Diego Rodrigues de Sa e Souza 2ad23f3b3c Merge pull request #42 from diegosouzapw/feature/ajustes-cosmeticos
feat: v0.5.0 - Dashboard refinements, evals framework, and combo strategies
2026-02-15 18:57:29 -03:00
diegosouzapw fa9687ae0d feat: Implement API key usage for EvalsTab LLM calls, enhance model filtering with provider aliases, and remove the random routing strategy option. 2026-02-15 18:56:33 -03:00
diegosouzapw 0ed7738c7c feat: add LLM evaluation framework with golden set testing
Introduce built-in eval system for testing LLM response quality against
golden sets. Includes eval runner with 4 match strategies (exact,
contains, regex, custom), a pre-loaded 10-case golden set, REST API
endpoints for managing and running suites, and a dashboard UI under
Analytics → Evals with real-time progress tracking and pass rate
summaries. Also adds OpenAPI tags for Audio, Moderations, and Rerank.
2026-02-15 18:37:18 -03:00
diegosouzapw a43b1c9218 feat: add random, least-used, and cost-optimized combo strategies
Extend combo routing with 3 new model selection strategies beyond the
existing priority, weighted, and round-robin options:
- random: Fisher-Yates shuffle for uniform distribution
- least-used: sorts models by request count via comboMetrics
- cost-optimized: sorts models by pricing (cheapest first)

Also enhance the dashboard provider cards with model counts and
clickable detail views for selected providers.
2026-02-15 16:57:00 -03:00
diegosouzapw 413a9f2a69 feat: add ModelAvailabilityBadge component and retheme landing page
- Add compact ModelAvailabilityBadge with popover for model status
  monitoring, cooldown clearing, and auto-refresh polling
- Retheme landing page from warm orange tones to cool blue-grey palette
  across FlowAnimation, Footer, and related components
- Update accent color from orange (#f97815) to rose (#E54D5E)
2026-02-15 16:26:01 -03:00
diegosouzapw 14714aa9f5 feat: refactor dashboard with shared UI component library
Introduce reusable shared components (Button, Card, Modal, Table,
StatusBadge, EmptyState) and refactor dashboard pages to use them.
Extract client components from server pages, centralize provider
constants, and update CI Node.js matrix from 18 to 20.
2026-02-15 16:11:56 -03:00
Diego Rodrigues de Sa e Souza ac4df197c3 Merge pull request #41 from diegosouzapw/fix/chat-completions-then-error
fix(api): resolve TypeError in chat/completions route
2026-02-15 13:25:28 -03:00
diegosouzapw 06826d4c86 fix(api): resolve TypeError in chat/completions ensureInitialized
initTranslators() is a no-op stub (translators self-register via static
imports). Wrapping the call with Promise.resolve() ensures .then() works
even when the function returns undefined instead of a Promise.
2026-02-15 13:24:54 -03:00
Diego Rodrigues de Sa e Souza 78b0782129 Merge pull request #40 from diegosouzapw/fix/ci-lint-build-errors
fix(ci): resolve build and lint failures
2026-02-15 12:56:44 -03:00
diegosouzapw 5c92f05079 fix(ci): resolve build and lint failures
- Remove non-existent DarkTooltip/CostTooltip exports from analytics/index.js
- Fix setState-in-effect in ProxyTab.js (inline fetch in useEffect)
- Fix anonymous default export warning in prettier.config.mjs
- Remove @ts-check from promptInjectionGuard.js (SanitizeResult type mismatch)
2026-02-15 12:56:09 -03:00
Diego Rodrigues de Sa e Souza 16cdb345a0 Merge pull request #39 from diegosouzapw/feature/v0.4.0-action-items
feat: implement 26 action items + bump v0.4.0
2026-02-15 12:34:45 -03:00
diegosouzapw 0e238a61fb feat(core): implement 26 action items from critical analysis + bump v0.4.0
- Security: AES-256-GCM encryption for API keys/tokens, CI security audit
- Accessibility: ARIA labels, aria-live regions, skip-to-content, contrast utility
- Components: Tooltip, CloudSyncStatus, SystemMonitor, StreamTracker
- Utils: costEstimator, promptInjectionGuard middleware, Zod validation schemas
- Docs: openapi.yaml +9 routes, API_REFERENCE internal APIs, version bumps
- Quality: coverage thresholds 60/50/50, error handling improvements
2026-02-15 12:33:56 -03:00
Diego Rodrigues de Sa e Souza 6964904bac Merge pull request #38 from diegosouzapw/feature/phase10-docs-ux-cleanup
feat(phase10): docs restructuring, component decomposition, and cleanup
2026-02-15 11:54:27 -03:00
diegosouzapw 597c590a1d feat(phase10): docs restructuring, component decomposition, and cleanup
- Split README (1326→211 lines): USER_GUIDE, API_REFERENCE, TROUBLESHOOTING
- Expand CONTRIBUTING.md (112→273 lines)
- Extract RequestLoggerDetail from RequestLoggerV2 (910→665 lines)
- Extract ProxyLogDetail from ProxyLogger (677→519 lines)
- Add accessibility attrs (aria-label, role=dialog) to extracted modals
- Migrate not-found.js inline styles to TailwindCSS gradient classes
- Remove continue-on-error from E2E CI step
- Add PolicyEngine class with glob-based policy matching
- Create a11yAudit.js (WCAG rule checker)
- Fix policyEngine test import path
2026-02-15 11:53:40 -03:00
Diego Rodrigues de Sa e Souza a94b6a8d8c Merge pull request #37 from diegosouzapw/feat/phase-9-llm-intelligence
feat(gateway): Phase 9 — LLM Gateway Intelligence
2026-02-15 11:05:37 -03:00
diegosouzapw b08fb31a28 feat(gateway): Phase 9 — LLM Gateway Intelligence
9.1 — Semantic Cache
  - New: src/lib/semanticCache.js — Two-tier cache (in-memory LRU + SQLite)
  - Signature = SHA-256(model + normalized messages + temperature + top_p)
  - Only caches non-streaming, temperature=0 requests
  - X-OmniRoute-No-Cache: true header bypass
  - Response headers: X-OmniRoute-Cache: HIT/MISS
  - DB table: semantic_cache with indexes on signature and model
  - New: src/app/api/cache/route.js — GET stats, DELETE clear

9.2 — Request Idempotency
  - New: src/lib/idempotencyLayer.js — In-memory 5s dedup window
  - Reads Idempotency-Key or X-Request-Id headers
  - Returns cached response with X-OmniRoute-Idempotent: true header
  - Ephemeral by design (no SQLite)

9.3 — Progress Tracking in Streaming
  - New: open-sse/utils/progressTracker.js
  - Emits SSE 'event: progress' with tokens_generated + elapsed_ms
  - Opt-in via X-OmniRoute-Progress: true header
  - Supports AbortSignal cancellation
  - Final event includes done: true

Integration:
  - chatCore.js: idempotency check → cache check → provider call → cache store → idempotency save
  - Streaming path: optional progress transform chain
  - DB: semantic_cache table added to db/core.js schema

Tests: 320 pass (+25 new) | Build: success
2026-02-15 11:04:51 -03:00
Diego Rodrigues de Sa e Souza 5bfd621438 Merge pull request #36 from diegosouzapw/feat/phase-8-missing-flows
feat(pages): Phase 8 — Missing Flows & Pages
2026-02-15 10:48:54 -03:00
diegosouzapw 034f1a7e1d feat(pages): Phase 8 — Missing Flows & Pages
8.1 — 403 Forbidden Page
  - New: src/app/forbidden/page.js
  - Gradient code + Access Denied message + Dashboard link
  - Consistent design with not-found.js

8.2 — Password Recovery Flow
  - New: src/app/forgot-password/page.js
  - Two methods: CLI reset + manual database reset
  - Added 'Forgot password?' link to login page

8.3 — Health/Status Dashboard
  - New: src/app/(dashboard)/dashboard/health/page.js
  - New: src/app/api/monitoring/health/route.js
  - Cards: Uptime, Version, Memory, Provider count
  - Provider health (circuit breaker states with color coding)
  - Rate limit status table
  - Active lockouts list
  - Auto-refresh every 15s
  - Added Health nav link to Sidebar

8.4 — Maintenance Banner
  - New: src/shared/components/MaintenanceBanner.js
  - Auto-detects server health issues every 10s
  - Shows/hides automatically, dismissible
  - Wired into DashboardLayout

8.5 — Empty States
  - Verified EmptyState component applied in 6+ pages
  - Providers page has its own empty handling

Bonus:
  - Fixed stale GitHub link in Sidebar (decolua → diegosouzapw)

Tests: 295 pass | Build: success
2026-02-15 10:46:11 -03:00
Diego Rodrigues de Sa e Souza bc95095f82 Merge pull request #35 from diegosouzapw/refactor/phase-7-api-code-quality
feat(api): Phase 7 — API & Code Quality
2026-02-15 10:16:34 -03:00
diegosouzapw 7cf8ae8db6 feat(api): Phase 7 — API & Code Quality
7.1 — Consolidated rate-limit routes
  - Merged rate-limit/ and rate-limits/ into rate-limits/route.js
  - GET returns connections + overview + lockouts + cacheStats (unified)
  - POST handles toggle protection
  - Old rate-limit/ now redirects 308 → rate-limits/
  - Updated frontend fetch URL in providers/[id]/page.js

7.2 — Zod schema for provider constants
  - New: src/shared/validation/providerSchema.js
  - Validates FREE_PROVIDERS, OAUTH_PROVIDERS, APIKEY_PROVIDERS at module load
  - Catches config drift (invalid colors, missing fields) at startup

7.3 — TailwindCSS error pages
  - Converted not-found.js inline styles → Tailwind classes
  - Converted global-error.js inline styles → Tailwind classes
  - Replaced JS hover handlers with Tailwind hover: utilities

7.4 — Fixed GitHub link in privacy page
  - decolua/omniroute → diegosouzapw/OmniRoute

7.5 — Already completed in Phase 5

Tests: 295 pass | Build: success
2026-02-15 10:15:44 -03:00
Diego Rodrigues de Sa e Souza 05e93fa9b0 Merge pull request #34 from diegosouzapw/fix/phase-6-6-sse-dedup-analysis
fix(sse): resolve ghost import in chatHelpers.js (Phase 6.6)
2026-02-15 08:17:00 -03:00
diegosouzapw 1acaf66dd0 fix(sse): resolve ghost import in chatHelpers.js
chatHelpers.js imported detectFormat/getTargetFormat/getModelTargetFormat
from '../services/translator.js' which does not exist.

Fixed to import from canonical sources:
- detectFormat, getTargetFormat → @omniroute/open-sse/services/provider.js
- getModelTargetFormat, PROVIDER_ID_TO_ALIAS → @omniroute/open-sse/config/providerModels.js

Phase 6.6 analysis: src/sse/ is an intentional adapter layer over
open-sse/ (not duplication). No further deduplication needed.
2026-02-15 08:16:26 -03:00
Diego Rodrigues de Sa e Souza 1cee3ec8ff Merge pull request #33 from diegosouzapw/refactor/phase-5-6-architecture
refactor(phase5-6): domain persistence, policy engine, OAuth extraction
2026-02-15 08:12:56 -03:00
diegosouzapw 33c28f73db refactor(phase5-6): domain persistence, policy engine, OAuth extraction, proxy decoupling
Phase 5 — Foundation & Security:
- SQLite domain state persistence (5 tables, 4 modules: fallback, budget, lockout, circuit breaker)
- Write-through cache pattern for state survival across restarts
- Race condition fix in route.js (Promise-based singleton)
- Default password hardening (.env.example)
- Server init error handling improvement

Phase 6 — Architecture Refactoring:
- OAuth providers extracted into 12 individual modules (providers.js 1051→144 lines)
- Policy Engine (lockout→budget→fallback) with evaluateRequest/evaluateFirstAllowed
- Deterministic round-robin via persistent counter Map
- Telemetry window fix with proper recordedAt timestamps
- Proxy decoupled from API settings (direct import vs HTTP self-fetch)

Tests: 295 pass (22 new: domain-persistence 16, policy-engine 6)
Docs: CHANGELOG, README, ARCHITECTURE.md updated
2026-02-15 08:12:12 -03:00
diegosouzapw 25f4f5987c Merge feature/release-0.3.0: docs and changelog for v0.3.0 release 2026-02-15 02:06:36 -03:00
diegosouzapw 4269c25a9f docs: update CHANGELOG, README, and ARCHITECTURE for v0.3.0 release
- CHANGELOG.md: comprehensive v0.3.0 entry with security hardening, domain layer,
  pipeline wiring, 9 API routes, frontend 100% coverage, rate limit overhaul
  (4 phases), ADRs, compliance, eval framework, 273+ tests
- README.md: add 10 new feature rows (circuit breaker, anti-thundering herd,
  resilience profiles/UI, cost budgets, telemetry, correlation IDs, compliance,
  model availability, eval framework), update tech stack and release example
- ARCHITECTURE.md: add domain layer modules, resilience modules, 11 new API
  routes, usageDb decomposition note, update date
2026-02-15 02:06:14 -03:00
diegosouzapw 87b36dc197 Merge feature/rate-limit-overhaul: rate limit overhaul (4 phases) 2026-02-15 01:47:14 -03:00
diegosouzapw 6e29bd1197 feat(resilience): rate limit overhaul — exponential backoff, circuit breaker, anti-thundering herd, Resilience UI
Phase 1: Error classification + provider profiles (constants, providerRegistry, accountFallback)
Phase 2: Circuit breaker integration in combo pipeline (combo.js)
Phase 3: Anti-thundering herd mutex + auto rate limits for API key providers (auth.js, rateLimitManager)
Phase 4: Frontend Resilience tab with Circuit Breaker, Provider Profiles, Rate Limit cards

Tests: 63/63 passing (error-classification, combo-circuit-breaker, thundering-herd, rate-limit-enhanced)
2026-02-15 01:42:50 -03:00
diegosouzapw 5db6676319 feat: detect and handle unrecoverable refresh token errors by marking connections as expired and requiring re-authentication. 2026-02-14 22:04:00 -03:00
Diego Rodrigues de Sa e Souza d08b3889d3 Merge pull request #32 from diegosouzapw/feature/frontend-100-coverage
feat(frontend): 100% backend API coverage — 7 batches
2026-02-14 21:05:32 -03:00
diegosouzapw 443e5b7b62 feat(frontend): 100% backend API coverage — 7 batches
Batch A: Fix Breadcrumbs (usePathname), rewrite ComplianceTab (DataTable+FilterBar+ColumnToggle), delete dead code (a11yAudit.js, policyEngine.js), wire toast notifications (Combos, Providers)
Batch B: ModelAvailabilityPanel — cooldown badges, clear action, auto-refresh
Batch C: BudgetTab — spend cards, progress bars, budget limits form
Batch D: FallbackChainsEditor — color-coded provider chains, create/delete
Batch E: PoliciesPanel — circuit breaker states, locked identifiers, force unlock
Batch F: EvalsTab — expandable suites, run eval, DataTable results
Batch G: TokenHealthBadge + /api/token-health — OAuth health in header

8 new files, 9 modified, 2 deleted. Build passes (exit 0).
2026-02-14 21:04:51 -03:00
Diego Rodrigues de Sa e Souza b87e04db22 Merge pull request #31 from diegosouzapw/feature/cleanup-and-tests
chore: integration wiring tests + branch cleanup
2026-02-14 20:24:01 -03:00
diegosouzapw 4341254b5d chore: add integration wiring tests + branch cleanup
Batch 5 — Cleanup + Tests:
- Delete 4 stale local branches
- Prune 18 stale remote refs
- Add integration-wiring.test.mjs: 44 tests across 12 suites
  verifying all 5 batches of pipeline/API/UI integration

All 44/44 tests pass.
2026-02-14 20:23:46 -03:00
Diego Rodrigues de Sa e Souza 6718b23d57 Merge pull request #30 from diegosouzapw/feature/page-integration
feat(ui): wire budget/telemetry/compliance into dashboard pages
2026-02-14 20:21:28 -03:00
diegosouzapw e3fc9387be feat(ui): wire budget/telemetry/compliance into dashboard pages
Batch 4 — Page Integration:
- Usage page: add BudgetTelemetryCards (latency p50/p95/p99, cache, system health)
- Settings page: add ComplianceTab (audit log), CacheStatsCard (prompt cache + flush)
- Combos page: replace inline empty state with EmptyState component

3 new components, 3 page modifications. Build verified: exit code 0
2026-02-14 20:21:15 -03:00
Diego Rodrigues de Sa e Souza c094c9c678 Merge pull request #29 from diegosouzapw/feature/barrel-exports-layout
feat(ui): export 6 shared components + notificationStore, wire into layout
2026-02-14 20:15:48 -03:00
diegosouzapw 388f06c74f feat(ui): export 6 shared components + notificationStore, wire into layout
Batch 3 — Barrel Exports + Layout:
- shared/components/index.js: export Breadcrumbs, EmptyState, NotificationToast,
  FilterBar, ColumnToggle, DataTable
- store/index.js: export useNotificationStore
- DashboardLayout.js: render <Breadcrumbs/> between Header and content,
  render <NotificationToast/> as global fixed overlay

Build verified: exit code 0
2026-02-14 20:15:33 -03:00
Diego Rodrigues de Sa e Souza 91f6750d59 Merge pull request #28 from diegosouzapw/feature/api-routes
feat(api): create 9 API routes for backend module access
2026-02-14 20:11:42 -03:00
diegosouzapw 7e066df6cd feat(api): create 9 API routes for backend module access
Batch 2 — API Routes:
- /api/cache/stats — GET cache stats, DELETE flush
- /api/models/availability — GET availability report, POST clear cooldown
- /api/telemetry/summary — GET p50/p95/p99 latency metrics
- /api/usage/budget — GET cost summary, POST set budget per key
- /api/fallback/chains — GET/POST/DELETE fallback chain management
- /api/compliance/audit-log — GET filterable audit log
- /api/evals — GET list suites, POST run suite
- /api/evals/[suiteId] — GET suite details
- /api/policies — GET circuit breaker + lockout status, POST force-unlock

Build verified: exit code 0
2026-02-14 20:11:27 -03:00
Diego Rodrigues de Sa e Souza 33f8123835 Merge pull request #27 from diegosouzapw/feature/pipeline-wiring
feat(pipeline): wire 7 backend modules into request pipeline
2026-02-14 20:07:02 -03:00
diegosouzapw e87067f2fb feat(pipeline): wire 7 backend modules into request pipeline
Batch 1 — Pipeline Wiring:
- server-init.js: initialize compliance audit_log, run expired log cleanup, log server.start
- chat.js: wire circuitBreaker (provider resilience), modelAvailability (TTL cooldowns),
  requestTelemetry (7-phase lifecycle), requestId, costRules (budget check/record),
  compliance audit logging. All wiring is non-breaking with try/catch guards.
- proxy.js: replace bare fetch() with fetchWithTimeout (5s timeout on /api/settings),
  add X-Request-Id header for end-to-end tracing
- 307/307 tests pass, build succeeds
2026-02-14 20:06:44 -03:00
diegosouzapw a9a85fdc1b fix: add Record type annotation to getAllFallbackChains result 2026-02-14 19:43:19 -03:00
Diego Rodrigues de Sa e Souza 31c09f08e1 Merge pull request #26 from diegosouzapw/fix/eslint-v9-compatibility
fix: downgrade ESLint v10→v9 for eslint-config-next compatibility
2026-02-14 19:36:19 -03:00
diegosouzapw 3e89560a33 fix: downgrade ESLint v10→v9 for eslint-config-next compatibility
- ESLint 10 broke with scopeManager.addGlobals error (eslint-config-next plugins only support ≤v9)
- Rewrote eslint.config.mjs: removed defineConfig/globalIgnores (ESLint 10-only APIs)
- Now using ESLint 9 flat config format with plain array export
- Fixed TS lint warnings in compliance/index.js and a11yAudit.js
- Added omniroute-reset-password bin entry to package.json
- Lint passes cleanly (1 pre-existing React useState-in-effect warning)
- All 144 tests pass
2026-02-14 19:36:01 -03:00
Diego Rodrigues de Sa e Souza 2dbb717377 Merge pull request #25 from diegosouzapw/feature/batch-b-final-tasks
feat: complete all 46 tasks — Batch B final (T-30, T-33, T-35, T-38, T-39, T-42, T-43)
2026-02-14 19:18:27 -03:00
diegosouzapw f44ec7e1f2 feat: complete all 46 tasks — ADRs, eval framework, compliance, a11y, CLI, Playwright specs (Batch B)
T-30 — ADRs:
- 6 ADRs: SQLite, Fallback Strategy, OAuth, JS+JSDoc, Single-Tenant, Translator Registry

T-33 — JSDoc Coverage:
- Full JSDoc on all new modules (100% exported functions documented)

T-35 — Accessibility:
- a11yAudit.js: lightweight WCAG AA checker (aria-label, dialog role, alt text, labels)

T-38 — Password Reset CLI:
- bin/reset-password.mjs: interactive CLI tool for admin password reset

T-39 — Playwright Specs:
- tests/e2e/responsiveSpecs.mjs: viewports (375/768/1280), 4 pages, test matrix

T-42 — Eval Framework:
- evalRunner.js: 4 strategies (exact, contains, regex, custom) + golden set (10 cases)

T-43 — Compliance:
- audit_log table, noLog opt-out per API key, LOG_RETENTION_DAYS cleanup

TASKS.md: 46/46 Concluído 
Tests: 144/144 pass (119 existing + 25 new)
2026-02-14 19:18:02 -03:00
Diego Rodrigues de Sa e Souza ad003b6226 Merge pull request #24 from diegosouzapw/feature/batch-a-domain-infra
feat: domain layer, error codes, request ID, fetch timeout (Batch A)
2026-02-14 19:04:15 -03:00
diegosouzapw c09978454b feat: domain layer, error codes, request ID, fetch timeout, JSDoc (T-19, T-22, T-23, T-25, T-27)
T-19 — Domain Layer:
- modelAvailability.js: Model availability tracking with TTL cooldowns
- costRules.js: Per-API-key budget management with daily/monthly limits
- fallbackPolicy.js: Declarative fallback chain routing

T-22 — Error Codes Catalog:
- errorCodes.js: 24 codes in 6 categories + createErrorResponse helper

T-23 — Correlation ID:
- requestId.js: AsyncLocalStorage-based x-request-id propagation

T-25 — Fetch Timeout:
- fetchTimeout.js: AbortController wrapper with FETCH_TIMEOUT_MS env var

T-27 — JSDoc + @ts-check:
- Added @ts-check to 8 critical files

TASKS.md updated: 37/46 tasks Concluído, 9 remaining

Tests: 119/119 pass (88 existing + 31 new)
2026-02-14 19:03:55 -03:00
Diego Rodrigues de Sa e Souza daffd6c9ca Merge pull request #23 from diegosouzapw/feature/decomposition-t15-t28-t29
refactor: decompose usageDb, chat handler, UI (T-15, T-28, T-29)
2026-02-14 18:53:34 -03:00
diegosouzapw 492afc4ff1 refactor: decompose usageDb, handleSingleModelChat, UI components (T-15, T-28, T-29)
T-15 — Decompose usageDb.js (969→40 lines):
- Extract src/lib/usage/migrations.js (legacy + JSON→SQLite migration)
- Extract src/lib/usage/usageHistory.js (tracking, pending, log.txt)
- Extract src/lib/usage/costCalculator.js (pure cost calculation)
- Extract src/lib/usage/usageStats.js (dashboard aggregation)
- Extract src/lib/usage/callLogs.js (structured logs, CRUD, rotation)
- usageDb.js is now a thin facade re-exporting all functions

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

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

Tests: 88/88 pass (no regressions)
2026-02-14 18:53:14 -03:00
Diego Rodrigues de Sa e Souza 967689d0a1 Merge pull request #22 from diegosouzapw/feature/remaining-tasks
feat(remaining): deferred tasks — error pages, UX, telemetry, domain
2026-02-14 18:43:30 -03:00
diegosouzapw f77dd89d53 feat(remaining): deferred tasks — error pages, UX components, telemetry, domain extraction
T-20 — .gitignore cleanup:
- Add .analysis/ and antigravity-manager-analysis/ to gitignore
- Whitelist FASE docs, PLANO-IMPLANTACAO.md, TASKS.md

T-21 — Error pages:
- Create not-found.js (404 page with gradient design)
- Create global-error.js (root error boundary with dev details)

T-36 — Breadcrumbs:
- Create Breadcrumbs.js with path-to-label mapping and ARIA semantics

T-37 — Empty states:
- Create EmptyState.js with bounce animation and optional CTA

T-45 — Request telemetry:
- Create requestTelemetry.js (7-phase lifecycle, p50/p95/p99 aggregation)

T-46 — Domain extraction:
- Create comboResolver.js (priority/round-robin/random/least-used strategies)
- Create lockoutPolicy.js (sliding window lockout with force-unlock)

Tests: 13/13 new tests pass (88/88 total)
2026-02-14 18:43:07 -03:00
Diego Rodrigues de Sa e Souza 0a5434cae5 Merge pull request #21 from diegosouzapw/feature/security-hardening
feat(security): FASE-01 to FASE-09 — Security Hardening & Advanced Features
2026-02-14 18:31:31 -03:00
diegosouzapw 2178e99da7 feat(advanced): FASE-07 to FASE-09 advanced features
FASE-07 — UX & Microinteractions:
- Create notificationStore.js (Zustand global toast store)
- Create NotificationToast.js (glassmorphism toast UI with ARIA)

FASE-08 — LLM Proxy Advanced:
- Create policyEngine.js (declarative routing/budget/access policies)
- Create cacheLayer.js (LRU cache with content hashing and TTL)

FASE-09 — E2E Flow Hardening:
- Create streamState.js (SSE stream state machine with TTFB tracking)

Tests: 23/23 advanced tests pass (75/75 total across all suites)
2026-02-14 18:28:55 -03:00
diegosouzapw 1cbbc33f20 feat(security): FASE-01 to FASE-06 security hardening
FASE-01 — Security Hardening:
- Remove hardcoded JWT_SECRET and API_KEY_SECRET fallbacks (fail-fast)
- Create secretsValidator.js with enforceSecrets() at startup
- Create inputSanitizer.js (prompt injection + PII detection)
- Integrate sanitizer in chat.js handler pipeline
- Add structured logging to silent catch blocks in proxy.js
- Remove .passthrough() from Zod updateSettingsSchema
- Remove insecure npm fs dependency
- Update .env.example with generation commands

FASE-02 — CI/CD & Tests:
- Create ci.yml workflow (lint, build, test, coverage, e2e)
- Fix test scripts (test now runs actual tests)
- Add test:unit, test:security, test:coverage (c8), test:all
- Add security rules to ESLint (no-eval, no-implied-eval, no-new-func)

FASE-03 — Architecture:
- Create settingsCache.js (eliminate self-fetch anti-pattern)
- Create domain/types.js and domain/responses.js

FASE-04 — Observability:
- Create correlationId.js (AsyncLocalStorage tracing)
- Create circuitBreaker.js (full state machine + registry)
- Create requestTimeout.js (per-provider timeouts)

FASE-05 — Code Quality:
- Create structuredLogger.js (JSON/human-readable logging)

FASE-06 — Documentation:
- Update SECURITY.md with hardening practices
- Create CONTRIBUTING.md with dev setup and PR checklist

Tests: 52/52 pass (23 security + 15 observability + 14 integration)
2026-02-14 18:21:47 -03:00
234 changed files with 23518 additions and 5217 deletions
+17 -5
View File
@@ -1,14 +1,22 @@
# OmniRoute environment contract
# This file reflects actual runtime usage in the current codebase.
# Required
JWT_SECRET=change-me-to-a-long-random-secret
INITIAL_PASSWORD=123456
# ═══════════════════════════════════════════════════
# REQUIRED SECRETS — Generate strong values!
# ═══════════════════════════════════════════════════
# Generate with: openssl rand -base64 48
JWT_SECRET=
# Generate with: openssl rand -hex 32
API_KEY_SECRET=
# Initial admin password — CHANGE THIS before first use!
INITIAL_PASSWORD=CHANGEME
DATA_DIR=/var/lib/omniroute
# Storage (SQLite)
STORAGE_DRIVER=sqlite
STORAGE_ENCRYPTION_KEY=change-me-storage-encryption-key
# Generate with: openssl rand -hex 32
STORAGE_ENCRYPTION_KEY=
STORAGE_ENCRYPTION_KEY_VERSION=v1
LOG_RETENTION_DAYS=90
SQLITE_MAX_SIZE_MB=2048
@@ -20,12 +28,16 @@ NODE_ENV=production
INSTANCE_NAME=omniroute
# Recommended security and ops variables
API_KEY_SECRET=endpoint-proxy-api-key-secret
MACHINE_ID_SALT=endpoint-proxy-salt
ENABLE_REQUEST_LOGS=false
AUTH_COOKIE_SECURE=false
REQUIRE_API_KEY=false
# Input Sanitizer (FASE-01 — prompt injection & PII protection)
# INPUT_SANITIZER_ENABLED=true
# INPUT_SANITIZER_MODE=warn # warn | block | redact
# PII_REDACTION_ENABLED=false
# Cloud sync variables
# Must point to this running instance so internal sync jobs can call /api/sync/cloud.
# Server-side preferred variables:
+110
View File
@@ -0,0 +1,110 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
security:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Dependency audit
run: npm audit --audit-level=high --omit=dev
- name: Check for known vulnerabilities
run: npx is-my-node-vulnerable || true
build:
name: Build
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm run build
test-unit:
name: Unit Tests
runs-on: ubuntu-latest
needs: build
strategy:
matrix:
node-version: [20, 22]
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm run test:unit
test-coverage:
name: Coverage
runs-on: ubuntu-latest
needs: build
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:coverage
- name: Check coverage threshold
run: |
echo "Coverage report generated. Check output for threshold compliance."
test-e2e:
name: E2E Tests
runs-on: ubuntu-latest
needs: build
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: npm run test:e2e
+55
View File
@@ -0,0 +1,55 @@
name: Publish to Docker Hub
on:
release:
types: [published]
permissions:
contents: read
jobs:
docker:
name: Build & Push Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract version from release tag
id: version
run: |
VERSION="${GITHUB_REF_NAME}"
VERSION="${VERSION#v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Publishing Docker image version: $VERSION"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
target: runner-base
push: true
tags: |
diegosouzapw/omniroute:${{ steps.version.outputs.version }}
diegosouzapw/omniroute:latest
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
- name: Update Docker Hub description
uses: peter-evans/dockerhub-description@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: diegosouzapw/omniroute
short-description: "OmniRoute — Unified AI proxy. Route any LLM through one endpoint."
readme-filepath: ./README.md
+12
View File
@@ -47,15 +47,25 @@ next-env.d.ts
data/
logs/*
# analysis directories (generated, not tracked)
.analysis/
antigravity-manager-analysis/
# docs (allow specific tracked files)
docs/*
!docs/ARCHITECTURE.md
!docs/CODEBASE_DOCUMENTATION.md
!docs/CONTRIBUTING.md
!docs/USER_GUIDE.md
!docs/API_REFERENCE.md
!docs/TROUBLESHOOTING.md
!docs/EXECUTION_CONTEXT_PROVIDER_SYNC.md
!docs/TASK_NEBIUS_BACKEND_ENABLEMENT.md
!docs/frontend-backend-provider-gap-report.md
!docs/openapi.yaml
!docs/PLANO-IMPLANTACAO.md
!docs/TASKS.md
!docs/FASE-*.md
!docs/adr/
!docs/cli-tools/
!docs/planning/
@@ -73,3 +83,5 @@ test-results/
playwright-report/
blob-report/
cloud/
omnirouteCloud/
omnirouteSite/
+130 -191
View File
@@ -2,213 +2,152 @@
All notable changes to OmniRoute are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [Unreleased]
## [0.8.0] — 2026-02-16
### Added
- 🌐 **Official website** — [omniroute.online](https://omniroute.online) live with static site on Akamai VM + Cloudflare proxy
- 🛡️ **Comprehensive SECURITY.md** — Full codebase audit documenting 10+ security features (AES-256-GCM, prompt injection guard, PII redaction, circuit breaker, etc.)
- 📖 **Documentation tracking**`USER_GUIDE.md`, `API_REFERENCE.md`, `TROUBLESHOOTING.md` now tracked in git
- 🏷️ **Website badge** — Official website badge and links in README, npm, and Docker Hub
- 🔗 **36+ providers** — Updated provider count across documentation
### Changed
- 📦 **npm homepage** — Points to `omniroute.online` instead of GitHub
- 🐳 **Docker OCI labels** — Added `org.opencontainers.image.url` for Docker Hub
- 🔒 **Security policy** — Updated supported versions, replaced email with GitHub Security Advisories
---
## [0.7.0] — 2026-02-16
### Added
- 🐳 **Docker Hub public image**`diegosouzapw/omniroute` available on [Docker Hub](https://hub.docker.com/r/diegosouzapw/omniroute) with `latest` and versioned tags
- 🔄 **Docker CI/CD** — GitHub Actions workflow (`docker-publish.yml`) auto-builds and pushes Docker image on every release
- ☁️ **Akamai VM deployment** — Nanode 1GB instance created for remote hosting
- 🎯 **Provider model filtering** — Filter model suggestions by selected provider in Translator and Chat Tester
- 🔌 **CLI status badges** — Extract `CliStatusBadge` component; status visible on collapsed tool cards
- ☁️ **Cloud connection UX** — GET status endpoint, toast feedback, and sidebar indicator for cloud sync
- 🔐 **OAuth provider secrets** — Default cloud URL and OAuth provider secrets set via environment variables
-**Edge compatibility** — Replace `uuid` package with native `crypto.randomUUID()` for Cloudflare Workers compatibility
---
## [0.6.0] — 2026-02-16
### Added
- 💰 **Costs & Budget page** — Dedicated dashboard page for cost tracking and budget management
- 📊 **Provider metrics display** — Show per-provider usage metrics and statistics
- 📥 **Model import for passthrough providers** — Import models from API-compatible providers (Deepgram, AssemblyAI, NanoBanana)
- 🎨 **App icon redesign** — New network node graph icon with updated color scheme
---
## [0.5.0] — 2026-02-15
### Added
- 🧪 **LLM Evaluations (Evals)** — Golden set testing framework with 4 match strategies (`exact`, `contains`, `regex`, `custom`)
- 🎲 **Advanced combo strategies**`random`, `least-used`, and `cost-optimized` balancing strategies for combos
- 📊 **API key usage in Evals** — Evals tab uses API key auth for real LLM calls through the proxy
- 🏷️ **Model availability badge** — Visual indicator for model availability per provider
- 🎨 **Landing page retheme** — Updated landing page design with new aesthetic
- 🧩 **Shared UI component library** — Refactored dashboard with reusable component library
### Fixed
- 🐛 Fix `TypeError` in `chat/completions` `ensureInitialized` call
---
## [0.4.0] — 2026-02-15
### Added
- 🧠 **LLM Gateway Intelligence** (Phase 9) — Smart routing, semantic caching, request idempotency, progress tracking
- 📄 **Missing flows & pages** (Phase 8) — Error pages, UX components, telemetry dashboards
- 🔧 **API & code quality** (Phase 7) — API restructuring, JSDoc documentation, code quality improvements
- 📚 **Documentation restructuring** (Phase 10) — Component decomposition, docs cleanup
-**26 action items** from critical analysis resolved
### Changed
- ♻️ **Architecture refactor** (Phase 5-6) — Domain persistence, policy engine, OAuth extraction, proxy decoupling
### Fixed
- 🐛 Fix CI build and lint failures
- 🐛 Fix ghost import in `chatHelpers.js` SSE handling
---
## [0.3.0] — 2026-02-15
### Added
-**Resilience system** — Exponential backoff, circuit breaker, anti-thundering herd mutex, Resilience UI settings page
- 🖥️ **100% frontend API coverage** — 7 implementation batches covering all backend routes
- 📊 **9 new API routes** — Budget, telemetry, compliance, tags, storage health, and more
- 🧪 **Eval framework & compliance** — ADRs, accessibility, CLI specs, Playwright test specs (46 tasks)
- 🏗️ **Pipeline integration** — 7 backend modules wired into request processing pipeline
- 🔐 **Security hardening** — Phases 0106 (input validation, CSRF, rate limiting, auth hardening)
- 🤖 **Advanced features** — Phases 0709 (domain extraction, error codes, request ID, fetch timeout)
- 🔄 **Unrecoverable token handling** — Detect and mark connections as expired on fatal refresh errors
### Changed
- ♻️ Decompose `usageDb`, `handleSingleModelChat`, and UI components for maintainability
- ⬇️ Downgrade ESLint v10 → v9 for `eslint-config-next` compatibility
---
## [0.2.0] — 2026-02-14
Major feature release: advanced routing services, security hardening, cost analytics dashboard, and pricing management overhaul.
### Added
#### Open-SSE Services
- **Account Selector** — intelligent provider account selection with priority and load-balancing strategies (`accountSelector.js`)
- **Context Manager** — request context tracking and lifecycle management (`contextManager.js`)
- **IP Filter** — allowlist/blocklist IP filtering with CIDR support (`ipFilter.js`)
- **Session Manager** — persistent session tracking across requests (`sessionManager.js`)
- **Signature Cache** — request signature caching for deduplication (`signatureCache.js`)
- **System Prompt** — global system prompt injection into all chat completions (`systemPrompt.js`)
- **Thinking Budget** — token budget management for reasoning models (`thinkingBudget.js`)
- **Wildcard Router** — pattern-based model routing with glob matching (`wildcardRouter.js`)
- Enhanced **Rate Limit Manager** with sliding-window algorithm and per-key quotas
#### Dashboard Settings
- **IP Filter** settings tab — configure allowed/blocked IPs from the UI (`IPFilterSection.js`)
- **System Prompt** settings tab — set global system prompt injection (`SystemPromptTab.js`)
- **Thinking Budget** settings tab — configure reasoning token budgets (`ThinkingBudgetTab.js`)
- **Pricing Tab** — full-page redesign with provider-centric organization, inline editing, search/filter, and save/reset per provider (`PricingTab.js`)
- **Rate Limit Status** component on Usage page (`RateLimitStatus.js`)
- **Sessions Tab** on Usage page — view and manage active sessions (`SessionsTab.js`)
#### Usage & Cost Analytics
- **Cost stat card** (amber accent) prominently displayed in analytics top row
- **Provider Cost Donut** — new chart showing cost distribution across providers
- **Daily Cost Trend** — cost line overlay (amber) on token trend chart with secondary Y-axis
- **Model Table Cost column** — sortable cost column in model breakdown table
- Cost-aware tooltip formatting throughout analytics charts
#### Pricing API
- `/api/pricing/models` endpoint — serves merged model catalog from 3 sources: registry, custom models (DB), and pricing-only models
- Custom model badge in pricing page for user-imported models
- `/api/rate-limits` endpoint for rate limit configuration
- `/api/sessions` endpoint for session management
- `/api/settings/ip-filter`, `/api/settings/system-prompt`, `/api/settings/thinking-budget` endpoints
#### Cloudflare Worker
- Cloud worker module for edge deployment (`cloud/`)
#### Tests
- Unit tests for account selector, context manager, IP filter, enhanced rate limiting, session manager, signature cache, system prompt, thinking budget, and wildcard router (9 new test files)
#### Documentation
- OpenAPI specification at `docs/openapi.yaml` covering all 89 API endpoints
- Enhanced `restart.sh` with clean build, health check, graceful shutdown (Ctrl+C), and real-time log tailing
- Updated architecture documentation and codebase docs with new services and API routes
- Model selector with autocomplete in Chat Tester and Test Bench modes
### Fixed
- Server port collision (EADDRINUSE) during restart — now kills port before `next start`
- Icon rendering corrected from `material-symbols-rounded` to `material-symbols-outlined`
- Pricing page only showed hardcoded registry models — now includes custom/imported models
### Changed
- Usage analytics layout reorganized: donuts separated into logical groupings, bottom stats simplified from 6 to 4 cards
- Daily trend chart upgraded from `BarChart` to `ComposedChart` with dual Y-axes
- Routing tab updated with new service integrations
- 🛣️ **Advanced routing services** — Priority-based routing, global strategy configuration
- 💰 **Cost analytics dashboard** — Token cost tracking and analytics visualization
- 💎 **Pricing overhaul**Comprehensive pricing data for all supported providers and models
- 📦 **npm badge & CLI options**npm version badge in README, CLI options table, automated release docs
---
## [0.0.1] — 2026-02-13
Initial public release of OmniRoute (rebranded from 9router).
## [0.1.0] — 2026-02-14
### Added
- **28 AI Providers** — OpenAI, Anthropic, Google Gemini, DeepSeek, Groq, xAI, Mistral, Perplexity, Together AI, Fireworks AI, Cerebras, Cohere, NVIDIA NIM, Nebius, GitHub Copilot, Cursor, Kiro, Kimi, MiniMax, iFlow, and more
- **OpenAI-compatible proxy** at `/api/v1/chat/completions` with automatic format translation, load balancing, and failover
- **Anthropic Messages API** at `/api/v1/messages` for Claude-native clients
- **OpenAI Responses API** at `/api/v1/responses` for modern OpenAI workflows
- **Embeddings API** at `/api/v1/embeddings` with 6 providers and 9 models
- **Image Generation API** at `/api/v1/images/generations` with 4 providers and 9 models
- **Format Translator** — automatic request/response conversion between OpenAI, Anthropic, Gemini, and OpenAI Responses formats
- **Translator Playground** with 4 modes: Playground, Chat Tester, Test Bench, Live Monitor
- **Combo Routing** — named route configurations with priority, weighted, and round-robin strategies
- **API Key Management** — create/revoke keys with usage attribution
- **Usage Dashboard** — analytics, call logs, request logger with API key filtering and cost tracking
- **Provider Health Diagnostics** — structured status (runtime errors, auth failures, token refresh) with per-connection retest
- **CLI Tools Integration** — runtime detection for Cline, Kiro, Droid, OpenClaw with backup/restore
- **OAuth Flows** — for Cursor, Kiro, Kimi, and GitHub Copilot
- **Docker Support** — multi-stage Dockerfile, docker-compose with 3 profiles (base, cli, host), production compose
- **SOCKS5 Proxy** — outbound proxy support enabled by default (`ab8d752`)
- **Unified Storage** — `DATA_DIR` / `XDG_CONFIG_HOME` resolution with auto-migration from `~/.omniroute`
- **In-app Documentation** at `/docs` with quick start, endpoint reference, and client compatibility notes
- **Dark Theme UI** — modern dashboard with glassmorphism, responsive layout
- `<think>` tag parser for reasoning models (DeepSeek, Qwen)
- Non-stream response translation for all formats
- Secure cookie handling for LAN/reverse-proxy deployments
### Fixed
- OAuth re-authentication no longer creates duplicate connections (`773f117`, `510aedd`)
- Connection test no longer corrupts valid OAuth tokens (`a2ba189`)
- Cloud sync disabled to prevent 404 log spam (`71d132e`)
- `.env.example` synced with current environment structure (`6bdc74b`)
- Select dropdown dark theme inconsistency (`1bd734d`)
### Dependencies
- `actions/github-script` bumped from 7 to 8 (`f6a994a`)
- `eslint` bumped from 9.39.2 to 10.0.0 (`ecd4aea`)
- 🎉 **Initial OmniRoute release** — Rebranded from 9router with full feature set
- 🔄 **28 AI providers** — OpenAI, Claude, Gemini, Copilot, DeepSeek, Groq, xAI, Mistral, Qwen, iFlow, and more
- 🎯 **Smart fallback** — 3-tier auto-routing (Subscription → Cheap → Free)
- 🔀 **Format translation** — Seamless OpenAI ↔ Claude ↔ Gemini format conversion
- 👥 **Multi-account support** — Multiple accounts per provider with round-robin
- 🔐 **OAuth 2.0 (PKCE)** — Automatic token management and refresh
- 📊 **Usage tracking**Real-time quota monitoring with reset countdown
- 🎨 **Custom combos** — Create model combinations with fallback chains
- ☁️ **Cloud sync**Sync configuration across devices via Cloudflare Worker
- 📖 **OpenAPI specification**Full API documentation
- 🛡️ **SOCKS5 proxy support**Outbound proxy for upstream provider calls
- 🔌 **New endpoints**`/v1/rerank`, `/v1/audio/*`, `/v1/moderations`
- 📦 **npm CLI package**`npm install -g omniroute` with auto-launch
- 🐳 **Docker support**Multi-stage Dockerfile with `base` and `cli` profiles
- 🔒 **Security policy**`SECURITY.md` with vulnerability reporting guidelines
- 🧪 **CI/CD pipeline**GitHub Actions for lint, build, test, and npm publish
---
## Pre-Release History (9router)
> The following entries document the legacy 9router project before it was
> rebranded to OmniRoute. All changes below were included in the initial
> `0.0.1` release.
### 0.2.75 — 2026-02-11
- API key attribution in usage/call logs with per-key analytics aggregates
- Usage dashboard API key observability (distribution donut, filterable table)
- In-app docs page (`/docs`) with quick start, endpoint reference, and client compatibility notes
- Unified storage path policy (`DATA_DIR``XDG_CONFIG_HOME``~/.omniroute`)
- Build-phase guard for `usageDb` (in-memory during `next build`)
- LAN/reverse-proxy cookie security detection
- Hardened Gemini 3 Flash normalization and non-stream SSE fallback parsing
- CLI tool runtime and OAuth refresh reliability improvements
- Provider health diagnostics with structured error types
### 0.2.74 — 2026-02-11
- Model resolution fallback fix for unprefixed models
- GitHub Copilot dynamic endpoint selection (Codex → `/responses`)
- Non-stream translation path for OpenAI Responses
- Updated GitHub model catalog with compatibility aliases
### 0.2.73 — 2026-02-09
- Expanded provider registry from 18 → 28 providers (DeepSeek, Groq, xAI, Mistral, Perplexity, Together AI, Fireworks AI, Cerebras, Cohere, NVIDIA NIM)
- `/v1/embeddings` endpoint with 6 providers and 9 models
- `/v1/images/generations` endpoint with 4 providers and 9 models
- `<think>` tag parser for reasoning models
- Available Endpoints card on Endpoint page (127 chat, 9 embedding, 9 image models)
### 0.2.72 — 2026-02-08
- Split Kimi into dual providers: `kimi` (OpenAI-compatible) and `kimi-coding` (Moonshot API)
- Hybrid CLI runtime support with Docker profiles (`runner-base`, `runner-cli`)
- Hardened cloud sync/auth flow with SSE fallback
### 0.2.66 — 2026-02-06
- Cursor provider end-to-end support with OAuth import flow
- `requireLogin` control and `hasPassword` state handling
- Usage/quota UX improvements
- Model support for custom providers
- Codex updates (GPT-5.3, thinking levels), Claude Opus 4.6, MiniMax Coding
- Auto-validation for provider API keys
### 0.2.56 — 2026-02-04
- Anthropic-compatible provider support
- Provider icons across dashboard
- Enhanced usage tracking pipeline
### 0.2.52 — 2026-02-02
- Codex Cursor compatibility and Next.js 16 proxy migration
- OpenAI-compatible provider nodes (CRUD/validation/test)
- Token expiration and key-validity checks
- Non-streaming response translation for multiple formats
- Kiro OAuth wiring and token refresh support
### 0.2.43 — 2026-01-27
- Fixed CLI tools model selection
- Fixed Kiro translator request handling
### 0.2.36 — 2026-01-19
- Usage dashboard page
- Outbound proxy support in Open SSE fetch pipeline
- Fixed combo fallback behavior
### 0.2.31 — 2026-01-18
- Fixed Kiro token refresh and executor behavior
- Fixed Kiro request translation handling
### 0.2.27 — 2026-01-15
- Added Kiro provider support with OAuth flow
- Fixed Codex provider behavior
### 0.2.21 — 2026-01-12
- Initial README and project setup
[0.8.0]: https://github.com/diegosouzapw/OmniRoute/compare/v0.7.0...v0.8.0
[0.7.0]: https://github.com/diegosouzapw/OmniRoute/compare/v0.6.0...v0.7.0
[0.6.0]: https://github.com/diegosouzapw/OmniRoute/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/diegosouzapw/OmniRoute/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/diegosouzapw/OmniRoute/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/diegosouzapw/OmniRoute/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/diegosouzapw/OmniRoute/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v0.1.0
+273
View File
@@ -0,0 +1,273 @@
# Contributing to OmniRoute
Thank you for your interest in contributing! This guide covers everything you need to get started.
---
## Development Setup
### Prerequisites
- **Node.js** 20+ (recommended: 22 LTS)
- **npm** 10+
- **Git**
### Clone & Install
```bash
git clone https://github.com/diegosouzapw/OmniRoute.git
cd OmniRoute
npm install
```
### Environment Variables
```bash
# Create your .env from the template
cp .env.example .env
# Generate required secrets
echo "JWT_SECRET=$(openssl rand -base64 48)" >> .env
echo "API_KEY_SECRET=$(openssl rand -hex 32)" >> .env
```
Key variables for development:
| Variable | Development Default | Description |
| ---------------------- | ----------------------- | ------------------------- |
| `PORT` | `3000` | Server port |
| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | Base URL for frontend |
| `JWT_SECRET` | (generate above) | JWT signing secret |
| `INITIAL_PASSWORD` | `123456` | First login password |
| `ENABLE_REQUEST_LOGS` | `false` | Enable debug request logs |
### Running Locally
```bash
# Development mode (hot reload)
npm run dev
# Production build
npm run build
npm run start
# Common port configuration
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
```
Default URLs:
- **Dashboard**: `http://localhost:3000/dashboard`
- **API**: `http://localhost:3000/v1`
---
## Git Workflow
> ⚠️ **NEVER commit directly to `main`.** Always use feature branches.
```bash
git checkout -b feat/your-feature-name
# ... make changes ...
git commit -m "feat: describe your change"
git push -u origin feat/your-feature-name
# Open a Pull Request on GitHub
```
### Branch Naming
| Prefix | Purpose |
| ----------- | ------------------------- |
| `feat/` | New features |
| `fix/` | Bug fixes |
| `refactor/` | Code restructuring |
| `docs/` | Documentation changes |
| `test/` | Test additions/fixes |
| `chore/` | Tooling, CI, dependencies |
### Commit Messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
feat: add circuit breaker for provider calls
fix: resolve JWT secret validation edge case
docs: update SECURITY.md with PII protection
test: add observability unit tests
refactor(db): consolidate rate limit tables
```
Scopes: `db`, `sse`, `oauth`, `dashboard`, `api`, `cli`, `docker`, `ci`.
---
## Running Tests
```bash
# All unit tests
npm test
npm run test:unit
# Specific test suites
npm run test:security # Security tests
npm run test:fixes # Fix verification tests
# With coverage
npm run test:coverage
# E2E tests (requires Playwright)
npm run test:e2e
# Lint + format check
npm run lint
npm run check
```
Current test status: **320+ unit tests** covering:
- Provider translators and format conversion
- Rate limiting, circuit breaker, and resilience
- Semantic cache, idempotency, progress tracking
- Database operations and schema
- OAuth flows and authentication
- API endpoint validation
---
## Code Style
- **ESLint** — Run `npm run lint` before committing
- **Prettier** — Auto-formatted via `lint-staged` on commit
- **JSDoc** — Document public functions with `@param`, `@returns`, `@throws`
- **No `eval()`** — ESLint enforces `no-eval`, `no-implied-eval`, `no-new-func`
- **Zod validation** — Use Zod schemas for API input validation
---
## Project Structure
```
src/
├── app/ # Next.js App Router
│ ├── (dashboard)/ # Dashboard pages
│ ├── api/ # API routes
│ └── login/ # Auth pages
├── domain/ # Domain types and response helpers
├── lib/ # Core business logic
│ ├── db/ # SQLite database layer
│ ├── oauth/ # OAuth services per provider
│ ├── cacheLayer.js # LRU cache
│ ├── semanticCache.js # Semantic response cache
│ ├── idempotencyLayer.js # Request deduplication
│ └── localDb.js # LowDB (JSON) storage
├── shared/
│ ├── components/ # React components
│ ├── middleware/ # Correlation IDs, etc.
│ ├── utils/ # Circuit breaker, sanitizer, etc.
│ └── validation/ # Zod schemas
└── sse/ # SSE chat handlers
open-sse/ # @omniroute/open-sse workspace
├── handlers/ # chatCore.js — main request handler
├── services/ # Rate limit, fallback
├── translators/ # Format converters (OpenAI ↔ Claude ↔ Gemini)
└── utils/ # Progress tracker, stream helpers
tests/
├── unit/ # Node.js test runner
└── e2e/ # Playwright tests
docs/ # Documentation
├── USER_GUIDE.md # Provider setup, CLI integration
├── API_REFERENCE.md # All endpoints
├── TROUBLESHOOTING.md # Common issues
├── ARCHITECTURE.md # System architecture
└── adr/ # Architecture Decision Records
```
---
## Adding a New Provider
### Step 1: OAuth Service (if using OAuth)
Create `src/lib/oauth/services/your-provider.js` extending `OAuthService`:
```javascript
import { OAuthService } from "../OAuthService.js";
export class YourProviderService extends OAuthService {
constructor() {
super({
name: "your-provider",
authUrl: "https://provider.com/oauth/authorize",
tokenUrl: "https://provider.com/oauth/token",
clientId: "...",
scopes: ["..."],
});
}
}
```
### Step 2: Register Provider
Add to `src/lib/oauth/providers.js`:
```javascript
import { YourProviderService } from "./services/your-provider.js";
// Add to the providers map
```
### Step 3: Add Constants
Add provider constants in `src/lib/providerConstants.js`:
- Provider prefix (e.g., `yp/`)
- Default models
- Pricing info
### Step 4: Add Translator (if non-OpenAI format)
Create translator in `open-sse/translators/` if the provider uses a custom API format.
### Step 5: Add Timeout
Add request timeout configuration in `src/shared/utils/requestTimeout.js`.
### Step 6: Add Tests
Write unit tests in `tests/unit/` covering at minimum:
- Provider registration
- Request/response translation
- Error handling
---
## Pull Request Checklist
- [ ] Tests pass (`npm test`)
- [ ] Linting passes (`npm run lint`)
- [ ] Build succeeds (`npm run build`)
- [ ] JSDoc added for new public functions
- [ ] No hardcoded secrets or fallback values
- [ ] CHANGELOG updated (if user-facing change)
- [ ] Documentation updated (if applicable)
---
## Releasing
When a new GitHub Release is created (e.g. `v0.4.0`), the package is **automatically published to npm** via GitHub Actions:
```bash
gh release create v0.4.0 --title "v0.4.0" --generate-notes
```
---
## Getting Help
- **Architecture**: See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
- **Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues)
- **ADRs**: See `docs/adr/` for architectural decision records
+1
View File
@@ -12,6 +12,7 @@ WORKDIR /app
LABEL org.opencontainers.image.title="omniroute" \
org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint" \
org.opencontainers.image.url="https://omniroute.online" \
org.opencontainers.image.source="https://github.com/diegosouzapw/OmniRoute" \
org.opencontainers.image.licenses="MIT"
+132 -1114
View File
File diff suppressed because it is too large Load Diff
+161 -13
View File
@@ -1,21 +1,169 @@
# Security Policy
## Reporting Vulnerabilities
If you discover a security vulnerability in OmniRoute, please report it responsibly:
1. **DO NOT** open a public GitHub issue
2. Use [GitHub Security Advisories](https://github.com/diegosouzapw/OmniRoute/security/advisories/new)
3. Include: description, reproduction steps, and potential impact
## Response Timeline
| Stage | Target |
| ------------------- | --------------------------- |
| Acknowledgment | 48 hours |
| Triage & Assessment | 5 business days |
| Patch Release | 14 business days (critical) |
## Supported Versions
Use this section to tell people about which versions of your project are
currently being supported with security updates.
| Version | Support Status |
| ------- | -------------- |
| 0.8.x | ✅ Active |
| 0.7.x | ✅ Security |
| < 0.7.0 | ❌ Unsupported |
| Version | Supported |
| ------- | ------------------ |
| 5.1.x | :white_check_mark: |
| 5.0.x | :x: |
| 4.0.x | :white_check_mark: |
| < 4.0 | :x: |
---
## Reporting a Vulnerability
## Security Architecture
Use this section to tell people how to report a vulnerability.
OmniRoute implements a multi-layered security model:
Tell them where to go, how often they can expect to get an update on a
reported vulnerability, what to expect if the vulnerability is accepted or
declined, etc.
```
Request → CORS → API Key Auth → Prompt Injection Guard → Input Sanitizer → Rate Limiter → Circuit Breaker → Provider
```
### 🔐 Authentication & Authorization
| Feature | Implementation |
| -------------------- | ---------------------------------------------------------- |
| **Dashboard Login** | Password-based auth with JWT tokens (HttpOnly cookies) |
| **API Key Auth** | HMAC-signed keys with CRC validation |
| **OAuth 2.0 + PKCE** | Secure provider auth (Claude, Codex, Gemini, Cursor, etc.) |
| **Token Refresh** | Automatic OAuth token refresh before expiry |
| **Secure Cookies** | `AUTH_COOKIE_SECURE=true` for HTTPS environments |
### 🛡️ Encryption at Rest
All sensitive data stored in SQLite is encrypted using **AES-256-GCM** with scrypt key derivation:
- API keys, access tokens, refresh tokens, and ID tokens
- Versioned format: `enc:v1:<iv>:<ciphertext>:<authTag>`
- Passthrough mode (plaintext) when `STORAGE_ENCRYPTION_KEY` is not set
```bash
# Generate encryption key:
STORAGE_ENCRYPTION_KEY=$(openssl rand -hex 32)
```
### 🧠 Prompt Injection Guard
Middleware that detects and blocks prompt injection attacks in LLM requests:
| Pattern Type | Severity | Example |
| ------------------- | -------- | ---------------------------------------------- |
| System Override | High | "ignore all previous instructions" |
| Role Hijack | High | "you are now DAN, you can do anything" |
| Delimiter Injection | Medium | Encoded separators to break context boundaries |
| DAN/Jailbreak | High | Known jailbreak prompt patterns |
| Instruction Leak | Medium | "show me your system prompt" |
Configure via dashboard (Settings → Security) or `.env`:
```env
INPUT_SANITIZER_ENABLED=true
INPUT_SANITIZER_MODE=block # warn | block | redact
```
### 🔒 PII Redaction
Automatic detection and optional redaction of personally identifiable information:
| PII Type | Pattern | Replacement |
| ------------- | --------------------- | ------------------ |
| Email | `user@domain.com` | `[EMAIL_REDACTED]` |
| CPF (Brazil) | `123.456.789-00` | `[CPF_REDACTED]` |
| CNPJ (Brazil) | `12.345.678/0001-00` | `[CNPJ_REDACTED]` |
| Credit Card | `4111-1111-1111-1111` | `[CC_REDACTED]` |
| Phone | `+55 11 99999-9999` | `[PHONE_REDACTED]` |
| SSN (US) | `123-45-6789` | `[SSN_REDACTED]` |
```env
PII_REDACTION_ENABLED=true
```
### 🌐 Network Security
| Feature | Description |
| ------------------------ | ---------------------------------------------------------------- |
| **CORS** | Configurable origin control (`CORS_ORIGIN` env var, default `*`) |
| **IP Filtering** | Whitelist/blacklist IP ranges in dashboard |
| **Rate Limiting** | Per-provider rate limits with automatic backoff |
| **Anti-Thundering Herd** | Mutex + per-connection locking prevents cascading 502s |
### 🔌 Resilience & Availability
| Feature | Description |
| ----------------------- | ------------------------------------------------------------------ |
| **Circuit Breaker** | 3-state (Closed → Open → Half-Open) per provider, SQLite-persisted |
| **Request Idempotency** | 5-second dedup window for duplicate requests |
| **Exponential Backoff** | Automatic retry with increasing delays |
| **Health Dashboard** | Real-time provider health monitoring |
### 📋 Compliance
| Feature | Description |
| ------------------ | --------------------------------------------------- |
| **Log Retention** | Automatic cleanup after `LOG_RETENTION_DAYS` |
| **No-Log Opt-out** | Per API key `noLog` flag disables request logging |
| **Audit Log** | Administrative actions tracked in `audit_log` table |
---
## Required Environment Variables
All secrets must be set before starting the server. The server will **fail fast** if they are missing or weak.
```bash
# REQUIRED — server will not start without these:
JWT_SECRET=$(openssl rand -base64 48) # min 32 chars
API_KEY_SECRET=$(openssl rand -hex 32) # min 16 chars
# RECOMMENDED — enables encryption at rest:
STORAGE_ENCRYPTION_KEY=$(openssl rand -hex 32)
```
The server actively rejects known-weak values like `changeme`, `secret`, or `password`.
---
## Docker Security
- Use non-root user in production
- Mount secrets as read-only volumes
- Never copy `.env` files into Docker images
- Use `.dockerignore` to exclude sensitive files
- Set `AUTH_COOKIE_SECURE=true` when behind HTTPS
```bash
docker run -d \
--name omniroute \
--restart unless-stopped \
--read-only \
-p 20128:20128 \
-v omniroute-data:/app/data \
-e JWT_SECRET="$(openssl rand -base64 48)" \
-e API_KEY_SECRET="$(openssl rand -hex 32)" \
-e STORAGE_ENCRYPTION_KEY="$(openssl rand -hex 32)" \
diegosouzapw/omniroute:latest
```
---
## Dependencies
- Run `npm audit` regularly
- Keep dependencies updated
- The project uses `husky` + `lint-staged` for pre-commit checks
- CI pipeline runs ESLint security rules on every push
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env node
/**
* Password Reset CLI — T-38
*
* Usage:
* node bin/reset-password.mjs
* npx omniroute reset-password
*
* Resets the admin password for OmniRoute.
* Prompts for a new password and updates the database directly.
*
* @module bin/reset-password
*/
import { createInterface } from "node:readline";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync } from "node:fs";
import { createHash } from "node:crypto";
const __dirname = dirname(fileURLToPath(import.meta.url));
// Resolve data directory — same logic as the server
const DATA_DIR = process.env.DATA_DIR || resolve(__dirname, "..", "data");
const DB_PATH = resolve(DATA_DIR, "settings.db");
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
function ask(question) {
return new Promise((resolve) => rl.question(question, resolve));
}
function hashPassword(password) {
return createHash("sha256").update(password).digest("hex");
}
console.log("\n🔑 OmniRoute — Password Reset\n");
async function main() {
// Check if database exists
if (!existsSync(DB_PATH)) {
console.error(`❌ Database not found at: ${DB_PATH}`);
console.error(` Make sure OmniRoute has been started at least once.`);
console.error(` Or set DATA_DIR env var to your data directory.\n`);
process.exit(1);
}
let Database;
try {
Database = (await import("better-sqlite3")).default;
} catch {
console.error("❌ better-sqlite3 not installed. Run: npm install");
process.exit(1);
}
const db = new Database(DB_PATH);
// Check current settings
const row = db.prepare("SELECT value FROM settings WHERE key = 'password'").get();
if (row) {
console.log("️ A password is currently set.");
} else {
console.log("️ No password is currently set.");
}
const password = await ask("Enter new password (min 4 chars): ");
if (!password || password.length < 4) {
console.error("\n❌ Password must be at least 4 characters.\n");
db.close();
rl.close();
process.exit(1);
}
const confirm = await ask("Confirm new password: ");
if (password !== confirm) {
console.error("\n❌ Passwords do not match.\n");
db.close();
rl.close();
process.exit(1);
}
const hashed = hashPassword(password);
// Upsert the password
const stmt = db.prepare(`
INSERT INTO settings (key, value) VALUES ('password', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`);
stmt.run(hashed);
// Also ensure requireLogin is true
const loginStmt = db.prepare(`
INSERT INTO settings (key, value) VALUES ('requireLogin', 'true')
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`);
loginStmt.run();
db.close();
rl.close();
console.log("\n✅ Password reset successfully!");
console.log(" Restart OmniRoute for changes to take effect.\n");
}
main().catch((err) => {
console.error(`\n❌ Error: ${err.message}\n`);
rl.close();
process.exit(1);
});
+428
View File
@@ -0,0 +1,428 @@
# API Reference
Complete reference for all OmniRoute API endpoints.
---
## Table of Contents
- [Chat Completions](#chat-completions)
- [Embeddings](#embeddings)
- [Image Generation](#image-generation)
- [List Models](#list-models)
- [Compatibility Endpoints](#compatibility-endpoints)
- [Semantic Cache](#semantic-cache)
- [Dashboard & Management](#dashboard--management)
- [Request Processing](#request-processing)
- [Authentication](#authentication)
---
## Chat Completions
```bash
POST /v1/chat/completions
Authorization: Bearer your-api-key
Content-Type: application/json
{
"model": "cc/claude-opus-4-6",
"messages": [
{"role": "user", "content": "Write a function to..."}
],
"stream": true
}
```
### Custom Headers
| Header | Direction | Description |
| ------------------------ | --------- | --------------------------------- |
| `X-OmniRoute-No-Cache` | Request | Set to `true` to bypass cache |
| `X-OmniRoute-Progress` | Request | Set to `true` for progress events |
| `Idempotency-Key` | Request | Dedup key (5s window) |
| `X-Request-Id` | Request | Alternative dedup key |
| `X-OmniRoute-Cache` | Response | `HIT` or `MISS` (non-streaming) |
| `X-OmniRoute-Idempotent` | Response | `true` if deduplicated |
| `X-OmniRoute-Progress` | Response | `enabled` if progress tracking on |
---
## Embeddings
```bash
POST /v1/embeddings
Authorization: Bearer your-api-key
Content-Type: application/json
{
"model": "nebius/Qwen/Qwen3-Embedding-8B",
"input": "The food was delicious"
}
```
Available providers: Nebius, OpenAI, Mistral, Together AI, Fireworks, NVIDIA.
```bash
# List all embedding models
GET /v1/embeddings
```
---
## Image Generation
```bash
POST /v1/images/generations
Authorization: Bearer your-api-key
Content-Type: application/json
{
"model": "openai/dall-e-3",
"prompt": "A beautiful sunset over mountains",
"size": "1024x1024"
}
```
Available providers: OpenAI (DALL-E), xAI (Grok Image), Together AI (FLUX), Fireworks AI.
```bash
# List all image models
GET /v1/images/generations
```
---
## List Models
```bash
GET /v1/models
Authorization: Bearer your-api-key
→ Returns all chat, embedding, and image models + combos in OpenAI format
```
---
## Compatibility Endpoints
| Method | Path | Format |
| ------ | --------------------------- | ---------------------- |
| POST | `/v1/chat/completions` | OpenAI |
| POST | `/v1/messages` | Anthropic |
| POST | `/v1/responses` | OpenAI Responses |
| POST | `/v1/embeddings` | OpenAI |
| POST | `/v1/images/generations` | OpenAI |
| GET | `/v1/models` | OpenAI |
| POST | `/v1/messages/count_tokens` | Anthropic |
| GET | `/v1beta/models` | Gemini |
| POST | `/v1beta/models/{...path}` | Gemini generateContent |
| POST | `/v1/api/chat` | Ollama |
### Dedicated Provider Routes
```bash
POST /v1/providers/{provider}/chat/completions
POST /v1/providers/{provider}/embeddings
POST /v1/providers/{provider}/images/generations
```
The provider prefix is auto-added if missing. Mismatched models return `400`.
---
## Semantic Cache
```bash
# Get cache stats
GET /api/cache
# Clear all caches
DELETE /api/cache
```
Response example:
```json
{
"semanticCache": {
"memorySize": 42,
"memoryMaxSize": 500,
"dbSize": 128,
"hitRate": 0.65
},
"idempotency": {
"activeKeys": 3,
"windowMs": 5000
}
}
```
---
## Dashboard & Management
### Authentication
| Endpoint | Method | Description |
| ----------------------------- | ------- | --------------------- |
| `/api/auth/login` | POST | Login |
| `/api/auth/logout` | POST | Logout |
| `/api/settings/require-login` | GET/PUT | Toggle login required |
### Provider Management
| Endpoint | Method | Description |
| ---------------------------- | --------------- | ------------------------ |
| `/api/providers` | GET/POST | List / create providers |
| `/api/providers/[id]` | GET/PUT/DELETE | Manage a provider |
| `/api/providers/[id]/test` | POST | Test provider connection |
| `/api/providers/[id]/models` | GET | List provider models |
| `/api/providers/validate` | POST | Validate provider config |
| `/api/provider-nodes*` | Various | Provider node management |
| `/api/provider-models` | GET/POST/DELETE | Custom models |
### OAuth Flows
| Endpoint | Method | Description |
| -------------------------------- | ------- | ----------------------- |
| `/api/oauth/[provider]/[action]` | Various | Provider-specific OAuth |
### Routing & Config
| Endpoint | Method | Description |
| --------------------- | -------- | ----------------------------- |
| `/api/models/alias` | GET/POST | Model aliases |
| `/api/models/catalog` | GET | All models by provider + type |
| `/api/combos*` | Various | Combo management |
| `/api/keys*` | Various | API key management |
| `/api/pricing` | GET | Model pricing |
### Usage & Analytics
| Endpoint | Method | Description |
| --------------------------- | ------ | -------------------- |
| `/api/usage/history` | GET | Usage history |
| `/api/usage/logs` | GET | Usage logs |
| `/api/usage/request-logs` | GET | Request-level logs |
| `/api/usage/[connectionId]` | GET | Per-connection usage |
### Settings
| Endpoint | Method | Description |
| ------------------------------- | ------- | ---------------------- |
| `/api/settings` | GET/PUT | General settings |
| `/api/settings/proxy` | GET/PUT | Network proxy config |
| `/api/settings/proxy/test` | POST | Test proxy connection |
| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist |
| `/api/settings/thinking-budget` | GET/PUT | Reasoning token budget |
| `/api/settings/system-prompt` | GET/PUT | Global system prompt |
### Monitoring
| Endpoint | Method | Description |
| ------------------------ | ---------- | ----------------------- |
| `/api/sessions` | GET | Active session tracking |
| `/api/rate-limits` | GET | Per-account rate limits |
| `/api/monitoring/health` | GET | Health check |
| `/api/cache` | GET/DELETE | Cache stats / clear |
### Cloud Sync
| Endpoint | Method | Description |
| ---------------------- | ------- | --------------------- |
| `/api/sync/cloud` | Various | Cloud sync operations |
| `/api/sync/initialize` | POST | Initialize sync |
| `/api/cloud/*` | Various | Cloud management |
### CLI Tools
| Endpoint | Method | Description |
| ---------------------------------- | ------ | ------------------- |
| `/api/cli-tools/claude-settings` | GET | Claude CLI status |
| `/api/cli-tools/codex-settings` | GET | Codex CLI status |
| `/api/cli-tools/droid-settings` | GET | Droid CLI status |
| `/api/cli-tools/openclaw-settings` | GET | OpenClaw CLI status |
| `/api/cli-tools/runtime/[toolId]` | GET | Generic CLI runtime |
CLI responses include: `installed`, `runnable`, `command`, `commandPath`, `runtimeMode`, `reason`.
### Resilience & Rate Limits
| Endpoint | Method | Description |
| ----------------------- | ------- | ------------------------------- |
| `/api/resilience` | GET/PUT | Get/update resilience profiles |
| `/api/resilience/reset` | POST | Reset circuit breakers |
| `/api/rate-limits` | GET | Per-account rate limit status |
| `/api/rate-limit` | GET | Global rate limit configuration |
### Evals
| Endpoint | Method | Description |
| ------------ | -------- | --------------------------------- |
| `/api/evals` | GET/POST | List eval suites / run evaluation |
### Policies
| Endpoint | Method | Description |
| --------------- | --------------- | ----------------------- |
| `/api/policies` | GET/POST/DELETE | Manage routing policies |
### Compliance
| Endpoint | Method | Description |
| --------------------------- | ------ | ----------------------------- |
| `/api/compliance/audit-log` | GET | Compliance audit log (last N) |
### v1beta (Gemini-Compatible)
| Endpoint | Method | Description |
| -------------------------- | ------ | --------------------------------- |
| `/v1beta/models` | GET | List models in Gemini format |
| `/v1beta/models/{...path}` | POST | Gemini `generateContent` endpoint |
These endpoints mirror Gemini's API format for clients that expect native Gemini SDK compatibility.
### Internal / System APIs
| Endpoint | Method | Description |
| --------------- | ------ | ---------------------------------------------------- |
| `/api/init` | GET | Application initialization check (used on first run) |
| `/api/tags` | GET | Ollama-compatible model tags (for Ollama clients) |
| `/api/restart` | POST | Trigger graceful server restart |
| `/api/shutdown` | POST | Trigger graceful server shutdown |
> **Note:** These endpoints are used internally by the system or for Ollama client compatibility. They are not typically called by end users.
---
## Audio Transcription
```bash
POST /v1/audio/transcriptions
Authorization: Bearer your-api-key
Content-Type: multipart/form-data
```
Transcribe audio files using Deepgram or AssemblyAI.
**Request:**
```bash
curl -X POST http://localhost:20128/v1/audio/transcriptions \
-H "Authorization: Bearer your-api-key" \
-F "file=@recording.mp3" \
-F "model=deepgram/nova-3"
```
**Response:**
```json
{
"text": "Hello, this is the transcribed audio content.",
"task": "transcribe",
"language": "en",
"duration": 12.5
}
```
**Supported providers:** `deepgram/nova-3`, `assemblyai/best`.
**Supported formats:** `mp3`, `wav`, `m4a`, `flac`, `ogg`, `webm`.
---
## Ollama Compatibility
For clients that use Ollama's API format:
```bash
# Chat endpoint (Ollama format)
POST /v1/api/chat
# Model listing (Ollama format)
GET /api/tags
```
Requests are automatically translated between Ollama and internal formats.
---
## Telemetry
```bash
# Get latency telemetry summary (p50/p95/p99 per provider)
GET /api/telemetry/summary
```
**Response:**
```json
{
"providers": {
"claudeCode": { "p50": 245, "p95": 890, "p99": 1200, "count": 150 },
"github": { "p50": 180, "p95": 620, "p99": 950, "count": 320 }
}
}
```
---
## Budget
```bash
# Get budget status for all API keys
GET /api/usage/budget
# Set or update a budget
POST /api/usage/budget
Content-Type: application/json
{
"keyId": "key-123",
"limit": 50.00,
"period": "monthly"
}
```
---
## Model Availability
```bash
# Get real-time model availability across all providers
GET /api/models/availability
# Check availability for a specific model
POST /api/models/availability
Content-Type: application/json
{
"model": "claude-sonnet-4-5-20250929"
}
```
---
## Request Processing
1. Client sends request to `/v1/*`
2. Route handler calls `handleChat`, `handleEmbedding`, `handleAudioTranscription`, or `handleImageGeneration`
3. Model is resolved (direct provider/model or alias/combo)
4. Credentials selected from local DB with account availability filtering
5. For chat: `handleChatCore` — format detection, translation, cache check, idempotency check
6. Provider executor sends upstream request
7. Response translated back to client format (chat) or returned as-is (embeddings/images/audio)
8. Usage/logging recorded
9. Fallback applies on errors according to combo rules
Full architecture reference: [`ARCHITECTURE.md`](ARCHITECTURE.md)
---
## Authentication
- Dashboard routes (`/dashboard/*`) use `auth_token` cookie
- Login uses saved password hash; fallback to `INITIAL_PASSWORD`
- `requireLogin` toggleable via `/api/settings/require-login`
- `/v1/*` routes optionally require Bearer API key when `REQUIRE_API_KEY=true`
+54 -2
View File
@@ -1,6 +1,6 @@
# OmniRoute Architecture
_Last updated: 2026-02-14_
_Last updated: 2026-02-15_
## Executive Summary
@@ -24,8 +24,19 @@ Core capabilities:
- Thinking budget management (passthrough/auto/custom/adaptive)
- Global system prompt injection
- Session tracking and fingerprinting
- Per-account enhanced rate limiting
- Per-account enhanced rate limiting with provider-specific profiles
- Circuit breaker pattern for provider resilience
- Anti-thundering herd protection with mutex locking
- Signature-based request deduplication cache
- Domain layer: model availability, cost rules, fallback policy, lockout policy
- Domain state persistence (SQLite write-through cache for fallbacks, budgets, lockouts, circuit breakers)
- Policy engine for centralized request evaluation (lockout → budget → fallback)
- Request telemetry with p50/p95/p99 latency aggregation
- Correlation ID (X-Request-Id) for end-to-end tracing
- Compliance audit logging with opt-out per API key
- Eval framework for LLM quality assurance
- Resilience UI dashboard with real-time circuit breaker status
- Modular OAuth providers (12 individual modules under `src/lib/oauth/providers/`)
Primary runtime model:
@@ -140,6 +151,16 @@ Management domains:
- System prompt: `src/app/api/settings/system-prompt` (GET/PUT)
- Sessions: `src/app/api/sessions` (GET)
- Rate limits: `src/app/api/rate-limits` (GET)
- Resilience: `src/app/api/resilience` (GET/PATCH) — provider profiles, circuit breaker, rate limit state
- Resilience reset: `src/app/api/resilience/reset` (POST) — reset breakers + cooldowns
- Cache stats: `src/app/api/cache/stats` (GET/DELETE)
- Model availability: `src/app/api/models/availability` (GET/POST)
- Telemetry: `src/app/api/telemetry/summary` (GET)
- Budget: `src/app/api/usage/budget` (GET/POST)
- Fallback chains: `src/app/api/fallback/chains` (GET/POST/DELETE)
- Compliance audit: `src/app/api/compliance/audit-log` (GET)
- Evals: `src/app/api/evals` (GET/POST), `src/app/api/evals/[suiteId]` (GET)
- Policies: `src/app/api/policies` (GET/POST)
## 2) SSE + Translation Core
@@ -170,6 +191,30 @@ Services (business logic):
- System prompt injection: `open-sse/services/systemPrompt.js`
- Thinking budget management: `open-sse/services/thinkingBudget.js`
- Wildcard model routing: `open-sse/services/wildcardRouter.js`
- Rate limit management: `open-sse/services/rateLimitManager.js`
- Circuit breaker: `open-sse/services/circuitBreaker.js`
Domain layer modules:
- Model availability: `src/lib/domain/modelAvailability.js`
- Cost rules/budgets: `src/lib/domain/costRules.js`
- Fallback policy: `src/lib/domain/fallbackPolicy.js`
- Combo resolver: `src/lib/domain/comboResolver.js`
- Lockout policy: `src/lib/domain/lockoutPolicy.js`
- Policy engine: `src/domain/policyEngine.js` — centralized lockout → budget → fallback evaluation
- Error codes catalog: `src/lib/domain/errorCodes.js`
- Request ID: `src/lib/domain/requestId.js`
- Fetch timeout: `src/lib/domain/fetchTimeout.js`
- Request telemetry: `src/lib/domain/requestTelemetry.js`
- Compliance/audit: `src/lib/domain/compliance/index.js`
- Eval runner: `src/lib/domain/evalRunner.js`
- Domain state persistence: `src/lib/db/domainState.js` — SQLite CRUD for fallback chains, budgets, cost history, lockout state, circuit breakers
OAuth provider modules (12 individual files under `src/lib/oauth/providers/`):
- Registry index: `src/lib/oauth/providers/index.js`
- Individual 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`
- Thin wrapper: `src/lib/oauth/providers.js` — re-exports from individual modules
## 3) Persistence Layer
@@ -184,6 +229,13 @@ Usage DB:
- `src/lib/usageDb.js`
- files: `${DATA_DIR}/usage.json`, `${DATA_DIR}/log.txt`, `${DATA_DIR}/call_logs/`
- follows same base directory policy as `localDb` (`DATA_DIR`, then `XDG_CONFIG_HOME/omniroute` when set)
- decomposed into focused sub-modules: `migrations.js`, `usageHistory.js`, `costCalculator.js`, `usageStats.js`, `callLogs.js`
Domain State DB (SQLite):
- `src/lib/db/domainState.js` — CRUD operations for domain state
- Tables (created in `src/lib/db/core.js`): `domain_fallback_chains`, `domain_budgets`, `domain_cost_history`, `domain_lockout_state`, `domain_circuit_breakers`
- Write-through cache pattern: in-memory Maps are authoritative at runtime; mutations are written synchronously to SQLite; state is restored from DB on cold start
## 4) Auth + Security Surfaces
+92
View File
@@ -0,0 +1,92 @@
# Rate Limiting & Flow Control Overhaul — Tasks
> Referência: [Relatório de Análise](../walkthrough.md) · Fase docs em `/docs/phases/`
---
## Fase 1 — Error Classification & Provider Profiles
### Backend Core
- [ ] `constants.js` — Substituir `COOLDOWN_MS.transient` por `transientInitial` (5s) + `transientMax` (60s)
- [ ] `constants.js` — Adicionar `PROVIDER_PROFILES` (oauth / apikey) com cooldowns diferenciados
- [ ] `constants.js` — Adicionar `DEFAULT_API_LIMITS` (100 RPM, 200ms minTime)
- [ ] `providerRegistry.js` — Criar helper `getProviderCategory(providerId)``"oauth"` | `"apikey"`
- [ ] `accountFallback.js` — Aceitar `provider` como parâmetro em `checkFallbackError`
- [ ] `accountFallback.js` — Implementar backoff exponencial para 502/503/504 transientes
- [ ] `accountFallback.js` — Calcular cooldown baseado no perfil do provedor
- [ ] `accountFallback.js` — Adicionar helper `getProviderProfile(provider)`
### Callers (propagar `provider`)
- [ ] `auth.js``markAccountUnavailable` — Passar `provider` para `checkFallbackError`
- [ ] `combo.js``handleComboChat` / `handleRoundRobinCombo` — Passar `provider` nos erros
### Testes
- [ ] Atualizar `rate-limit-enhanced.test.mjs` — Teste "transient errors don't increase backoff" → `newBackoffLevel = 1`
- [ ] Criar `error-classification.test.mjs` — Cooldown exponencial 502, perfis OAuth/API, helper `getProviderCategory`
---
## Fase 2 — Circuit Breaker no Combo Pipeline
### Backend
- [ ] `combo.js` — Importar `getCircuitBreaker` e `CircuitBreakerOpenError`
- [ ] `combo.js``handleComboChat` — Verificar `breaker.canExecute()` antes de cada modelo
- [ ] `combo.js``handleRoundRobinCombo` — Integrar breaker per-model
- [ ] `combo.js` — Marcar `semaphore.markRateLimited` para 502/503/504 (não só 429)
- [ ] `combo.js` — Implementar early exit quando todos os modelos têm breaker OPEN
### Testes
- [ ] Criar `combo-circuit-breaker.test.mjs` — Combo skip breaker OPEN, early exit, semáforo 502
---
## Fase 3 — Anti-Thundering Herd & Auto Rate Limit
### Backend
- [ ] `rateLimitManager.js` — Auto-enable para `apikey` providers com limites elevados
- [ ] `rateLimitManager.js` — Criar limiter com defaults (100 RPM) quando não configurado
- [ ] `auth.js` — Adicionar mutex na `markAccountUnavailable` para evitar marcação paralela
### Testes
- [ ] Criar `thundering-herd.test.mjs` — Mutex, auto-enable, limites não restritivos
---
## Fase 4 — Frontend Resilience UI
### Settings Page
- [ ] `settings/page.js` — Adicionar tab "Resilience" (icon: `health_and_safety`) entre Routing e Pricing
### Novos Componentes
- [ ] Criar `ResilienceTab.js` — Layout com 3 cards
- [ ] Criar `ProviderProfilesCard.js` — Toggle OAuth/API Key, inputs para cooldowns
- [ ] Criar `CircuitBreakerCard.js` — Status real-time per-provider, auto-refresh 5s, botão reset
- [ ] Criar `RateLimitOverviewCard.js` — Tabela providers × accounts × cooldown
### API Routes
- [ ] Criar `api/resilience/route.js` — GET (estado completo) + PATCH (salvar perfis)
- [ ] Criar `api/resilience/reset/route.js` — POST (resetar breakers + cooldowns)
### Migração
- [ ] Avaliar se `PoliciesPanel.js` pode ser removido ou simplificado após nova aba
---
## Verificação Final
- [ ] Rodar todos os testes unitários: `node --test tests/unit/*.test.mjs`
- [ ] Build do Next.js: `npm run build`
- [ ] Verificar aba Resilience no browser
- [ ] Testar persistência dos perfis (salvar → reload)
- [ ] Testar Reset All Breakers
+211
View File
@@ -0,0 +1,211 @@
# Troubleshooting
Common problems and solutions for OmniRoute.
---
## Quick Fixes
| Problem | Solution |
| ----------------------------- | ------------------------------------------------------------------ |
| First login not working | Check `INITIAL_PASSWORD` in `.env` (default: `123456`) |
| Dashboard opens on wrong port | Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128` |
| No request logs under `logs/` | Set `ENABLE_REQUEST_LOGS=true` |
---
## Provider Issues
### "Language model did not provide messages"
**Cause:** Provider quota exhausted.
**Fix:**
1. Check dashboard quota tracker
2. Use a combo with fallback tiers
3. Switch to cheaper/free tier
### Rate Limiting
**Cause:** Subscription quota exhausted.
**Fix:**
- Add fallback: `cc/claude-opus-4-6 → glm/glm-4.7 → if/kimi-k2-thinking`
- Use GLM/MiniMax as cheap backup
### OAuth Token Expired
OmniRoute auto-refreshes tokens. If issues persist:
1. Dashboard → Provider → Reconnect
2. Delete and re-add the provider connection
---
## Cloud Issues
### Cloud Sync Errors
1. Verify `BASE_URL` points to your running instance (e.g., `http://localhost:20128`)
2. Verify `CLOUD_URL` points to your cloud endpoint (e.g., `https://omniroute.dev`)
3. Keep `NEXT_PUBLIC_*` values aligned with server-side values
### Cloud `stream=false` Returns 500
**Symptom:** `Unexpected token 'd'...` on cloud endpoint for non-streaming calls.
**Cause:** Upstream returns SSE payload while client expects JSON.
**Workaround:** Use `stream=true` for cloud direct calls. Local runtime includes SSE→JSON fallback.
### Cloud Says Connected but "Invalid API key"
1. Create a fresh key from local dashboard (`/api/keys`)
2. Run cloud sync: Enable Cloud → Sync Now
3. Old/non-synced keys can still return `401` on cloud
---
## Docker Issues
### CLI Tool Shows Not Installed
1. Check runtime fields: `curl http://localhost:20128/api/cli-tools/runtime/codex | jq`
2. For portable mode: use image target `runner-cli` (bundled CLIs)
3. For host mount mode: set `CLI_EXTRA_PATHS` and mount host bin directory as read-only
4. If `installed=true` and `runnable=false`: binary was found but failed healthcheck
### Quick Runtime Validation
```bash
curl -s http://localhost:20128/api/cli-tools/codex-settings | jq '{installed,runnable,commandPath,runtimeMode,reason}'
curl -s http://localhost:20128/api/cli-tools/claude-settings | jq '{installed,runnable,commandPath,runtimeMode,reason}'
curl -s http://localhost:20128/api/cli-tools/openclaw-settings | jq '{installed,runnable,commandPath,runtimeMode,reason}'
```
---
## Cost Issues
### High Costs
1. Check usage stats in Dashboard → Usage
2. Switch primary model to GLM/MiniMax
3. Use free tier (Gemini CLI, iFlow) for non-critical tasks
4. Set cost budgets per API key: Dashboard → API Keys → Budget
---
## Debugging
### Enable Request Logs
Set `ENABLE_REQUEST_LOGS=true` in your `.env` file. Logs appear under `logs/` directory.
### Check Provider Health
```bash
# Health dashboard
http://localhost:20128/dashboard/health
# API health check
curl http://localhost:20128/api/monitoring/health
```
### Runtime Storage
- Main state: `${DATA_DIR}/db.json` (providers, combos, aliases, keys, settings)
- Usage: `${DATA_DIR}/usage.json`, `${DATA_DIR}/log.txt`, `${DATA_DIR}/call_logs/`
- Request logs: `<repo>/logs/...` (when `ENABLE_REQUEST_LOGS=true`)
---
## Circuit Breaker Issues
### Provider stuck in OPEN state
When a provider's circuit breaker is OPEN, requests are blocked until the cooldown expires.
**Fix:**
1. Go to **Dashboard → Settings → Resilience**
2. Check the circuit breaker card for the affected provider
3. Click **Reset All** to clear all breakers, or wait for the cooldown to expire
4. Verify the provider is actually available before resetting
### Provider keeps tripping the circuit breaker
If a provider repeatedly enters OPEN state:
1. Check **Dashboard → Health → Provider Health** for the failure pattern
2. Go to **Settings → Resilience → Provider Profiles** and increase the failure threshold
3. Check if the provider has changed API limits or requires re-authentication
4. Review latency telemetry — high latency may cause timeout-based failures
---
## Audio Transcription Issues
### "Unsupported model" error
- Ensure you're using the correct prefix: `deepgram/nova-3` or `assemblyai/best`
- Verify the provider is connected in **Dashboard → Providers**
### Transcription returns empty or fails
- Check supported audio formats: `mp3`, `wav`, `m4a`, `flac`, `ogg`, `webm`
- Verify file size is within provider limits (typically < 25MB)
- Check provider API key validity in the provider card
---
## Translator Debugging
Use **Dashboard → Translator** to debug format translation issues:
| Mode | When to Use |
| ---------------- | -------------------------------------------------------------------------------------------- |
| **Playground** | Compare input/output formats side by side — paste a failing request to see how it translates |
| **Chat Tester** | Send live messages and inspect the full request/response payload including headers |
| **Test Bench** | Run batch tests across format combinations to find which translations are broken |
| **Live Monitor** | Watch real-time request flow to catch intermittent translation issues |
### Common format issues
- **Thinking tags not appearing** — Check if the target provider supports thinking and the thinking budget setting
- **Tool calls dropping** — Some format translations may strip unsupported fields; verify in Playground mode
- **System prompt missing** — Claude and Gemini handle system prompts differently; check translation output
---
## Resilience Settings
### Auto rate-limit not triggering
- Auto rate-limit only applies to API key providers (not OAuth/subscription)
- Verify **Settings → Resilience → Provider Profiles** has auto-rate-limit enabled
- Check if the provider returns `429` status codes or `Retry-After` headers
### Tuning exponential backoff
Provider profiles support these settings:
- **Base delay** — Initial wait time after first failure (default: 1s)
- **Max delay** — Maximum wait time cap (default: 30s)
- **Multiplier** — How much to increase delay per consecutive failure (default: 2x)
### Anti-thundering herd
When many concurrent requests hit a rate-limited provider, OmniRoute uses mutex + auto rate-limiting to serialize requests and prevent cascading failures. This is automatic for API key providers.
---
## Still Stuck?
- **GitHub Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues)
- **Architecture**: See [`docs/ARCHITECTURE.md`](ARCHITECTURE.md) for internal details
- **API Reference**: See [`docs/API_REFERENCE.md`](API_REFERENCE.md) for all endpoints
- **Health Dashboard**: Check **Dashboard → Health** for real-time system status
- **Translator**: Use **Dashboard → Translator** to debug format issues
+639
View File
@@ -0,0 +1,639 @@
# User Guide
Complete guide for configuring providers, creating combos, integrating CLI tools, and deploying OmniRoute.
---
## Table of Contents
- [Pricing at a Glance](#-pricing-at-a-glance)
- [Use Cases](#-use-cases)
- [Provider Setup](#-provider-setup)
- [CLI Integration](#-cli-integration)
- [Deployment](#-deployment)
- [Available Models](#-available-models)
- [Advanced Features](#-advanced-features)
---
## 💰 Pricing at a Glance
| Tier | Provider | Cost | Quota Reset | Best For |
| ------------------- | ----------------- | ----------- | ---------------- | -------------------- |
| **💳 SUBSCRIPTION** | Claude Code (Pro) | $20/mo | 5h + weekly | Already subscribed |
| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users |
| | Gemini CLI | **FREE** | 180K/mo + 1K/day | Everyone! |
| | GitHub Copilot | $10-19/mo | Monthly | GitHub users |
| **🔑 API KEY** | DeepSeek | Pay per use | None | Cheap reasoning |
| | Groq | Pay per use | None | Ultra-fast inference |
| | xAI (Grok) | Pay per use | None | Grok 4 reasoning |
| | Mistral | Pay per use | None | EU-hosted models |
| | Perplexity | Pay per use | None | Search-augmented |
| | Together AI | Pay per use | None | Open-source models |
| | Fireworks AI | Pay per use | None | Fast FLUX images |
| | Cerebras | Pay per use | None | Wafer-scale speed |
| | Cohere | Pay per use | None | Command R+ RAG |
| | NVIDIA NIM | Pay per use | None | Enterprise models |
| **💰 CHEAP** | GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup |
| | MiniMax M2.1 | $0.2/1M | 5-hour rolling | Cheapest option |
| | Kimi K2 | $9/mo flat | 10M tokens/mo | Predictable cost |
| **🆓 FREE** | iFlow | $0 | Unlimited | 8 models free |
| | Qwen | $0 | Unlimited | 3 models free |
| | Kiro | $0 | Unlimited | Claude free |
**💡 Pro Tip:** Start with Gemini CLI (180K free/month) + iFlow (unlimited free) combo = $0 cost!
---
## 🎯 Use Cases
### Case 1: "I have Claude Pro subscription"
**Problem:** Quota expires unused, rate limits during heavy coding
```
Combo: "maximize-claude"
1. cc/claude-opus-4-6 (use subscription fully)
2. glm/glm-4.7 (cheap backup when quota out)
3. if/kimi-k2-thinking (free emergency fallback)
Monthly cost: $20 (subscription) + ~$5 (backup) = $25 total
vs. $20 + hitting limits = frustration
```
### Case 2: "I want zero cost"
**Problem:** Can't afford subscriptions, need reliable AI coding
```
Combo: "free-forever"
1. gc/gemini-3-flash (180K free/month)
2. if/kimi-k2-thinking (unlimited free)
3. qw/qwen3-coder-plus (unlimited free)
Monthly cost: $0
Quality: Production-ready models
```
### Case 3: "I need 24/7 coding, no interruptions"
**Problem:** Deadlines, can't afford downtime
```
Combo: "always-on"
1. cc/claude-opus-4-6 (best quality)
2. cx/gpt-5.2-codex (second subscription)
3. glm/glm-4.7 (cheap, resets daily)
4. minimax/MiniMax-M2.1 (cheapest, 5h reset)
5. if/kimi-k2-thinking (free unlimited)
Result: 5 layers of fallback = zero downtime
Monthly cost: $20-200 (subscriptions) + $10-20 (backup)
```
### Case 4: "I want FREE AI in OpenClaw"
**Problem:** Need AI assistant in messaging apps, completely free
```
Combo: "openclaw-free"
1. if/glm-4.7 (unlimited free)
2. if/minimax-m2.1 (unlimited free)
3. if/kimi-k2-thinking (unlimited free)
Monthly cost: $0
Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
```
---
## 📖 Provider Setup
### 🔐 Subscription Providers
#### Claude Code (Pro/Max)
```bash
Dashboard → Providers → Connect Claude Code
→ OAuth login → Auto token refresh
→ 5-hour + weekly quota tracking
Models:
cc/claude-opus-4-6
cc/claude-sonnet-4-5-20250929
cc/claude-haiku-4-5-20251001
```
**Pro Tip:** Use Opus for complex tasks, Sonnet for speed. OmniRoute tracks quota per model!
#### OpenAI Codex (Plus/Pro)
```bash
Dashboard → Providers → Connect Codex
→ OAuth login (port 1455)
→ 5-hour + weekly reset
Models:
cx/gpt-5.2-codex
cx/gpt-5.1-codex-max
```
#### Gemini CLI (FREE 180K/month!)
```bash
Dashboard → Providers → Connect Gemini CLI
→ Google OAuth
→ 180K completions/month + 1K/day
Models:
gc/gemini-3-flash-preview
gc/gemini-2.5-pro
```
**Best Value:** Huge free tier! Use this before paid tiers.
#### GitHub Copilot
```bash
Dashboard → Providers → Connect GitHub
→ OAuth via GitHub
→ Monthly reset (1st of month)
Models:
gh/gpt-5
gh/claude-4.5-sonnet
gh/gemini-3-pro
```
### 💰 Cheap Providers
#### GLM-4.7 (Daily reset, $0.6/1M)
1. Sign up: [Zhipu AI](https://open.bigmodel.cn/)
2. Get API key from Coding Plan
3. Dashboard → Add API Key: Provider: `glm`, API Key: `your-key`
**Use:** `glm/glm-4.7`**Pro Tip:** Coding Plan offers 3× quota at 1/7 cost! Reset daily 10:00 AM.
#### MiniMax M2.1 (5h reset, $0.20/1M)
1. Sign up: [MiniMax](https://www.minimax.io/)
2. Get API key → Dashboard → Add API Key
**Use:** `minimax/MiniMax-M2.1`**Pro Tip:** Cheapest option for long context (1M tokens)!
#### Kimi K2 ($9/month flat)
1. Subscribe: [Moonshot AI](https://platform.moonshot.ai/)
2. Get API key → Dashboard → Add API Key
**Use:** `kimi/kimi-latest`**Pro Tip:** Fixed $9/month for 10M tokens = $0.90/1M effective cost!
### 🆓 FREE Providers
#### iFlow (8 FREE models)
```bash
Dashboard → Connect iFlow → OAuth login → Unlimited usage
Models: if/kimi-k2-thinking, if/qwen3-coder-plus, if/glm-4.7, if/minimax-m2, if/deepseek-r1
```
#### Qwen (3 FREE models)
```bash
Dashboard → Connect Qwen → Device code auth → Unlimited usage
Models: qw/qwen3-coder-plus, qw/qwen3-coder-flash
```
#### Kiro (Claude FREE)
```bash
Dashboard → Connect Kiro → AWS Builder ID or Google/GitHub → Unlimited
Models: kr/claude-sonnet-4.5, kr/claude-haiku-4.5
```
---
## 🎨 Combos
### Example 1: Maximize Subscription → Cheap Backup
```
Dashboard → Combos → Create New
Name: premium-coding
Models:
1. cc/claude-opus-4-6 (Subscription primary)
2. glm/glm-4.7 (Cheap backup, $0.6/1M)
3. minimax/MiniMax-M2.1 (Cheapest fallback, $0.20/1M)
Use in CLI: premium-coding
```
### Example 2: Free-Only (Zero Cost)
```
Name: free-combo
Models:
1. gc/gemini-3-flash-preview (180K free/month)
2. if/kimi-k2-thinking (unlimited)
3. qw/qwen3-coder-plus (unlimited)
Cost: $0 forever!
```
---
## 🔧 CLI Integration
### Cursor IDE
```
Settings → Models → Advanced:
OpenAI API Base URL: http://localhost:20128/v1
OpenAI API Key: [from omniroute dashboard]
Model: cc/claude-opus-4-6
```
### Claude Code
Edit `~/.claude/config.json`:
```json
{
"anthropic_api_base": "http://localhost:20128/v1",
"anthropic_api_key": "your-omniroute-api-key"
}
```
### Codex CLI
```bash
export OPENAI_BASE_URL="http://localhost:20128"
export OPENAI_API_KEY="your-omniroute-api-key"
codex "your prompt"
```
### OpenClaw
Edit `~/.openclaw/openclaw.json`:
```json
{
"agents": {
"defaults": {
"model": { "primary": "omniroute/if/glm-4.7" }
}
},
"models": {
"providers": {
"omniroute": {
"baseUrl": "http://localhost:20128/v1",
"apiKey": "your-omniroute-api-key",
"api": "openai-completions",
"models": [{ "id": "if/glm-4.7", "name": "glm-4.7" }]
}
}
}
}
```
**Or use Dashboard:** CLI Tools → OpenClaw → Auto-config
### Cline / Continue / RooCode
```
Provider: OpenAI Compatible
Base URL: http://localhost:20128/v1
API Key: [from dashboard]
Model: cc/claude-opus-4-6
```
---
## 🚀 Deployment
### VPS Deployment
```bash
git clone https://github.com/diegosouzapw/OmniRoute.git
cd OmniRoute && npm install && npm run build
export JWT_SECRET="your-secure-secret-change-this"
export INITIAL_PASSWORD="your-password"
export DATA_DIR="/var/lib/omniroute"
export PORT="20128"
export HOSTNAME="0.0.0.0"
export NODE_ENV="production"
export NEXT_PUBLIC_BASE_URL="http://localhost:20128"
export API_KEY_SECRET="endpoint-proxy-api-key-secret"
npm run start
# Or: pm2 start npm --name omniroute -- start
```
### Docker
```bash
# Build image (default = runner-cli with codex/claude/droid preinstalled)
docker build -t omniroute:cli .
# Portable mode (recommended)
docker run -d --name omniroute -p 20128:20128 --env-file ./.env -v omniroute-data:/app/data omniroute:cli
```
For host-integrated mode with CLI binaries, see the Docker section in the main docs.
### Environment Variables
| Variable | Default | Description |
| --------------------- | ------------------------------------ | ------------------------------------------------------- |
| `JWT_SECRET` | `omniroute-default-secret-change-me` | JWT signing secret (**change in production**) |
| `INITIAL_PASSWORD` | `123456` | First login password |
| `DATA_DIR` | `~/.omniroute` | Data directory (db, usage, logs) |
| `PORT` | framework default | Service port (`20128` in examples) |
| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) |
| `NODE_ENV` | runtime default | Set `production` for deploy |
| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL |
| `CLOUD_URL` | `https://omniroute.dev` | Cloud sync endpoint base URL |
| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys |
| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` |
| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs |
| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (behind HTTPS reverse proxy) |
For the full environment variable reference, see the [README](../README.md).
---
## 📊 Available Models
<details>
<summary><b>View all available models</b></summary>
**Claude Code (`cc/`)** — Pro/Max: `cc/claude-opus-4-6`, `cc/claude-sonnet-4-5-20250929`, `cc/claude-haiku-4-5-20251001`
**Codex (`cx/`)** — Plus/Pro: `cx/gpt-5.2-codex`, `cx/gpt-5.1-codex-max`
**Gemini CLI (`gc/`)** — FREE: `gc/gemini-3-flash-preview`, `gc/gemini-2.5-pro`
**GitHub Copilot (`gh/`)**: `gh/gpt-5`, `gh/claude-4.5-sonnet`
**GLM (`glm/`)** — $0.6/1M: `glm/glm-4.7`
**MiniMax (`minimax/`)** — $0.2/1M: `minimax/MiniMax-M2.1`
**iFlow (`if/`)** — FREE: `if/kimi-k2-thinking`, `if/qwen3-coder-plus`, `if/deepseek-r1`
**Qwen (`qw/`)** — FREE: `qw/qwen3-coder-plus`, `qw/qwen3-coder-flash`
**Kiro (`kr/`)** — FREE: `kr/claude-sonnet-4.5`, `kr/claude-haiku-4.5`
**DeepSeek (`ds/`)**: `ds/deepseek-chat`, `ds/deepseek-reasoner`
**Groq (`groq/`)**: `groq/llama-3.3-70b-versatile`, `groq/llama-4-maverick-17b-128e-instruct`
**xAI (`xai/`)**: `xai/grok-4`, `xai/grok-4-0709-fast-reasoning`, `xai/grok-code-mini`
**Mistral (`mistral/`)**: `mistral/mistral-large-2501`, `mistral/codestral-2501`
**Perplexity (`pplx/`)**: `pplx/sonar-pro`, `pplx/sonar`
**Together AI (`together/`)**: `together/meta-llama/Llama-3.3-70B-Instruct-Turbo`
**Fireworks AI (`fireworks/`)**: `fireworks/accounts/fireworks/models/deepseek-v3p1`
**Cerebras (`cerebras/`)**: `cerebras/llama-3.3-70b`
**Cohere (`cohere/`)**: `cohere/command-r-plus-08-2024`
**NVIDIA NIM (`nvidia/`)**: `nvidia/nvidia/llama-3.3-70b-instruct`
</details>
---
## 🧩 Advanced Features
### Custom Models
Add any model ID to any provider without waiting for an app update:
```bash
# Via API
curl -X POST http://localhost:20128/api/provider-models \
-H "Content-Type: application/json" \
-d '{"provider": "openai", "modelId": "gpt-4.5-preview", "modelName": "GPT-4.5 Preview"}'
# List: curl http://localhost:20128/api/provider-models?provider=openai
# Remove: curl -X DELETE "http://localhost:20128/api/provider-models?provider=openai&model=gpt-4.5-preview"
```
Or use Dashboard: **Providers → [Provider] → Custom Models**.
### Dedicated Provider Routes
Route requests directly to a specific provider with model validation:
```bash
POST http://localhost:20128/v1/providers/openai/chat/completions
POST http://localhost:20128/v1/providers/openai/embeddings
POST http://localhost:20128/v1/providers/fireworks/images/generations
```
The provider prefix is auto-added if missing. Mismatched models return `400`.
### Network Proxy Configuration
```bash
# Set global proxy
curl -X PUT http://localhost:20128/api/settings/proxy \
-d '{"global": {"type":"http","host":"proxy.example.com","port":"8080"}}'
# Per-provider proxy
curl -X PUT http://localhost:20128/api/settings/proxy \
-d '{"providers": {"openai": {"type":"socks5","host":"proxy.example.com","port":"1080"}}}'
# Test proxy
curl -X POST http://localhost:20128/api/settings/proxy/test \
-d '{"proxy":{"type":"socks5","host":"proxy.example.com","port":"1080"}}'
```
**Precedence:** Key-specific → Combo-specific → Provider-specific → Global → Environment.
### Model Catalog API
```bash
curl http://localhost:20128/api/models/catalog
```
Returns models grouped by provider with types (`chat`, `embedding`, `image`).
### Cloud Sync
- Sync providers, combos, and settings across devices
- Automatic background sync with timeout + fail-fast
- Prefer server-side `BASE_URL`/`CLOUD_URL` in production
### LLM Gateway Intelligence (Phase 9)
- **Semantic Cache** — Auto-caches non-streaming, temperature=0 responses (bypass with `X-OmniRoute-No-Cache: true`)
- **Request Idempotency** — Deduplicates requests within 5s via `Idempotency-Key` or `X-Request-Id` header
- **Progress Tracking** — Opt-in SSE `event: progress` events via `X-OmniRoute-Progress: true` header
---
### Translator Playground
Access via **Dashboard → Translator**. Debug and visualize how OmniRoute translates API requests between providers.
| Mode | Purpose |
| ---------------- | -------------------------------------------------------------------------------------- |
| **Playground** | Select source/target formats, paste a request, and see the translated output instantly |
| **Chat Tester** | Send live chat messages through the proxy and inspect the full request/response cycle |
| **Test Bench** | Run batch tests across multiple format combinations to verify translation correctness |
| **Live Monitor** | Watch real-time translations as requests flow through the proxy |
**Use cases:**
- Debug why a specific client/provider combination fails
- Verify that thinking tags, tool calls, and system prompts translate correctly
- Compare format differences between OpenAI, Claude, Gemini, and Responses API formats
---
### Routing Strategies
Configure via **Dashboard → Settings → Routing**.
| Strategy | Description |
| ------------------------------ | ------------------------------------------------------------------------------------------------ |
| **Fill First** | Uses accounts in priority order — primary account handles all requests until unavailable |
| **Round Robin** | Cycles through all accounts with a configurable sticky limit (default: 3 calls per account) |
| **P2C (Power of Two Choices)** | Picks 2 random accounts and routes to the healthier one — balances load with awareness of health |
#### Wildcard Model Aliases
Create wildcard patterns to remap model names:
```
Pattern: claude-sonnet-* → Target: cc/claude-sonnet-4-5-20250929
Pattern: gpt-* → Target: gh/gpt-5.1-codex
```
Wildcards support `*` (any characters) and `?` (single character).
#### Fallback Chains
Define global fallback chains that apply across all requests:
```
Chain: production-fallback
1. cc/claude-opus-4-6
2. gh/gpt-5.1-codex
3. glm/glm-4.7
```
---
### Resilience & Circuit Breakers
Configure via **Dashboard → Settings → Resilience**.
OmniRoute implements provider-level resilience with three components:
1. **Circuit Breaker** — Tracks failures per provider and automatically opens the circuit when a threshold is reached:
- **CLOSED** (Healthy) — Requests flow normally
- **OPEN** — Provider is temporarily blocked after repeated failures
- **HALF_OPEN** — Testing if provider has recovered
2. **Provider Profiles** — Per-provider configuration for:
- Failure threshold (how many failures before opening)
- Cooldown duration
- Rate limit detection sensitivity
- Exponential backoff parameters
3. **Rate Limit Auto-Detection** — Monitors `429` and `Retry-After` headers to proactively avoid hitting provider rate limits.
**Pro Tip:** Use **Reset All** button to clear all circuit breakers and cooldowns when a provider recovers from an outage.
---
### Costs & Budget Management
Access via **Dashboard → Costs**.
| Tab | Purpose |
| ----------- | ---------------------------------------------------------------------------------------- |
| **Budget** | Set spending limits per API key with daily/weekly/monthly budgets and real-time tracking |
| **Pricing** | View and edit model pricing entries — cost per 1K input/output tokens per provider |
```bash
# API: Set a budget
curl -X POST http://localhost:20128/api/usage/budget \
-H "Content-Type: application/json" \
-d '{"keyId": "key-123", "limit": 50.00, "period": "monthly"}'
# API: Get current budget status
curl http://localhost:20128/api/usage/budget
```
**Cost Tracking:** Every request logs token usage and calculates cost using the pricing table. View breakdowns in **Dashboard → Usage** by provider, model, and API key.
---
### Audio Transcription
OmniRoute supports audio transcription via the OpenAI-compatible endpoint:
```bash
POST /v1/audio/transcriptions
Authorization: Bearer your-api-key
Content-Type: multipart/form-data
# Example with curl
curl -X POST http://localhost:20128/v1/audio/transcriptions \
-H "Authorization: Bearer your-api-key" \
-F "file=@audio.mp3" \
-F "model=deepgram/nova-3"
```
Available providers: **Deepgram** (`deepgram/`), **AssemblyAI** (`assemblyai/`).
Supported audio formats: `mp3`, `wav`, `m4a`, `flac`, `ogg`, `webm`.
---
### Combo Balancing Strategies
Configure per-combo balancing in **Dashboard → Combos → Create/Edit → Strategy**.
| Strategy | Description |
| ------------------ | ------------------------------------------------------------------------ |
| **Round-Robin** | Rotates through models sequentially |
| **Priority** | Always tries the first model; falls back only on error |
| **Random** | Picks a random model from the combo for each request |
| **Weighted** | Routes proportionally based on assigned weights per model |
| **Least-Used** | Routes to the model with the fewest recent requests (uses combo metrics) |
| **Cost-Optimized** | Routes to the cheapest available model (uses pricing table) |
Global combo defaults can be set in **Dashboard → Settings → Routing → Combo Defaults**.
---
### Health Dashboard
Access via **Dashboard → Health**. Real-time system health overview with 6 cards:
| Card | What It Shows |
| --------------------- | ----------------------------------------------------------- |
| **System Status** | Uptime, version, memory usage, data directory |
| **Provider Health** | Per-provider circuit breaker state (Closed/Open/Half-Open) |
| **Rate Limits** | Active rate limit cooldowns per account with remaining time |
| **Active Lockouts** | Providers temporarily blocked by the lockout policy |
| **Signature Cache** | Deduplication cache stats (active keys, hit rate) |
| **Latency Telemetry** | p50/p95/p99 latency aggregation per provider |
**Pro Tip:** The Health page auto-refreshes every 10 seconds. Use the circuit breaker card to identify which providers are experiencing issues.
+979 -5
View File
File diff suppressed because it is too large Load Diff
+24 -11
View File
@@ -1,16 +1,29 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
const eslintConfig = defineConfig([
/** @type {import("eslint").Linter.Config[]} */
const eslintConfig = [
...nextVitals,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
// FASE-02: Security rules
{
rules: {
"no-eval": "error",
"no-implied-eval": "error",
"no-new-func": "error",
},
},
// Global ignores
{
ignores: [
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
"tests/**",
"scripts/**",
"bin/**",
"node_modules/**",
],
},
];
export default eslintConfig;
+39 -9
View File
@@ -120,20 +120,22 @@ export const BACKOFF_STEPS_MS = [60_000, 120_000, 300_000, 600_000, 1_200_000];
// Structured error classification for rate limiting decisions
export const RateLimitReason = {
QUOTA_EXHAUSTED: 'quota_exhausted', // Daily/monthly quota depleted
RATE_LIMIT_EXCEEDED: 'rate_limit_exceeded', // RPM/RPD limits hit
MODEL_CAPACITY: 'model_capacity', // Model overloaded (529, 503)
SERVER_ERROR: 'server_error', // 5xx errors
AUTH_ERROR: 'auth_error', // 401, 403
UNKNOWN: 'unknown',
QUOTA_EXHAUSTED: "quota_exhausted", // Daily/monthly quota depleted
RATE_LIMIT_EXCEEDED: "rate_limit_exceeded", // RPM/RPD limits hit
MODEL_CAPACITY: "model_capacity", // Model overloaded (529, 503)
SERVER_ERROR: "server_error", // 5xx errors
AUTH_ERROR: "auth_error", // 401, 403
UNKNOWN: "unknown",
};
// Error-based cooldown times (aligned with CLIProxyAPI)
export const COOLDOWN_MS = {
unauthorized: 2 * 60 * 1000, // 401 → 30 min
paymentRequired: 2 * 60 * 1000, // 402/403 → 30 min
unauthorized: 2 * 60 * 1000, // 401 → 2 min
paymentRequired: 2 * 60 * 1000, // 402/403 → 2 min
notFound: 2 * 60 * 1000, // 404 → 2 minutes
transient: 30 * 1000, // 408/500/502/503/504 → 1 min
transientInitial: 5 * 1000, // 408/500/502/503/504 first hit → 5s (backoff from here)
transientMax: 60 * 1000, // 502/503/504 backoff ceiling → 60s
transient: 5 * 1000, // Legacy alias → points to transientInitial
requestNotAllowed: 5 * 1000, // "Request not allowed" → 5 sec
// Legacy aliases for backward compatibility
rateLimit: 2 * 60 * 1000,
@@ -141,5 +143,33 @@ export const COOLDOWN_MS = {
authExpired: 2 * 60 * 1000,
};
// ─── Provider Resilience Profiles ───────────────────────────────────────────
// Separate behavior for OAuth (low-limit, session-based) vs API Key (high-limit, metered)
export const PROVIDER_PROFILES = {
oauth: {
transientCooldown: 5000, // 5s (session tokens — short recovery)
rateLimitCooldown: 60000, // 60s default when no retry-after header
maxBackoffLevel: 8, // Higher ceiling (sessions may stay bad longer)
circuitBreakerThreshold: 3, // Opens fast (low limit providers)
circuitBreakerReset: 60000, // 1min reset
},
apikey: {
transientCooldown: 3000, // 3s (API providers recover faster)
rateLimitCooldown: 0, // 0 = respect retry-after header from provider
maxBackoffLevel: 5, // Lower ceiling (API quotas reset at known intervals)
circuitBreakerThreshold: 5, // More tolerant (occasional 502 is normal)
circuitBreakerReset: 30000, // 30s reset
},
};
// Default rate limit values for API Key providers (auto-enabled safety net)
// These are intentionally HIGH — they won't restrict normal usage.
// Real limits are learned from provider response headers.
export const DEFAULT_API_LIMITS = {
requestsPerMinute: 100, // 100 RPM (most APIs allow 60-600 RPM)
minTimeBetweenRequests: 200, // 200ms minimum gap
concurrentRequests: 10, // Max 10 parallel per provider
};
// Skip patterns - requests containing these texts will bypass provider
export const SKIP_PATTERNS = ["Please write a 5-10 word title for the following conversation:"];
+12
View File
@@ -849,3 +849,15 @@ export function getRegistryEntry(provider) {
export function getRegisteredProviders() {
return Object.keys(REGISTRY);
}
/**
* Get provider category: "oauth" or "apikey"
* Used by the resilience layer to apply different cooldown/backoff profiles.
* @param {string} provider - Provider ID or alias
* @returns {"oauth"|"apikey"}
*/
export function getProviderCategory(provider) {
const entry = getRegistryEntry(provider);
if (!entry) return "apikey"; // Safe default for unknown providers
return entry.authType === "apikey" ? "apikey" : "oauth";
}
+70 -3
View File
@@ -29,6 +29,14 @@ import {
updateFromHeaders,
initializeRateLimits,
} from "../services/rateLimitManager.js";
import {
generateSignature,
getCachedResponse,
setCachedResponse,
isCacheable,
} from "@/lib/semanticCache.js";
import { getIdempotencyKey, checkIdempotency, saveIdempotency } from "@/lib/idempotencyLayer.js";
import { createProgressTransform, wantsProgress } from "../utils/progressTracker.js";
/**
* Core chat handler - shared between SSE and Worker
@@ -61,6 +69,24 @@ export async function handleChatCore({
const { provider, model } = modelInfo;
const startTime = Date.now();
// ── Phase 9.2: Idempotency check ──
const idempotencyKey = getIdempotencyKey(clientRawRequest?.headers);
const cachedIdemp = checkIdempotency(idempotencyKey);
if (cachedIdemp) {
log?.debug?.("IDEMPOTENCY", `Hit for key=${idempotencyKey?.slice(0, 12)}...`);
return {
success: true,
response: new Response(JSON.stringify(cachedIdemp.response), {
status: cachedIdemp.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"X-OmniRoute-Idempotent": "true",
},
}),
};
}
// Initialize rate limit settings from persisted DB (once, lazy)
await initializeRateLimits();
@@ -84,6 +110,25 @@ export async function handleChatCore({
// Default to streaming unless client explicitly sets stream: false
const stream = body.stream !== false;
// ── Phase 9.1: Semantic cache check (non-streaming, temp=0 only) ──
if (isCacheable(body, clientRawRequest?.headers)) {
const signature = generateSignature(model, body.messages, body.temperature, body.top_p);
const cached = getCachedResponse(signature);
if (cached) {
log?.debug?.("CACHE", `Semantic cache HIT for ${model}`);
return {
success: true,
response: new Response(JSON.stringify(cached), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"X-OmniRoute-Cache": "HIT",
},
}),
};
}
}
// Create request logger for this session: sourceFormat_targetFormat_model
const reqLogger = await createRequestLogger(sourceFormat, targetFormat, model);
@@ -444,12 +489,24 @@ export async function handleChatCore({
}
}
// ── Phase 9.1: Cache store (non-streaming, temp=0) ──
if (isCacheable(body, clientRawRequest?.headers)) {
const signature = generateSignature(model, body.messages, body.temperature, body.top_p);
const tokensSaved = usage?.prompt_tokens + usage?.completion_tokens || 0;
setCachedResponse(signature, model, translatedResponse, tokensSaved);
log?.debug?.("CACHE", `Stored response for ${model} (${tokensSaved} tokens)`);
}
// ── Phase 9.2: Save for idempotency ──
saveIdempotency(idempotencyKey, translatedResponse, 200);
return {
success: true,
response: new Response(JSON.stringify(translatedResponse), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"X-OmniRoute-Cache": "MISS",
},
}),
};
@@ -546,12 +603,22 @@ export async function handleChatCore({
);
}
// Pipe response through transform with disconnect detection
const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController);
// ── Phase 9.3: Progress tracking (opt-in) ──
const progressEnabled = wantsProgress(clientRawRequest?.headers);
let finalStream;
if (progressEnabled) {
const progressTransform = createProgressTransform({ signal: streamController.signal });
// Chain: provider → transform → progress → client
const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController);
finalStream = transformedBody.pipeThrough(progressTransform);
responseHeaders["X-OmniRoute-Progress"] = "enabled";
} else {
finalStream = pipeWithDisconnect(providerResponse, transformStream, streamController);
}
return {
success: true,
response: new Response(transformedBody, {
response: new Response(finalStream, {
headers: responseHeaders,
}),
};
+114 -19
View File
@@ -1,4 +1,24 @@
import { COOLDOWN_MS, BACKOFF_CONFIG, BACKOFF_STEPS_MS, RateLimitReason, HTTP_STATUS } from "../config/constants.js";
import {
COOLDOWN_MS,
BACKOFF_CONFIG,
BACKOFF_STEPS_MS,
RateLimitReason,
HTTP_STATUS,
PROVIDER_PROFILES,
} from "../config/constants.js";
import { getProviderCategory } from "../config/providerRegistry.js";
// ─── Provider Profile Helper ────────────────────────────────────────────────
/**
* Get the resilience profile for a provider (oauth or apikey).
* @param {string} provider - Provider ID or alias
* @returns {import('../config/constants.js').PROVIDER_PROFILES['oauth']}
*/
export function getProviderProfile(provider) {
const category = getProviderCategory(provider);
return PROVIDER_PROFILES[category] ?? PROVIDER_PROFILES.apikey;
}
// ─── Per-Model Lockout Tracking ─────────────────────────────────────────────
// In-memory map: "provider:connectionId:model" → { reason, until, lockedAt }
@@ -71,7 +91,13 @@ export function getAllModelLockouts() {
for (const [key, entry] of modelLockouts) {
if (now <= entry.until) {
const [provider, connectionId, model] = key.split(":");
active.push({ provider, connectionId, model, reason: entry.reason, remainingMs: entry.until - now });
active.push({
provider,
connectionId,
model,
reason: entry.reason,
remainingMs: entry.until - now,
});
}
}
return active;
@@ -100,7 +126,10 @@ export function parseRetryAfterFromBody(responseBody) {
const details = body.error?.details || body.details || [];
for (const detail of Array.isArray(details) ? details : []) {
if (detail.retryDelay) {
return { retryAfterMs: parseDelayString(detail.retryDelay), reason: RateLimitReason.RATE_LIMIT_EXCEEDED };
return {
retryAfterMs: parseDelayString(detail.retryDelay),
reason: RateLimitReason.RATE_LIMIT_EXCEEDED,
};
}
}
@@ -108,7 +137,10 @@ export function parseRetryAfterFromBody(responseBody) {
const msg = body.error?.message || body.message || "";
const retryMatch = msg.match(/retry\s+after\s+(\d+)\s*s/i);
if (retryMatch) {
return { retryAfterMs: parseInt(retryMatch[1], 10) * 1000, reason: RateLimitReason.RATE_LIMIT_EXCEEDED };
return {
retryAfterMs: parseInt(retryMatch[1], 10) * 1000,
reason: RateLimitReason.RATE_LIMIT_EXCEEDED,
};
}
// Anthropic: error type classification
@@ -150,16 +182,32 @@ export function classifyErrorText(errorText) {
if (!errorText) return RateLimitReason.UNKNOWN;
const lower = String(errorText).toLowerCase();
if (lower.includes("quota exceeded") || lower.includes("quota depleted") || lower.includes("billing")) {
if (
lower.includes("quota exceeded") ||
lower.includes("quota depleted") ||
lower.includes("billing")
) {
return RateLimitReason.QUOTA_EXHAUSTED;
}
if (lower.includes("rate limit") || lower.includes("too many requests") || lower.includes("rate_limit")) {
if (
lower.includes("rate limit") ||
lower.includes("too many requests") ||
lower.includes("rate_limit")
) {
return RateLimitReason.RATE_LIMIT_EXCEEDED;
}
if (lower.includes("capacity") || lower.includes("overloaded") || lower.includes("resource exhausted")) {
if (
lower.includes("capacity") ||
lower.includes("overloaded") ||
lower.includes("resource exhausted")
) {
return RateLimitReason.MODEL_CAPACITY;
}
if (lower.includes("unauthorized") || lower.includes("invalid api key") || lower.includes("authentication")) {
if (
lower.includes("unauthorized") ||
lower.includes("invalid api key") ||
lower.includes("authentication")
) {
return RateLimitReason.AUTH_ERROR;
}
if (lower.includes("server error") || lower.includes("internal error")) {
@@ -226,20 +274,35 @@ export function getQuotaCooldown(backoffLevel = 0) {
* @param {string} errorText - Error message text
* @param {number} backoffLevel - Current backoff level for exponential backoff
* @param {string} [model] - Optional model name for model-level lockout
* @param {string} [provider] - Provider ID for profile-aware cooldowns
* @returns {{ shouldFallback: boolean, cooldownMs: number, newBackoffLevel?: number, reason?: string }}
*/
export function checkFallbackError(status, errorText, backoffLevel = 0, model = null) {
export function checkFallbackError(
status,
errorText,
backoffLevel = 0,
model = null,
provider = null
) {
// Check error message FIRST - specific patterns take priority over status codes
if (errorText) {
const errorStr = typeof errorText === "string" ? errorText : JSON.stringify(errorText);
const lowerError = errorStr.toLowerCase();
if (lowerError.includes("no credentials")) {
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.notFound, reason: RateLimitReason.AUTH_ERROR };
return {
shouldFallback: true,
cooldownMs: COOLDOWN_MS.notFound,
reason: RateLimitReason.AUTH_ERROR,
};
}
if (lowerError.includes("request not allowed")) {
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.requestNotAllowed, reason: RateLimitReason.RATE_LIMIT_EXCEEDED };
return {
shouldFallback: true,
cooldownMs: COOLDOWN_MS.requestNotAllowed,
reason: RateLimitReason.RATE_LIMIT_EXCEEDED,
};
}
// Rate limit keywords - exponential backoff
@@ -262,15 +325,27 @@ export function checkFallbackError(status, errorText, backoffLevel = 0, model =
}
if (status === HTTP_STATUS.UNAUTHORIZED) {
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.unauthorized, reason: RateLimitReason.AUTH_ERROR };
return {
shouldFallback: true,
cooldownMs: COOLDOWN_MS.unauthorized,
reason: RateLimitReason.AUTH_ERROR,
};
}
if (status === HTTP_STATUS.PAYMENT_REQUIRED || status === HTTP_STATUS.FORBIDDEN) {
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.paymentRequired, reason: RateLimitReason.QUOTA_EXHAUSTED };
return {
shouldFallback: true,
cooldownMs: COOLDOWN_MS.paymentRequired,
reason: RateLimitReason.QUOTA_EXHAUSTED,
};
}
if (status === HTTP_STATUS.NOT_FOUND) {
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.notFound, reason: RateLimitReason.UNKNOWN };
return {
shouldFallback: true,
cooldownMs: COOLDOWN_MS.notFound,
reason: RateLimitReason.UNKNOWN,
};
}
// 429 - Rate limit with exponential backoff
@@ -284,7 +359,7 @@ export function checkFallbackError(status, errorText, backoffLevel = 0, model =
};
}
// Transient / server errors — DON'T increase backoff level
// Transient / server errors — exponential backoff with provider profile
const transientStatuses = [
HTTP_STATUS.NOT_ACCEPTABLE,
HTTP_STATUS.REQUEST_TIMEOUT,
@@ -294,7 +369,17 @@ export function checkFallbackError(status, errorText, backoffLevel = 0, model =
HTTP_STATUS.GATEWAY_TIMEOUT,
];
if (transientStatuses.includes(status)) {
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.transient, reason: RateLimitReason.SERVER_ERROR };
const profile = provider ? getProviderProfile(provider) : null;
const baseCooldown = profile?.transientCooldown ?? COOLDOWN_MS.transientInitial;
const maxLevel = profile?.maxBackoffLevel ?? BACKOFF_CONFIG.maxLevel;
const cooldownMs = Math.min(baseCooldown * Math.pow(2, backoffLevel), COOLDOWN_MS.transientMax);
const newLevel = Math.min(backoffLevel + 1, maxLevel);
return {
shouldFallback: true,
cooldownMs,
newBackoffLevel: newLevel,
reason: RateLimitReason.SERVER_ERROR,
};
}
// 400 Bad Request - don't fallback (same request will fail on all accounts)
@@ -303,7 +388,11 @@ export function checkFallbackError(status, errorText, backoffLevel = 0, model =
}
// All other errors - fallback with transient cooldown
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.transient, reason: RateLimitReason.UNKNOWN };
return {
shouldFallback: true,
cooldownMs: COOLDOWN_MS.transient,
reason: RateLimitReason.UNKNOWN,
};
}
// ─── Account State Management ───────────────────────────────────────────────
@@ -389,11 +478,17 @@ export function resetAccountState(account) {
/**
* Apply error state to account
*/
export function applyErrorState(account, status, errorText) {
export function applyErrorState(account, status, errorText, provider = null) {
if (!account) return account;
const backoffLevel = account.backoffLevel || 0;
const { cooldownMs, newBackoffLevel, reason } = checkFallbackError(status, errorText, backoffLevel);
const { cooldownMs, newBackoffLevel, reason } = checkFallbackError(
status,
errorText,
backoffLevel,
null,
provider
);
return {
...account,
+164 -10
View File
@@ -1,13 +1,18 @@
/**
* Shared combo (model combo) handling with fallback support
* Supports: priority (sequential), weighted (probabilistic), and round-robin (circular) strategies
* Supports: priority, weighted, round-robin, random, least-used, and cost-optimized strategies
*/
import { checkFallbackError, formatRetryAfter } from "./accountFallback.js";
import { checkFallbackError, formatRetryAfter, getProviderProfile } from "./accountFallback.js";
import { unavailableResponse } from "../utils/error.js";
import { recordComboRequest } from "./comboMetrics.js";
import { recordComboRequest, getComboMetrics } from "./comboMetrics.js";
import { resolveComboConfig, getDefaultComboConfig } from "./comboConfig.js";
import * as semaphore from "./rateLimitSemaphore.js";
import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker.js";
import { parseModel } from "./model.js";
// Status codes that should mark semaphore + record circuit breaker failures
const TRANSIENT_FOR_BREAKER = [429, 502, 503, 504];
const MAX_COMBO_DEPTH = 3;
@@ -145,9 +150,69 @@ function orderModelsForWeightedFallback(models, selectedModel) {
return [selected, ...rest].filter(Boolean).map((e) => e.model);
}
/**
* Fisher-Yates shuffle (in-place)
* @param {Array} arr
* @returns {Array} The shuffled array
*/
function shuffleArray(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
/**
* Sort models by pricing (cheapest first) for cost-optimized strategy
* @param {Array<string>} models - Model strings in "provider/model" format
* @returns {Promise<Array<string>>} Sorted model strings
*/
async function sortModelsByCost(models) {
try {
const { getPricingForModel } = await import("../../src/lib/localDb.js");
const withCost = await Promise.all(
models.map(async (modelStr) => {
const parsed = parseModel(modelStr);
const provider = parsed.provider || parsed.providerAlias || "unknown";
const model = parsed.model || modelStr;
try {
const pricing = await getPricingForModel(provider, model);
return { modelStr, cost: pricing?.input ?? Infinity };
} catch {
return { modelStr, cost: Infinity };
}
})
);
withCost.sort((a, b) => a.cost - b.cost);
return withCost.map((e) => e.modelStr);
} catch {
// If pricing lookup fails entirely, return original order
return models;
}
}
/**
* Sort models by usage count (least-used first) for least-used strategy
* @param {Array<string>} models - Model strings
* @param {string} comboName - Combo name for metrics lookup
* @returns {Array<string>} Sorted model strings
*/
function sortModelsByUsage(models, comboName) {
const metrics = getComboMetrics(comboName);
if (!metrics || !metrics.byModel) return models;
const withUsage = models.map((modelStr) => ({
modelStr,
requests: metrics.byModel[modelStr]?.requests ?? 0,
}));
withUsage.sort((a, b) => a.requests - b.requests);
return withUsage.map((e) => e.modelStr);
}
/**
* Handle combo chat with fallback
* Supports priority (sequential) and weighted (probabilistic) strategies
* Supports all 6 strategies: priority, weighted, round-robin, random, least-used, cost-optimized
* @param {Object} options
* @param {Object} options.body - Request body
* @param {Object} options.combo - Full combo object { name, models, strategy, config }
@@ -210,7 +275,7 @@ export async function handleComboChat({
);
} else {
orderedModels = flatModels;
log.info("COMBO", `Priority with nested resolution: ${orderedModels.length} total models`);
log.info("COMBO", `${strategy} with nested resolution: ${orderedModels.length} total models`);
}
} else if (strategy === "weighted") {
const selected = selectWeightedModel(models);
@@ -220,6 +285,18 @@ export async function handleComboChat({
orderedModels = models.map((m) => normalizeModelEntry(m).model);
}
// Apply strategy-specific ordering
if (strategy === "random") {
orderedModels = shuffleArray([...orderedModels]);
log.info("COMBO", `Random shuffle: ${orderedModels.length} models`);
} else if (strategy === "least-used") {
orderedModels = sortModelsByUsage(orderedModels, combo.name);
log.info("COMBO", `Least-used ordering: ${orderedModels[0]} has fewest requests`);
} else if (strategy === "cost-optimized") {
orderedModels = await sortModelsByCost(orderedModels);
log.info("COMBO", `Cost-optimized ordering: cheapest first (${orderedModels[0]})`);
}
let lastError = null;
let earliestRetryAfter = null;
let lastStatus = null;
@@ -229,6 +306,20 @@ export async function handleComboChat({
for (let i = 0; i < orderedModels.length; i++) {
const modelStr = orderedModels[i];
const parsed = parseModel(modelStr);
const provider = parsed.provider || parsed.providerAlias || "unknown";
const profile = getProviderProfile(provider);
const breaker = getCircuitBreaker(`combo:${provider}`, {
failureThreshold: profile.circuitBreakerThreshold,
resetTimeout: profile.circuitBreakerReset,
});
// Skip model if circuit breaker is OPEN
if (!breaker.canExecute()) {
log.info("COMBO", `Skipping ${modelStr}: circuit breaker OPEN for ${provider}`);
if (i > 0) fallbackCount++;
continue;
}
// Pre-check: skip models where all accounts are in cooldown
if (isModelAvailable) {
@@ -313,7 +404,18 @@ export async function handleComboChat({
}
}
const { shouldFallback } = checkFallbackError(result.status, errorText);
const { shouldFallback, cooldownMs } = checkFallbackError(
result.status,
errorText,
0,
null,
provider
);
// Record failure in circuit breaker for transient errors
if (TRANSIENT_FOR_BREAKER.includes(result.status)) {
breaker._onFailure();
}
if (!shouldFallback) {
log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status });
@@ -335,10 +437,24 @@ export async function handleComboChat({
}
}
// Early exit: check if all models have breaker OPEN
const allBreakersOpen = orderedModels.every((m) => {
const p = parseModel(m).provider || parseModel(m).providerAlias || "unknown";
return !getCircuitBreaker(`combo:${p}`).canExecute();
});
// All models failed
const latencyMs = Date.now() - startTime;
recordComboRequest(combo.name, null, { success: false, latencyMs, fallbackCount, strategy });
if (allBreakersOpen) {
log.warn("COMBO", "All models have circuit breaker OPEN — aborting");
return unavailableResponse(
503,
"All providers temporarily unavailable (circuit breakers open)"
);
}
const status = lastStatus || 406;
const msg = lastError || "All combo models unavailable";
@@ -412,6 +528,20 @@ async function handleRoundRobinCombo({
for (let offset = 0; offset < modelCount; offset++) {
const modelIndex = (startIndex + offset) % modelCount;
const modelStr = orderedModels[modelIndex];
const parsed = parseModel(modelStr);
const provider = parsed.provider || parsed.providerAlias || "unknown";
const profile = getProviderProfile(provider);
const breaker = getCircuitBreaker(`combo:${provider}`, {
failureThreshold: profile.circuitBreakerThreshold,
resetTimeout: profile.circuitBreakerReset,
});
// Skip model if circuit breaker is OPEN
if (!breaker.canExecute()) {
log.info("COMBO-RR", `Skipping ${modelStr}: circuit breaker OPEN for ${provider}`);
if (offset > 0) fallbackCount++;
continue;
}
// Pre-check availability
if (isModelAvailable) {
@@ -510,12 +640,22 @@ async function handleRoundRobinCombo({
}
}
const { shouldFallback, cooldownMs } = checkFallbackError(result.status, errorText);
const { shouldFallback, cooldownMs } = checkFallbackError(
result.status,
errorText,
0,
null,
provider
);
// Rate-limited → mark in semaphore so queue pauses
if (result.status === 429 && cooldownMs > 0) {
// Transient errors → mark in semaphore AND record circuit breaker failure
if (TRANSIENT_FOR_BREAKER.includes(result.status) && cooldownMs > 0) {
semaphore.markRateLimited(modelStr, cooldownMs);
log.warn("COMBO-RR", `${modelStr} rate-limited, cooldown ${cooldownMs}ms`);
breaker._onFailure();
log.warn(
"COMBO-RR",
`${modelStr} error ${result.status}, cooldown ${cooldownMs}ms (breaker: ${breaker.getStatus().failureCount}/${profile.circuitBreakerThreshold})`
);
}
if (!shouldFallback) {
@@ -551,6 +691,20 @@ async function handleRoundRobinCombo({
strategy: "round-robin",
});
// Early exit: check if all models have breaker OPEN
const allBreakersOpen = orderedModels.every((m) => {
const p = parseModel(m).provider || parseModel(m).providerAlias || "unknown";
return !getCircuitBreaker(`combo:${p}`).canExecute();
});
if (allBreakersOpen) {
log.warn("COMBO-RR", "All models have circuit breaker OPEN — aborting");
return unavailableResponse(
503,
"All providers temporarily unavailable (circuit breakers open)"
);
}
const status = lastStatus || 406;
const msg = lastError || "All round-robin combo models unavailable";
+38 -6
View File
@@ -4,11 +4,14 @@
* Creates per-provider+connection limiters that auto-learn rate limits
* from API response headers (x-ratelimit-*, retry-after, anthropic-ratelimit-*).
*
* Default: DISABLED. Must be enabled per provider connection via dashboard toggle.
* Default: ENABLED for API key providers (safety net), DISABLED for OAuth.
* Can be toggled per provider connection via dashboard.
*/
import Bottleneck from "bottleneck";
import { parseRetryAfterFromBody, lockModel } from "./accountFallback.js";
import { getProviderCategory } from "../config/providerRegistry.js";
import { DEFAULT_API_LIMITS } from "../config/constants.js";
// Store limiters keyed by "provider:connectionId" (and optionally ":model")
const limiters = new Map();
@@ -39,15 +42,45 @@ export async function initializeRateLimits() {
try {
const { getProviderConnections } = await import("@/lib/localDb.js");
const connections = await getProviderConnections();
let count = 0;
let explicitCount = 0;
let autoCount = 0;
for (const conn of connections) {
if (conn.rateLimitProtection) {
// Explicitly enabled by user
enabledConnections.add(conn.id);
count++;
explicitCount++;
} else if (
conn.provider &&
getProviderCategory(conn.provider) === "apikey" &&
conn.isActive
) {
// Auto-enable for API key providers (safety net)
enabledConnections.add(conn.id);
autoCount++;
// Create a pre-configured limiter with conservative defaults
const key = `${conn.provider}:${conn.id}`;
if (!limiters.has(key)) {
limiters.set(
key,
new Bottleneck({
maxConcurrent: DEFAULT_API_LIMITS.concurrentRequests,
minTime: DEFAULT_API_LIMITS.minTimeBetweenRequests,
reservoir: DEFAULT_API_LIMITS.requestsPerMinute,
reservoirRefreshAmount: DEFAULT_API_LIMITS.requestsPerMinute,
reservoirRefreshInterval: 60 * 1000, // Refresh every minute
id: key,
})
);
}
}
}
if (count > 0) {
console.log(`🛡️ [RATE-LIMIT] Loaded ${count} connection(s) with rate limit protection`);
if (explicitCount > 0 || autoCount > 0) {
console.log(
`🛡️ [RATE-LIMIT] Loaded ${explicitCount} explicit + ${autoCount} auto-enabled (API key) protection(s)`
);
}
} catch (err) {
console.error("[RATE-LIMIT] Failed to load settings:", err.message);
@@ -361,4 +394,3 @@ export function updateFromResponseBody(provider, connectionId, responseBody, sta
}
}
}
+74 -34
View File
@@ -328,45 +328,76 @@ export async function refreshQwenToken(refreshToken, log) {
}
/**
* Specialized refresh for Codex (OpenAI) OAuth tokens
* Specialized refresh for Codex (OpenAI) OAuth tokens.
* OpenAI uses rotating (one-time-use) refresh tokens.
* Returns { error: 'refresh_token_reused' } when the token has already been consumed,
* so callers can stop retrying and request re-authentication.
*/
export async function refreshCodexToken(refreshToken, log) {
const response = await fetch(OAUTH_ENDPOINTS.openai.token, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.codex.clientId,
scope: "openid profile email offline_access",
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", {
status: response.status,
error: errorText,
try {
const response = await fetch(OAUTH_ENDPOINTS.openai.token, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.codex.clientId,
scope: "openid profile email offline_access",
}),
});
if (!response.ok) {
const errorText = await response.text();
// Detect unrecoverable "refresh_token_reused" error from OpenAI
// This means the token was already consumed and a new one was issued.
// Retrying with the same token will never succeed.
let errorCode = null;
try {
const parsed = JSON.parse(errorText);
errorCode = parsed?.error?.code;
} catch {
// not JSON, ignore
}
if (errorCode === "refresh_token_reused") {
log?.error?.(
"TOKEN_REFRESH",
"Codex refresh token already used (rotating token consumed). Re-authentication required.",
{
status: response.status,
}
);
return { error: "refresh_token_reused" };
}
log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`);
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
}
/**
@@ -666,6 +697,15 @@ export function supportsTokenRefresh(provider) {
return !!(config?.refreshUrl || config?.tokenUrl);
}
/**
* Check if a refresh result indicates an unrecoverable error
* (e.g. the refresh token was already consumed and cannot be reused).
* Callers should stop retrying and request re-authentication.
*/
export function isUnrecoverableRefreshError(result) {
return result && typeof result === "object" && result.error === "refresh_token_reused";
}
/**
* Get access token for a specific provider (with deduplication).
* If a refresh is already in-flight for the same provider+token,
+101
View File
@@ -0,0 +1,101 @@
/**
* Progress Tracker Phase 9.3
*
* Emits SSE `event: progress` events during long streaming responses.
* Opt-in via X-OmniRoute-Progress: true header.
*
* Progress events contain:
* { tokens_generated, elapsed_ms }
*
* @module utils/progressTracker
*/
const DEFAULT_INTERVAL_MS = 2000;
/**
* Create a progress emitter for a streaming response.
* Returns a TransformStream that injects progress events periodically.
*
* @param {object} options
* @param {number} [options.intervalMs=2000] - Interval between events
* @param {AbortSignal} [options.signal] - Abort signal for cancellation
* @returns {TransformStream}
*/
export function createProgressTransform({ intervalMs = DEFAULT_INTERVAL_MS, signal } = {}) {
let tokenCount = 0;
let startTime = Date.now();
let intervalId;
let writer;
const encoder = new TextEncoder();
return new TransformStream({
start(controller) {
writer = controller;
startTime = Date.now();
intervalId = setInterval(() => {
if (signal?.aborted) {
clearInterval(intervalId);
return;
}
const progressEvent = `event: progress\ndata: ${JSON.stringify({
tokens_generated: tokenCount,
elapsed_ms: Date.now() - startTime,
})}\n\n`;
try {
controller.enqueue(encoder.encode(progressEvent));
} catch {
// Stream closed
clearInterval(intervalId);
}
}, intervalMs);
// Clean up on abort
signal?.addEventListener(
"abort",
() => {
clearInterval(intervalId);
},
{ once: true }
);
},
transform(chunk, controller) {
// Count token events in the chunk
const text = typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
// Count data lines (each is roughly one token event)
const dataLines = text.split("\n").filter((l) => l.startsWith("data: "));
tokenCount += dataLines.length;
controller.enqueue(chunk);
},
flush() {
clearInterval(intervalId);
// Final progress event
if (writer) {
try {
const finalEvent = `event: progress\ndata: ${JSON.stringify({
tokens_generated: tokenCount,
elapsed_ms: Date.now() - startTime,
done: true,
})}\n\n`;
writer.enqueue(encoder.encode(finalEvent));
} catch {
// Stream already closed
}
}
},
});
}
/**
* Check if client opted into progress tracking.
* @param {Headers|object} headers
* @returns {boolean}
*/
export function wantsProgress(headers) {
if (!headers) return false;
const get = typeof headers.get === "function" ? (k) => headers.get(k) : (k) => headers[k];
return get("x-omniroute-progress") === "true";
}
+291 -174
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "0.1.0",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "0.1.0",
"version": "0.7.0",
"license": "MIT",
"workspaces": [
"open-sse"
@@ -18,7 +18,6 @@
"bottleneck": "^2.19.5",
"express": "^5.2.1",
"fetch-socks": "^1.3.2",
"fs": "^0.0.1-security",
"http-proxy-middleware": "^3.0.5",
"https-proxy-agent": "^7.0.6",
"jose": "^6.1.3",
@@ -40,7 +39,8 @@
"zustand": "^5.0.10"
},
"bin": {
"omniroute": "bin/omniroute.mjs"
"omniroute": "bin/omniroute.mjs",
"omniroute-reset-password": "bin/reset-password.mjs"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
@@ -49,7 +49,7 @@
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^10",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.6",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
@@ -403,68 +403,105 @@
}
},
"node_modules/@eslint/config-array": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.1.tgz",
"integrity": "sha512-uVSdg/V4dfQmTjJzR0szNczjOH/J+FyUMMjYtr07xFRXR7EDf9i1qdxrD0VusZH9knj1/ecxzCQQxyic5NzAiA==",
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^3.0.1",
"@eslint/object-schema": "^2.1.7",
"debug": "^4.3.1",
"minimatch": "^10.1.1"
"minimatch": "^3.1.2"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz",
"integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==",
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^1.1.0"
"@eslint/core": "^0.17.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/core": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz",
"integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==",
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/eslintrc": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz",
"integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^6.12.4",
"debug": "^4.3.2",
"espree": "^10.0.1",
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
"js-yaml": "^4.1.1",
"minimatch": "^3.1.2",
"strip-json-comments": "^3.1.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/js": {
"version": "9.39.2",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
"integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
}
},
"node_modules/@eslint/object-schema": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.1.tgz",
"integrity": "sha512-P9cq2dpr+LU8j3qbLygLcSZrl2/ds/pUpfnHNNuk5HW7mnngHs+6WSq5C9mO3rqRX8A1poxqLTC9cu0KOyJlBg==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz",
"integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^1.1.0",
"@eslint/core": "^0.17.0",
"levn": "^0.4.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@humanfs/core": {
@@ -985,16 +1022,6 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/cliui": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1938,13 +1965,6 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2116,13 +2136,6 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@@ -2180,19 +2193,6 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
@@ -2552,6 +2552,29 @@
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-query": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
@@ -2799,17 +2822,11 @@
}
},
"node_modules/balanced-match": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz",
"integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"jackspeak": "^4.2.3"
},
"engines": {
"node": "20 || >=22"
}
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
@@ -2914,16 +2931,14 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/braces": {
@@ -3077,6 +3092,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001769",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
@@ -3097,6 +3122,23 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
@@ -3162,6 +3204,26 @@
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
@@ -3908,30 +3970,33 @@
}
},
"node_modules/eslint": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.0.tgz",
"integrity": "sha512-O0piBKY36YSJhlFSG8p9VUdPV/SxxS4FYDWVpr/9GJuMaepzwlf4J8I4ov1b+ySQfDTPhc3DtLaxcT1fN0yqCg==",
"version": "9.39.2",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.0",
"@eslint/config-helpers": "^0.5.2",
"@eslint/core": "^1.1.0",
"@eslint/plugin-kit": "^0.6.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.1",
"@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.39.2",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
"ajv": "^6.12.4",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^9.1.0",
"eslint-visitor-keys": "^5.0.0",
"espree": "^11.1.0",
"esquery": "^1.7.0",
"eslint-scope": "^8.4.0",
"eslint-visitor-keys": "^4.2.1",
"espree": "^10.4.0",
"esquery": "^1.5.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
"file-entry-cache": "^8.0.0",
@@ -3941,7 +4006,8 @@
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"minimatch": "^10.1.1",
"lodash.merge": "^4.6.2",
"minimatch": "^3.1.2",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
@@ -3949,7 +4015,7 @@
"eslint": "bin/eslint.js"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
@@ -3990,24 +4056,6 @@
}
}
},
"node_modules/eslint-config-next/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/eslint-config-next/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/eslint-config-next/node_modules/eslint-import-resolver-typescript": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz",
@@ -4193,19 +4241,6 @@
"node": ">= 4"
}
},
"node_modules/eslint-config-next/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/eslint-config-next/node_modules/resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
@@ -4402,50 +4437,48 @@
}
},
"node_modules/eslint-scope": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.0.tgz",
"integrity": "sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ==",
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8",
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-visitor-keys": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz",
"integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/espree": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-11.1.0.tgz",
"integrity": "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^5.0.0"
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
@@ -4791,12 +4824,6 @@
"node": ">= 0.8"
}
},
"node_modules/fs": {
"version": "0.0.1-security",
"resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz",
"integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==",
"license": "ISC"
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -4977,6 +5004,19 @@
"node": ">=10.13.0"
}
},
"node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globalthis": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
@@ -5026,6 +5066,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
@@ -5254,6 +5304,23 @@
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"parent-module": "^1.0.0",
"resolve-from": "^4.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -5877,22 +5944,6 @@
"node": ">= 0.4"
}
},
"node_modules/jackspeak": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^9.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -5928,6 +5979,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -6362,6 +6426,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/log-symbols": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
@@ -6561,19 +6632,16 @@
}
},
"node_modules/minimatch": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz",
"integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "BlueOak-1.0.0",
"license": "ISC",
"dependencies": {
"brace-expansion": "^5.0.2"
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
"node": "*"
}
},
"node_modules/minimist": {
@@ -7083,6 +7151,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"callsites": "^3.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -7761,6 +7842,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -8616,6 +8707,19 @@
"node": ">=4"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -8639,6 +8743,19 @@
}
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+10 -6
View File
@@ -1,10 +1,11 @@
{
"name": "omniroute",
"version": "0.2.0",
"version": "0.8.0",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
"omniroute": "bin/omniroute.mjs"
"omniroute": "bin/omniroute.mjs",
"omniroute-reset-password": "bin/reset-password.mjs"
},
"files": [
"bin/",
@@ -39,17 +40,21 @@
"type": "git",
"url": "https://github.com/diegosouzapw/OmniRoute"
},
"homepage": "https://github.com/diegosouzapw/OmniRoute",
"homepage": "https://omniroute.online",
"scripts": {
"dev": "next dev --webpack --port 20128",
"build": "next build --webpack",
"build:cli": "node scripts/prepublish.mjs",
"start": "next start --port 20128",
"lint": "eslint .",
"test": "node --test tests/unit/*.test.mjs",
"test:unit": "node --test tests/unit/*.test.mjs",
"test:plan3": "node --test tests/unit/plan3-p0.test.mjs",
"test:fixes": "node --test tests/unit/fixes-p1.test.mjs",
"test": "npm run build",
"test:security": "node --test tests/unit/security-fase01.test.mjs",
"test:e2e": "npx playwright test",
"test:coverage": "npx c8 --check-coverage --lines 60 --functions 50 --branches 50 node --test tests/unit/*.test.mjs",
"test:all": "npm run test:unit && npm run test:e2e",
"check": "npm run lint && npm run test",
"prepublishOnly": "npm run build:cli",
"prepare": "husky"
@@ -61,7 +66,6 @@
"bottleneck": "^2.19.5",
"express": "^5.2.1",
"fetch-socks": "^1.3.2",
"fs": "^0.0.1-security",
"http-proxy-middleware": "^3.0.5",
"https-proxy-agent": "^7.0.6",
"jose": "^6.1.3",
@@ -89,7 +93,7 @@
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^10",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.6",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
+3 -1
View File
@@ -1,7 +1,9 @@
export default {
const prettierConfig = {
semi: true,
singleQuote: false,
tabWidth: 2,
trailingComma: "es5",
printWidth: 100,
};
export default prettierConfig;
+26
View File
@@ -0,0 +1,26 @@
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="180" height="180" rx="36" fill="url(#gradient)" />
<!-- Central node -->
<circle cx="90" cy="90" r="17" fill="white" />
<!-- Outer nodes -->
<circle cx="45" cy="45" r="11" fill="white" />
<circle cx="135" cy="45" r="11" fill="white" />
<circle cx="45" cy="135" r="11" fill="white" />
<circle cx="135" cy="135" r="11" fill="white" />
<circle cx="90" cy="28" r="8.5" fill="white" />
<circle cx="90" cy="152" r="8.5" fill="white" />
<!-- Connection lines -->
<line x1="90" y1="73" x2="45" y2="45" stroke="white" stroke-width="6.5" stroke-linecap="round" />
<line x1="90" y1="73" x2="135" y2="45" stroke="white" stroke-width="6.5" stroke-linecap="round" />
<line x1="90" y1="107" x2="45" y2="135" stroke="white" stroke-width="6.5" stroke-linecap="round" />
<line x1="90" y1="107" x2="135" y2="135" stroke="white" stroke-width="6.5"
stroke-linecap="round" />
<line x1="90" y1="73" x2="90" y2="28" stroke="white" stroke-width="6.5" stroke-linecap="round" />
<line x1="90" y1="107" x2="90" y2="152" stroke="white" stroke-width="6.5" stroke-linecap="round" />
<defs>
<linearGradient id="gradient" x1="0" y1="0" x2="180" y2="180" gradientUnits="userSpaceOnUse">
<stop stop-color="#E54D5E" />
<stop offset="1" stop-color="#C93D4E" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+18 -4
View File
@@ -1,11 +1,25 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="6" fill="url(#gradient)"/>
<text x="16" y="24" font-family="system-ui, -apple-system, sans-serif" font-size="20" font-weight="700" fill="white" text-anchor="middle">9</text>
<!-- Central node -->
<circle cx="16" cy="16" r="3" fill="white"/>
<!-- Outer nodes -->
<circle cx="8" cy="8" r="2" fill="white"/>
<circle cx="24" cy="8" r="2" fill="white"/>
<circle cx="8" cy="24" r="2" fill="white"/>
<circle cx="24" cy="24" r="2" fill="white"/>
<circle cx="16" cy="5" r="1.5" fill="white"/>
<circle cx="16" cy="27" r="1.5" fill="white"/>
<!-- Connection lines -->
<line x1="16" y1="13" x2="8" y2="8" stroke="white" stroke-width="1.2" stroke-linecap="round"/>
<line x1="16" y1="13" x2="24" y2="8" stroke="white" stroke-width="1.2" stroke-linecap="round"/>
<line x1="16" y1="19" x2="8" y2="24" stroke="white" stroke-width="1.2" stroke-linecap="round"/>
<line x1="16" y1="19" x2="24" y2="24" stroke="white" stroke-width="1.2" stroke-linecap="round"/>
<line x1="16" y1="13" x2="16" y2="5" stroke="white" stroke-width="1.2" stroke-linecap="round"/>
<line x1="16" y1="19" x2="16" y2="27" stroke="white" stroke-width="1.2" stroke-linecap="round"/>
<defs>
<linearGradient id="gradient" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop stop-color="#f97815"/>
<stop offset="1" stop-color="#c2590a"/>
<stop stop-color="#E54D5E"/>
<stop offset="1" stop-color="#C93D4E"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 533 B

After

Width:  |  Height:  |  Size: 1.3 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg width="192" height="192" viewBox="0 0 192 192" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="192" height="192" rx="36" fill="url(#gradient)" />
<!-- Central node -->
<circle cx="96" cy="96" r="18" fill="white" />
<!-- Outer nodes -->
<circle cx="48" cy="48" r="12" fill="white" />
<circle cx="144" cy="48" r="12" fill="white" />
<circle cx="48" cy="144" r="12" fill="white" />
<circle cx="144" cy="144" r="12" fill="white" />
<circle cx="96" cy="30" r="9" fill="white" />
<circle cx="96" cy="162" r="9" fill="white" />
<!-- Connection lines -->
<line x1="96" y1="78" x2="48" y2="48" stroke="white" stroke-width="7" stroke-linecap="round" />
<line x1="96" y1="78" x2="144" y2="48" stroke="white" stroke-width="7" stroke-linecap="round" />
<line x1="96" y1="114" x2="48" y2="144" stroke="white" stroke-width="7" stroke-linecap="round" />
<line x1="96" y1="114" x2="144" y2="144" stroke="white" stroke-width="7" stroke-linecap="round" />
<line x1="96" y1="78" x2="96" y2="30" stroke="white" stroke-width="7" stroke-linecap="round" />
<line x1="96" y1="114" x2="96" y2="162" stroke="white" stroke-width="7" stroke-linecap="round" />
<defs>
<linearGradient id="gradient" x1="0" y1="0" x2="192" y2="192" gradientUnits="userSpaceOnUse">
<stop stop-color="#E54D5E" />
<stop offset="1" stop-color="#C93D4E" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,499 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import PropTypes from "prop-types";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Card, CardSkeleton, Button, Modal } from "@/shared/components";
import { AI_PROVIDERS, FREE_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constants/providers";
import { useNotificationStore } from "@/store/notificationStore";
export default function HomePageClient({ machineId }) {
const [providerConnections, setProviderConnections] = useState([]);
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(true);
const [baseUrl, setBaseUrl] = useState("/v1");
const [selectedProvider, setSelectedProvider] = useState(null);
const [providerMetrics, setProviderMetrics] = useState({});
useEffect(() => {
if (typeof window !== "undefined") {
setBaseUrl(`${window.location.origin}/v1`);
}
}, []);
const fetchData = useCallback(async () => {
try {
const [provRes, modelsRes, metricsRes] = await Promise.all([
fetch("/api/providers"),
fetch("/api/models"),
fetch("/api/provider-metrics"),
]);
if (provRes.ok) {
const provData = await provRes.json();
setProviderConnections(provData.connections || []);
}
if (modelsRes.ok) {
const modelsData = await modelsRes.json();
setModels(modelsData.models || []);
}
if (metricsRes.ok) {
const metricsData = await metricsRes.json();
setProviderMetrics(metricsData.metrics || {});
}
} catch (e) {
console.log("Error fetching data:", e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const providerStats = useMemo(() => {
return Object.entries(AI_PROVIDERS).map(([providerId, providerInfo]) => {
const connections = providerConnections.filter((conn) => conn.provider === providerId);
const connected = connections.filter(
(conn) =>
conn.isActive !== false &&
(conn.testStatus === "active" ||
conn.testStatus === "success" ||
conn.testStatus === "unknown")
).length;
const errors = connections.filter(
(conn) =>
conn.isActive !== false &&
(conn.testStatus === "error" ||
conn.testStatus === "expired" ||
conn.testStatus === "unavailable")
).length;
const providerKeys = new Set([providerId, providerInfo.alias].filter(Boolean));
const providerModels = models.filter((m) => providerKeys.has(m.provider));
// Determine auth type
const authType = FREE_PROVIDERS[providerId]
? "free"
: OAUTH_PROVIDERS[providerId]
? "oauth"
: "apikey";
return {
id: providerId,
provider: providerInfo,
total: connections.length,
connected,
errors,
modelCount: providerModels.length,
authType,
};
});
}, [providerConnections, models]);
// Models for selected provider
const selectedProviderModels = useMemo(() => {
if (!selectedProvider) return [];
const providerKeys = new Set(
[selectedProvider.id, selectedProvider.provider?.alias].filter(Boolean)
);
return models.filter((m) => providerKeys.has(m.provider));
}, [selectedProvider, models]);
const quickStartLinks = [
{ label: "Documentation", href: "/docs", icon: "menu_book" },
{ label: "Providers", href: "/dashboard/providers", icon: "dns" },
{ label: "Combos", href: "/dashboard/combos", icon: "layers" },
{ label: "Analytics", href: "/dashboard/analytics", icon: "analytics" },
{ label: "Health Monitor", href: "/dashboard/health", icon: "health_and_safety" },
{ label: "CLI Tools", href: "/dashboard/cli-tools", icon: "terminal" },
{
label: "Report issue",
href: "https://github.com/diegosouzapw/OmniRoute/issues",
external: true,
icon: "bug_report",
},
];
if (loading) {
return (
<div className="flex flex-col gap-8">
<CardSkeleton />
<CardSkeleton />
</div>
);
}
const currentEndpoint = baseUrl;
return (
<div className="flex flex-col gap-8">
{/* Quick Start */}
<Card>
<div className="flex flex-col gap-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Quick Start</h2>
<p className="text-sm text-text-muted">
Get up and running in 4 steps. Connect providers, route models, monitor everything.
</p>
</div>
<Link
href="/docs"
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
>
<span className="material-symbols-outlined text-[14px]">menu_book</span>
Full Docs
</Link>
</div>
<ol className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<li className="rounded-lg border border-border bg-bg-subtle p-4 flex gap-3">
<div className="flex items-center justify-center size-8 rounded-lg bg-primary/10 text-primary shrink-0">
<span className="material-symbols-outlined text-[18px]">key</span>
</div>
<div>
<span className="font-semibold">1. Create API key</span>
<p className="text-text-muted mt-0.5">
Go to{" "}
<Link href="/dashboard/settings" className="text-primary hover:underline">
Settings
</Link>{" "}
API Keys. Generate one key per environment.
</p>
</div>
</li>
<li className="rounded-lg border border-border bg-bg-subtle p-4 flex gap-3">
<div className="flex items-center justify-center size-8 rounded-lg bg-green-500/10 text-green-500 shrink-0">
<span className="material-symbols-outlined text-[18px]">dns</span>
</div>
<div>
<span className="font-semibold">2. Connect providers</span>
<p className="text-text-muted mt-0.5">
Add accounts in{" "}
<Link href="/dashboard/providers" className="text-primary hover:underline">
Providers
</Link>
. Supports OAuth, API Key, and free tiers.
</p>
</div>
</li>
<li className="rounded-lg border border-border bg-bg-subtle p-4 flex gap-3">
<div className="flex items-center justify-center size-8 rounded-lg bg-blue-500/10 text-blue-500 shrink-0">
<span className="material-symbols-outlined text-[18px]">link</span>
</div>
<div>
<span className="font-semibold">3. Point your client</span>
<p className="text-text-muted mt-0.5">
Set base URL to{" "}
<code className="px-1.5 py-0.5 rounded bg-surface text-xs font-mono">
{currentEndpoint}
</code>{" "}
in your IDE or API client.
</p>
</div>
</li>
<li className="rounded-lg border border-border bg-bg-subtle p-4 flex gap-3">
<div className="flex items-center justify-center size-8 rounded-lg bg-amber-500/10 text-amber-500 shrink-0">
<span className="material-symbols-outlined text-[18px]">analytics</span>
</div>
<div>
<span className="font-semibold">4. Monitor & optimize</span>
<p className="text-text-muted mt-0.5">
Track tokens, cost and errors in{" "}
<Link href="/dashboard/usage" className="text-primary hover:underline">
Usage
</Link>{" "}
and{" "}
<Link href="/dashboard/analytics" className="text-primary hover:underline">
Analytics
</Link>
.
</p>
</div>
</li>
</ol>
<div className="flex flex-wrap gap-2">
{quickStartLinks.map((link) => (
<a
key={link.href}
href={link.href}
target={link.external ? "_blank" : undefined}
rel={link.external ? "noopener noreferrer" : undefined}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
>
<span className="material-symbols-outlined text-[14px]">
{link.icon || (link.external ? "open_in_new" : "arrow_forward")}
</span>
{link.label}
</a>
))}
</div>
</div>
</Card>
{/* Providers Overview */}
<Card>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold">Providers Overview</h2>
<p className="text-sm text-text-muted">
{providerStats.filter((item) => item.total > 0).length} configured of{" "}
{providerStats.length} available providers
</p>
</div>
<div className="flex items-center gap-4">
<div className="hidden sm:flex items-center gap-3 text-[11px] text-text-muted">
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-green-500" /> Free
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-blue-500" /> OAuth
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-amber-500" /> API Key
</span>
</div>
<Link
href="/dashboard/providers"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
>
<span className="material-symbols-outlined text-[14px]">settings</span>
Manage
</Link>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{providerStats.map((item) => (
<ProviderOverviewCard
key={item.id}
item={item}
metrics={providerMetrics[item.provider.alias] || providerMetrics[item.id]}
onClick={() => setSelectedProvider(item)}
/>
))}
</div>
</Card>
{/* Provider Models Modal */}
{selectedProvider && (
<ProviderModelsModal
provider={selectedProvider}
models={selectedProviderModels}
onClose={() => setSelectedProvider(null)}
/>
)}
</div>
);
}
HomePageClient.propTypes = {
machineId: PropTypes.string,
};
function ProviderOverviewCard({ item, metrics, onClick }) {
const [imgError, setImgError] = useState(false);
const statusVariant =
item.errors > 0 ? "text-red-500" : item.connected > 0 ? "text-green-500" : "text-text-muted";
const authTypeConfig = {
free: { color: "bg-green-500", label: "Free" },
oauth: { color: "bg-blue-500", label: "OAuth" },
apikey: { color: "bg-amber-500", label: "API Key" },
};
const authInfo = authTypeConfig[item.authType] || authTypeConfig.apikey;
return (
<button
onClick={onClick}
className="border border-border rounded-lg p-3 hover:bg-surface/40 transition-colors text-left cursor-pointer w-full"
>
<div className="flex items-center gap-2.5">
<div
className="size-8 rounded-lg flex items-center justify-center shrink-0"
style={{ backgroundColor: `${item.provider.color || "#888"}15` }}
>
{imgError ? (
<span
className="text-[10px] font-bold"
style={{ color: item.provider.color || "#888" }}
>
{item.provider.textIcon || item.provider.id.slice(0, 2).toUpperCase()}
</span>
) : (
<Image
src={`/providers/${item.provider.id}.png`}
alt={item.provider.name}
width={26}
height={26}
className="object-contain rounded-lg"
sizes="26px"
onError={() => setImgError(true)}
/>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="text-sm font-semibold truncate">{item.provider.name}</p>
<span
className={`size-2 rounded-full ${authInfo.color} shrink-0`}
title={authInfo.label}
/>
</div>
<p className={`text-xs ${statusVariant}`}>
{item.total === 0
? "Not configured"
: `${item.connected} active · ${item.errors} error`}
</p>
{metrics && metrics.totalRequests > 0 && (
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-text-muted">
<span className="text-emerald-500">{metrics.totalSuccesses}</span>/
{metrics.totalRequests} reqs
</span>
<span className="text-[10px] text-text-muted">{metrics.successRate}%</span>
<span className="text-[10px] text-text-muted">~{metrics.avgLatencyMs}ms</span>
</div>
)}
</div>
<div className="text-right shrink-0">
<p className="text-xs font-medium text-text-main">{item.modelCount}</p>
<p className="text-[10px] text-text-muted">models</p>
</div>
</div>
</button>
);
}
ProviderOverviewCard.propTypes = {
item: PropTypes.shape({
id: PropTypes.string.isRequired,
provider: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
color: PropTypes.string,
textIcon: PropTypes.string,
alias: PropTypes.string,
}).isRequired,
total: PropTypes.number.isRequired,
connected: PropTypes.number.isRequired,
errors: PropTypes.number.isRequired,
modelCount: PropTypes.number.isRequired,
authType: PropTypes.string.isRequired,
}).isRequired,
metrics: PropTypes.shape({
totalRequests: PropTypes.number,
totalSuccesses: PropTypes.number,
successRate: PropTypes.number,
avgLatencyMs: PropTypes.number,
}),
onClick: PropTypes.func.isRequired,
};
function ProviderModelsModal({ provider, models, onClose }) {
const [copiedModel, setCopiedModel] = useState(null);
const notify = useNotificationStore();
const router = useRouter();
const navigateTo = (path) => {
onClose();
router.push(path);
};
const handleCopy = (text) => {
navigator.clipboard.writeText(text);
setCopiedModel(text);
notify.success(`Copied: ${text}`);
setTimeout(() => setCopiedModel(null), 2000);
};
return (
<Modal isOpen={true} title={`${provider.provider.name} — Models`} onClose={onClose}>
<div className="flex flex-col gap-3">
{/* Summary */}
<div className="flex items-center gap-2 text-sm text-text-muted">
<span className="material-symbols-outlined text-[16px]">token</span>
{models.length} model{models.length !== 1 ? "s" : ""} available
{provider.total > 0 && (
<span className="ml-auto text-xs text-green-500">
{provider.connected} connection{provider.connected !== 1 ? "s" : ""} active
</span>
)}
</div>
{models.length === 0 ? (
<div className="text-center py-6">
<span className="material-symbols-outlined text-[32px] text-text-muted mb-2">
search_off
</span>
<p className="text-sm text-text-muted">No models available for this provider.</p>
<p className="text-xs text-text-muted mt-1">
Configure a connection first in{" "}
<button
onClick={() => navigateTo("/dashboard/providers")}
className="text-primary hover:underline cursor-pointer"
>
Providers
</button>
</p>
</div>
) : (
<div className="flex flex-col gap-1 max-h-[400px] overflow-y-auto">
{models.map((m) => (
<div
key={m.fullModel}
className="flex items-center justify-between px-3 py-2 rounded-lg hover:bg-surface/50 transition-colors group"
>
<div className="min-w-0 flex-1">
<p className="font-mono text-sm text-text-main truncate">{m.fullModel}</p>
{m.alias !== m.model && (
<p className="text-[10px] text-text-muted">alias: {m.alias}</p>
)}
</div>
<button
onClick={() => handleCopy(m.fullModel)}
className="shrink-0 ml-2 p-1.5 rounded-lg text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors opacity-0 group-hover:opacity-100"
title="Copy model name"
>
<span className="material-symbols-outlined text-[14px]">
{copiedModel === m.fullModel ? "check" : "content_copy"}
</span>
</button>
</div>
))}
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-2 border-t border-border">
<Button
variant="secondary"
fullWidth
size="sm"
onClick={() => navigateTo(`/dashboard/providers/${provider.id}`)}
className="flex-1"
>
<span className="material-symbols-outlined text-[14px] mr-1">settings</span>
Configure Provider
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
Close
</Button>
</div>
</div>
</Modal>
);
}
ProviderModelsModal.propTypes = {
provider: PropTypes.object.isRequired,
models: PropTypes.array.isRequired,
onClose: PropTypes.func.isRequired,
};
@@ -0,0 +1,29 @@
"use client";
import { useState, Suspense } from "react";
import { UsageAnalytics, CardSkeleton, SegmentedControl } from "@/shared/components";
import EvalsTab from "../usage/components/EvalsTab";
export default function AnalyticsPage() {
const [activeTab, setActiveTab] = useState("overview");
return (
<div className="flex flex-col gap-6">
<SegmentedControl
options={[
{ value: "overview", label: "Overview" },
{ value: "evals", label: "Evals" },
]}
value={activeTab}
onChange={setActiveTab}
/>
{activeTab === "overview" && (
<Suspense fallback={<CardSkeleton />}>
<UsageAnalytics />
</Suspense>
)}
{activeTab === "evals" && <EvalsTab />}
</div>
);
}
@@ -28,11 +28,13 @@ export default function CLIToolsPageClient({ machineId }) {
const [modelMappings, setModelMappings] = useState({});
const [cloudEnabled, setCloudEnabled] = useState(false);
const [apiKeys, setApiKeys] = useState([]);
const [toolStatuses, setToolStatuses] = useState({});
useEffect(() => {
fetchConnections();
loadCloudSettings();
fetchApiKeys();
fetchToolStatuses();
}, []);
const loadCloudSettings = async () => {
@@ -59,6 +61,18 @@ export default function CLIToolsPageClient({ machineId }) {
}
};
const fetchToolStatuses = async () => {
try {
const res = await fetch("/api/cli-tools/status");
if (res.ok) {
const data = await res.json();
setToolStatuses(data || {});
}
} catch (error) {
console.log("Error fetching CLI tool statuses:", error);
}
};
const fetchConnections = async () => {
try {
const res = await fetch("/api/providers");
@@ -152,6 +166,7 @@ export default function CLIToolsPageClient({ machineId }) {
onToggle: () => setExpandedTool(expandedTool === toolId ? null : toolId),
baseUrl: getBaseUrl(),
apiKeys,
batchStatus: toolStatuses[toolId] || null,
};
switch (toolId) {
@@ -3,6 +3,7 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -17,6 +18,7 @@ export default function ClaudeToolCard({
hasActiveProviders,
apiKeys,
cloudEnabled,
batchStatus,
}) {
const [claudeStatus, setClaudeStatus] = useState(null);
const [checkingClaude, setCheckingClaude] = useState(false);
@@ -49,6 +51,9 @@ export default function ClaudeToolCard({
const configStatus = getConfigStatus();
// Use batch status as fallback when card hasn't been expanded yet
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
@@ -269,21 +274,10 @@ export default function ClaudeToolCard({
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
Connected
</span>
)}
{configStatus === "not_configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
Not configured
</span>
)}
{configStatus === "other" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
Other
</span>
)}
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
@@ -0,0 +1,52 @@
"use client";
/**
* Shared status badge for CLI tool cards.
* Shows the effective config/installation status using batch data,
* so badges are visible even when cards are collapsed.
*/
export default function CliStatusBadge({ effectiveConfigStatus, batchStatus }) {
// Determine badge from effectiveConfigStatus or batchStatus
const status = effectiveConfigStatus || batchStatus?.configStatus || null;
if (!status) return null;
const badges = {
configured: {
dotClass: "bg-green-500",
badgeClass: "bg-green-500/10 text-green-600 dark:text-green-400",
text: "Configured",
},
not_configured: {
dotClass: "bg-yellow-500",
badgeClass: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
text: "Not configured",
},
not_installed: {
dotClass: "bg-zinc-400 dark:bg-zinc-500",
badgeClass: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400",
text: "Not installed",
},
other: {
dotClass: "bg-blue-500",
badgeClass: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
text: "Custom",
},
unknown: {
dotClass: "bg-zinc-400 dark:bg-zinc-500",
badgeClass: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400",
text: "Unknown",
},
};
const badge = badges[status] || badges.unknown;
return (
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full ${badge.badgeClass}`}
>
<span className={`size-1.5 rounded-full ${badge.dotClass}`} />
{badge.text}
</span>
);
}
@@ -3,6 +3,7 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -15,6 +16,7 @@ export default function ClineToolCard({
apiKeys,
activeProviders,
cloudEnabled,
batchStatus,
}) {
const [clineStatus, setClineStatus] = useState(null);
const [checkingCline, setCheckingCline] = useState(false);
@@ -46,6 +48,9 @@ export default function ClineToolCard({
const configStatus = getConfigStatus();
// Use batch status as fallback when card hasn't been expanded yet
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
@@ -84,7 +89,7 @@ export default function ClineToolCard({
const fetchBackups = async () => {
try {
const res = await fetch("/api/cli-tools/backups?toolId=cline");
const res = await fetch("/api/cli-tools/backups?tool=cline");
if (res.ok) {
const data = await res.json();
setBackups(data.backups || []);
@@ -100,7 +105,7 @@ export default function ClineToolCard({
const res = await fetch("/api/cli-tools/backups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ toolId: "cline", backupId }),
body: JSON.stringify({ tool: "cline", backupId }),
});
if (res.ok) {
setMessage({ type: "success", text: "Backup restored! Reloading status..." });
@@ -202,28 +207,6 @@ export default function ClineToolCard({
setShowManualConfigModal(false);
};
const renderStatusBadge = () => {
if (!cliReady) return null;
const badges = {
configured: {
class: "bg-green-500/10 text-green-600 dark:text-green-400",
text: "Connected",
},
not_configured: {
class: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
text: "Not configured",
},
other: { class: "bg-blue-500/10 text-blue-600 dark:text-blue-400", text: "Custom config" },
};
const badge = badges[configStatus];
if (!badge) return null;
return (
<span className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full ${badge.class}`}>
{badge.text}
</span>
);
};
return (
<Card padding="sm" className="overflow-hidden">
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
@@ -250,7 +233,10 @@ export default function ClineToolCard({
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{renderStatusBadge()}
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
export default function CodexToolCard({
tool,
@@ -12,6 +13,7 @@ export default function CodexToolCard({
apiKeys,
activeProviders,
cloudEnabled,
batchStatus,
}) {
const [codexStatus, setCodexStatus] = useState(null);
const [checkingCodex, setCheckingCodex] = useState(false);
@@ -82,6 +84,9 @@ export default function CodexToolCard({
const configStatus = getConfigStatus();
// Use batch status as fallback when card hasn't been expanded yet
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
const getEffectiveBaseUrl = () => {
const url = customBaseUrl || `${baseUrl}/v1`;
// Ensure URL ends with /v1
@@ -329,21 +334,10 @@ wire_api = "responses"
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
Connected
</span>
)}
{configStatus === "not_configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
Not configured
</span>
)}
{configStatus === "other" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
Other
</span>
)}
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
@@ -13,6 +13,7 @@ export default function DefaultToolCard({
apiKeys,
activeProviders = [],
cloudEnabled = false,
batchStatus,
}) {
const [copiedField, setCopiedField] = useState(null);
const [showModelModal, setShowModelModal] = useState(false);
@@ -483,23 +484,48 @@ export default function DefaultToolCard({
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{runtimeStatus && !runtimeStatus.error && (
<span
className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full ${
runtimeStatus.reason === "not_required"
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
: runtimeStatus.installed && runtimeStatus.runnable
? "bg-green-500/10 text-green-600 dark:text-green-400"
: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400"
}`}
>
{runtimeStatus.reason === "not_required"
? "Guide"
: runtimeStatus.installed && runtimeStatus.runnable
? "Detected"
: "Not ready"}
</span>
)}
{(() => {
// Use runtime status if available (after expanding), otherwise use batch status
const rs = runtimeStatus;
const bs = batchStatus;
const isGuide = rs?.reason === "not_required" || tool.configType === "guide";
const isDetected = rs ? rs.installed && rs.runnable : bs?.installed && bs?.runnable;
const isInstalled = rs ? rs.installed : bs?.installed;
if (isGuide) {
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400">
<span className="size-1.5 rounded-full bg-blue-500" />
Guide
</span>
);
}
if (isDetected) {
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-green-500/10 text-green-600 dark:text-green-400">
<span className="size-1.5 rounded-full bg-green-500" />
Detected
</span>
);
}
if (isInstalled === false && (rs || bs)) {
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-zinc-500/10 text-zinc-500 dark:text-zinc-400">
<span className="size-1.5 rounded-full bg-zinc-400 dark:bg-zinc-500" />
Not installed
</span>
);
}
if (isInstalled && !isDetected && (rs || bs)) {
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400">
<span className="size-1.5 rounded-full bg-yellow-500" />
Not ready
</span>
);
}
return null;
})()}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
@@ -3,6 +3,7 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -15,6 +16,7 @@ export default function DroidToolCard({
apiKeys,
activeProviders,
cloudEnabled,
batchStatus,
}) {
const [droidStatus, setDroidStatus] = useState(null);
const [checkingDroid, setCheckingDroid] = useState(false);
@@ -49,6 +51,9 @@ export default function DroidToolCard({
const configStatus = getConfigStatus();
// Use batch status as fallback when card hasn't been expanded yet
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
@@ -262,21 +267,10 @@ export default function DroidToolCard({
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
Connected
</span>
)}
{configStatus === "not_configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
Not configured
</span>
)}
{configStatus === "other" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
Other
</span>
)}
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
@@ -3,6 +3,7 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -15,6 +16,7 @@ export default function KiloToolCard({
apiKeys,
activeProviders,
cloudEnabled,
batchStatus,
}) {
const [kiloStatus, setKiloStatus] = useState(null);
const [checkingKilo, setCheckingKilo] = useState(false);
@@ -42,6 +44,9 @@ export default function KiloToolCard({
const configStatus = getConfigStatus();
// Use batch status as fallback when card hasn't been expanded yet
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
@@ -70,7 +75,7 @@ export default function KiloToolCard({
const fetchBackups = async () => {
try {
const res = await fetch("/api/cli-tools/backups?toolId=kilo");
const res = await fetch("/api/cli-tools/backups?tool=kilo");
if (res.ok) {
const data = await res.json();
setBackups(data.backups || []);
@@ -86,7 +91,7 @@ export default function KiloToolCard({
const res = await fetch("/api/cli-tools/backups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ toolId: "kilo", backupId }),
body: JSON.stringify({ tool: "kilo", backupId }),
});
if (res.ok) {
setMessage({ type: "success", text: "Backup restored! Reloading status..." });
@@ -188,27 +193,6 @@ export default function KiloToolCard({
setShowManualConfigModal(false);
};
const renderStatusBadge = () => {
if (!cliReady) return null;
const badges = {
configured: {
class: "bg-green-500/10 text-green-600 dark:text-green-400",
text: "Connected",
},
not_configured: {
class: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
text: "Not configured",
},
};
const badge = badges[configStatus];
if (!badge) return null;
return (
<span className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full ${badge.class}`}>
{badge.text}
</span>
);
};
return (
<Card padding="sm" className="overflow-hidden">
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
@@ -235,7 +219,10 @@ export default function KiloToolCard({
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{renderStatusBadge()}
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
@@ -3,6 +3,7 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -15,6 +16,7 @@ export default function OpenClawToolCard({
apiKeys,
activeProviders,
cloudEnabled,
batchStatus,
}) {
const [openclawStatus, setOpenclawStatus] = useState(null);
const [checkingOpenclaw, setCheckingOpenclaw] = useState(false);
@@ -48,6 +50,9 @@ export default function OpenClawToolCard({
const configStatus = getConfigStatus();
// Use batch status as fallback when card hasn't been expanded yet
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
@@ -266,21 +271,10 @@ export default function OpenClawToolCard({
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
Connected
</span>
)}
{configStatus === "not_configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
Not configured
</span>
)}
{configStatus === "other" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
Other
</span>
)}
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
+61 -65
View File
@@ -9,8 +9,10 @@ import {
CardSkeleton,
ModelSelectModal,
ProxyConfigModal,
EmptyState,
} from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useNotificationStore } from "@/store/notificationStore";
// Validate combo name: letters, numbers, -, _, /, .
const VALID_NAME_REGEX = /^[a-zA-Z0-9_/.-]+$/;
@@ -40,6 +42,7 @@ export default function CombosPage() {
const [testResults, setTestResults] = useState(null);
const [testingCombo, setTestingCombo] = useState(null);
const { copied, copy } = useCopyToClipboard();
const notify = useNotificationStore();
const [proxyTargetCombo, setProxyTargetCombo] = useState(null);
const [proxyConfig, setProxyConfig] = useState(null);
@@ -87,12 +90,13 @@ export default function CombosPage() {
if (res.ok) {
await fetchData();
setShowCreateModal(false);
notify.success("Combo created successfully");
} else {
const err = await res.json();
alert(err.error?.message || err.error || "Failed to create combo");
notify.error(err.error?.message || err.error || "Failed to create combo");
}
} catch (error) {
console.log("Error creating combo:", error);
notify.error("Error creating combo");
}
};
@@ -106,12 +110,13 @@ export default function CombosPage() {
if (res.ok) {
await fetchData();
setEditingCombo(null);
notify.success("Combo updated successfully");
} else {
const err = await res.json();
alert(err.error?.message || err.error || "Failed to update combo");
notify.error(err.error?.message || err.error || "Failed to update combo");
}
} catch (error) {
console.log("Error updating combo:", error);
notify.error("Error updating combo");
}
};
@@ -121,9 +126,10 @@ export default function CombosPage() {
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
if (res.ok) {
setCombos(combos.filter((c) => c.id !== id));
notify.success("Combo deleted");
}
} catch (error) {
console.log("Error deleting combo:", error);
notify.error("Error deleting combo");
}
};
@@ -160,6 +166,7 @@ export default function CombosPage() {
setTestResults(data);
} catch (error) {
setTestResults({ error: "Test request failed" });
notify.error("Test request failed");
}
};
@@ -189,20 +196,13 @@ export default function CombosPage() {
{/* Combos List */}
{combos.length === 0 ? (
<Card>
<div className="text-center py-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
<span className="material-symbols-outlined text-[32px]">layers</span>
</div>
<p className="text-text-main font-medium mb-1">No combos yet</p>
<p className="text-sm text-text-muted mb-4">
Create model combos with weighted routing and fallback support
</p>
<Button icon="add" onClick={() => setShowCreateModal(true)}>
Create Combo
</Button>
</div>
</Card>
<EmptyState
icon="🧩"
title="No combos yet"
description="Create model combos with weighted routing and fallback support"
actionLabel="Create Combo"
onAction={() => setShowCreateModal(true)}
/>
) : (
<div className="flex flex-col gap-4">
{combos.map((combo) => (
@@ -308,7 +308,13 @@ function ComboCard({
? "bg-amber-500/15 text-amber-600 dark:text-amber-400"
: strategy === "round-robin"
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: "bg-blue-500/15 text-blue-600 dark:text-blue-400"
: strategy === "random"
? "bg-purple-500/15 text-purple-600 dark:text-purple-400"
: strategy === "least-used"
? "bg-cyan-500/15 text-cyan-600 dark:text-cyan-400"
: strategy === "cost-optimized"
? "bg-teal-500/15 text-teal-600 dark:text-teal-400"
: "bg-blue-500/15 text-blue-600 dark:text-blue-400"
}`}
>
{strategy}
@@ -704,53 +710,43 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
{/* Strategy Toggle */}
<div>
<label className="text-sm font-medium mb-1.5 block">Routing Strategy</label>
<div className="flex gap-1 p-0.5 bg-black/5 dark:bg-white/5 rounded-lg">
<button
onClick={() => setStrategy("priority")}
className={`flex-1 py-1.5 px-3 rounded-md text-xs font-medium transition-all ${
strategy === "priority"
? "bg-white dark:bg-bg-main shadow-sm text-primary"
: "text-text-muted hover:text-text-main"
}`}
>
<span className="material-symbols-outlined text-[14px] align-middle mr-1">
sort
</span>
Priority
</button>
<button
onClick={() => setStrategy("weighted")}
className={`flex-1 py-1.5 px-3 rounded-md text-xs font-medium transition-all ${
strategy === "weighted"
? "bg-white dark:bg-bg-main shadow-sm text-primary"
: "text-text-muted hover:text-text-main"
}`}
>
<span className="material-symbols-outlined text-[14px] align-middle mr-1">
percent
</span>
Weighted
</button>
<button
onClick={() => setStrategy("round-robin")}
className={`flex-1 py-1.5 px-3 rounded-md text-xs font-medium transition-all ${
strategy === "round-robin"
? "bg-white dark:bg-bg-main shadow-sm text-primary"
: "text-text-muted hover:text-text-main"
}`}
>
<span className="material-symbols-outlined text-[14px] align-middle mr-1">
autorenew
</span>
Round-Robin
</button>
<div className="grid grid-cols-3 gap-1 p-0.5 bg-black/5 dark:bg-white/5 rounded-lg">
{[
{ value: "priority", label: "Priority", icon: "sort" },
{ value: "weighted", label: "Weighted", icon: "percent" },
{ value: "round-robin", label: "Round-Robin", icon: "autorenew" },
{ value: "random", label: "Random", icon: "shuffle" },
{ value: "least-used", label: "Least-Used", icon: "low_priority" },
{ value: "cost-optimized", label: "Cost-Opt", icon: "savings" },
].map((s) => (
<button
key={s.value}
onClick={() => setStrategy(s.value)}
className={`py-1.5 px-2 rounded-md text-xs font-medium transition-all ${
strategy === s.value
? "bg-white dark:bg-bg-main shadow-sm text-primary"
: "text-text-muted hover:text-text-main"
}`}
>
<span className="material-symbols-outlined text-[14px] align-middle mr-0.5">
{s.icon}
</span>
{s.label}
</button>
))}
</div>
<p className="text-[10px] text-text-muted mt-0.5">
{strategy === "priority"
? "Sequential fallback: tries model 1 first, then 2, etc."
: strategy === "weighted"
? "Distributes traffic by weight percentage with fallback"
: "Circular distribution: each request goes to the next model in rotation"}
{
{
priority: "Sequential fallback: tries model 1 first, then 2, etc.",
weighted: "Distributes traffic by weight percentage with fallback",
"round-robin":
"Circular distribution: each request goes to the next model in rotation",
random: "Uniform random selection, then fallback to remaining models",
"least-used": "Picks the model with fewest requests, balancing load over time",
"cost-optimized": "Routes to the cheapest model first based on pricing",
}[strategy]
}
</p>
</div>
@@ -0,0 +1,26 @@
"use client";
import { useState } from "react";
import { SegmentedControl } from "@/shared/components";
import BudgetTab from "../usage/components/BudgetTab";
import PricingTab from "../settings/components/PricingTab";
export default function CostsPage() {
const [activeTab, setActiveTab] = useState("budget");
return (
<div className="flex flex-col gap-6">
<SegmentedControl
options={[
{ value: "budget", label: "Budget" },
{ value: "pricing", label: "Pricing" },
]}
value={activeTab}
onChange={setActiveTab}
/>
{activeTab === "budget" && <BudgetTab />}
{activeTab === "pricing" && <PricingTab />}
</div>
);
}
@@ -28,7 +28,8 @@ export default function APIPageClient({ machineId }) {
const [showDisableModal, setShowDisableModal] = useState(false);
const [cloudSyncing, setCloudSyncing] = useState(false);
const [cloudStatus, setCloudStatus] = useState(null);
const [syncStep, setSyncStep] = useState(""); // "syncing" | "verifying" | "disabling" | ""
const [syncStep, setSyncStep] = useState(""); // "syncing" | "verifying" | "disabling" | "done" | ""
const [modalSuccess, setModalSuccess] = useState(false); // show success state in modal before closing
const [selectedProvider, setSelectedProvider] = useState(null); // for provider models popup
const { copied, copy } = useCopyToClipboard();
@@ -158,36 +159,72 @@ export default function APIPageClient({ machineId }) {
}
};
// Auto-dismiss cloudStatus after 5s
useEffect(() => {
if (cloudStatus) {
const timer = setTimeout(() => setCloudStatus(null), 5000);
return () => clearTimeout(timer);
}
}, [cloudStatus]);
const dispatchCloudChange = () => {
globalThis.dispatchEvent(new Event("cloud-status-changed"));
};
const handleEnableCloud = async () => {
setCloudSyncing(true);
setModalSuccess(false);
setSyncStep("syncing");
try {
const { ok, data } = await postCloudAction("enable");
const { ok, status, data } = await postCloudAction("enable");
if (ok) {
setSyncStep("verifying");
// Brief delay so user sees the verifying step
await new Promise((r) => setTimeout(r, 600));
// Sync succeeded — mark as enabled regardless of verify result
setCloudEnabled(true);
setSyncStep("done");
setModalSuccess(true);
setCloudSyncing(false);
dispatchCloudChange();
// Show success in modal for a moment, then close
await new Promise((r) => setTimeout(r, 1200));
setShowCloudModal(false);
setModalSuccess(false);
if (data.verified) {
setCloudEnabled(true);
setCloudStatus({ type: "success", message: "Cloud Proxy connected and verified!" });
setShowCloudModal(false);
} else {
setCloudEnabled(true);
setCloudStatus({
type: "warning",
message: data.verifyError || "Connected but verification failed",
message: data.verifyError
? `Connected — verification pending: ${data.verifyError}`
: "Connected — verification pending",
});
setShowCloudModal(false);
}
// Refresh keys list if new key was created
if (data.createdKey) {
await fetchData();
}
// Reload settings to ensure fresh state
await loadCloudSettings();
} else {
setCloudStatus({ type: "error", message: data.error || "Failed to enable cloud" });
// Sync failed — provide a helpful error message
let errorMessage = data.error || "Failed to enable cloud";
if (status === 502 || status === 408) {
errorMessage =
"Could not reach cloud worker. Make sure the cloud service is running (npm run dev in /cloud).";
}
setCloudStatus({ type: "error", message: errorMessage });
setShowCloudModal(false);
}
} catch (error) {
setCloudStatus({ type: "error", message: error.message });
setCloudStatus({ type: "error", message: error.message || "Connection failed" });
setShowCloudModal(false);
} finally {
setCloudSyncing(false);
setSyncStep("");
@@ -209,8 +246,10 @@ export default function APIPageClient({ machineId }) {
if (ok) {
setCloudEnabled(false);
setCloudStatus({ type: "success", message: "Cloud disabled" });
setCloudStatus({ type: "success", message: "Cloud disabled successfully" });
setShowDisableModal(false);
dispatchCloudChange();
await loadCloudSettings();
} else {
setCloudStatus({ type: "error", message: data.error || "Failed to disable cloud" });
}
@@ -309,7 +348,11 @@ export default function APIPageClient({ machineId }) {
{ label: "Documentation", href: "/docs" },
{ label: "OpenAI API compatibility", href: "/docs#api-reference" },
{ label: "Cherry/Codex compatibility", href: "/docs#client-compatibility" },
{ label: "Report issue", href: "https://github.com/decolua/omniroute/issues", external: true },
{
label: "Report issue",
href: "https://github.com/diegosouzapw/OmniRoute/issues",
external: true,
},
];
return (
@@ -352,6 +395,34 @@ export default function APIPageClient({ machineId }) {
</div>
</div>
{/* Cloud Status Toast */}
{cloudStatus && (
<div
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg mb-4 text-sm font-medium animate-in fade-in slide-in-from-top-2 duration-300 ${
cloudStatus.type === "success"
? "bg-green-500/10 border border-green-500/30 text-green-400"
: cloudStatus.type === "warning"
? "bg-amber-500/10 border border-amber-500/30 text-amber-400"
: "bg-red-500/10 border border-red-500/30 text-red-400"
}`}
>
<span className="material-symbols-outlined text-[18px]">
{cloudStatus.type === "success"
? "check_circle"
: cloudStatus.type === "warning"
? "warning"
: "error"}
</span>
<span className="flex-1">{cloudStatus.message}</span>
<button
onClick={() => setCloudStatus(null)}
className="p-0.5 hover:bg-white/10 rounded transition-colors"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
)}
{/* Endpoint URL */}
<div className="flex gap-2 mb-3">
<Input
@@ -367,141 +438,94 @@ export default function APIPageClient({ machineId }) {
{copied === "endpoint_url" ? "Copied!" : "Copy"}
</Button>
</div>
</Card>
{/* Quick Start */}
<Card>
<div className="flex flex-col gap-4">
<div>
<h2 className="text-lg font-semibold">Quick Start</h2>
<p className="text-sm text-text-muted">
First-time setup checklist for API clients and IDE tools.
</p>
</div>
<ol className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<li className="rounded-lg border border-border bg-bg-subtle p-3">
<span className="font-semibold">1. Create API key</span>
<p className="text-text-muted mt-1">
Generate one key per environment to isolate usage and revoke safely.
</p>
</li>
<li className="rounded-lg border border-border bg-bg-subtle p-3">
<span className="font-semibold">2. Connect provider account</span>
<p className="text-text-muted mt-1">
Configure providers in Dashboard and validate with Test Connection.
</p>
</li>
<li className="rounded-lg border border-border bg-bg-subtle p-3">
<span className="font-semibold">3. Use endpoint</span>
<p className="text-text-muted mt-1">
Point clients to <code>{currentEndpoint}</code> and send requests to{" "}
<code>/chat/completions</code>.
</p>
</li>
<li className="rounded-lg border border-border bg-bg-subtle p-3">
<span className="font-semibold">4. Monitor usage</span>
<p className="text-text-muted mt-1">
Track requests, tokens, errors, and cost in Usage and Request Logger.
</p>
</li>
</ol>
<div className="flex flex-wrap gap-2">
{quickStartLinks.map((link) => (
<a
key={link.href}
href={link.href}
target={link.external ? "_blank" : undefined}
rel={link.external ? "noopener noreferrer" : undefined}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
>
<span className="material-symbols-outlined text-[14px]">
{link.external ? "open_in_new" : "arrow_forward"}
</span>
{link.label}
</a>
))}
</div>
</div>
</Card>
{/* API Keys */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">API Keys</h2>
<Button icon="add" onClick={() => setShowAddModal(true)}>
Create Key
</Button>
</div>
{keys.length === 0 ? (
<div className="text-center py-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
<span className="material-symbols-outlined text-[32px]">vpn_key</span>
{/* Registered Keys — collapsible section inside API Endpoint card */}
<div className="border border-border rounded-lg overflow-hidden mt-4">
<button
onClick={() => setExpandedEndpoint(expandedEndpoint === "keys" ? null : "keys")}
className="w-full flex items-center gap-3 p-4 hover:bg-surface/50 transition-colors text-left"
>
<div className="flex items-center justify-center size-10 rounded-lg bg-amber-500/10 shrink-0">
<span className="material-symbols-outlined text-xl text-amber-500">vpn_key</span>
</div>
<p className="text-text-main font-medium mb-1">No API keys yet</p>
<p className="text-sm text-text-muted mb-4">Create your first API key to get started</p>
<Button icon="add" onClick={() => setShowAddModal(true)}>
Create Key
</Button>
</div>
) : (
<div className="flex flex-col">
{keys.map((key) => (
<div
key={key.id}
className="group flex items-center justify-between py-3 border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{key.name}</p>
<div className="flex items-center gap-2 mt-1">
<code className="text-xs text-text-muted font-mono">{key.key}</code>
<button
onClick={() => copy(key.key, key.id)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all"
>
<span className="material-symbols-outlined text-[14px]">
{copied === key.id ? "check" : "content_copy"}
</span>
</button>
</div>
<p className="text-xs text-text-muted mt-1">
Created {new Date(key.createdAt).toLocaleDateString()}
</p>
</div>
<button
onClick={() => handleDeleteKey(key.id)}
className="p-2 hover:bg-red-500/10 rounded text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">Registered Keys</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-surface text-text-muted font-medium">
{keys.length} {keys.length === 1 ? "key" : "keys"}
</span>
</div>
))}
</div>
)}
</Card>
<p className="text-xs text-text-muted mt-0.5">
Manage API keys used to authenticate requests to this endpoint
</p>
</div>
<span
className={`material-symbols-outlined text-text-muted text-lg transition-transform ${expandedEndpoint === "keys" ? "rotate-180" : ""}`}
>
expand_more
</span>
</button>
{/* Providers Overview */}
<Card>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold">Providers Overview</h2>
<p className="text-sm text-text-muted">
{providerStats.filter((item) => item.total > 0).length} configured of{" "}
{providerStats.length} available providers
</p>
</div>
</div>
{expandedEndpoint === "keys" && (
<div className="border-t border-border px-4 pb-4">
<div className="flex items-center justify-between mt-3 mb-3">
<p className="text-xs text-text-muted">
Each key isolates usage tracking and can be revoked independently.
</p>
<Button size="sm" icon="add" onClick={() => setShowAddModal(true)}>
Create Key
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{providerStats.map((item) => (
<ProviderOverviewCard
key={item.id}
item={item}
onClick={() => setSelectedProvider(item)}
/>
))}
{keys.length === 0 ? (
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 text-primary mb-3">
<span className="material-symbols-outlined text-[24px]">vpn_key</span>
</div>
<p className="text-text-main font-medium mb-1 text-sm">No API keys yet</p>
<p className="text-xs text-text-muted mb-3">
Create your first API key to get started
</p>
<Button size="sm" icon="add" onClick={() => setShowAddModal(true)}>
Create Key
</Button>
</div>
) : (
<div className="flex flex-col">
{keys.map((key) => (
<div
key={key.id}
className="group flex items-center justify-between py-3 border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{key.name}</p>
<div className="flex items-center gap-2 mt-1">
<code className="text-xs text-text-muted font-mono">{key.key}</code>
<button
onClick={() => copy(key.key, key.id)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all"
>
<span className="material-symbols-outlined text-[14px]">
{copied === key.id ? "check" : "content_copy"}
</span>
</button>
</div>
<p className="text-xs text-text-muted mt-1">
Created {new Date(key.createdAt).toLocaleDateString()}
</p>
</div>
<button
onClick={() => handleDeleteKey(key.id)}
className="p-2 hover:bg-red-500/10 rounded text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
</Card>
@@ -747,29 +771,51 @@ export default function APIPageClient({ machineId }) {
</ul>
</div>
{/* Sync Progress */}
{cloudSyncing && (
<div className="flex items-center gap-3 p-3 bg-primary/10 border border-primary/30 rounded-lg">
<span className="material-symbols-outlined animate-spin text-primary">
progress_activity
</span>
{/* Sync Progress / Success */}
{(cloudSyncing || modalSuccess) && (
<div
className={`flex items-center gap-3 p-3 rounded-lg border transition-all duration-300 ${
modalSuccess
? "bg-green-500/10 border-green-500/30"
: "bg-primary/10 border-primary/30"
}`}
>
{modalSuccess ? (
<span className="material-symbols-outlined text-green-500 text-xl">
check_circle
</span>
) : (
<span className="material-symbols-outlined animate-spin text-primary">
progress_activity
</span>
)}
<div className="flex-1">
<p className="text-sm font-medium text-primary">
{syncStep === "syncing" && "Syncing data to cloud..."}
{syncStep === "verifying" && "Verifying connection..."}
<p
className={`text-sm font-medium ${
modalSuccess ? "text-green-500" : "text-primary"
}`}
>
{modalSuccess && "Cloud Proxy connected!"}
{!modalSuccess && syncStep === "syncing" && "Connecting to cloud..."}
{!modalSuccess && syncStep === "verifying" && "Verifying connection..."}
</p>
</div>
</div>
)}
<div className="flex gap-2">
<Button onClick={handleEnableCloud} fullWidth disabled={cloudSyncing}>
<Button onClick={handleEnableCloud} fullWidth disabled={cloudSyncing || modalSuccess}>
{cloudSyncing ? (
<span className="flex items-center gap-2">
<span className="material-symbols-outlined animate-spin text-sm">
progress_activity
</span>
{syncStep === "syncing" ? "Syncing..." : "Verifying..."}
{syncStep === "syncing" ? "Connecting..." : "Verifying..."}
</span>
) : modalSuccess ? (
<span className="flex items-center gap-2">
<span className="material-symbols-outlined text-sm">check</span>
Connected!
</span>
) : (
"Enable Cloud"
@@ -779,7 +825,7 @@ export default function APIPageClient({ machineId }) {
onClick={() => setShowCloudModal(false)}
variant="ghost"
fullWidth
disabled={cloudSyncing}
disabled={cloudSyncing || modalSuccess}
>
Cancel
</Button>
@@ -0,0 +1,607 @@
"use client";
/**
* Health Dashboard Phase 8.3
*
* System health overview with cards for:
* - System status (uptime, version, memory)
* - Provider health (circuit breaker states)
* - Rate limit status
* - Active lockouts
* - Signature cache stats
* - Latency telemetry & prompt cache
*/
import { useState, useEffect, useCallback } from "react";
import { Card } from "@/shared/components";
import { AI_PROVIDERS } from "@/shared/constants/providers";
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function formatBytes(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const CB_COLORS = {
CLOSED: { bg: "bg-green-500/10", text: "text-green-500", label: "Healthy" },
OPEN: { bg: "bg-red-500/10", text: "text-red-500", label: "Open" },
HALF_OPEN: { bg: "bg-amber-500/10", text: "text-amber-500", label: "Half-Open" },
};
export default function HealthPage() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [lastRefresh, setLastRefresh] = useState(null);
const [telemetry, setTelemetry] = useState(null);
const [cache, setCache] = useState(null);
const [signatureCache, setSignatureCache] = useState(null);
const fetchHealth = useCallback(async () => {
try {
const res = await fetch("/api/monitoring/health");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
setData(json);
setError(null);
setLastRefresh(new Date());
} catch (err) {
setError(err.message);
}
}, []);
// Fetch telemetry, cache, and signature cache stats
const fetchExtras = useCallback(async () => {
const results = await Promise.allSettled([
fetch("/api/telemetry/summary").then((r) => r.json()),
fetch("/api/cache/stats").then((r) => r.json()),
fetch("/api/rate-limits").then((r) => r.json()),
]);
if (results[0].status === "fulfilled") setTelemetry(results[0].value);
if (results[1].status === "fulfilled") setCache(results[1].value);
if (results[2].status === "fulfilled" && results[2].value.cacheStats) {
setSignatureCache(results[2].value.cacheStats);
}
}, []);
useEffect(() => {
fetchHealth();
fetchExtras();
const interval = setInterval(() => {
fetchHealth();
fetchExtras();
}, 15000);
return () => clearInterval(interval);
}, [fetchHealth, fetchExtras]);
const fmtMs = (ms) => (ms != null ? `${Math.round(ms)}ms` : "—");
if (!data && !error) {
return (
<div className="p-6 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-text-muted mt-4">Loading health data...</p>
</div>
</div>
);
}
if (error && !data) {
return (
<div className="p-6">
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-6 text-center">
<span className="material-symbols-outlined text-red-500 text-[32px] mb-2">error</span>
<p className="text-red-400">Failed to load health data: {error}</p>
<button
onClick={fetchHealth}
className="mt-4 px-4 py-2 rounded-lg bg-primary/10 text-primary text-sm hover:bg-primary/20 transition-colors"
>
Retry
</button>
</div>
</div>
);
}
const { system, providerHealth, rateLimitStatus, lockouts } = data;
const cbEntries = Object.entries(providerHealth || {});
const lockoutEntries = Object.entries(lockouts || {});
return (
<div className="p-6 space-y-6 max-w-6xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-main">System Health</h1>
<p className="text-sm text-text-muted mt-1">
Real-time monitoring of your OmniRoute instance
</p>
</div>
<div className="flex items-center gap-3">
{lastRefresh && (
<span className="text-xs text-text-muted">
Updated {lastRefresh.toLocaleTimeString()}
</span>
)}
<button
onClick={() => {
fetchHealth();
fetchExtras();
}}
className="p-2 rounded-lg bg-surface hover:bg-surface/80 text-text-muted hover:text-text-main transition-colors"
title="Refresh"
>
<span className="material-symbols-outlined text-[18px]">refresh</span>
</button>
</div>
</div>
{/* Status Banner */}
<div
role="status"
aria-live="polite"
className={`rounded-xl p-4 flex items-center gap-3 ${
data.status === "healthy"
? "bg-green-500/10 border border-green-500/20"
: "bg-red-500/10 border border-red-500/20"
}`}
>
<span
className={`material-symbols-outlined text-[24px] ${
data.status === "healthy" ? "text-green-500" : "text-red-500"
}`}
>
{data.status === "healthy" ? "check_circle" : "error"}
</span>
<span className={data.status === "healthy" ? "text-green-400" : "text-red-400"}>
{data.status === "healthy" ? "All systems operational" : "System issues detected"}
</span>
</div>
{/* System Info Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="p-4">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center size-8 rounded-lg bg-primary/10 text-primary">
<span className="material-symbols-outlined text-[18px]">timer</span>
</div>
<span className="text-sm text-text-muted">Uptime</span>
</div>
<p className="text-xl font-semibold text-text-main">{formatUptime(system.uptime)}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center size-8 rounded-lg bg-blue-500/10 text-blue-500">
<span className="material-symbols-outlined text-[18px]">info</span>
</div>
<span className="text-sm text-text-muted">Version</span>
</div>
<p className="text-xl font-semibold text-text-main">v{system.version}</p>
<p className="text-xs text-text-muted mt-1">Node {system.nodeVersion}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center size-8 rounded-lg bg-purple-500/10 text-purple-500">
<span className="material-symbols-outlined text-[18px]">memory</span>
</div>
<span className="text-sm text-text-muted">Memory (RSS)</span>
</div>
<p className="text-xl font-semibold text-text-main">
{formatBytes(system.memoryUsage?.rss || 0)}
</p>
<p className="text-xs text-text-muted mt-1">
Heap: {formatBytes(system.memoryUsage?.heapUsed || 0)} /{" "}
{formatBytes(system.memoryUsage?.heapTotal || 0)}
</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center size-8 rounded-lg bg-amber-500/10 text-amber-500">
<span className="material-symbols-outlined text-[18px]">dns</span>
</div>
<span className="text-sm text-text-muted">Providers</span>
</div>
<p className="text-xl font-semibold text-text-main">{cbEntries.length}</p>
<p className="text-xs text-text-muted mt-1">
{cbEntries.filter(([, v]) => v.state === "CLOSED").length} healthy
</p>
</Card>
</div>
{/* Telemetry Cards — Latency & Prompt Cache */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Latency Card */}
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">speed</span>
Latency
</h3>
{telemetry ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">p50</span>
<span className="font-mono">{fmtMs(telemetry.p50)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">p95</span>
<span className="font-mono">{fmtMs(telemetry.p95)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">p99</span>
<span className="font-mono">{fmtMs(telemetry.p99)}</span>
</div>
<div className="flex justify-between border-t border-border pt-2 mt-2">
<span className="text-text-muted">Total requests</span>
<span className="font-mono">{telemetry.totalRequests ?? 0}</span>
</div>
</div>
) : (
<p className="text-sm text-text-muted">No data yet</p>
)}
</Card>
{/* Prompt Cache Card */}
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">cached</span>
Prompt Cache
</h3>
{cache ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">Entries</span>
<span className="font-mono">
{cache.size}/{cache.maxSize}
</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">Hit Rate</span>
<span className="font-mono">{cache.hitRate?.toFixed(1) ?? 0}%</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">Hits / Misses</span>
<span className="font-mono">
{cache.hits ?? 0} / {cache.misses ?? 0}
</span>
</div>
</div>
) : (
<p className="text-sm text-text-muted">No data yet</p>
)}
</Card>
{/* Signature Cache Card */}
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">database</span>
Signature Cache
</h3>
{signatureCache ? (
<div className="grid grid-cols-2 gap-2">
{[
{ label: "Defaults", value: signatureCache.defaultCount, color: "text-text-muted" },
{
label: "Tool",
value: `${signatureCache.tool.entries}/${signatureCache.tool.patterns}`,
color: "text-blue-400",
},
{
label: "Family",
value: `${signatureCache.family.entries}/${signatureCache.family.patterns}`,
color: "text-purple-400",
},
{
label: "Session",
value: `${signatureCache.session.entries}/${signatureCache.session.patterns}`,
color: "text-cyan-400",
},
].map(({ label, value, color }) => (
<div
key={label}
className="text-center p-2 rounded-lg bg-surface/30 border border-border/30"
>
<p className={`text-lg font-bold tabular-nums ${color}`}>{value}</p>
<p className="text-xs text-text-muted mt-0.5">{label}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-text-muted">No data yet</p>
)}
</Card>
</div>
{/* Provider Health */}
<Card className="p-5" role="region" aria-label="Provider health status">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-primary">
health_and_safety
</span>
Provider Health
</h2>
{cbEntries.length > 0 && (
<div className="flex items-center gap-3 text-xs text-text-muted">
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-green-500" /> Healthy
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-amber-500" /> Recovering
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-red-500" /> Down
</span>
</div>
)}
</div>
{cbEntries.length === 0 ? (
<p className="text-sm text-text-muted text-center py-4">
No circuit breaker data available. Make some requests first.
</p>
) : (
(() => {
const unhealthy = cbEntries.filter(([, cb]) => cb.state !== "CLOSED");
const healthy = cbEntries.filter(([, cb]) => cb.state === "CLOSED");
return (
<div className="space-y-4">
{/* Unhealthy providers first */}
{unhealthy.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-red-400 uppercase tracking-wide">
Issues Detected
</p>
{unhealthy.map(([provider, cb]) => {
const style = CB_COLORS[cb.state] || CB_COLORS.OPEN;
const providerInfo = AI_PROVIDERS[provider];
const displayName = providerInfo?.name || provider;
return (
<div
key={provider}
className={`rounded-lg p-3 ${style.bg} border border-white/5 flex items-center gap-3`}
>
<div
className="size-8 rounded-lg flex items-center justify-center shrink-0 text-xs font-bold"
style={{
backgroundColor: `${providerInfo?.color || "#888"}15`,
color: providerInfo?.color || "#888",
}}
>
{providerInfo?.textIcon || provider.slice(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-main truncate">
{displayName}
</span>
<span
className={`text-xs font-semibold px-1.5 py-0.5 rounded ${style.bg} ${style.text}`}
>
{style.label}
</span>
</div>
<div className="text-xs text-text-muted mt-0.5">
{cb.failures} failure{cb.failures !== 1 ? "s" : ""}
{cb.lastFailure && (
<span className="ml-2">
· Last: {new Date(cb.lastFailure).toLocaleTimeString()}
</span>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{/* Healthy providers in compact grid */}
{healthy.length > 0 && (
<div>
{unhealthy.length > 0 && (
<p className="text-xs font-medium text-green-400 uppercase tracking-wide mb-2">
Operational
</p>
)}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
{healthy.map(([provider]) => {
const providerInfo = AI_PROVIDERS[provider];
const displayName = providerInfo?.name || provider;
return (
<div
key={provider}
className="rounded-lg p-2.5 bg-green-500/5 border border-white/5 flex items-center gap-2"
>
<span className="size-2 rounded-full bg-green-500 shrink-0" />
<span
className="text-xs font-medium text-text-main truncate"
title={displayName}
>
{displayName}
</span>
</div>
);
})}
</div>
</div>
)}
</div>
);
})()
)}
</Card>
{/* Rate Limit Status */}
{rateLimitStatus &&
Object.keys(rateLimitStatus).length > 0 &&
(() => {
// Parse rate limit keys ("provider:connectionId" or "provider:connectionId:model")
const parseKey = (key) => {
const parts = key.split(":");
const providerId = parts[0];
const connectionId = parts[1] || "";
const model = parts.slice(2).join(":") || null;
// Resolve friendly name
let displayName;
let providerInfo = AI_PROVIDERS[providerId];
if (providerId.startsWith("openai-compatible-")) {
const customName = providerId.replace("openai-compatible-", "");
displayName = `OpenAI Compatible`;
providerInfo = { color: "#10A37F", textIcon: "OC" };
if (customName.length > 12) displayName += ` (${customName.slice(0, 8)}…)`;
else if (customName) displayName += ` (${customName})`;
} else if (providerId.startsWith("anthropic-compatible-")) {
const customName = providerId.replace("anthropic-compatible-", "");
displayName = `Anthropic Compatible`;
providerInfo = { color: "#D97757", textIcon: "AC" };
if (customName.length > 12) displayName += ` (${customName.slice(0, 8)}…)`;
else if (customName) displayName += ` (${customName})`;
} else {
displayName = providerInfo?.name || providerId;
}
return { providerId, displayName, providerInfo, connectionId, model };
};
// Group entries by provider for a cleaner display
const entries = Object.entries(rateLimitStatus).map(([key, status]) => ({
key,
...parseKey(key),
status,
}));
// Sort: active (queued/running > 0) first, then alphabetically
entries.sort((a, b) => {
const aActive = (a.status.queued || 0) + (a.status.running || 0);
const bActive = (b.status.queued || 0) + (b.status.running || 0);
if (aActive !== bActive) return bActive - aActive;
return a.displayName.localeCompare(b.displayName);
});
return (
<Card className="p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-amber-500">
speed
</span>
Rate Limit Status
</h2>
<span className="text-xs text-text-muted">
{entries.length} active limiter{entries.length !== 1 ? "s" : ""}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{entries.map(({ key, displayName, providerInfo, connectionId, model, status }) => {
const isActive = (status.queued || 0) + (status.running || 0) > 0;
const isQueued = (status.queued || 0) > 0;
return (
<div
key={key}
className={`rounded-lg p-3 border transition-colors ${
isQueued
? "bg-amber-500/5 border-amber-500/20"
: isActive
? "bg-blue-500/5 border-blue-500/15"
: "bg-surface/30 border-white/5"
}`}
title={key}
>
<div className="flex items-center gap-2.5 mb-2">
<div
className="size-7 rounded-md flex items-center justify-center shrink-0 text-[10px] font-bold"
style={{
backgroundColor: `${providerInfo?.color || "#888"}15`,
color: providerInfo?.color || "#888",
}}
>
{providerInfo?.textIcon || displayName.slice(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-main truncate">
{displayName}
</p>
{connectionId && (
<p className="text-[10px] text-text-muted font-mono truncate">
{connectionId.length > 12
? connectionId.slice(0, 8) + "…"
: connectionId}
{model && <span className="ml-1 text-text-muted/60">· {model}</span>}
</p>
)}
</div>
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${
isQueued
? "bg-amber-500/15 text-amber-400"
: isActive
? "bg-blue-500/15 text-blue-400"
: "bg-green-500/10 text-green-400"
}`}
>
{isQueued ? "Queued" : isActive ? "Active" : "OK"}
</span>
</div>
<div className="flex items-center gap-3 text-[11px] text-text-muted">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">schedule</span>
{status.queued || 0} queued
</span>
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">play_arrow</span>
{status.running || 0} running
</span>
</div>
</div>
);
})}
</div>
</Card>
);
})()}
{/* Active Lockouts */}
{lockoutEntries.length > 0 && (
<Card className="p-5">
<h2 className="text-lg font-semibold text-text-main mb-4 flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-red-500">lock</span>
Active Lockouts
</h2>
<div className="space-y-2">
{lockoutEntries.map(([key, lockout]) => (
<div
key={key}
className="rounded-lg p-3 bg-red-500/5 border border-red-500/10 flex items-center justify-between"
>
<div>
<span className="text-sm font-medium text-text-main">{key}</span>
{lockout.reason && (
<span className="text-xs text-text-muted ml-2">({lockout.reason})</span>
)}
</div>
{lockout.until && (
<span className="text-xs text-red-400">
Until {new Date(lockout.until).toLocaleTimeString()}
</span>
)}
</div>
))}
</div>
</Card>
)}
</div>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { getMachineId } from "@/shared/utils/machine";
import { getSettings } from "@/lib/localDb";
import EndpointPageClient from "./endpoint/EndpointPageClient";
import HomePageClient from "./HomePageClient";
// Must be dynamic — depends on DB state (setupComplete) that changes at runtime
export const dynamic = "force-dynamic";
@@ -12,5 +12,5 @@ export default async function DashboardPage() {
redirect("/dashboard/onboarding");
}
const machineId = await getMachineId();
return <EndpointPageClient machineId={machineId} />;
return <HomePageClient machineId={machineId} />;
}
@@ -276,7 +276,7 @@ export default function ProviderDetailPage() {
const handleToggleRateLimit = async (connectionId, enabled) => {
try {
const res = await fetch("/api/rate-limit", {
const res = await fetch("/api/rate-limits", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ connectionId, enabled }),
@@ -370,8 +370,21 @@ export default function ProviderDetailPage() {
if (!modelId) continue;
const parts = modelId.split("/");
const baseAlias = parts[parts.length - 1];
if (modelAliases[baseAlias]) continue;
await handleSetAlias(modelId, baseAlias, providerStorageAlias);
// Save as imported (default) model in the DB
await fetch("/api/provider-models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: providerId,
modelId,
modelName: model.name || modelId,
source: "imported",
}),
});
// Also create an alias for routing
if (!modelAliases[baseAlias]) {
await handleSetAlias(modelId, baseAlias, providerStorageAlias);
}
importedCount += 1;
}
if (importedCount === 0) {
@@ -405,14 +418,30 @@ export default function ProviderDetailPage() {
}
if (providerInfo.passthroughModels) {
return (
<PassthroughModelsSection
providerAlias={providerAlias}
modelAliases={modelAliases}
copied={copied}
onCopy={copy}
onSetAlias={handleSetAlias}
onDeleteAlias={handleDeleteAlias}
/>
<div>
<div className="flex items-center gap-2 mb-4">
<Button
size="sm"
variant="secondary"
icon="download"
onClick={handleImportModels}
disabled={!canImportModels || importingModels}
>
{importingModels ? "Importing..." : "Import from /models"}
</Button>
{!canImportModels && (
<span className="text-xs text-text-muted">Add a connection to enable importing.</span>
)}
</div>
<PassthroughModelsSection
providerAlias={providerAlias}
modelAliases={modelAliases}
copied={copied}
onCopy={copy}
onSetAlias={handleSetAlias}
onDeleteAlias={handleDeleteAlias}
/>
</div>
);
}
@@ -704,9 +733,7 @@ export default function ProviderDetailPage() {
{/* Models */}
<Card>
<h2 className="text-lg font-semibold mb-4">
{providerInfo.passthroughModels ? "Model Aliases" : "Available Models"}
</h2>
<h2 className="text-lg font-semibold mb-4">Available Models</h2>
{renderModelsSection()}
{/* Custom Models — available for ALL providers */}
@@ -0,0 +1,194 @@
"use client";
/**
* ModelAvailabilityBadge compact inline status indicator
*
* Replaces the full ModelAvailabilityPanel card with a small badge
* that shows green when all models are operational, or amber/red
* when there are issues, with a hover popover for details.
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
const STATUS_CONFIG = {
available: { icon: "check_circle", color: "#22c55e", label: "Available" },
cooldown: { icon: "schedule", color: "#f59e0b", label: "Cooldown" },
unavailable: { icon: "error", color: "#ef4444", label: "Unavailable" },
unknown: { icon: "help", color: "#6b7280", label: "Unknown" },
};
export default function ModelAvailabilityBadge() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
const [clearing, setClearing] = useState(null);
const ref = useRef(null);
const notify = useNotificationStore();
const fetchStatus = useCallback(async () => {
try {
const res = await fetch("/api/models/availability");
if (res.ok) {
const json = await res.json();
setData(json);
}
} catch {
// silent fail — will retry
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
const interval = setInterval(fetchStatus, 30000);
return () => clearInterval(interval);
}, [fetchStatus]);
// Close popover on outside click
useEffect(() => {
const handleClick = (e) => {
if (ref.current && !ref.current.contains(e.target)) {
setExpanded(false);
}
};
if (expanded) document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [expanded]);
const handleClearCooldown = async (provider, model) => {
setClearing(`${provider}:${model}`);
try {
const res = await fetch("/api/models/availability", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "clearCooldown", provider, model }),
});
if (res.ok) {
notify.success(`Cooldown cleared for ${model}`);
await fetchStatus();
} else {
notify.error("Failed to clear cooldown");
}
} catch {
notify.error("Failed to clear cooldown");
} finally {
setClearing(null);
}
};
if (loading) return null;
const models = data?.models || [];
const unavailableCount =
data?.unavailableCount || models.filter((m) => m.status !== "available").length;
const isHealthy = unavailableCount === 0;
// Group unhealthy models by provider
const byProvider = {};
models.forEach((m) => {
if (m.status === "available") return;
const key = m.provider || "unknown";
if (!byProvider[key]) byProvider[key] = [];
byProvider[key].push(m);
});
return (
<div className="relative" ref={ref}>
<button
onClick={() => setExpanded(!expanded)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
isHealthy
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-500 hover:bg-emerald-500/15"
: "bg-amber-500/10 border-amber-500/20 text-amber-500 hover:bg-amber-500/15"
}`}
>
<span className="material-symbols-outlined text-[14px]">
{isHealthy ? "verified" : "warning"}
</span>
{isHealthy
? "All models operational"
: `${unavailableCount} model${unavailableCount !== 1 ? "s" : ""} with issues`}
</button>
{/* Expanded popover */}
{expanded && (
<div className="absolute top-full right-0 mt-2 w-80 bg-surface border border-border rounded-xl shadow-2xl z-50 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-bg">
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-[16px]"
style={{ color: isHealthy ? "#22c55e" : "#f59e0b" }}
>
{isHealthy ? "verified" : "warning"}
</span>
<span className="text-sm font-semibold text-text-main">Model Status</span>
</div>
<button
onClick={fetchStatus}
className="p-1 rounded-lg hover:bg-surface text-text-muted hover:text-text-main transition-colors"
title="Refresh"
>
<span className="material-symbols-outlined text-[14px]">refresh</span>
</button>
</div>
<div className="px-4 py-3 max-h-60 overflow-y-auto">
{isHealthy ? (
<p className="text-sm text-text-muted text-center py-2">
All models are responding normally.
</p>
) : (
<div className="flex flex-col gap-2.5">
{Object.entries(byProvider).map(([provider, provModels]) => (
<div key={provider}>
<p className="text-xs font-semibold text-text-main mb-1.5 capitalize">
{provider}
</p>
<div className="flex flex-col gap-1">
{provModels.map((m) => {
const status = STATUS_CONFIG[m.status] || STATUS_CONFIG.unknown;
const isClearing = clearing === `${m.provider}:${m.model}`;
return (
<div
key={`${m.provider}-${m.model}`}
className="flex items-center justify-between px-2.5 py-1.5 rounded-lg bg-surface/30"
>
<div className="flex items-center gap-1.5 min-w-0">
<span
className="material-symbols-outlined text-[14px] shrink-0"
style={{ color: status.color }}
>
{status.icon}
</span>
<span className="font-mono text-xs text-text-main truncate">
{m.model}
</span>
</div>
{m.status === "cooldown" && (
<Button
size="sm"
variant="ghost"
onClick={() => handleClearCooldown(m.provider, m.model)}
disabled={isClearing}
className="text-[10px] px-1.5! py-0.5! ml-2"
>
{isClearing ? "..." : "Clear"}
</Button>
)}
</div>
);
})}
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,183 @@
"use client";
/**
* ModelAvailabilityPanel Batch B
*
* Shows real-time model availability and cooldown status.
* Fetched from /api/models/availability.
*/
import { useState, useEffect, useCallback } from "react";
import { Card, Button, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
const STATUS_CONFIG = {
available: { icon: "check_circle", color: "#22c55e", label: "Available" },
cooldown: { icon: "schedule", color: "#f59e0b", label: "Cooldown" },
unavailable: { icon: "error", color: "#ef4444", label: "Unavailable" },
unknown: { icon: "help", color: "#6b7280", label: "Unknown" },
};
export default function ModelAvailabilityPanel() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [clearing, setClearing] = useState(null);
const notify = useNotificationStore();
const fetchStatus = useCallback(async () => {
try {
const res = await fetch("/api/models/availability");
if (res.ok) {
const json = await res.json();
setData(json);
}
} catch {
// silent fail — will retry
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
const interval = setInterval(fetchStatus, 30000);
return () => clearInterval(interval);
}, [fetchStatus]);
const handleClearCooldown = async (provider, model) => {
setClearing(`${provider}:${model}`);
try {
const res = await fetch("/api/models/availability", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "clearCooldown", provider, model }),
});
if (res.ok) {
notify.success(`Cooldown cleared for ${model}`);
await fetchStatus();
} else {
notify.error("Failed to clear cooldown");
}
} catch {
notify.error("Failed to clear cooldown");
} finally {
setClearing(null);
}
};
if (loading) {
return (
<Card className="p-6 mt-6">
<div className="flex items-center gap-2 text-text-muted animate-pulse">
<span className="material-symbols-outlined text-[20px]">monitoring</span>
Loading model availability...
</div>
</Card>
);
}
const models = data?.models || [];
const unavailableCount =
data?.unavailableCount || models.filter((m) => m.status !== "available").length;
if (models.length === 0 || unavailableCount === 0) {
return (
<Card className="p-6 mt-6">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
<span className="material-symbols-outlined text-[20px]">verified</span>
</div>
<div>
<h3 className="text-lg font-semibold text-text-main">Model Availability</h3>
<p className="text-sm text-text-muted">All models operational</p>
</div>
</div>
</Card>
);
}
// Group by provider
const byProvider = {};
models.forEach((m) => {
if (m.status === "available") return;
const key = m.provider || "unknown";
if (!byProvider[key]) byProvider[key] = [];
byProvider[key].push(m);
});
return (
<Card className="p-6 mt-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
<span className="material-symbols-outlined text-[20px]">warning</span>
</div>
<div>
<h3 className="text-lg font-semibold text-text-main">Model Availability</h3>
<p className="text-sm text-text-muted">
{unavailableCount} model{unavailableCount !== 1 ? "s" : ""} with issues
</p>
</div>
</div>
<Button size="sm" variant="ghost" onClick={fetchStatus} className="text-text-muted">
<span className="material-symbols-outlined text-[16px]">refresh</span>
</Button>
</div>
<div className="flex flex-col gap-3">
{Object.entries(byProvider).map(([provider, provModels]) => (
<div key={provider} className="border border-border/30 rounded-lg p-3">
<p className="text-sm font-medium text-text-main mb-2 capitalize">{provider}</p>
<div className="flex flex-col gap-1.5">
{provModels.map((m) => {
const status = STATUS_CONFIG[m.status] || STATUS_CONFIG.unknown;
const isClearing = clearing === `${m.provider}:${m.model}`;
return (
<div
key={`${m.provider}-${m.model}`}
className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface/30"
>
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-[16px]"
style={{ color: status.color }}
>
{status.icon}
</span>
<span className="font-mono text-sm text-text-main">{m.model}</span>
<span
className="text-xs px-1.5 py-0.5 rounded-full"
style={{
backgroundColor: `${status.color}15`,
color: status.color,
}}
>
{status.label}
</span>
{m.cooldownUntil && (
<span className="text-xs text-text-muted">
until {new Date(m.cooldownUntil).toLocaleTimeString()}
</span>
)}
</div>
{m.status === "cooldown" && (
<Button
size="sm"
variant="ghost"
onClick={() => handleClearCooldown(m.provider, m.model)}
disabled={isClearing}
className="text-xs"
>
{isClearing ? "Clearing..." : "Clear"}
</Button>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</Card>
);
}
+83 -23
View File
@@ -12,6 +12,8 @@ import {
} from "@/shared/constants/providers";
import Link from "next/link";
import { getErrorCode, getRelativeTime } from "@/shared/utils";
import { useNotificationStore } from "@/store/notificationStore";
import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge";
// Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard
function getStatusDisplay(connected, error, errorCode) {
@@ -85,6 +87,7 @@ export default function ProvidersPage() {
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false);
const [testingMode, setTestingMode] = useState(null);
const [testResults, setTestResults] = useState(null);
const notify = useNotificationStore();
useEffect(() => {
const fetchData = async () => {
@@ -153,8 +156,14 @@ export default function ProvidersPage() {
});
const data = await res.json();
setTestResults(data);
if (data.summary) {
const { passed, failed, total } = data.summary;
if (failed === 0) notify.success(`All ${total} tests passed`);
else notify.warning(`${passed}/${total} passed, ${failed} failed`);
}
} catch (error) {
setTestResults({ error: "Test request failed" });
notify.error("Provider test failed");
} finally {
setTestingMode(null);
}
@@ -193,22 +202,28 @@ export default function ProvidersPage() {
{/* OAuth Providers */}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">OAuth Providers</h2>
<button
onClick={() => handleBatchTest("oauth")}
disabled={!!testingMode}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
testingMode === "oauth"
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title="Test all OAuth connections"
>
<span className="material-symbols-outlined text-[14px]">
{testingMode === "oauth" ? "sync" : "play_arrow"}
</span>
{testingMode === "oauth" ? "Testing..." : "Test All"}
</button>
<h2 className="text-xl font-semibold flex items-center gap-2">
OAuth Providers <span className="size-2.5 rounded-full bg-blue-500" title="OAuth" />
</h2>
<div className="flex items-center gap-2">
<ModelAvailabilityBadge />
<button
onClick={() => handleBatchTest("oauth")}
disabled={!!testingMode}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
testingMode === "oauth"
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title="Test all OAuth connections"
aria-label="Test all OAuth connections"
>
<span className="material-symbols-outlined text-[14px]">
{testingMode === "oauth" ? "sync" : "play_arrow"}
</span>
{testingMode === "oauth" ? "Testing..." : "Test All"}
</button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Object.entries(OAUTH_PROVIDERS).map(([key, info]) => (
@@ -217,6 +232,7 @@ export default function ProvidersPage() {
providerId={key}
provider={info}
stats={getProviderStats(key, "oauth")}
authType="oauth"
/>
))}
</div>
@@ -225,7 +241,9 @@ export default function ProvidersPage() {
{/* Free Providers */}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Free Providers</h2>
<h2 className="text-xl font-semibold flex items-center gap-2">
Free Providers <span className="size-2.5 rounded-full bg-green-500" title="Free" />
</h2>
<button
onClick={() => handleBatchTest("free")}
disabled={!!testingMode}
@@ -235,6 +253,7 @@ export default function ProvidersPage() {
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title="Test all Free connections"
aria-label="Test all Free provider connections"
>
<span className="material-symbols-outlined text-[14px]">
{testingMode === "free" ? "sync" : "play_arrow"}
@@ -249,6 +268,7 @@ export default function ProvidersPage() {
providerId={key}
provider={info}
stats={getProviderStats(key, "oauth")}
authType="free"
/>
))}
</div>
@@ -257,7 +277,10 @@ export default function ProvidersPage() {
{/* API Key Providers — fixed list */}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">API Key Providers</h2>
<h2 className="text-xl font-semibold flex items-center gap-2">
API Key Providers{" "}
<span className="size-2.5 rounded-full bg-amber-500" title="API Key" />
</h2>
<button
onClick={() => handleBatchTest("apikey")}
disabled={!!testingMode}
@@ -267,6 +290,7 @@ export default function ProvidersPage() {
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title="Test all API Key connections"
aria-label="Test all API Key connections"
>
<span className="material-symbols-outlined text-[14px]">
{testingMode === "apikey" ? "sync" : "play_arrow"}
@@ -281,6 +305,7 @@ export default function ProvidersPage() {
providerId={key}
provider={info}
stats={getProviderStats(key, "apikey")}
authType="apikey"
/>
))}
</div>
@@ -289,7 +314,10 @@ export default function ProvidersPage() {
{/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">API Key Compatible Providers</h2>
<h2 className="text-xl font-semibold flex items-center gap-2">
API Key Compatible Providers{" "}
<span className="size-2.5 rounded-full bg-orange-500" title="Compatible" />
</h2>
<div className="flex gap-2">
{(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && (
<button
@@ -340,6 +368,7 @@ export default function ProvidersPage() {
providerId={info.id}
provider={info}
stats={getProviderStats(info.id, "apikey")}
authType="compatible"
/>
))}
</div>
@@ -377,6 +406,7 @@ export default function ProvidersPage() {
<button
onClick={() => setTestResults(null)}
className="p-1 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
aria-label="Close test results"
>
<span className="material-symbols-outlined text-lg">close</span>
</button>
@@ -391,10 +421,18 @@ export default function ProvidersPage() {
);
}
function ProviderCard({ providerId, provider, stats }) {
function ProviderCard({ providerId, provider, stats, authType }) {
const { connected, error, errorCode, errorTime } = stats;
const [imgError, setImgError] = useState(false);
const dotColors = {
free: "bg-green-500",
oauth: "bg-blue-500",
apikey: "bg-amber-500",
compatible: "bg-orange-500",
};
const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" };
return (
<Link href={`/dashboard/providers/${providerId}`} className="group">
<Card
@@ -424,7 +462,13 @@ function ProviderCard({ providerId, provider, stats }) {
)}
</div>
<div>
<h3 className="font-semibold">{provider.name}</h3>
<h3 className="font-semibold flex items-center gap-1.5">
{provider.name}
<span
className={`size-2 rounded-full ${dotColors[authType] || dotColors.oauth} shrink-0`}
title={dotLabels[authType] || "OAuth"}
/>
</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
{getStatusDisplay(connected, error, errorCode)}
{errorTime && <span className="text-text-muted"> {errorTime}</span>}
@@ -454,15 +498,24 @@ ProviderCard.propTypes = {
errorCode: PropTypes.string,
errorTime: PropTypes.string,
}).isRequired,
authType: PropTypes.string,
};
// API Key providers - use image with textIcon fallback (same as OAuth providers)
function ApiKeyProviderCard({ providerId, provider, stats }) {
function ApiKeyProviderCard({ providerId, provider, stats, authType }) {
const { connected, error, errorCode, errorTime } = stats;
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
const [imgError, setImgError] = useState(false);
const dotColors = {
free: "bg-green-500",
oauth: "bg-blue-500",
apikey: "bg-amber-500",
compatible: "bg-orange-500",
};
const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" };
// Determine icon path: OpenAI Compatible providers use specialized icons
const getIconPath = () => {
if (isCompatible) {
@@ -503,7 +556,13 @@ function ApiKeyProviderCard({ providerId, provider, stats }) {
)}
</div>
<div>
<h3 className="font-semibold">{provider.name}</h3>
<h3 className="font-semibold flex items-center gap-1.5">
{provider.name}
<span
className={`size-2 rounded-full ${dotColors[authType] || dotColors.apikey} shrink-0`}
title={dotLabels[authType] || "API Key"}
/>
</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
{getStatusDisplay(connected, error, errorCode)}
{isCompatible && (
@@ -544,6 +603,7 @@ ApiKeyProviderCard.propTypes = {
errorCode: PropTypes.string,
errorTime: PropTypes.string,
}).isRequired,
authType: PropTypes.string,
};
function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
@@ -0,0 +1,71 @@
"use client";
import { useState, useEffect } from "react";
import { Card } from "@/shared/components";
export default function CacheStatsCard() {
const [cache, setCache] = useState(null);
const [flushing, setFlushing] = useState(false);
const fetchStats = () => {
fetch("/api/cache/stats")
.then((r) => r.json())
.then(setCache)
.catch(() => {});
};
useEffect(fetchStats, []);
const handleFlush = async () => {
setFlushing(true);
try {
await fetch("/api/cache/stats", { method: "DELETE" });
fetchStats();
} finally {
setFlushing(false);
}
};
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[20px]">cached</span>
Prompt Cache
</h3>
<button
onClick={handleFlush}
disabled={flushing}
className="px-3 py-1.5 text-xs rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors disabled:opacity-50"
>
{flushing ? "Flushing…" : "Flush Cache"}
</button>
</div>
{cache ? (
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-text-muted">Size</p>
<p className="font-mono text-lg text-text-main">
{cache.size}/{cache.maxSize}
</p>
</div>
<div>
<p className="text-text-muted">Hit Rate</p>
<p className="font-mono text-lg text-text-main">{cache.hitRate?.toFixed(1) ?? 0}%</p>
</div>
<div>
<p className="text-text-muted">Hits</p>
<p className="font-mono text-text-main">{cache.hits ?? 0}</p>
</div>
<div>
<p className="text-text-muted">Evictions</p>
<p className="font-mono text-text-main">{cache.evictions ?? 0}</p>
</div>
</div>
) : (
<p className="text-sm text-text-muted">Loading cache stats</p>
)}
</Card>
);
}
@@ -82,22 +82,30 @@ export default function ComboDefaultsTab() {
<div
role="tablist"
aria-label="Combo strategy"
className="inline-flex p-0.5 rounded-md bg-black/5 dark:bg-white/5"
className="grid grid-cols-3 gap-1 p-0.5 rounded-md bg-black/5 dark:bg-white/5"
>
{["priority", "weighted", "round-robin"].map((s) => (
{[
{ value: "priority", label: "Priority", icon: "sort" },
{ value: "weighted", label: "Weighted", icon: "percent" },
{ value: "round-robin", label: "Round-Robin", icon: "autorenew" },
{ value: "random", label: "Random", icon: "shuffle" },
{ value: "least-used", label: "Least-Used", icon: "low_priority" },
{ value: "cost-optimized", label: "Cost-Opt", icon: "savings" },
].map((s) => (
<button
key={s}
key={s.value}
role="tab"
aria-selected={comboDefaults.strategy === s}
onClick={() => setComboDefaults((prev) => ({ ...prev, strategy: s }))}
aria-selected={comboDefaults.strategy === s.value}
onClick={() => setComboDefaults((prev) => ({ ...prev, strategy: s.value }))}
className={cn(
"px-3 py-1 rounded text-xs font-medium transition-all capitalize",
comboDefaults.strategy === s
"px-2 py-1 rounded text-xs font-medium transition-all flex items-center justify-center gap-0.5",
comboDefaults.strategy === s.value
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
: "text-text-muted hover:text-text-main"
)}
>
{s === "round-robin" ? "Round-Robin" : s}
<span className="material-symbols-outlined text-[14px]">{s.icon}</span>
{s.label}
</button>
))}
</div>
@@ -0,0 +1,123 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, DataTable, FilterBar, ColumnToggle } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
const ALL_COLUMNS = [
{ key: "timestamp", label: "Time" },
{ key: "action", label: "Action" },
{ key: "actor", label: "Actor" },
{ key: "details", label: "Details" },
];
export default function ComplianceTab() {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [filters, setFilters] = useState({});
const [visibleCols, setVisibleCols] = useState({
timestamp: true,
action: true,
actor: true,
details: true,
});
const notify = useNotificationStore();
useEffect(() => {
fetch("/api/compliance/audit-log?limit=100")
.then((r) => r.json())
.then((data) => {
setLogs(Array.isArray(data) ? data : data.logs || []);
setLoading(false);
})
.catch(() => {
setLoading(false);
notify.error("Failed to load audit log");
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const actionOptions = [...new Set(logs.map((l) => l.action).filter(Boolean))];
const actorOptions = [...new Set(logs.map((l) => l.actor).filter(Boolean))];
const filtered = logs.filter((l) => {
if (search) {
const q = search.toLowerCase();
const matchesSearch =
l.action?.toLowerCase().includes(q) ||
l.actor?.toLowerCase().includes(q) ||
(l.details && JSON.stringify(l.details).toLowerCase().includes(q));
if (!matchesSearch) return false;
}
if (filters.action && l.action !== filters.action) return false;
if (filters.actor && l.actor !== filters.actor) return false;
return true;
});
const columns = ALL_COLUMNS.filter((c) => visibleCols[c.key]);
const handleToggleCol = useCallback((key) => {
setVisibleCols((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
const renderCell = useCallback((row, col) => {
switch (col.key) {
case "timestamp":
return (
<span className="font-mono text-xs text-text-muted whitespace-nowrap">
{row.timestamp ? new Date(row.timestamp).toLocaleString() : "—"}
</span>
);
case "action":
return (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">
{row.action || "—"}
</span>
);
case "actor":
return <span className="text-text-main">{row.actor || "system"}</span>;
case "details":
return (
<span className="text-text-muted text-xs max-w-xs truncate block">
{row.details ? JSON.stringify(row.details) : "—"}
</span>
);
default:
return row[col.key] || "—";
}
}, []);
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[20px]">policy</span>
Audit Log
</h3>
<ColumnToggle columns={ALL_COLUMNS} visible={visibleCols} onToggle={handleToggleCol} />
</div>
<FilterBar
searchValue={search}
onSearchChange={setSearch}
placeholder="Search audit logs..."
filters={[
{ key: "action", label: "Action", options: actionOptions },
{ key: "actor", label: "Actor", options: actorOptions },
]}
activeFilters={filters}
onFilterChange={(key, val) => setFilters((prev) => ({ ...prev, [key]: val }))}
/>
<DataTable
columns={columns}
data={filtered}
renderCell={renderCell}
loading={loading}
maxHeight="400px"
emptyIcon="📋"
emptyMessage="No audit events found"
/>
</Card>
);
}
@@ -0,0 +1,215 @@
"use client";
/**
* FallbackChainsEditor Batch D
*
* Editor for model fallback chains. Each chain maps a model name
* to a prioritized list of providers that can serve it.
* API: /api/fallback/chains
*/
import { useState, useEffect, useCallback } from "react";
import { Card, Button, Input, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
const CHAIN_COLORS = [
"#6366f1",
"#22c55e",
"#f59e0b",
"#ef4444",
"#8b5cf6",
"#06b6d4",
"#ec4899",
"#14b8a6",
];
export default function FallbackChainsEditor() {
const [chains, setChains] = useState({});
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [newModel, setNewModel] = useState("");
const [newProviders, setNewProviders] = useState("");
const [saving, setSaving] = useState(false);
const notify = useNotificationStore();
const fetchChains = useCallback(async () => {
try {
const res = await fetch("/api/fallback/chains");
if (res.ok) {
const data = await res.json();
setChains(data.chains || data || {});
}
} catch {
// silent
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchChains();
}, [fetchChains]);
const handleCreate = async () => {
if (!newModel.trim() || !newProviders.trim()) {
notify.warning("Please fill model name and providers");
return;
}
const providers = newProviders
.split(",")
.map((p) => p.trim())
.filter(Boolean)
.map((provider, i) => ({ provider, priority: i + 1, enabled: true }));
if (providers.length === 0) {
notify.warning("Add at least one provider");
return;
}
setSaving(true);
try {
const res = await fetch("/api/fallback/chains", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: newModel.trim(), chain: providers }),
});
if (res.ok) {
notify.success(`Chain created for ${newModel.trim()}`);
setNewModel("");
setNewProviders("");
setShowCreate(false);
await fetchChains();
} else {
notify.error("Failed to create chain");
}
} catch {
notify.error("Failed to create chain");
} finally {
setSaving(false);
}
};
const handleDelete = async (model) => {
if (!confirm(`Delete fallback chain for "${model}"?`)) return;
try {
const res = await fetch("/api/fallback/chains", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
if (res.ok) {
notify.success(`Chain deleted for ${model}`);
await fetchChains();
} else {
notify.error("Failed to delete chain");
}
} catch {
notify.error("Failed to delete chain");
}
};
if (loading) {
return (
<Card className="p-6 mt-6">
<div className="flex items-center gap-2 text-text-muted animate-pulse">
<span className="material-symbols-outlined text-[20px]">timeline</span>
Loading fallback chains...
</div>
</Card>
);
}
const chainEntries = Object.entries(chains);
return (
<Card className="mt-6">
<div className="flex items-center gap-3 mb-4 p-6 pb-0">
<div className="p-2 rounded-lg bg-cyan-500/10 text-cyan-500">
<span className="material-symbols-outlined text-[20px]">timeline</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">Fallback Chains</h3>
<p className="text-sm text-text-muted">Define provider fallback order per model</p>
</div>
<Button size="sm" variant="primary" onClick={() => setShowCreate(!showCreate)}>
{showCreate ? "Cancel" : "+ Add Chain"}
</Button>
</div>
{/* Create Form */}
{showCreate && (
<div className="mx-6 p-4 rounded-lg border border-border/30 bg-surface/20 mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
<Input
label="Model Name"
placeholder="claude-sonnet-4-20250514"
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
/>
<Input
label="Providers (comma-separated, in priority order)"
placeholder="anthropic, openai, gemini"
value={newProviders}
onChange={(e) => setNewProviders(e.target.value)}
/>
</div>
<Button variant="primary" size="sm" onClick={handleCreate} loading={saving}>
Create Chain
</Button>
</div>
)}
{/* Chains List */}
<div className="px-6 pb-6">
{chainEntries.length === 0 ? (
<EmptyState
icon="timeline"
title="No Fallback Chains"
description="Create a chain to define provider fallback order for a model."
/>
) : (
<div className="flex flex-col gap-2">
{chainEntries.map(([model, chain]) => (
<div
key={model}
className="flex items-center justify-between px-4 py-3 rounded-lg border border-border/20 bg-surface/20 hover:bg-surface/40 transition-colors"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<span className="font-mono text-sm text-text-main truncate max-w-[200px]">
{model}
</span>
<span className="material-symbols-outlined text-[14px] text-text-muted">
arrow_forward
</span>
<div className="flex gap-1.5 flex-wrap">
{(Array.isArray(chain) ? chain : []).map((entry, i) => (
<span
key={`${entry.provider}-${i}`}
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{
backgroundColor: `${CHAIN_COLORS[i % CHAIN_COLORS.length]}20`,
color: CHAIN_COLORS[i % CHAIN_COLORS.length],
border: `1px solid ${CHAIN_COLORS[i % CHAIN_COLORS.length]}40`,
}}
>
{i + 1}. {entry.provider}
</span>
))}
</div>
</div>
<button
onClick={() => handleDelete(model)}
className="text-text-muted hover:text-red-400 transition-colors ml-2"
title="Delete chain"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
))}
</div>
)}
</div>
</Card>
);
}
@@ -0,0 +1,200 @@
"use client";
/**
* PoliciesPanel Batch E
*
* Shows circuit breaker states and locked identifiers.
* Allows force-unlocking locked identifiers.
* API: /api/policies
*/
import { useState, useEffect, useCallback } from "react";
import { Card, Button, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
const CB_STATUS = {
closed: { icon: "check_circle", color: "#22c55e", label: "Closed" },
"half-open": { icon: "pending", color: "#f59e0b", label: "Half-Open" },
open: { icon: "error", color: "#ef4444", label: "Open" },
};
export default function PoliciesPanel() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [unlocking, setUnlocking] = useState(null);
const notify = useNotificationStore();
const fetchPolicies = useCallback(async () => {
try {
const res = await fetch("/api/policies");
if (res.ok) {
const json = await res.json();
setData(json);
}
} catch {
// silent
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchPolicies();
const interval = setInterval(fetchPolicies, 15000);
return () => clearInterval(interval);
}, [fetchPolicies]);
const handleUnlock = async (identifier) => {
setUnlocking(identifier);
try {
const res = await fetch("/api/policies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "unlock", identifier }),
});
if (res.ok) {
notify.success(`Unlocked: ${identifier}`);
await fetchPolicies();
} else {
notify.error("Failed to unlock");
}
} catch {
notify.error("Failed to unlock");
} finally {
setUnlocking(null);
}
};
if (loading) {
return (
<Card className="p-6 mt-6">
<div className="flex items-center gap-2 text-text-muted animate-pulse">
<span className="material-symbols-outlined text-[20px]">security</span>
Loading policies...
</div>
</Card>
);
}
const circuitBreakers = data?.circuitBreakers || [];
const lockedIds = data?.lockedIdentifiers || [];
const hasIssues = circuitBreakers.some((cb) => cb.state !== "closed") || lockedIds.length > 0;
if (!hasIssues) {
return (
<Card className="p-6 mt-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
<span className="material-symbols-outlined text-[20px]">verified_user</span>
</div>
<div>
<h3 className="text-lg font-semibold text-text-main">Policies & Circuit Breakers</h3>
<p className="text-sm text-text-muted">
All systems operational no lockouts or tripped breakers
</p>
</div>
</div>
</Card>
);
}
return (
<Card className="p-6 mt-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-red-500/10 text-red-500">
<span className="material-symbols-outlined text-[20px]">gpp_maybe</span>
</div>
<div>
<h3 className="text-lg font-semibold text-text-main">Policies & Circuit Breakers</h3>
<p className="text-sm text-text-muted">Active issues detected</p>
</div>
</div>
<Button size="sm" variant="ghost" onClick={fetchPolicies}>
<span className="material-symbols-outlined text-[16px]">refresh</span>
</Button>
</div>
{/* Circuit Breakers */}
{circuitBreakers.filter((cb) => cb.state !== "closed").length > 0 && (
<div className="mb-4">
<p className="text-sm font-medium text-text-muted mb-2">Circuit Breakers</p>
<div className="flex flex-col gap-1.5">
{circuitBreakers
.filter((cb) => cb.state !== "closed")
.map((cb, i) => {
const status = CB_STATUS[cb.state] || CB_STATUS.open;
return (
<div
key={cb.name || i}
className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface/30 border border-border/20"
>
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-[16px]"
style={{ color: status.color }}
>
{status.icon}
</span>
<span className="text-sm text-text-main font-medium">
{cb.name || cb.provider || "Unknown"}
</span>
<span
className="text-xs px-1.5 py-0.5 rounded-full"
style={{
backgroundColor: `${status.color}15`,
color: status.color,
}}
>
{status.label}
</span>
{cb.failures > 0 && (
<span className="text-xs text-text-muted">{cb.failures} failures</span>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Locked Identifiers */}
{lockedIds.length > 0 && (
<div>
<p className="text-sm font-medium text-text-muted mb-2">Locked Identifiers</p>
<div className="flex flex-col gap-1.5">
{lockedIds.map((id, i) => {
const identifier = typeof id === "string" ? id : id.identifier || id.id;
return (
<div
key={identifier || i}
className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface/30 border border-border/20"
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-red-400">lock</span>
<span className="font-mono text-sm text-text-main">{identifier}</span>
{typeof id === "object" && id.lockedAt && (
<span className="text-xs text-text-muted">
since {new Date(id.lockedAt).toLocaleString()}
</span>
)}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleUnlock(identifier)}
disabled={unlocking === identifier}
className="text-xs"
>
{unlocking === identifier ? "Unlocking..." : "Force Unlock"}
</Button>
</div>
);
})}
</div>
</div>
)}
</Card>
);
}
@@ -1,11 +1,12 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { Card, Button, ProxyConfigModal } from "@/shared/components";
export default function ProxyTab() {
const [proxyModalOpen, setProxyModalOpen] = useState(false);
const [globalProxy, setGlobalProxy] = useState(null);
const mountedRef = useRef(true);
const loadGlobalProxy = async () => {
try {
@@ -18,7 +19,21 @@ export default function ProxyTab() {
};
useEffect(() => {
loadGlobalProxy();
mountedRef.current = true;
async function init() {
try {
const res = await fetch("/api/settings/proxy?level=global");
if (!mountedRef.current) return;
if (res.ok) {
const data = await res.json();
if (mountedRef.current) setGlobalProxy(data.proxy || null);
}
} catch {}
}
init();
return () => {
mountedRef.current = false;
};
}, []);
return (
@@ -0,0 +1,384 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, Button } from "@/shared/components";
// ─── State colors and labels ──────────────────────────────────────────────
const STATE_STYLES = {
CLOSED: {
bg: "bg-emerald-500/15",
text: "text-emerald-400",
border: "border-emerald-500/30",
label: "CLOSED",
icon: "check_circle",
},
OPEN: {
bg: "bg-red-500/15",
text: "text-red-400",
border: "border-red-500/30",
label: "OPEN",
icon: "error",
},
HALF_OPEN: {
bg: "bg-amber-500/15",
text: "text-amber-400",
border: "border-amber-500/30",
label: "HALF-OPEN",
icon: "warning",
},
};
function formatMs(ms) {
if (!ms || ms <= 0) return "—";
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
// ─── Circuit Breaker Card ────────────────────────────────────────────────
function CircuitBreakerCard({ breakers, onReset, loading }) {
const activeBreakers = breakers.filter((b) => b.state !== "CLOSED");
const totalBreakers = breakers.length;
return (
<Card className="p-0 overflow-hidden">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
electrical_services
</span>
<h2 className="text-lg font-bold">Circuit Breakers</h2>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">
{activeBreakers.length > 0
? `${activeBreakers.length} tripped`
: `${totalBreakers} healthy`}
</span>
{activeBreakers.length > 0 && (
<Button
size="sm"
variant="danger"
icon="restart_alt"
onClick={onReset}
disabled={loading}
>
Reset All
</Button>
)}
</div>
</div>
{breakers.length === 0 ? (
<p className="text-sm text-text-muted">
No circuit breakers active yet. They are created automatically when requests flow
through the combo pipeline.
</p>
) : (
<div className="space-y-2">
{breakers.map((b) => {
const style = STATE_STYLES[b.state] || STATE_STYLES.CLOSED;
return (
<div
key={b.name}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-black/5 dark:bg-white/5"
>
<div className="flex items-center gap-2">
<span
className={`material-symbols-outlined text-base ${style.text}`}
aria-hidden="true"
>
{style.icon}
</span>
<span className="text-sm font-medium">{b.name.replace("combo:", "")}</span>
</div>
<div className="flex items-center gap-3">
{b.failureCount > 0 && (
<span className="text-xs text-text-muted">
{b.failureCount} failure{b.failureCount !== 1 ? "s" : ""}
</span>
)}
<span
className={`px-2 py-0.5 rounded text-xs font-bold uppercase ${style.bg} ${style.text} border ${style.border}`}
>
{style.label}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
</Card>
);
}
// ─── Provider Profiles Card ──────────────────────────────────────────────
function ProviderProfilesCard({ profiles, onSave, saving }) {
const [editMode, setEditMode] = useState(false);
const [draft, setDraft] = useState(profiles);
useEffect(() => {
setDraft(profiles);
}, [profiles]);
const fields = [
{ key: "transientCooldown", label: "Transient Cooldown", suffix: "ms" },
{ key: "rateLimitCooldown", label: "Rate Limit Cooldown", suffix: "ms" },
{ key: "maxBackoffLevel", label: "Max Backoff Level", suffix: "" },
{ key: "circuitBreakerThreshold", label: "CB Threshold", suffix: " fails" },
{ key: "circuitBreakerReset", label: "CB Reset Time", suffix: "ms" },
];
const handleSave = () => {
onSave(draft);
setEditMode(false);
};
return (
<Card className="p-0 overflow-hidden">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
tune
</span>
<h2 className="text-lg font-bold">Provider Profiles</h2>
</div>
{editMode ? (
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setEditMode(false)}>
Cancel
</Button>
<Button
size="sm"
variant="primary"
icon="save"
onClick={handleSave}
disabled={saving}
>
Save
</Button>
</div>
) : (
<Button size="sm" variant="secondary" icon="edit" onClick={() => setEditMode(true)}>
Edit
</Button>
)}
</div>
<p className="text-sm text-text-muted mb-4">
Separate resilience settings for OAuth (session-based) and API Key (metered) providers.
OAuth providers have stricter thresholds due to lower rate limits.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{["oauth", "apikey"].map((type) => (
<div key={type} className="rounded-lg bg-black/5 dark:bg-white/5 p-4">
<h3 className="text-sm font-bold uppercase tracking-wider mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-base" aria-hidden="true">
{type === "oauth" ? "lock" : "key"}
</span>
{type === "oauth" ? "OAuth Providers" : "API Key Providers"}
</h3>
<div className="space-y-2">
{fields.map(({ key, label, suffix }) => (
<div key={key} className="flex items-center justify-between">
<span className="text-xs text-text-muted">{label}</span>
{editMode ? (
<input
type="number"
min="0"
value={draft?.[type]?.[key] ?? 0}
onChange={(e) =>
setDraft({
...draft,
[type]: { ...draft[type], [key]: Number(e.target.value) },
})
}
className="w-24 px-2 py-1 text-xs rounded bg-white/10 border border-white/20 text-right"
/>
) : (
<span className="text-sm font-mono">
{profiles?.[type]?.[key] ?? "—"}
{suffix && profiles?.[type]?.[key] != null ? suffix : ""}
</span>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
</Card>
);
}
// ─── Rate Limit Overview Card ────────────────────────────────────────────
function RateLimitOverviewCard({ rateLimitStatus, defaults }) {
return (
<Card className="p-0 overflow-hidden">
<div className="p-6">
<div className="flex items-center gap-2 mb-4">
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
speed
</span>
<h2 className="text-lg font-bold">Rate Limiting</h2>
</div>
<p className="text-sm text-text-muted mb-4">
API Key providers are automatically rate-limited with safe defaults. Limits are learned
from response headers and adapt over time.
</p>
<div className="rounded-lg bg-black/5 dark:bg-white/5 p-4 mb-4">
<h3 className="text-xs font-bold uppercase tracking-wider mb-2 text-text-muted">
Default Safety Net
</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<div className="text-lg font-bold">{defaults?.requestsPerMinute ?? "—"}</div>
<div className="text-xs text-text-muted">RPM</div>
</div>
<div>
<div className="text-lg font-bold">{formatMs(defaults?.minTimeBetweenRequests)}</div>
<div className="text-xs text-text-muted">Min Gap</div>
</div>
<div>
<div className="text-lg font-bold">{defaults?.concurrentRequests ?? "—"}</div>
<div className="text-xs text-text-muted">Max Concurrent</div>
</div>
</div>
</div>
{rateLimitStatus && rateLimitStatus.length > 0 ? (
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-text-muted">
Active Limiters
</h3>
{rateLimitStatus.map((rl, i) => (
<div
key={i}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-black/5 dark:bg-white/5"
>
<span className="text-sm font-medium">{rl.provider || rl.key}</span>
<div className="flex items-center gap-3 text-xs text-text-muted">
{rl.reservoir != null && <span>Reservoir: {rl.reservoir}</span>}
{rl.running != null && <span>Running: {rl.running}</span>}
{rl.queued != null && <span>Queued: {rl.queued}</span>}
</div>
</div>
))}
</div>
) : (
<p className="text-xs text-text-muted">No active rate limiters yet.</p>
)}
</div>
</Card>
);
}
// ─── Main Resilience Tab ─────────────────────────────────────────────────
export default function ResilienceTab() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const loadData = useCallback(async () => {
try {
setLoading(true);
const res = await fetch("/api/resilience");
if (!res.ok) throw new Error(`Failed to load: ${res.status}`);
const json = await res.json();
setData(json);
setError(null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
// Auto-refresh every 10s
const interval = setInterval(loadData, 10000);
return () => clearInterval(interval);
}, [loadData]);
const handleResetBreakers = async () => {
try {
setLoading(true);
const res = await fetch("/api/resilience/reset", { method: "POST" });
if (!res.ok) throw new Error("Reset failed");
await loadData();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleSaveProfiles = async (profiles) => {
try {
setSaving(true);
const res = await fetch("/api/resilience", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profiles }),
});
if (!res.ok) throw new Error("Save failed");
await loadData();
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
if (loading && !data) {
return (
<div className="flex items-center justify-center py-12 text-text-muted">
<span className="material-symbols-outlined animate-spin mr-2">hourglass_empty</span>
Loading resilience status...
</div>
);
}
if (error && !data) {
return (
<Card className="p-6">
<div className="flex items-center gap-2 text-red-400">
<span className="material-symbols-outlined">error</span>
<span className="text-sm">{error}</span>
</div>
<Button size="sm" variant="secondary" icon="refresh" onClick={loadData} className="mt-3">
Retry
</Button>
</Card>
);
}
return (
<div className="flex flex-col gap-6">
<CircuitBreakerCard
breakers={data?.circuitBreakers || []}
onReset={handleResetBreakers}
loading={loading}
/>
<ProviderProfilesCard
profiles={data?.profiles || {}}
onSave={handleSaveProfiles}
saving={saving}
/>
<RateLimitOverviewCard
rateLimitStatus={data?.rateLimitStatus || []}
defaults={data?.defaults || {}}
/>
</div>
);
}
@@ -2,9 +2,15 @@
import { useState, useEffect } from "react";
import { Card, Input, Toggle, Button } from "@/shared/components";
import FallbackChainsEditor from "./FallbackChainsEditor";
const STRATEGIES = [
{ value: "fill-first", label: "Fill First", desc: "Use accounts in priority order", icon: "vertical_align_top" },
{
value: "fill-first",
label: "Fill First",
desc: "Use accounts in priority order",
icon: "vertical_align_top",
},
{ value: "round-robin", label: "Round Robin", desc: "Cycle through all accounts", icon: "loop" },
{ value: "p2c", label: "P2C", desc: "Pick 2 random, use the healthier one", icon: "balance" },
];
@@ -90,7 +96,9 @@ export default function RoutingTab() {
{s.icon}
</span>
<div>
<p className={`text-sm font-medium ${settings.fallbackStrategy === s.value ? "text-blue-400" : ""}`}>
<p
className={`text-sm font-medium ${settings.fallbackStrategy === s.value ? "text-blue-400" : ""}`}
>
{s.label}
</p>
<p className="text-xs text-text-muted mt-0.5">{s.desc}</p>
@@ -120,7 +128,8 @@ export default function RoutingTab() {
<p className="text-xs text-text-muted italic pt-3 border-t border-border/30 mt-3">
{settings.fallbackStrategy === "round-robin" &&
`Distributing requests across accounts with ${settings.stickyRoundRobinLimit || 3} calls per account.`}
{settings.fallbackStrategy === "fill-first" && "Using accounts in priority order (Fill First)."}
{settings.fallbackStrategy === "fill-first" &&
"Using accounts in priority order (Fill First)."}
{settings.fallbackStrategy === "p2c" &&
"Power of Two Choices: picks 2 random accounts and routes to the healthier one."}
</p>
@@ -136,7 +145,9 @@ export default function RoutingTab() {
</div>
<div>
<h3 className="text-lg font-semibold">Model Aliases</h3>
<p className="text-sm text-text-muted">Wildcard patterns to remap model names Use * and ?</p>
<p className="text-sm text-text-muted">
Wildcard patterns to remap model names Use * and ?
</p>
</div>
</div>
@@ -149,7 +160,9 @@ export default function RoutingTab() {
>
<div className="flex items-center gap-3 text-sm">
<span className="font-mono text-purple-400">{a.pattern}</span>
<span className="material-symbols-outlined text-[14px] text-text-muted">arrow_forward</span>
<span className="material-symbols-outlined text-[14px] text-text-muted">
arrow_forward
</span>
<span className="font-mono text-text-main">{a.target}</span>
</div>
<button
@@ -185,6 +198,9 @@ export default function RoutingTab() {
</Button>
</div>
</Card>
{/* Fallback Chains */}
<FallbackChainsEditor />
</div>
);
}
@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { Card, Button, Input, Toggle } from "@/shared/components";
import IPFilterSection from "./IPFilterSection";
import PoliciesPanel from "./PoliciesPanel";
export default function SecurityTab() {
const [settings, setSettings] = useState({ requireLogin: false, hasPassword: false });
@@ -146,6 +147,7 @@ export default function SecurityTab() {
</div>
</Card>
<IPFilterSection />
<PoliciesPanel />
</div>
);
}
+14 -4
View File
@@ -11,15 +11,18 @@ import ProxyTab from "./components/ProxyTab";
import AppearanceTab from "./components/AppearanceTab";
import ThinkingBudgetTab from "./components/ThinkingBudgetTab";
import SystemPromptTab from "./components/SystemPromptTab";
import PricingTab from "./components/PricingTab";
import ComplianceTab from "./components/ComplianceTab";
import CacheStatsCard from "./components/CacheStatsCard";
import ResilienceTab from "./components/ResilienceTab";
const tabs = [
{ id: "general", label: "General", icon: "settings" },
{ id: "ai", label: "AI", icon: "smart_toy" },
{ id: "security", label: "Security", icon: "shield" },
{ id: "routing", label: "Routing", icon: "route" },
{ id: "pricing", label: "Pricing", icon: "payments" },
{ id: "resilience", label: "Resilience", icon: "electrical_services" },
{ id: "advanced", label: "Advanced", icon: "tune" },
{ id: "compliance", label: "Compliance", icon: "policy" },
];
export default function SettingsPage() {
@@ -83,9 +86,16 @@ export default function SettingsPage() {
</div>
)}
{activeTab === "pricing" && <PricingTab />}
{activeTab === "resilience" && <ResilienceTab />}
{activeTab === "advanced" && <ProxyTab />}
{activeTab === "advanced" && (
<div className="flex flex-col gap-6">
<ProxyTab />
<CacheStatsCard />
</div>
)}
{activeTab === "compliance" && <ComplianceTab />}
</div>
{/* App Info */}
@@ -27,7 +27,7 @@ export default function TranslatorPageClient() {
Translator Playground
</h1>
<p className="text-sm text-text-muted mt-1">
Debug, test, and visualize API format translations
Debug, test, and visualize how OmniRoute translates API requests between providers
</p>
</div>
<SegmentedControl options={MODES} value={mode} onChange={setMode} size="md" />
@@ -3,11 +3,8 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, Select, Badge } from "@/shared/components";
import { FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
import {
AI_PROVIDERS,
OPENAI_COMPATIBLE_PREFIX,
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
import { useProviderOptions } from "../hooks/useProviderOptions";
import { useAvailableModels } from "../hooks/useAvailableModels";
import dynamic from "next/dynamic";
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
@@ -16,20 +13,18 @@ const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
* Chat Tester Mode:
* - Left: Chat interface (send messages as a specific client format)
* - Right: Pipeline visualization showing each translation step
*
* How it works:
* 1. You type a message and select a "Client Format" (how the request is structured)
* 2. The message is built into a request body matching the client format
* 3. OmniRoute detects the format, translates it through the pipeline, and sends to the provider
* 4. Each pipeline step is shown on the right: Client Detect OpenAI Provider Response
*/
const DEFAULT_MODELS = {
openai: "gpt-4o",
claude: "claude-sonnet-4-20250514",
gemini: "gemini-2.5-flash",
"openai-responses": "gpt-4o",
};
export default function ChatTesterMode() {
const [provider, setProvider] = useState("openai");
const [providerOptions, setProviderOptions] = useState([]);
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels();
const [clientFormat, setClientFormat] = useState("openai");
const [model, setModel] = useState(DEFAULT_MODELS.openai);
const [availableModels, setAvailableModels] = useState([]);
const [message, setMessage] = useState("");
const [sending, setSending] = useState(false);
const [chatHistory, setChatHistory] = useState([]);
@@ -37,79 +32,11 @@ export default function ChatTesterMode() {
const [expandedStep, setExpandedStep] = useState(null);
const messagesEndRef = useRef(null);
// Update default model when client format changes
// Pick a smart default model when format changes or models finish loading
useEffect(() => {
setModel(DEFAULT_MODELS[clientFormat] || "gpt-4o");
}, [clientFormat]);
// Load available models
useEffect(() => {
const fetchModels = async () => {
try {
const res = await fetch("/api/v1/models");
const data = await res.json();
const models = (data.data || []).map((m) => m.id).sort((a, b) => a.localeCompare(b));
setAvailableModels(models);
} catch {
setAvailableModels([]);
}
};
fetchModels();
}, []);
// Load providers
useEffect(() => {
const fetchProviders = async () => {
try {
const [connRes, nodesRes] = await Promise.all([
fetch("/api/providers"),
fetch("/api/provider-nodes"),
]);
const [connData, nodesData] = await Promise.all([connRes.json(), nodesRes.json()]);
const nodeMap = new Map((nodesData.nodes || []).map((n) => [n.id, n]));
const activeProviders = new Set(
(connData.connections || []).filter((c) => c.isActive !== false).map((c) => c.provider)
);
const options = [...activeProviders]
.map((pid) => {
const info = AI_PROVIDERS[pid];
const node = nodeMap.get(pid);
let label = info?.name || node?.name || pid;
if (!info && pid.startsWith(OPENAI_COMPATIBLE_PREFIX))
label = node?.name || "OpenAI Compatible";
if (!info && pid.startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
label = node?.name || "Anthropic Compatible";
return { value: pid, label };
})
.sort((a, b) => a.label.localeCompare(b.label));
const nextOptions =
options.length > 0
? options
: Object.entries(AI_PROVIDERS).map(([id, info]) => ({ value: id, label: info.name }));
setProviderOptions(nextOptions);
if (nextOptions.length > 0) {
setProvider((current) =>
nextOptions.some((opt) => opt.value === current) ? current : nextOptions[0].value
);
}
} catch {
const fallbackOptions = Object.entries(AI_PROVIDERS).map(([id, info]) => ({
value: id,
label: info.name,
}));
setProviderOptions(fallbackOptions);
if (fallbackOptions.length > 0) {
setProvider((current) =>
fallbackOptions.some((opt) => opt.value === current)
? current
: fallbackOptions[0].value
);
}
}
};
fetchProviders();
}, []);
const picked = pickModelForFormat(clientFormat);
if (picked) setModel(picked);
}, [clientFormat, pickModelForFormat, setModel]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -170,6 +97,7 @@ export default function ChatTesterMode() {
steps.push({
id: 1,
name: "Client Request",
description: "The request body as your client would send it",
format: clientFormat,
content: JSON.stringify(clientRequest, null, 2),
status: "done",
@@ -187,6 +115,7 @@ export default function ChatTesterMode() {
steps.push({
id: 2,
name: "Format Detected",
description: "OmniRoute auto-detects the API format from the request structure",
format: detectedFormat,
content: JSON.stringify(
{ detectedFormat, clientFormat, match: detectedFormat === clientFormat },
@@ -212,6 +141,7 @@ export default function ChatTesterMode() {
steps.push({
id: 3,
name: "OpenAI Intermediate",
description: "All formats are first normalized to OpenAI format (the universal bridge)",
format: "openai",
content: JSON.stringify(toOpenaiData.result || toOpenaiData, null, 2),
status: toOpenaiData.success ? "done" : "error",
@@ -234,12 +164,13 @@ export default function ChatTesterMode() {
steps.push({
id: 4,
name: "Provider Format",
description: `OpenAI format is translated to the provider's native format`,
format: targetFmt,
content: JSON.stringify(providerTargetData.result || providerTargetData, null, 2),
status: providerTargetData.success ? "done" : "error",
});
// Step 5: Send to provider (use the OpenAI intermediate since the proxy handles translation)
// Step 5: Send to provider
const sendRes = await fetch("/api/translator/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -251,6 +182,7 @@ export default function ChatTesterMode() {
steps.push({
id: 5,
name: "Provider Response",
description: "The raw response from the provider API",
format: targetFmt,
content: JSON.stringify(errData, null, 2),
status: "error",
@@ -274,6 +206,7 @@ export default function ChatTesterMode() {
steps.push({
id: 5,
name: "Provider Response",
description: "The raw SSE stream from the provider API",
format: targetFmt,
content:
fullResponse.slice(0, 5000) + (fullResponse.length > 5000 ? "\n... (truncated)" : ""),
@@ -291,6 +224,7 @@ export default function ChatTesterMode() {
steps.push({
id: steps.length + 1,
name: "Error",
description: "An unexpected error occurred",
format: "error",
content: JSON.stringify({ error: err.message }, null, 2),
status: "error",
@@ -305,229 +239,269 @@ export default function ChatTesterMode() {
};
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left: Chat Interface */}
<div className="space-y-4">
{/* Controls */}
<Card>
<div className="p-4 flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
Client Format
</label>
<Select
value={clientFormat}
onChange={(e) => setClientFormat(e.target.value)}
options={FORMAT_OPTIONS.filter((o) =>
["openai", "claude", "gemini", "openai-responses"].includes(o.value)
)}
/>
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
Provider
</label>
<Select
value={provider}
onChange={(e) => setProvider(e.target.value)}
options={providerOptions}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
Model
</label>
<div className="relative">
<input
type="text"
value={model}
onChange={(e) => setModel(e.target.value)}
list="model-suggestions"
placeholder="e.g. gpt-4o, claude-sonnet-4-20250514"
className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
/>
<datalist id="model-suggestions">
{availableModels.map((m) => (
<option key={m} value={m} />
))}
</datalist>
</div>
</div>
</div>
</Card>
{/* Chat Messages */}
<Card className="min-h-[400px] flex flex-col">
<div className="p-4 flex-1 overflow-y-auto max-h-[500px] space-y-3">
{chatHistory.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-text-muted py-12">
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">chat</span>
<p className="text-sm">Send a message to see the translation pipeline</p>
</div>
)}
{chatHistory.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
msg.role === "user"
? "bg-primary/10 text-text-main border border-primary/20"
: "bg-bg-subtle text-text-main border border-border"
}`}
>
<p className="text-[10px] font-semibold text-text-muted mb-1 uppercase">
{msg.role === "user"
? `You (${FORMAT_META[clientFormat]?.label})`
: "Assistant"}
</p>
<p className="whitespace-pre-wrap">{msg.content}</p>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-3 border-t border-border">
<div className="flex gap-2">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()}
placeholder="Type a message..."
className="flex-1 bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
disabled={sending}
/>
<Button
icon="send"
onClick={handleSend}
loading={sending}
disabled={!message.trim() || sending}
>
Send
</Button>
</div>
</div>
</Card>
<div className="space-y-4">
{/* Info Banner */}
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
info
</span>
<div>
<p className="font-medium text-text-main mb-0.5">Pipeline Debugger</p>
<p>
Send messages as a specific client format and see how each step of the translation
pipeline works. The right panel shows the full flow:{" "}
<strong className="text-text-main">
Client Request Format Detection OpenAI Intermediate Provider Format Response
</strong>
. Click any step to inspect the data at that stage.
</p>
</div>
</div>
{/* Right: Pipeline Visualization */}
<div className="space-y-4">
<Card>
<div className="p-4 space-y-1">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-primary">
account_tree
</span>
<h3 className="text-sm font-semibold text-text-main">Translation Pipeline</h3>
</div>
<p className="text-xs text-text-muted">Click on any step to inspect the data</p>
</div>
</Card>
{!pipeline ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left: Chat Interface */}
<div className="space-y-4">
{/* Controls */}
<Card>
<div className="p-8 flex flex-col items-center justify-center text-text-muted">
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
account_tree
</span>
<p className="text-sm">Send a message to see the pipeline</p>
<div className="p-4 flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
Client Format
</label>
<Select
value={clientFormat}
onChange={(e) => setClientFormat(e.target.value)}
options={FORMAT_OPTIONS.filter((o) =>
["openai", "claude", "gemini", "openai-responses"].includes(o.value)
)}
/>
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
Provider
</label>
<Select
value={provider}
onChange={(e) => setProvider(e.target.value)}
options={providerOptions}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
Model
</label>
<div className="relative">
<input
type="text"
value={model}
onChange={(e) => setModel(e.target.value)}
list="model-suggestions"
placeholder="Select or type a model name..."
className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
/>
<datalist id="model-suggestions">
{availableModels.map((m) => (
<option key={m} value={m} />
))}
</datalist>
</div>
</div>
</div>
</Card>
) : (
<div className="space-y-2">
{pipeline.map((step, i) => {
const meta = FORMAT_META[step.format] || {
label: step.format,
color: "gray",
icon: "code",
};
const isExpanded = expandedStep === step.id;
return (
<div key={step.id}>
{/* Connector line */}
{i > 0 && (
<div className="flex justify-center py-1">
<div className="w-px h-4 bg-border" />
</div>
)}
<Card
className={
step.status === "error"
? "border-red-500/30"
: isExpanded
? "border-primary/30"
: ""
}
{/* Chat Messages */}
<Card className="min-h-[400px] flex flex-col">
<div className="p-4 flex-1 overflow-y-auto max-h-[500px] space-y-3">
{chatHistory.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-text-muted py-12">
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
chat
</span>
<p className="text-sm font-medium mb-1">
Send a message to see the translation pipeline
</p>
<p className="text-xs text-center max-w-xs">
Your message will be formatted as a{" "}
<strong>{FORMAT_META[clientFormat]?.label}</strong> request, translated through
the pipeline, and sent to the selected provider.
</p>
</div>
)}
{chatHistory.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
msg.role === "user"
? "bg-primary/10 text-text-main border border-primary/20"
: "bg-bg-subtle text-text-main border border-border"
}`}
>
<button
onClick={() => setExpandedStep(isExpanded ? null : step.id)}
className="w-full p-3 flex items-center gap-3 text-left"
>
{/* Step number */}
<div
className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${
step.status === "error"
? "bg-red-500/10 text-red-500"
: step.status === "done"
? `bg-${meta.color}-500/10 text-${meta.color}-500`
: "bg-bg-subtle text-text-muted"
}`}
>
{step.status === "error" ? "!" : step.id}
</div>
<p className="text-[10px] font-semibold text-text-muted mb-1 uppercase">
{msg.role === "user"
? `You (${FORMAT_META[clientFormat]?.label})`
: "Assistant"}
</p>
<p className="whitespace-pre-wrap">{msg.content}</p>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Step info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-main">{step.name}</p>
</div>
{/* Input */}
<div className="p-3 border-t border-border">
<div className="flex gap-2">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()}
placeholder="Type a message..."
className="flex-1 bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
disabled={sending}
/>
<Button
icon="send"
onClick={handleSend}
loading={sending}
disabled={!message.trim() || sending}
>
Send
</Button>
</div>
</div>
</Card>
</div>
{/* Format badge */}
<Badge variant={step.status === "error" ? "error" : "default"} size="sm">
{meta.label}
</Badge>
{/* Right: Pipeline Visualization */}
<div className="space-y-4">
<Card>
<div className="p-4 space-y-1">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-primary">
account_tree
</span>
<h3 className="text-sm font-semibold text-text-main">Translation Pipeline</h3>
</div>
<p className="text-xs text-text-muted">
Click on any step to inspect the data at that stage
</p>
</div>
</Card>
{/* Expand icon */}
<span className="material-symbols-outlined text-[18px] text-text-muted">
{isExpanded ? "expand_less" : "expand_more"}
</span>
</button>
{!pipeline ? (
<Card>
<div className="p-8 flex flex-col items-center justify-center text-text-muted">
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
account_tree
</span>
<p className="text-sm font-medium mb-1">Pipeline visualization</p>
<p className="text-xs text-center max-w-xs">
Send a message to see how your request flows through detection translation
provider call.
</p>
</div>
</Card>
) : (
<div className="space-y-2">
{pipeline.map((step, i) => {
const meta = FORMAT_META[step.format] || {
label: step.format,
color: "gray",
icon: "code",
};
const isExpanded = expandedStep === step.id;
{/* Expanded content */}
{isExpanded && (
<div className="px-3 pb-3">
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="250px"
defaultLanguage="json"
value={step.content}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 11,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
readOnly: true,
}}
/>
</div>
return (
<div key={step.id}>
{/* Connector line */}
{i > 0 && (
<div className="flex justify-center py-1">
<div className="w-px h-4 bg-border" />
</div>
)}
</Card>
</div>
);
})}
</div>
)}
<Card
className={
step.status === "error"
? "border-red-500/30"
: isExpanded
? "border-primary/30"
: ""
}
>
<button
onClick={() => setExpandedStep(isExpanded ? null : step.id)}
className="w-full p-3 flex items-center gap-3 text-left"
>
{/* Step number */}
<div
className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${
step.status === "error"
? "bg-red-500/10 text-red-500"
: step.status === "done"
? `bg-${meta.color}-500/10 text-${meta.color}-500`
: "bg-bg-subtle text-text-muted"
}`}
>
{step.status === "error" ? "!" : step.id}
</div>
{/* Step info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-main">{step.name}</p>
{step.description && (
<p className="text-[10px] text-text-muted truncate">
{step.description}
</p>
)}
</div>
{/* Format badge */}
<Badge variant={step.status === "error" ? "error" : "default"} size="sm">
{meta.label}
</Badge>
{/* Expand icon */}
<span className="material-symbols-outlined text-[18px] text-text-muted">
{isExpanded ? "expand_less" : "expand_more"}
</span>
</button>
{/* Expanded content */}
{isExpanded && (
<div className="px-3 pb-3">
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="250px"
defaultLanguage="json"
value={step.content}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 11,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
readOnly: true,
}}
/>
</div>
</div>
)}
</Card>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
@@ -49,6 +49,23 @@ export default function LiveMonitorMode() {
return (
<div className="space-y-5">
{/* Info Banner */}
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
info
</span>
<div>
<p className="font-medium text-text-main mb-0.5">Real-Time Translation Activity</p>
<p>
Shows translation events as API calls flow through OmniRoute. Events come from the
in-memory buffer (resets on restart). Use{" "}
<strong className="text-text-main">Chat Tester</strong>,{" "}
<strong className="text-text-main">Test Bench</strong>, or external API calls to
generate events.
</p>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard icon="translate" label="Total Translations" value={events.length} color="blue" />
@@ -99,11 +116,26 @@ export default function LiveMonitorMode() {
monitoring
</span>
<p className="text-sm font-medium mb-1">No translations yet</p>
<p className="text-xs">
Translations will appear here as requests flow through the proxy.
<p className="text-xs text-center max-w-sm">
Translation events appear here as requests flow through OmniRoute. Use any of these
methods to generate events:
</p>
<p className="text-xs mt-2">
Make API calls to your OmniRoute endpoints to see live translation data.
<div className="flex flex-wrap gap-2 mt-3 text-xs">
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
Chat Tester tab
</span>
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
Test Bench tab
</span>
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
External API calls
</span>
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
IDE/CLI integrations
</span>
</div>
<p className="text-[10px] mt-3 text-text-muted/70">
Note: Events are stored in-memory and reset when the server restarts.
</p>
</div>
) : (
@@ -65,12 +65,6 @@ export default function PlaygroundMode() {
step: "direct",
sourceFormat,
targetFormat,
provider:
targetFormat === "claude"
? "anthropic"
: targetFormat === "gemini"
? "google"
: "openai",
body: parsed,
}),
});
@@ -114,6 +108,20 @@ export default function PlaygroundMode() {
return (
<div className="space-y-5">
{/* Info Banner */}
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
info
</span>
<div>
<p className="font-medium text-text-main mb-0.5">Format Converter</p>
<p>
Paste or type a JSON request body. The translator will auto-detect the source format and
convert it to the target format. Use this to debug how OmniRoute translates requests
between formats (OpenAI Claude Gemini Responses API).
</p>
</div>
</div>
{/* Format Controls Bar */}
<Card>
<div className="p-4 flex flex-col sm:flex-row items-center gap-4">
@@ -2,16 +2,18 @@
import { useState, useEffect } from "react";
import { Card, Button, Select, Badge } from "@/shared/components";
import { EXAMPLE_TEMPLATES, FORMAT_META } from "../exampleTemplates";
import {
AI_PROVIDERS,
OPENAI_COMPATIBLE_PREFIX,
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
import { EXAMPLE_TEMPLATES, FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
import { useProviderOptions } from "../hooks/useProviderOptions";
import { useAvailableModels } from "../hooks/useAvailableModels";
/**
* Test Bench Mode:
* Run translation + send scenarios between providers to validate compatibility.
*
* How it works:
* Predefined scenarios (Simple Chat, Tool Calling, etc.) are loaded from example templates,
* translated from the source format to the target provider, and sent to the provider API.
* Results show pass/fail, latency, and chunk count, with a compatibility percentage.
*/
const SCENARIOS = [
@@ -23,92 +25,18 @@ const SCENARIOS = [
{ id: "streaming", name: "Streaming", icon: "stream", templateId: "streaming" },
];
const DEFAULT_MODELS = {
openai: "gpt-4o",
claude: "claude-sonnet-4-20250514",
gemini: "gemini-2.5-flash",
};
export default function TestBenchMode() {
const [sourceFormat, setSourceFormat] = useState("claude");
const [provider, setProvider] = useState("openai");
const [providerOptions, setProviderOptions] = useState([]);
const [model, setModel] = useState(DEFAULT_MODELS.claude);
const [availableModels, setAvailableModels] = useState([]);
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels();
const [results, setResults] = useState({});
const [runningAll, setRunningAll] = useState(false);
// Update default model when source format changes
// Pick a smart default model when source format changes or models finish loading
useEffect(() => {
setModel(DEFAULT_MODELS[sourceFormat] || "gpt-4o");
}, [sourceFormat]);
// Load available models
useEffect(() => {
const fetchModels = async () => {
try {
const res = await fetch("/api/v1/models");
const data = await res.json();
const models = (data.data || []).map((m) => m.id).sort((a, b) => a.localeCompare(b));
setAvailableModels(models);
} catch {
setAvailableModels([]);
}
};
fetchModels();
}, []);
useEffect(() => {
const fetchProviders = async () => {
try {
const [connRes, nodesRes] = await Promise.all([
fetch("/api/providers"),
fetch("/api/provider-nodes"),
]);
const [connData, nodesData] = await Promise.all([connRes.json(), nodesRes.json()]);
const nodeMap = new Map((nodesData.nodes || []).map((n) => [n.id, n]));
const activeProviders = new Set(
(connData.connections || []).filter((c) => c.isActive !== false).map((c) => c.provider)
);
const options = [...activeProviders]
.map((pid) => {
const info = AI_PROVIDERS[pid];
const node = nodeMap.get(pid);
let label = info?.name || node?.name || pid;
if (!info && pid.startsWith(OPENAI_COMPATIBLE_PREFIX))
label = node?.name || "OpenAI Compatible";
if (!info && pid.startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
label = node?.name || "Anthropic Compatible";
return { value: pid, label };
})
.sort((a, b) => a.label.localeCompare(b.label));
const nextOptions =
options.length > 0
? options
: Object.entries(AI_PROVIDERS).map(([id, info]) => ({ value: id, label: info.name }));
setProviderOptions(nextOptions);
if (nextOptions.length > 0) {
setProvider((current) =>
nextOptions.some((opt) => opt.value === current) ? current : nextOptions[0].value
);
}
} catch {
const fallbackOptions = Object.entries(AI_PROVIDERS).map(([id, info]) => ({
value: id,
label: info.name,
}));
setProviderOptions(fallbackOptions);
if (fallbackOptions.length > 0) {
setProvider((current) =>
fallbackOptions.some((opt) => opt.value === current)
? current
: fallbackOptions[0].value
);
}
}
};
fetchProviders();
}, []);
const picked = pickModelForFormat(sourceFormat);
if (picked) setModel(picked);
}, [sourceFormat, pickModelForFormat, setModel]);
const runScenario = async (scenario) => {
setResults((prev) => ({ ...prev, [scenario.id]: { status: "running" } }));
@@ -213,6 +141,22 @@ export default function TestBenchMode() {
return (
<div className="space-y-5">
{/* Info Banner */}
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
info
</span>
<div>
<p className="font-medium text-text-main mb-0.5">Compatibility Tester</p>
<p>
Run predefined scenarios (Simple Chat, Tool Calling, etc.) to verify translation and
provider compatibility. Select a source format and target provider, then run all tests
to see a compatibility percentage. Use this to find which features work across
providers.
</p>
</div>
</div>
{/* Controls */}
<Card>
<div className="p-4 flex flex-col gap-4">
@@ -227,11 +171,9 @@ export default function TestBenchMode() {
setSourceFormat(e.target.value);
setResults({});
}}
options={[
{ value: "openai", label: "OpenAI" },
{ value: "claude", label: "Claude" },
{ value: "gemini", label: "Gemini" },
]}
options={FORMAT_OPTIONS.filter((o) =>
["openai", "claude", "gemini", "openai-responses"].includes(o.value)
)}
/>
</div>
<div className="flex items-center justify-center px-2">
@@ -271,7 +213,7 @@ export default function TestBenchMode() {
value={model}
onChange={(e) => setModel(e.target.value)}
list="testbench-model-suggestions"
placeholder="e.g. gpt-4o, claude-sonnet-4-20250514"
placeholder="Select or type a model name..."
className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
/>
<datalist id="testbench-model-suggestions">
@@ -0,0 +1,66 @@
"use client";
import { useState, useEffect, useCallback } from "react";
/**
* Prefix-based formatmodel matching, used to pick a smart default
* model from the available models list when the user changes format.
*/
const FORMAT_MODEL_PREFIXES = {
openai: ["gpt-", "o1-", "o3-", "o4-"],
"openai-responses": ["gpt-", "o1-", "o3-", "o4-"],
claude: ["claude-"],
gemini: ["gemini-"],
};
/**
* Hook to fetch available models and provide smart default selection.
*
* @returns {{
* model: string,
* setModel: Function,
* availableModels: string[],
* loading: boolean,
* pickModelForFormat: (format: string) => string
* }}
*/
export function useAvailableModels() {
const [model, setModel] = useState("");
const [availableModels, setAvailableModels] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchModels = async () => {
try {
const res = await fetch("/api/v1/models");
const data = await res.json();
const models = (data.data || []).map((m) => m.id).sort((a, b) => a.localeCompare(b));
setAvailableModels(models);
} catch {
setAvailableModels([]);
} finally {
setLoading(false);
}
};
fetchModels();
}, []);
/**
* Pick the best model for a given format from the available models.
* Returns the first model matching the format prefixes, or the first available model.
*/
const pickModelForFormat = useCallback(
(format) => {
if (availableModels.length === 0) return "";
const prefixes = FORMAT_MODEL_PREFIXES[format] || [];
for (const prefix of prefixes) {
const match = availableModels.find((m) => m.startsWith(prefix));
if (match) return match;
}
return availableModels[0];
},
[availableModels]
);
return { model, setModel, availableModels, loading, pickModelForFormat };
}
@@ -0,0 +1,79 @@
"use client";
import { useState, useEffect } from "react";
import {
AI_PROVIDERS,
OPENAI_COMPATIBLE_PREFIX,
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
/**
* Hook to fetch and manage provider options for the Translator tools.
* Fetches active providers from the API and builds a sorted list of options.
* Falls back to the static AI_PROVIDERS list if the API is unreachable.
*
* @param {string} [initialProvider="openai"] - Initial provider value
* @returns {{ provider: string, setProvider: Function, providerOptions: Array<{value: string, label: string}>, loading: boolean }}
*/
export function useProviderOptions(initialProvider = "openai") {
const [provider, setProvider] = useState(initialProvider);
const [providerOptions, setProviderOptions] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchProviders = async () => {
try {
const [connRes, nodesRes] = await Promise.all([
fetch("/api/providers"),
fetch("/api/provider-nodes"),
]);
const [connData, nodesData] = await Promise.all([connRes.json(), nodesRes.json()]);
const nodeMap = new Map((nodesData.nodes || []).map((n) => [n.id, n]));
const activeProviders = new Set(
(connData.connections || []).filter((c) => c.isActive !== false).map((c) => c.provider)
);
const options = [...activeProviders]
.map((pid) => {
const info = AI_PROVIDERS[pid];
const node = nodeMap.get(pid);
let label = info?.name || node?.name || pid;
if (!info && pid.startsWith(OPENAI_COMPATIBLE_PREFIX))
label = node?.name || "OpenAI Compatible";
if (!info && pid.startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
label = node?.name || "Anthropic Compatible";
return { value: pid, label };
})
.sort((a, b) => a.label.localeCompare(b.label));
const nextOptions =
options.length > 0
? options
: Object.entries(AI_PROVIDERS).map(([id, info]) => ({ value: id, label: info.name }));
setProviderOptions(nextOptions);
if (nextOptions.length > 0) {
setProvider((current) =>
nextOptions.some((opt) => opt.value === current) ? current : nextOptions[0].value
);
}
} catch {
const fallbackOptions = Object.entries(AI_PROVIDERS).map(([id, info]) => ({
value: id,
label: info.name,
}));
setProviderOptions(fallbackOptions);
if (fallbackOptions.length > 0) {
setProvider((current) =>
fallbackOptions.some((opt) => opt.value === current)
? current
: fallbackOptions[0].value
);
}
} finally {
setLoading(false);
}
};
fetchProviders();
}, []);
return { provider, setProvider, providerOptions, loading };
}
@@ -0,0 +1,239 @@
"use client";
/**
* BudgetTab Batch C
*
* Budget management for API keys set daily/monthly limits,
* view current spend, and monitor warning thresholds.
* API: /api/usage/budget
*/
import { useState, useEffect, useCallback } from "react";
import { Card, Button, Input, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
function ProgressBar({ value, max, warningAt = 0.8 }) {
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
const ratio = max > 0 ? value / max : 0;
const color = ratio >= 1 ? "#ef4444" : ratio >= warningAt ? "#f59e0b" : "#22c55e";
return (
<div className="w-full">
<div className="flex justify-between text-xs mb-1">
<span className="text-text-muted">${value.toFixed(2)}</span>
<span className="text-text-muted">${max.toFixed(2)}</span>
</div>
<div className="w-full h-2 rounded-full bg-surface/50 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: color }}
/>
</div>
</div>
);
}
export default function BudgetTab() {
const [keys, setKeys] = useState([]);
const [selectedKey, setSelectedKey] = useState(null);
const [budget, setBudget] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
dailyLimitUsd: "",
monthlyLimitUsd: "",
warningThreshold: "80",
});
const notify = useNotificationStore();
// Load API keys
useEffect(() => {
fetch("/api/keys")
.then((r) => r.json())
.then((data) => {
const keyList = Array.isArray(data) ? data : data.keys || [];
setKeys(keyList);
if (keyList.length > 0) setSelectedKey(keyList[0].id);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
// Load budget for selected key
const fetchBudget = useCallback(async () => {
if (!selectedKey) return;
try {
const res = await fetch(`/api/usage/budget?apiKeyId=${selectedKey}`);
if (res.ok) {
const data = await res.json();
setBudget(data);
if (data.dailyLimitUsd)
setForm((f) => ({ ...f, dailyLimitUsd: String(data.dailyLimitUsd) }));
if (data.monthlyLimitUsd)
setForm((f) => ({ ...f, monthlyLimitUsd: String(data.monthlyLimitUsd) }));
if (data.warningThreshold)
setForm((f) => ({ ...f, warningThreshold: String(data.warningThreshold) }));
}
} catch {
// silent
}
}, [selectedKey]);
useEffect(() => {
fetchBudget();
}, [fetchBudget]);
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch("/api/usage/budget", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apiKeyId: selectedKey,
dailyLimitUsd: form.dailyLimitUsd ? parseFloat(form.dailyLimitUsd) : null,
monthlyLimitUsd: form.monthlyLimitUsd ? parseFloat(form.monthlyLimitUsd) : null,
warningThreshold: parseInt(form.warningThreshold) || 80,
}),
});
if (res.ok) {
notify.success("Budget limits saved");
await fetchBudget();
} else {
notify.error("Failed to save budget");
}
} catch {
notify.error("Failed to save budget");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center gap-2 text-text-muted p-8 animate-pulse">
<span className="material-symbols-outlined text-[20px]">account_balance_wallet</span>
Loading budget data...
</div>
);
}
if (keys.length === 0) {
return (
<EmptyState
icon="vpn_key"
title="No API Keys"
description="Add API keys first to set up budget limits."
/>
);
}
const dailyLimit = budget?.dailyLimitUsd || parseFloat(form.dailyLimitUsd) || 0;
const monthlyLimit = budget?.monthlyLimitUsd || parseFloat(form.monthlyLimitUsd) || 0;
const dailyCost = budget?.totalCostToday || 0;
const monthlyCost = budget?.totalCostMonth || 0;
const warnPct = (parseInt(form.warningThreshold) || 80) / 100;
return (
<div className="flex flex-col gap-6">
{/* Key Selector */}
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
<span className="material-symbols-outlined text-[20px]">account_balance_wallet</span>
</div>
<h3 className="text-lg font-semibold">Budget Management</h3>
</div>
<div className="mb-4">
<label className="text-sm text-text-muted mb-1 block">API Key</label>
<select
value={selectedKey || ""}
onChange={(e) => setSelectedKey(e.target.value)}
className="w-full md:w-auto px-3 py-2 rounded-lg border border-border/50 bg-surface/30 text-text-main text-sm focus:outline-none focus:ring-1 focus:ring-primary"
>
{keys.map((k) => (
<option key={k.id} value={k.id}>
{k.name || k.id} {k.provider ? `(${k.provider})` : ""}
</option>
))}
</select>
</div>
{/* Current Spend */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div className="p-4 rounded-lg border border-border/30 bg-surface/20">
<p className="text-sm text-text-muted mb-2">Today&apos;s Spend</p>
<p className="text-2xl font-bold text-text-main">${dailyCost.toFixed(2)}</p>
{dailyLimit > 0 && (
<ProgressBar value={dailyCost} max={dailyLimit} warningAt={warnPct} />
)}
</div>
<div className="p-4 rounded-lg border border-border/30 bg-surface/20">
<p className="text-sm text-text-muted mb-2">This Month</p>
<p className="text-2xl font-bold text-text-main">${monthlyCost.toFixed(2)}</p>
{monthlyLimit > 0 && (
<ProgressBar value={monthlyCost} max={monthlyLimit} warningAt={warnPct} />
)}
</div>
</div>
{/* Budget Form */}
<div className="border-t border-border/30 pt-4">
<p className="text-sm font-medium mb-3">Set Limits</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<Input
label="Daily Limit (USD)"
type="number"
step="0.01"
min="0"
placeholder="e.g. 5.00"
value={form.dailyLimitUsd}
onChange={(e) => setForm({ ...form, dailyLimitUsd: e.target.value })}
/>
<Input
label="Monthly Limit (USD)"
type="number"
step="0.01"
min="0"
placeholder="e.g. 50.00"
value={form.monthlyLimitUsd}
onChange={(e) => setForm({ ...form, monthlyLimitUsd: e.target.value })}
/>
<Input
label="Warning Threshold (%)"
type="number"
min="1"
max="100"
placeholder="80"
value={form.warningThreshold}
onChange={(e) => setForm({ ...form, warningThreshold: e.target.value })}
/>
</div>
<Button variant="primary" onClick={handleSave} loading={saving}>
Save Limits
</Button>
</div>
</Card>
{/* Budget Check Status */}
{budget?.budgetCheck && (
<Card className="p-4">
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-[20px]"
style={{ color: budget.budgetCheck.allowed ? "#22c55e" : "#ef4444" }}
>
{budget.budgetCheck.allowed ? "check_circle" : "block"}
</span>
<span className="text-sm">
{budget.budgetCheck.allowed
? `Budget OK — $${(budget.budgetCheck.remaining || 0).toFixed(2)} remaining`
: "Budget exceeded — requests may be blocked"}
</span>
</div>
</Card>
)}
</div>
);
}
@@ -0,0 +1,115 @@
"use client";
import { useState, useEffect } from "react";
import { Card } from "@/shared/components";
export default function BudgetTelemetryCards() {
const [telemetry, setTelemetry] = useState(null);
const [cache, setCache] = useState(null);
const [policies, setPolicies] = useState(null);
useEffect(() => {
Promise.allSettled([
fetch("/api/telemetry/summary").then((r) => r.json()),
fetch("/api/cache/stats").then((r) => r.json()),
fetch("/api/policies").then((r) => r.json()),
]).then(([t, c, p]) => {
if (t.status === "fulfilled") setTelemetry(t.value);
if (c.status === "fulfilled") setCache(c.value);
if (p.status === "fulfilled") setPolicies(p.value);
});
}, []);
const fmt = (ms) => (ms != null ? `${Math.round(ms)}ms` : "—");
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
{/* Latency Card */}
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">speed</span>
Latency
</h3>
{telemetry ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">p50</span>
<span className="font-mono">{fmt(telemetry.p50)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">p95</span>
<span className="font-mono">{fmt(telemetry.p95)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">p99</span>
<span className="font-mono">{fmt(telemetry.p99)}</span>
</div>
<div className="flex justify-between border-t border-border pt-2 mt-2">
<span className="text-text-muted">Total requests</span>
<span className="font-mono">{telemetry.totalRequests ?? 0}</span>
</div>
</div>
) : (
<p className="text-sm text-text-muted">No data yet</p>
)}
</Card>
{/* Cache Card */}
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">cached</span>
Prompt Cache
</h3>
{cache ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">Entries</span>
<span className="font-mono">
{cache.size}/{cache.maxSize}
</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">Hit Rate</span>
<span className="font-mono">{cache.hitRate?.toFixed(1) ?? 0}%</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">Hits / Misses</span>
<span className="font-mono">
{cache.hits ?? 0} / {cache.misses ?? 0}
</span>
</div>
</div>
) : (
<p className="text-sm text-text-muted">No data yet</p>
)}
</Card>
{/* System Health Card */}
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">monitor_heart</span>
System Health
</h3>
{policies ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">Circuit Breakers</span>
<span className="font-mono">{policies.circuitBreakers?.length ?? 0} active</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">Locked IPs</span>
<span className="font-mono">{policies.lockedIdentifiers?.length ?? 0}</span>
</div>
{policies.circuitBreakers?.some((cb) => cb.state === "OPEN") && (
<div className="mt-2 px-2 py-1 rounded bg-red-500/10 text-red-400 text-xs">
Open circuit breakers detected
</div>
)}
</div>
) : (
<p className="text-sm text-text-muted">No data yet</p>
)}
</Card>
</div>
);
}
@@ -0,0 +1,392 @@
"use client";
/**
* EvalsTab Batch F
*
* Lists evaluation suites, runs evals against real LLM endpoints,
* and shows results.
* API: GET/POST /api/evals, GET /api/evals/[suiteId]
*/
import { useState, useEffect, useCallback } from "react";
import { Card, Button, EmptyState, DataTable, FilterBar } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
export default function EvalsTab() {
const [suites, setSuites] = useState([]);
const [apiKey, setApiKey] = useState(null);
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(null);
const [progress, setProgress] = useState({ current: 0, total: 0 });
const [results, setResults] = useState({});
const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState(null);
const notify = useNotificationStore();
const fetchSuites = useCallback(async () => {
try {
const res = await fetch("/api/evals");
if (res.ok) {
const data = await res.json();
setSuites(Array.isArray(data) ? data : data.suites || []);
}
} catch {
// silent
} finally {
setLoading(false);
}
}, []);
const fetchApiKey = useCallback(async () => {
try {
const res = await fetch("/api/keys");
if (!res.ok) return;
const data = await res.json();
const firstKey = data?.keys?.[0]?.key || null;
setApiKey(firstKey);
} catch {
// silent
}
}, []);
useEffect(() => {
fetchSuites();
fetchApiKey();
}, [fetchSuites, fetchApiKey]);
/**
* Call the proxy LLM endpoint for a single eval case.
* Returns the assistant's response text.
*/
const callLLM = async (evalCase) => {
try {
const headers = { "Content-Type": "application/json" };
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
const res = await fetch("/v1/chat/completions", {
method: "POST",
headers,
body: JSON.stringify({
model: evalCase.model || "gpt-4o",
messages: evalCase.input?.messages || [],
max_tokens: 512,
stream: false,
}),
});
if (!res.ok) {
return `[ERROR: HTTP ${res.status}]`;
}
const data = await res.json();
return data.choices?.[0]?.message?.content || "[No content returned]";
} catch (err) {
return `[ERROR: ${err.message}]`;
}
};
/**
* Run all cases: call LLM for each, then submit outputs for evaluation.
*/
const handleRunEval = async (suite) => {
const cases = suite.cases || [];
if (cases.length === 0) {
notify.warning("No test cases defined for this suite");
return;
}
setRunning(suite.id);
setProgress({ current: 0, total: cases.length });
try {
// Step 1: Call LLM for each case and collect outputs
const outputs = {};
for (let i = 0; i < cases.length; i++) {
setProgress({ current: i + 1, total: cases.length });
const response = await callLLM(cases[i]);
outputs[cases[i].id] = response;
}
// Step 2: Submit outputs for evaluation
const res = await fetch("/api/evals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
suiteId: suite.id,
outputs,
}),
});
const data = await res.json();
setResults((prev) => ({ ...prev, [suite.id]: data }));
// Notify with results
if (data.summary) {
const { passed, failed, total } = data.summary;
if (failed === 0) {
notify.success(`All ${total} cases passed ✅`, `Eval: ${suite.name}`);
} else {
notify.warning(`${passed}/${total} passed, ${failed} failed`, `Eval: ${suite.name}`);
}
}
// Auto-expand to show results
setExpanded(suite.id);
} catch {
notify.error("Eval run failed");
} finally {
setRunning(null);
setProgress({ current: 0, total: 0 });
}
};
const filtered = suites.filter((s) => {
if (!search) return true;
return (
s.name?.toLowerCase().includes(search.toLowerCase()) ||
s.id?.toLowerCase().includes(search.toLowerCase())
);
});
if (loading) {
return (
<div className="flex items-center gap-2 text-text-muted p-8 animate-pulse">
<span className="material-symbols-outlined text-[20px]">science</span>
Loading eval suites...
</div>
);
}
if (suites.length === 0) {
return (
<EmptyState
icon="science"
title="No Eval Suites"
description="Eval suites can be defined via the API to test model outputs against expected results."
/>
);
}
const RESULT_COLUMNS = [
{ key: "caseName", label: "Case" },
{ key: "status", label: "Status" },
{ key: "durationMs", label: "Latency" },
{ key: "details", label: "Details" },
];
return (
<div className="flex flex-col gap-6">
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-violet-500/10 text-violet-500">
<span className="material-symbols-outlined text-[20px]">science</span>
</div>
<div>
<h3 className="text-lg font-semibold">Evaluation Suites</h3>
<p className="text-xs text-text-muted">
Run test cases against your LLM endpoints to validate response quality
</p>
</div>
</div>
<FilterBar
searchValue={search}
onSearchChange={setSearch}
placeholder="Search suites..."
filters={[]}
activeFilters={{}}
onFilterChange={() => {}}
/>
<div className="flex flex-col gap-3 mt-4">
{filtered.map((suite) => {
const suiteResult = results[suite.id];
const isRunning = running === suite.id;
const isExpanded = expanded === suite.id;
const caseCount = suite.cases?.length || suite.caseCount || 0;
return (
<div key={suite.id} className="border border-border/30 rounded-lg overflow-hidden">
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-surface/30 transition-colors"
onClick={() => setExpanded(isExpanded ? null : suite.id)}
>
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-[16px] text-text-muted">
{isExpanded ? "expand_more" : "chevron_right"}
</span>
<div>
<p className="text-sm font-medium text-text-main">{suite.name || suite.id}</p>
<p className="text-xs text-text-muted">
{caseCount} case{caseCount !== 1 ? "s" : ""}
{suite.description && <span className="ml-1"> {suite.description}</span>}
{suiteResult?.summary && (
<span className="ml-2">
Last run: {suiteResult.summary.passed || 0} {" "}
{suiteResult.summary.failed || 0} ({suiteResult.summary.passRate}%)
</span>
)}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{isRunning && progress.total > 0 && (
<span className="text-xs text-text-muted font-mono tabular-nums">
{progress.current}/{progress.total}
</span>
)}
<Button
size="sm"
variant="primary"
onClick={(e) => {
e.stopPropagation();
handleRunEval(suite);
}}
loading={isRunning}
disabled={isRunning}
>
{isRunning ? `Running ${progress.current}/${progress.total}...` : "Run Eval"}
</Button>
</div>
</div>
{isExpanded && (
<div className="border-t border-border/20 p-4">
{suiteResult?.results ? (
<>
{/* Summary bar */}
{suiteResult.summary && (
<div className="flex items-center gap-4 mb-4 p-3 rounded-lg bg-surface/30 border border-border/20">
<div className="flex items-center gap-2">
<span
className={`text-lg font-bold ${
suiteResult.summary.passRate === 100
? "text-emerald-400"
: suiteResult.summary.passRate >= 80
? "text-amber-400"
: "text-red-400"
}`}
>
{suiteResult.summary.passRate}%
</span>
<span className="text-xs text-text-muted">pass rate</span>
</div>
<div className="text-xs text-text-muted">
{suiteResult.summary.passed} passed · {suiteResult.summary.failed}{" "}
failed · {suiteResult.summary.total} total
</div>
</div>
)}
<DataTable
columns={RESULT_COLUMNS}
data={suiteResult.results.map((r, i) => ({
...r,
id: r.caseId || i,
}))}
renderCell={(row, col) => {
if (col.key === "status") {
return row.passed ? (
<span className="text-emerald-400"> Passed</span>
) : (
<span className="text-red-400"> Failed</span>
);
}
if (col.key === "durationMs") {
return (
<span className="text-text-muted text-xs font-mono">
{row.durationMs != null ? `${row.durationMs}ms` : "—"}
</span>
);
}
if (col.key === "details") {
const d = row.details || {};
return (
<span className="text-text-muted text-xs truncate max-w-[300px] block">
{d.searchTerm
? `Contains: "${d.searchTerm}"`
: d.pattern
? `Regex: ${d.pattern}`
: d.expected
? `Expected: "${String(d.expected).slice(0, 50)}"`
: row.error || "—"}
</span>
);
}
return (
<span className="text-sm text-text-main">{row[col.key] || "—"}</span>
);
}}
maxHeight="400px"
emptyMessage="No results yet"
/>
</>
) : (
/* Show test cases before running eval */
<>
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-[16px] text-text-muted">
checklist
</span>
<span className="text-xs text-text-muted font-medium">
Test Cases ({(suite.cases || []).length})
</span>
</div>
<DataTable
columns={[
{ key: "name", label: "Case" },
{ key: "model", label: "Model" },
{ key: "strategy", label: "Strategy" },
{ key: "expected", label: "Expected" },
]}
data={(suite.cases || []).map((c, i) => ({
id: c.id || i,
name: c.name,
model: c.model || "—",
strategy: c.expected?.strategy || "—",
expected: c.expected?.value
? String(c.expected.value).slice(0, 80)
: "—",
}))}
renderCell={(row, col) => {
if (col.key === "strategy") {
const colorMap = {
contains: "text-sky-400",
exact: "text-emerald-400",
regex: "text-amber-400",
custom: "text-violet-400",
};
return (
<span
className={`text-xs font-mono ${colorMap[row.strategy] || "text-text-muted"}`}
>
{row.strategy}
</span>
);
}
if (col.key === "expected") {
return (
<span className="text-text-muted text-xs font-mono truncate max-w-[300px] block">
{row.expected}
</span>
);
}
return (
<span className="text-sm text-text-main">{row[col.key] || "—"}</span>
);
}}
maxHeight="400px"
emptyMessage="No test cases defined"
/>
<p className="text-xs text-text-muted mt-3 flex items-center gap-1.5">
<span className="material-symbols-outlined text-[14px]">info</span>
Click &quot;Run Eval&quot; to execute all cases against your LLM endpoint
</p>
</>
)}
</div>
)}
</div>
);
})}
</div>
</Card>
</div>
);
}
@@ -350,10 +350,10 @@ export default function ProviderLimits() {
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold cursor-pointer"
style={{
border: active
? "1px solid var(--primary, #f97815)"
? "1px solid var(--primary, #E54D5E)"
: "1px solid rgba(255,255,255,0.12)",
background: active ? "rgba(249,120,21,0.14)" : "transparent",
color: active ? "var(--primary, #f97815)" : "var(--text-muted)",
color: active ? "var(--primary, #E54D5E)" : "var(--text-muted)",
}}
>
<span>{tier.label}</span>
@@ -11,7 +11,8 @@ export default function RateLimitStatus() {
try {
const res = await fetch("/api/rate-limits");
if (res.ok) setData(await res.json());
} catch {} finally {
} catch {
} finally {
setLoading(false);
}
}, []);
@@ -65,11 +66,14 @@ export default function RateLimitStatus() {
bg-orange-500/5 border border-orange-500/15"
>
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-[16px] text-orange-400">lock</span>
<span className="material-symbols-outlined text-[16px] text-orange-400">
lock
</span>
<div>
<p className="text-sm font-medium">{lock.model}</p>
<p className="text-xs text-text-muted">
Account: <span className="font-mono">{lock.accountId?.slice(0, 12) || "N/A"}</span>
Account:{" "}
<span className="font-mono">{lock.accountId?.slice(0, 12) || "N/A"}</span>
{lock.reason && <> {lock.reason}</>}
</p>
</div>
@@ -82,33 +86,6 @@ export default function RateLimitStatus() {
</div>
)}
</Card>
{/* Signature Cache Stats */}
{data.cacheStats && (
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
database
</span>
</div>
<h3 className="text-lg font-semibold">Signature Cache</h3>
</div>
<div className="grid grid-cols-4 gap-3">
{[
{ label: "Defaults", value: data.cacheStats.defaultCount, color: "text-text-muted" },
{ label: "Tool", value: `${data.cacheStats.tool.entries}/${data.cacheStats.tool.patterns}`, color: "text-blue-400" },
{ label: "Family", value: `${data.cacheStats.family.entries}/${data.cacheStats.family.patterns}`, color: "text-purple-400" },
{ label: "Session", value: `${data.cacheStats.session.entries}/${data.cacheStats.session.patterns}`, color: "text-cyan-400" },
].map(({ label, value, color }) => (
<div key={label} className="text-center p-3 rounded-lg bg-surface/30 border border-border/30">
<p className={`text-lg font-bold tabular-nums ${color}`}>{value}</p>
<p className="text-xs text-text-muted mt-0.5">{label}</p>
</div>
))}
</div>
</Card>
)}
</div>
);
}
+5 -18
View File
@@ -1,51 +1,38 @@
"use client";
import { useState, Suspense } from "react";
import {
UsageAnalytics,
RequestLoggerV2,
ProxyLogger,
CardSkeleton,
SegmentedControl,
} from "@/shared/components";
import { RequestLoggerV2, ProxyLogger, CardSkeleton, SegmentedControl } from "@/shared/components";
import ProviderLimits from "./components/ProviderLimits";
import SessionsTab from "./components/SessionsTab";
import RateLimitStatus from "./components/RateLimitStatus";
export default function UsagePage() {
const [activeTab, setActiveTab] = useState("overview");
const [activeTab, setActiveTab] = useState("logs");
return (
<div className="flex flex-col gap-6">
<SegmentedControl
options={[
{ value: "overview", label: "Overview" },
{ value: "logs", label: "Logger" },
{ value: "proxy-logs", label: "Proxy" },
{ value: "limits", label: "Limits" },
{ value: "sessions", label: "Sessions" },
]}
value={activeTab}
onChange={setActiveTab}
/>
{/* Content */}
{activeTab === "overview" && (
<Suspense fallback={<CardSkeleton />}>
<UsageAnalytics />
</Suspense>
)}
{activeTab === "logs" && <RequestLoggerV2 />}
{activeTab === "proxy-logs" && <ProxyLogger />}
{activeTab === "limits" && (
<div className="flex flex-col gap-6">
<Suspense fallback={<CardSkeleton />}>
<ProviderLimits />
</Suspense>
<RateLimitStatus />
<SessionsTab />
</div>
)}
{activeTab === "sessions" && <SessionsTab />}
{activeTab === "logs" && <RequestLoggerV2 />}
{activeTab === "proxy-logs" && <ProxyLogger />}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { getCacheStats, clearCache, cleanExpiredEntries } from "@/lib/semanticCache";
import { getIdempotencyStats } from "@/lib/idempotencyLayer";
/**
* GET /api/cache Cache statistics
*/
export async function GET() {
try {
const cacheStats = getCacheStats();
const idempotencyStats = getIdempotencyStats();
return NextResponse.json({
semanticCache: cacheStats,
idempotency: idempotencyStats,
});
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
/**
* DELETE /api/cache Clear all caches
*/
export async function DELETE() {
try {
clearCache();
const cleaned = cleanExpiredEntries();
return NextResponse.json({ ok: true, expiredRemoved: cleaned });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { getPromptCache } from "@/lib/cacheLayer";
export async function GET() {
try {
const cache = getPromptCache();
const stats = cache.getStats();
return NextResponse.json(stats);
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function DELETE() {
try {
const cache = getPromptCache();
cache.clear();
return NextResponse.json({ success: true, message: "Cache cleared" });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+8 -4
View File
@@ -4,13 +4,13 @@ import { NextResponse } from "next/server";
import { listBackups, restoreBackup, deleteBackup } from "@/shared/services/backupService";
import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime";
const VALID_TOOLS = ["claude", "codex", "droid", "openclaw"];
const VALID_TOOLS = ["claude", "codex", "droid", "openclaw", "cline", "kilo"];
// GET /api/cli-tools/backups?tool=claude — list backups
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const tool = searchParams.get("tool");
const tool = searchParams.get("tool") || searchParams.get("toolId");
if (tool && !VALID_TOOLS.includes(tool)) {
return NextResponse.json({ error: `Invalid tool: ${tool}` }, { status: 400 });
@@ -41,7 +41,9 @@ export async function POST(request) {
return NextResponse.json({ error: writeGuard }, { status: 403 });
}
const { tool, backupId } = await request.json();
const body = await request.json();
const tool = body.tool || body.toolId;
const backupId = body.backupId;
if (!tool || !backupId) {
return NextResponse.json({ error: "tool and backupId are required" }, { status: 400 });
@@ -69,7 +71,9 @@ export async function POST(request) {
// DELETE /api/cli-tools/backups { tool, backupId } — delete a backup
export async function DELETE(request) {
try {
const { tool, backupId } = await request.json();
const body = await request.json();
const tool = body.tool || body.toolId;
const backupId = body.backupId;
if (!tool || !backupId) {
return NextResponse.json({ error: "tool and backupId are required" }, { status: 400 });
+66
View File
@@ -0,0 +1,66 @@
"use server";
import { NextResponse } from "next/server";
import { getCliRuntimeStatus, CLI_TOOL_IDS } from "@/shared/services/cliRuntime";
/**
* GET /api/cli-tools/status
* Returns runtime + config status for all CLI tools in one batch call.
* Used by the CLI Tools page to show status badges in collapsed state.
*/
export async function GET() {
try {
const statuses = {};
await Promise.all(
CLI_TOOL_IDS.map(async (toolId) => {
try {
const runtime = await getCliRuntimeStatus(toolId);
statuses[toolId] = {
installed: runtime.installed,
runnable: runtime.runnable,
command: runtime.command,
commandPath: runtime.commandPath,
reason: runtime.reason || null,
};
} catch (error) {
statuses[toolId] = {
installed: false,
runnable: false,
reason: error.message,
};
}
})
);
// Now fetch configStatus for the 6 tools that have settings endpoints
const settingsTools = ["claude", "codex", "droid", "openclaw", "cline", "kilo"];
await Promise.all(
settingsTools.map(async (toolId) => {
if (!statuses[toolId]?.installed || !statuses[toolId]?.runnable) {
statuses[toolId].configStatus = "not_installed";
return;
}
try {
const settingsRes = await fetch(
`${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:20128"}/api/cli-tools/${toolId}-settings`
);
if (settingsRes.ok) {
const data = await settingsRes.json();
statuses[toolId].configStatus = data.hasOmniRoute ? "configured" : "not_configured";
} else {
statuses[toolId].configStatus = "unknown";
}
} catch {
statuses[toolId].configStatus = "unknown";
}
})
);
return NextResponse.json(statuses);
} catch (error) {
console.log("Error fetching CLI tool statuses:", error);
return NextResponse.json({ error: "Failed to fetch statuses" }, { status: 500 });
}
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { getAuditLog, logAuditEvent } from "@/lib/compliance/index";
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const action = searchParams.get("action") || undefined;
const actor = searchParams.get("actor") || undefined;
const limit = parseInt(searchParams.get("limit") || "50", 10);
const offset = parseInt(searchParams.get("offset") || "0", 10);
const logs = getAuditLog({ action, actor, limit, offset });
return NextResponse.json(logs);
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { getSuite } from "@/lib/evals/evalRunner";
export async function GET(request, { params }) {
try {
const { suiteId } = await params;
const suite = getSuite(suiteId);
if (!suite) {
return NextResponse.json({ error: `Suite not found: ${suiteId}` }, { status: 404 });
}
return NextResponse.json(suite);
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { listSuites, runSuite } from "@/lib/evals/evalRunner";
export async function GET() {
try {
const suites = listSuites();
return NextResponse.json(suites);
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(request) {
try {
const { suiteId, outputs } = await request.json();
if (!suiteId || !outputs) {
return NextResponse.json(
{ error: "suiteId and outputs (Record<caseId, actualOutput>) are required" },
{ status: 400 }
);
}
const result = runSuite(suiteId, outputs);
return NextResponse.json(result);
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import {
getAllFallbackChains,
registerFallback,
removeFallback,
} from "@/domain/fallbackPolicy";
export async function GET() {
try {
const chains = getAllFallbackChains();
return NextResponse.json(chains);
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(request) {
try {
const { model, chain } = await request.json();
if (!model || !Array.isArray(chain)) {
return NextResponse.json(
{ error: "model (string) and chain (array of {provider, priority?, enabled?}) are required" },
{ status: 400 }
);
}
registerFallback(model, chain);
return NextResponse.json({ success: true, model });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function DELETE(request) {
try {
const { model } = await request.json();
if (!model) {
return NextResponse.json({ error: "model is required" }, { status: 400 });
}
const removed = removeFallback(model);
return NextResponse.json({ success: true, removed });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import {
getAvailabilityReport,
clearModelUnavailability,
getUnavailableCount,
} from "@/domain/modelAvailability";
export async function GET() {
try {
const report = getAvailabilityReport();
const count = getUnavailableCount();
return NextResponse.json({ unavailableCount: count, models: report });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(request) {
try {
const { provider, model } = await request.json();
if (!provider || !model) {
return NextResponse.json({ error: "provider and model are required" }, { status: 400 });
}
const removed = clearModelUnavailability(provider, model);
return NextResponse.json({ success: true, removed });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+4 -2
View File
@@ -74,7 +74,7 @@ export async function GET() {
});
}
// Custom models
// Custom models (from DB)
for (const [providerId, models] of Object.entries(customModelsMap)) {
const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId;
if (!catalog[alias]) {
@@ -89,11 +89,13 @@ export async function GET() {
const fullId = `${alias}/${model.id}`;
// Skip duplicates
if (catalog[alias].models.some((m) => m.id === fullId)) continue;
// Imported models are treated as default (not custom)
const isCustom = model.source !== "imported";
catalog[alias].models.push({
id: fullId,
name: model.name || model.id,
type: "chat",
custom: true,
custom: isCustom,
});
}
}
+59
View File
@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { getSettings } from "@/lib/localDb";
import { APP_CONFIG } from "@/shared/constants/config";
/**
* GET /api/monitoring/health System health overview
*
* Returns system info, provider health (circuit breakers),
* rate limit status, and database stats.
*/
export async function GET() {
try {
const { getAllCircuitBreakerStatuses } =
await import("@/../../src/shared/utils/circuitBreaker.js");
const { getAllRateLimitStatus } =
await import("@omniroute/open-sse/services/rateLimitManager.js");
const { getAllModelLockouts } = await import("@omniroute/open-sse/services/accountFallback.js");
const settings = await getSettings();
const circuitBreakers = getAllCircuitBreakerStatuses();
const rateLimitStatus = getAllRateLimitStatus();
const lockouts = getAllModelLockouts();
// System info
const system = {
version: APP_CONFIG.version,
nodeVersion: process.version,
uptime: process.uptime(),
memoryUsage: process.memoryUsage(),
pid: process.pid,
platform: process.platform,
};
// Provider health summary (circuitBreakers is an Array of { name, state, ... })
const providerHealth = {};
for (const cb of circuitBreakers) {
// Skip test circuit breakers (leftover from unit tests)
if (cb.name.startsWith("test-") || cb.name.startsWith("test_")) continue;
providerHealth[cb.name] = {
state: cb.state,
failures: cb.failureCount || 0,
lastFailure: cb.lastFailureTime,
};
}
return NextResponse.json({
status: "healthy",
timestamp: new Date().toISOString(),
system,
providerHealth,
rateLimitStatus,
lockouts,
setupComplete: settings?.setupComplete || false,
});
} catch (error) {
console.error("[API] GET /api/monitoring/health error:", error);
return NextResponse.json({ status: "error", error: error.message }, { status: 500 });
}
}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import {
getAllCircuitBreakerStatuses,
} from "@/shared/utils/circuitBreaker";
import {
getLockedIdentifiers,
forceUnlock,
} from "@/domain/lockoutPolicy";
export async function GET() {
try {
const circuitBreakers = getAllCircuitBreakerStatuses();
const lockedIdentifiers = getLockedIdentifiers();
return NextResponse.json({ circuitBreakers, lockedIdentifiers });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(request) {
try {
const { action, identifier } = await request.json();
if (action === "unlock" && identifier) {
forceUnlock(identifier);
return NextResponse.json({ success: true, action: "unlocked", identifier });
}
return NextResponse.json({ error: "Unknown action. Supported: unlock" }, { status: 400 });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { getDbInstance } from "@/lib/db/core.js";
/**
* GET /api/providers/metrics Aggregate per-provider stats from call_logs
* Returns: { metrics: { [provider]: { totalRequests, totalSuccesses, successRate, avgLatencyMs } } }
*/
export async function GET() {
try {
const db = getDbInstance();
const rows = db
.prepare(
`SELECT
provider,
COUNT(*) as totalRequests,
SUM(CASE WHEN status >= 200 AND status < 400 THEN 1 ELSE 0 END) as totalSuccesses,
ROUND(AVG(duration)) as avgLatencyMs
FROM call_logs
WHERE provider IS NOT NULL AND provider != '-'
GROUP BY provider`
)
.all();
const metrics = {};
for (const row of rows) {
metrics[row.provider] = {
totalRequests: row.totalRequests,
totalSuccesses: row.totalSuccesses,
successRate:
row.totalRequests > 0 ? Math.round((row.totalSuccesses / row.totalRequests) * 100) : 0,
avgLatencyMs: row.avgLatencyMs || 0,
};
}
return NextResponse.json({ metrics });
} catch (error) {
console.error("[providers/metrics] Error:", error);
return NextResponse.json({ metrics: {} });
}
}
+2 -2
View File
@@ -32,7 +32,7 @@ export async function GET(request) {
export async function POST(request) {
try {
const body = await request.json();
const { provider, modelId, modelName } = body;
const { provider, modelId, modelName, source } = body;
if (!provider || !modelId) {
return Response.json(
@@ -41,7 +41,7 @@ export async function POST(request) {
);
}
const model = await addCustomModel(provider, modelId, modelName);
const model = await addCustomModel(provider, modelId, modelName, source || "manual");
return Response.json({ model });
} catch (error) {
return Response.json(
+38 -8
View File
@@ -5,6 +5,33 @@ import {
isAnthropicCompatibleProvider,
} from "@/shared/constants/providers";
// Providers that return hardcoded models (no remote /models API)
const STATIC_MODEL_PROVIDERS = {
deepgram: () => [
{ id: "nova-3", name: "Nova 3 (Transcription)" },
{ id: "nova-2", name: "Nova 2 (Transcription)" },
{ id: "whisper-large", name: "Whisper Large (Transcription)" },
{ id: "aura-asteria-en", name: "Aura Asteria EN (TTS)" },
{ id: "aura-luna-en", name: "Aura Luna EN (TTS)" },
{ id: "aura-stella-en", name: "Aura Stella EN (TTS)" },
],
assemblyai: () => [
{ id: "universal-3-pro", name: "Universal 3 Pro (Transcription)" },
{ id: "universal-2", name: "Universal 2 (Transcription)" },
],
nanobanana: () => [
{ id: "nanobanana-flash", name: "NanoBanana Flash (Gemini 2.5 Flash)" },
{ id: "nanobanana-pro", name: "NanoBanana Pro (Gemini 3 Pro)" },
],
perplexity: () => [
{ id: "sonar", name: "Sonar (Fast Search)" },
{ id: "sonar-pro", name: "Sonar Pro (Advanced Search)" },
{ id: "sonar-reasoning", name: "Sonar Reasoning (CoT + Search)" },
{ id: "sonar-reasoning-pro", name: "Sonar Reasoning Pro (Advanced CoT + Search)" },
{ id: "sonar-deep-research", name: "Sonar Deep Research (Expert Analysis)" },
],
};
// Provider models endpoints configuration
const PROVIDER_MODELS_CONFIG = {
claude: {
@@ -122,14 +149,7 @@ const PROVIDER_MODELS_CONFIG = {
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
perplexity: {
url: "https://api.perplexity.ai/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
together: {
url: "https://api.together.xyz/v1/models",
method: "GET",
@@ -272,6 +292,16 @@ export async function GET(request, { params }) {
});
}
// Static model providers (no remote /models API)
const staticModelsFn = STATIC_MODEL_PROVIDERS[connection.provider];
if (staticModelsFn) {
return NextResponse.json({
provider: connection.provider,
connectionId: connection.id,
models: staticModelsFn(),
});
}
const config = PROVIDER_MODELS_CONFIG[connection.provider];
if (!config) {
return NextResponse.json(
+11
View File
@@ -290,6 +290,17 @@ async function testOAuthConnection(connection) {
// Check if token exists
if (!connection.accessToken) {
// If the refresh token is also missing on a refreshable provider,
// this means re-authentication is needed (e.g. after refresh_token_reused)
if (config.refreshable && !connection.refreshToken) {
const error = "Refresh token expired. Please re-authenticate this account.";
return {
valid: false,
error,
refreshed: false,
diagnosis: makeDiagnosis("reauth_required", "oauth", error, "reauth_required"),
};
}
const error = "No access token";
return {
valid: false,
+9 -54
View File
@@ -1,63 +1,18 @@
import { NextResponse } from "next/server";
import { getProviderConnections, updateProviderConnection } from "@/lib/localDb";
import {
enableRateLimitProtection,
disableRateLimitProtection,
getRateLimitStatus,
getAllRateLimitStatus,
} from "@omniroute/open-sse/services/rateLimitManager.js";
/**
* GET /api/rate-limit Get rate limit status for all connections
* @deprecated Use /api/rate-limits instead.
* This route redirects to the consolidated rate-limits endpoint.
*/
export async function GET() {
try {
const connections = await getProviderConnections();
const statuses = connections.map((conn) => ({
connectionId: conn.id,
provider: conn.provider,
name: conn.name || conn.email || conn.id.slice(0, 8),
rateLimitProtection: !!conn.rateLimitProtection,
...getRateLimitStatus(conn.provider, conn.id),
}));
return NextResponse.json({
connections: statuses,
overview: getAllRateLimitStatus(),
});
} catch (error) {
console.error("[API ERROR] /api/rate-limit GET:", error);
return NextResponse.json({ error: "Failed to get rate limit status" }, { status: 500 });
}
export async function GET(request) {
const url = new URL(request.url);
url.pathname = "/api/rate-limits";
return NextResponse.redirect(url, 308);
}
/**
* POST /api/rate-limit Toggle rate limit protection for a connection
* Body: { connectionId: string, enabled: boolean }
*/
export async function POST(request) {
try {
const { connectionId, enabled } = await request.json();
if (!connectionId) {
return NextResponse.json({ error: "Missing connectionId" }, { status: 400 });
}
// Update in-memory state
if (enabled) {
enableRateLimitProtection(connectionId);
} else {
disableRateLimitProtection(connectionId);
}
// Persist to database
await updateProviderConnection(connectionId, {
rateLimitProtection: !!enabled,
});
return NextResponse.json({ success: true, connectionId, enabled: !!enabled });
} catch (error) {
console.error("[API ERROR] /api/rate-limit POST:", error);
return NextResponse.json({ error: "Failed to toggle rate limit" }, { status: 500 });
}
const url = new URL(request.url);
url.pathname = "/api/rate-limits";
return NextResponse.redirect(url, 308);
}
+66 -5
View File
@@ -1,15 +1,76 @@
import { NextResponse } from "next/server";
import {
getAllModelLockouts,
} from "@omniroute/open-sse/services/accountFallback.js";
import { getAllModelLockouts } from "@omniroute/open-sse/services/accountFallback.js";
import { getCacheStats } from "@omniroute/open-sse/services/signatureCache.js";
import { getProviderConnections, updateProviderConnection } from "@/lib/localDb";
import {
enableRateLimitProtection,
disableRateLimitProtection,
getRateLimitStatus,
getAllRateLimitStatus,
} from "@omniroute/open-sse/services/rateLimitManager.js";
/**
* GET /api/rate-limits Consolidated rate-limit status
*
* Returns:
* - Per-connection rate-limit status (protection toggle, current state)
* - Global overview (all providers)
* - Model lockouts
* - Signature cache stats
*/
export async function GET() {
try {
const connections = await getProviderConnections();
const statuses = connections.map((conn) => ({
connectionId: conn.id,
provider: conn.provider,
name: conn.name || conn.email || conn.id.slice(0, 8),
rateLimitProtection: !!conn.rateLimitProtection,
...getRateLimitStatus(conn.provider, conn.id),
}));
const lockouts = getAllModelLockouts();
const cacheStats = getCacheStats();
return NextResponse.json({ lockouts, cacheStats });
return NextResponse.json({
connections: statuses,
overview: getAllRateLimitStatus(),
lockouts,
cacheStats,
});
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
console.error("[API ERROR] /api/rate-limits GET:", error);
return NextResponse.json({ error: "Failed to get rate limit status" }, { status: 500 });
}
}
/**
* POST /api/rate-limits Toggle rate limit protection for a connection
* Body: { connectionId: string, enabled: boolean }
*/
export async function POST(request) {
try {
const { connectionId, enabled } = await request.json();
if (!connectionId) {
return NextResponse.json({ error: "Missing connectionId" }, { status: 400 });
}
// Update in-memory state
if (enabled) {
enableRateLimitProtection(connectionId);
} else {
disableRateLimitProtection(connectionId);
}
// Persist to database
await updateProviderConnection(connectionId, {
rateLimitProtection: !!enabled,
});
return NextResponse.json({ success: true, connectionId, enabled: !!enabled });
} catch (error) {
console.error("[API ERROR] /api/rate-limits POST:", error);
return NextResponse.json({ error: "Failed to toggle rate limit" }, { status: 500 });
}
}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
/**
* POST /api/resilience/reset Reset all circuit breakers and clear cooldowns
*/
export async function POST() {
try {
// Reset all circuit breakers
const { getAllCircuitBreakerStatuses, getCircuitBreaker } =
await import("@/../../src/shared/utils/circuitBreaker.js");
const statuses = getAllCircuitBreakerStatuses();
let resetCount = 0;
for (const { name } of statuses) {
const breaker = getCircuitBreaker(name);
breaker.reset();
resetCount++;
}
return NextResponse.json({
ok: true,
resetCount,
message: `Reset ${resetCount} circuit breaker(s)`,
});
} catch (err) {
console.error("[API] POST /api/resilience/reset error:", err);
return NextResponse.json(
{ error: err.message || "Failed to reset resilience state" },
{ status: 500 }
);
}
}
+80
View File
@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
/**
* GET /api/resilience Get current resilience configuration and status
*/
export async function GET() {
try {
// Dynamic imports for open-sse modules
const { getAllCircuitBreakerStatuses } =
await import("@/../../src/shared/utils/circuitBreaker.js");
const { getAllRateLimitStatus } =
await import("@omniroute/open-sse/services/rateLimitManager.js");
const { PROVIDER_PROFILES, DEFAULT_API_LIMITS } =
await import("@omniroute/open-sse/config/constants.js");
const settings = await getSettings();
const circuitBreakers = getAllCircuitBreakerStatuses();
const rateLimitStatus = getAllRateLimitStatus();
return NextResponse.json({
profiles: settings.providerProfiles || PROVIDER_PROFILES,
defaults: DEFAULT_API_LIMITS,
circuitBreakers,
rateLimitStatus,
});
} catch (err) {
console.error("[API] GET /api/resilience error:", err);
return NextResponse.json(
{ error: err.message || "Failed to load resilience status" },
{ status: 500 }
);
}
}
/**
* PATCH /api/resilience Update provider resilience profiles
*/
export async function PATCH(request) {
try {
const body = await request.json();
const { profiles } = body;
if (!profiles || typeof profiles !== "object") {
return NextResponse.json({ error: "Invalid profiles payload" }, { status: 400 });
}
// Validate profile shape
for (const [key, profile] of Object.entries(profiles)) {
if (!["oauth", "apikey"].includes(key)) {
return NextResponse.json({ error: `Invalid profile key: ${key}` }, { status: 400 });
}
const required = [
"transientCooldown",
"rateLimitCooldown",
"maxBackoffLevel",
"circuitBreakerThreshold",
"circuitBreakerReset",
];
for (const field of required) {
if (typeof profile[field] !== "number" || profile[field] < 0) {
return NextResponse.json(
{ error: `Invalid ${key}.${field}: must be a non-negative number` },
{ status: 400 }
);
}
}
}
await updateSettings({ providerProfiles: profiles });
return NextResponse.json({ ok: true, profiles });
} catch (err) {
console.error("[API] PATCH /api/resilience error:", err);
return NextResponse.json(
{ error: err.message || "Failed to save resilience profiles" },
{ status: 500 }
);
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ export async function GET() {
return NextResponse.json({
...safeSettings,
enableRequestLogs,
hasPassword: !!password,
hasPassword: !!password || !!process.env.INITIAL_PASSWORD,
});
} catch (error) {
console.log("Error getting settings:", error);
+103 -32
View File
@@ -6,6 +6,53 @@ import fs from "fs/promises";
import path from "path";
import os from "os";
/**
* GET /api/sync/cloud
* Returns current cloud sync status for sidebar indicator
*/
export async function GET() {
try {
const { isCloudEnabled } = await import("@/lib/db/settings.js");
const enabled = await isCloudEnabled();
if (!enabled) {
return NextResponse.json({ enabled: false });
}
// Cloud is enabled — try to verify connection
const machineId = await getConsistentMachineId();
const keys = await getApiKeys();
const apiKey = keys[0]?.key;
if (!apiKey || !CLOUD_URL) {
return NextResponse.json({ enabled: true, connected: false });
}
try {
const pingRes = await fetchWithTimeout(
`${CLOUD_URL}/${machineId}/v1/verify`,
{
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
},
5000
);
return NextResponse.json({
enabled: true,
connected: pingRes.ok,
lastSync: new Date().toISOString(),
});
} catch {
return NextResponse.json({ enabled: true, connected: false });
}
} catch (error) {
return NextResponse.json({ enabled: false, error: error.message }, { status: 500 });
}
}
/**
* POST /api/sync/cloud
* Sync data with Cloud
@@ -19,15 +66,22 @@ export async function POST(request) {
const machineId = await getConsistentMachineId();
switch (action) {
case "enable":
await updateSettings({ cloudEnabled: true });
// Auto create key if none exists
case "enable": {
// Auto create key if none exists (before sync, so it's included in sync data)
const keys = await getApiKeys();
let createdKey = null;
if (keys.length === 0) {
createdKey = await createApiKey("Default Key", machineId);
}
return syncAndVerify(machineId, createdKey?.key, keys);
// Sync first — only enable if sync succeeds
const enableResult = await syncAndVerify(machineId, createdKey?.key, keys);
const enableBody = await enableResult.clone().json().catch(() => ({}));
// Only persist cloudEnabled if sync succeeded (body.success exists)
if (enableBody.success) {
await updateSettings({ cloudEnabled: true });
}
return enableResult;
}
case "sync": {
const syncResult = await syncToCloud(machineId);
if (syncResult.error) {
@@ -48,16 +102,19 @@ export async function POST(request) {
}
/**
* Sync and verify connection with ping
* Sync and verify connection with ping (retry on verify)
*/
async function syncAndVerify(machineId, createdKey, existingKeys) {
// Step 1: Sync data to cloud
const syncResult = await syncToCloud(machineId, createdKey);
if (syncResult.error) {
return NextResponse.json(syncResult, { status: 502 });
return NextResponse.json(
{ error: `Cloud sync failed: ${syncResult.error}` },
{ status: 502 }
);
}
// Step 2: Verify connection by pinging the cloud
// Step 2: Verify connection by pinging the cloud (with retry)
const apiKey = createdKey || existingKeys[0]?.key;
if (!apiKey) {
return NextResponse.json({
@@ -67,34 +124,48 @@ async function syncAndVerify(machineId, createdKey, existingKeys) {
});
}
try {
const pingResponse = await fetchWithTimeout(`${CLOUD_URL}/${machineId}/v1/verify`, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
});
// Retry verify up to 2 times with a delay (cloud may need a moment after sync)
const MAX_VERIFY_ATTEMPTS = 2;
const VERIFY_RETRY_DELAY_MS = 1500;
let lastVerifyError = null;
if (pingResponse.ok) {
return NextResponse.json({
...syncResult,
verified: true,
});
} else {
return NextResponse.json({
...syncResult,
verified: false,
verifyError: `Ping failed: ${pingResponse.status}`,
});
for (let attempt = 1; attempt <= MAX_VERIFY_ATTEMPTS; attempt++) {
try {
const pingResponse = await fetchWithTimeout(
`${CLOUD_URL}/${machineId}/v1/verify`,
{
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
},
5000
);
if (pingResponse.ok) {
return NextResponse.json({
...syncResult,
verified: true,
});
}
lastVerifyError = `Ping failed: ${pingResponse.status}`;
} catch (error) {
lastVerifyError = error?.name === "AbortError" ? "Verify timeout" : error.message;
}
// Wait before retry (except on last attempt)
if (attempt < MAX_VERIFY_ATTEMPTS) {
await new Promise((r) => setTimeout(r, VERIFY_RETRY_DELAY_MS));
}
} catch (error) {
return NextResponse.json({
...syncResult,
verified: false,
verifyError: error.message,
});
}
// Sync succeeded but verify failed — still return success with warning
return NextResponse.json({
...syncResult,
verified: false,
verifyError: lastVerifyError || "Verification failed after retries",
});
}
/**
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { getTelemetrySummary } from "@/shared/utils/requestTelemetry";
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const windowMs = parseInt(searchParams.get("windowMs") || "300000", 10);
const summary = getTelemetrySummary(windowMs);
return NextResponse.json(summary);
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Token Health API Route Batch G
*
* Exposes aggregate health status of OAuth tokens.
* Used by TokenHealthBadge in the Header.
*/
import { getProviderConnections } from "@/lib/localDb";
export async function GET() {
try {
const connections = await getProviderConnections({ authType: "oauth" });
const oauthConns = (connections || []).filter((c) => c.isActive && c.refreshToken);
const total = oauthConns.length;
const healthy = oauthConns.filter((c) => c.testStatus === "active" || !c.lastError).length;
const errored = oauthConns.filter(
(c) => c.testStatus === "error" || c.lastErrorType === "token_refresh_failed"
).length;
const lastCheck = oauthConns.reduce((latest, c) => {
if (!c.lastHealthCheckAt) return latest;
return latest && latest > c.lastHealthCheckAt ? latest : c.lastHealthCheckAt;
}, null);
return Response.json({
total,
healthy,
errored,
warning: total - healthy - errored,
lastCheckAt: lastCheck,
status: errored > 0 ? "error" : healthy < total ? "warning" : "healthy",
});
} catch (err) {
return Response.json({ error: err.message, status: "unknown" }, { status: 500 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { getCostSummary, setBudget, checkBudget } from "@/domain/costRules";
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const apiKeyId = searchParams.get("apiKeyId");
if (!apiKeyId) {
return NextResponse.json({ error: "apiKeyId query param is required" }, { status: 400 });
}
const summary = getCostSummary(apiKeyId);
const budgetCheck = checkBudget(apiKeyId);
return NextResponse.json({ ...summary, budgetCheck });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(request) {
try {
const { apiKeyId, dailyLimitUsd, monthlyLimitUsd, warningThreshold } = await request.json();
if (!apiKeyId || !dailyLimitUsd) {
return NextResponse.json({ error: "apiKeyId and dailyLimitUsd are required" }, { status: 400 });
}
setBudget(apiKeyId, { dailyLimitUsd, monthlyLimitUsd, warningThreshold });
return NextResponse.json({ success: true, apiKeyId, dailyLimitUsd });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+8 -7
View File
@@ -2,17 +2,18 @@ import { callCloudWithMachineId } from "@/shared/utils/cloud.js";
import { handleChat } from "@/sse/handlers/chat.js";
import { initTranslators } from "@omniroute/open-sse/translator/index.js";
let initialized = false;
let initPromise = null;
/**
* Initialize translators once
* Initialize translators once (Promise-based singleton no race condition)
*/
async function ensureInitialized() {
if (!initialized) {
await initTranslators();
initialized = true;
console.log("[SSE] Translators initialized");
function ensureInitialized() {
if (!initPromise) {
initPromise = Promise.resolve(initTranslators()).then(() => {
console.log("[SSE] Translators initialized");
});
}
return initPromise;
}
/**

Some files were not shown because too many files have changed in this diff Show More