From 050e289a8c48115920a97e00e3a273c15ea9d84e Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 17 Feb 2026 14:47:06 -0300 Subject: [PATCH] release(v0.8.8): bump version, analytics redesign, settings restructure, routing expansion, editable rate limits, CLI tools state persistence, docs update --- CHANGELOG.md | 29 ++ README.md | 68 +-- docs/ARCHITECTURE.md | 6 +- docs/TASKS.md | 91 ++-- docs/USER_GUIDE.md | 41 +- package.json | 2 +- .../cli-tools/CLIToolsPageClient.tsx | 6 +- .../cli-tools/components/ClaudeToolCard.tsx | 2 + .../cli-tools/components/CliStatusBadge.tsx | 66 ++- .../cli-tools/components/ClineToolCard.tsx | 15 +- .../cli-tools/components/CodexToolCard.tsx | 2 + .../cli-tools/components/DroidToolCard.tsx | 2 + .../cli-tools/components/KiloToolCard.tsx | 15 +- .../cli-tools/components/OpenClawToolCard.tsx | 2 + .../settings/components/ResilienceTab.tsx | 476 ++++++++++++++---- .../settings/components/RoutingTab.tsx | 21 +- .../settings/components/SecurityTab.tsx | 2 - .../(dashboard)/dashboard/settings/page.tsx | 8 +- .../api/cli-tools/claude-settings/route.ts | 15 + src/app/api/cli-tools/cline-settings/route.ts | 15 + src/app/api/cli-tools/codex-settings/route.ts | 15 + src/app/api/cli-tools/droid-settings/route.ts | 15 + src/app/api/cli-tools/kilo-settings/route.ts | 15 + .../api/cli-tools/openclaw-settings/route.ts | 15 + src/app/api/cli-tools/status/route.ts | 13 + src/app/api/resilience/route.ts | 79 ++- src/lib/db/cliToolState.ts | 123 +++++ src/shared/components/UsageAnalytics.tsx | 87 +++- src/shared/components/analytics/charts.tsx | 276 +++++++++- src/shared/components/analytics/index.tsx | 2 + src/sse/services/auth.ts | 43 +- src/types/settings.ts | 8 +- 32 files changed, 1308 insertions(+), 267 deletions(-) create mode 100644 src/lib/db/cliToolState.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cbd0ea0da5..8c53445602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.8.8] β€” 2026-02-17 + +### Added + +- πŸ“Š **Analytics page redesign** β€” Rebuilt analytics dashboard with Recharts (SVG-based) charts replacing the previous implementation. New layout: stat cards β†’ model usage bar chart β†’ provider breakdown table with success rates and avg latency +- 🎯 **6 global routing strategies** β€” Expanded from 3 (Fill-First, Round-Robin, P2C) to 6, adding Random, Least-Used, and Cost-Optimized. All strategies now have descriptions and icons in the Settings β†’ Routing tab +- πŸ”§ **Editable rate limits** β€” Rate limit defaults (RPM, Min Gap, Max Concurrent) are now editable in Settings β†’ Resilience with save/cancel functionality. Values persist via the resilience API +- πŸ“‹ **Policies in Resilience tab** β€” Moved PoliciesPanel (circuit breaker status + locked identifiers) from Security to Resilience tab for better logical grouping +- 🧠 **Prompt Cache in AI tab** β€” Relocated CacheStatsCard from Advanced to AI tab alongside Thinking Budget and System Prompt + +### Changed + +- ♻️ **Settings page restructure** β€” Reorganized all settings tabs for better UX: + - **Security**: Simplified to Login/Password and IP Access Control only + - **Routing**: Expanded strategy grid with all 6 routing strategies + - **Resilience**: Reordered cards (Provider Profiles β†’ Rate Limiting β†’ Circuit Breakers β†’ Policies & Locked Identifiers) + - **AI**: Now includes Thinking Budget, System Prompt, and Prompt Cache + - **Advanced**: Simplified to only Global Proxy configuration +- πŸ”„ **Backend routing strategies** β€” Implemented `random` (Fisher-Yates shuffle), `least-used` (sorted by `lastUsedAt`), `cost-optimized` (sorted by priority ascending), and fixed `p2c` (power-of-two-choices with health scoring) in `auth.ts` +- πŸ”Œ **Resilience API updates** β€” GET endpoint now merges saved rate limit defaults with constants; PATCH endpoint accepts both `profiles` and `defaults` +- πŸ“Š **Usage page split** β€” Refactored Usage page into "Request Logs" (with updated icon) and a new dedicated "Limits & Quotas" page + +### Fixed + +- πŸ› **Provider add error** β€” Improved error handling for API responses when adding new provider connections, with clear validation feedback + +--- + ## [0.8.5] β€” 2026-02-17 ### Added @@ -191,6 +219,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +[0.8.8]: https://github.com/diegosouzapw/OmniRoute/compare/v0.8.5...v0.8.8 [0.8.5]: https://github.com/diegosouzapw/OmniRoute/compare/v0.8.0...v0.8.5 [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 diff --git a/README.md b/README.md index 5969487cef..f619fc9be6 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ docker compose --profile cli up -d | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | | `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `0.8.5` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `0.8.8` | ~250MB | Current version | --- @@ -166,19 +166,19 @@ docker compose --profile cli up -d ### 🧠 Core Routing & Intelligence -| Feature | What It Does | -| ------------------------------- | --------------------------------------------------------------------------------- | -| 🎯 **Smart 4-Tier Fallback** | Auto-route: Subscription β†’ API Key β†’ Cheap β†’ Free | -| πŸ“Š **Real-Time Quota Tracking** | Live token count + reset countdown per provider | -| πŸ”„ **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro seamless | -| πŸ‘₯ **Multi-Account Support** | Multiple accounts per provider with P2C selection | -| πŸ”„ **Auto Token Refresh** | OAuth tokens refresh automatically with retry | -| 🎨 **Custom Combos** | 6 strategies: priority, weighted, round-robin, random, least-used, cost-optimized | -| 🧩 **Custom Models** | Add any model ID to any provider | -| 🌐 **Wildcard Router** | Route `provider/*` patterns to any provider dynamically | -| 🧠 **Thinking Budget** | Passthrough, auto, custom, and adaptive modes for reasoning models | -| πŸ’¬ **System Prompt Injection** | Global system prompt applied across all requests | -| πŸ“„ **Responses API** | Full OpenAI Responses API (`/v1/responses`) support for Codex | +| Feature | What It Does | +| ------------------------------- | ------------------------------------------------------------------------------ | +| 🎯 **Smart 4-Tier Fallback** | Auto-route: Subscription β†’ API Key β†’ Cheap β†’ Free | +| πŸ“Š **Real-Time Quota Tracking** | Live token count + reset countdown per provider | +| πŸ”„ **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro seamless | +| πŸ‘₯ **Multi-Account Support** | Multiple accounts per provider with intelligent selection | +| πŸ”„ **Auto Token Refresh** | OAuth tokens refresh automatically with retry | +| 🎨 **Custom Combos** | 6 strategies: fill-first, round-robin, p2c, random, least-used, cost-optimized | +| 🧩 **Custom Models** | Add any model ID to any provider | +| 🌐 **Wildcard Router** | Route `provider/*` patterns to any provider dynamically | +| 🧠 **Thinking Budget** | Passthrough, auto, custom, and adaptive modes for reasoning models | +| πŸ’¬ **System Prompt Injection** | Global system prompt applied across all requests | +| πŸ“„ **Responses API** | Full OpenAI Responses API (`/v1/responses`) support for Codex | ### 🎡 Multi-Modal APIs @@ -193,26 +193,28 @@ docker compose --profile cli up -d ### πŸ›‘οΈ Resilience & Security -| Feature | What It Does | -| ------------------------------- | ------------------------------------------------------------ | -| πŸ”Œ **Circuit Breaker** | Auto-open/close per-provider with configurable thresholds | -| πŸ›‘οΈ **Anti-Thundering Herd** | Mutex + semaphore rate-limit for API key providers | -| 🧠 **Semantic Cache** | Two-tier cache (signature + semantic) reduces cost & latency | -| ⚑ **Request Idempotency** | 5s dedup window for duplicate requests | -| πŸ”’ **TLS Fingerprint Spoofing** | Bypass TLS-based bot detection via wreq-js | -| 🌐 **IP Filtering** | Allowlist/blocklist for API access control | -| πŸ“‹ **Compliance Audit Log** | Tamper-proof request logs with opt-out per API key | +| Feature | What It Does | +| ------------------------------- | ------------------------------------------------------------- | +| πŸ”Œ **Circuit Breaker** | Auto-open/close per-provider with configurable thresholds | +| πŸ›‘οΈ **Anti-Thundering Herd** | Mutex + semaphore rate-limit for API key providers | +| 🧠 **Semantic Cache** | Two-tier cache (signature + semantic) reduces cost & latency | +| ⚑ **Request Idempotency** | 5s dedup window for duplicate requests | +| πŸ”’ **TLS Fingerprint Spoofing** | Bypass TLS-based bot detection via wreq-js | +| 🌐 **IP Filtering** | Allowlist/blocklist for API access control | +| πŸ“‹ **Compliance Audit Log** | Tamper-proof request logs with opt-out per API key | +| πŸ“Š **Editable Rate Limits** | Configurable RPM, min gap, and max concurrent at system level | ### πŸ“Š Observability & Analytics -| Feature | What It Does | -| ------------------------ | ------------------------------------------------------ | -| πŸ“ **Request Logging** | Debug mode with full request/response logs | -| πŸ’Ύ **SQLite Proxy Logs** | Persistent proxy logs survive server restarts | -| πŸ“Š **Usage Analytics** | Track tokens, cost, trends over time | -| πŸ“ˆ **Progress Tracking** | Opt-in SSE progress events for streaming | -| πŸ§ͺ **LLM Evaluations** | Golden set testing with 4 match strategies | -| πŸ” **Request Telemetry** | p50/p95/p99 latency aggregation + X-Request-Id tracing | +| Feature | What It Does | +| ---------------------------- | --------------------------------------------------------------- | +| πŸ“ **Request Logging** | Debug mode with full request/response logs | +| πŸ’Ύ **SQLite Proxy Logs** | Persistent proxy logs survive server restarts | +| πŸ“Š **Analytics Dashboard** | Recharts-powered: stat cards, model usage chart, provider table | +| πŸ“ˆ **Progress Tracking** | Opt-in SSE progress events for streaming | +| πŸ§ͺ **LLM Evaluations** | Golden set testing with 4 match strategies | +| πŸ” **Request Telemetry** | p50/p95/p99 latency aggregation + X-Request-Id tracing | +| πŸ“‹ **Request Logs + Quotas** | Dedicated pages for log browsing and limits/quotas tracking | ### ☁️ Deployment & Sync @@ -292,7 +294,7 @@ registerSuite({ ## πŸ› οΈ Tech Stack - **Runtime**: Node.js 20+ -- **Language**: TypeScript 5.9 β€” **100% TypeScript** across `src/` and `open-sse/` (v0.8.5) +- **Language**: TypeScript 5.9 β€” **100% TypeScript** across `src/` and `open-sse/` (v0.8.8) - **Framework**: Next.js 16 + React 19 + Tailwind CSS 4 - **Database**: LowDB (JSON) + SQLite (domain state + proxy logs) - **Streaming**: Server-Sent Events (SSE) @@ -347,7 +349,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. ```bash # Create a release β€” npm publish happens automatically -gh release create v0.8.5 --title "v0.8.5" --generate-notes +gh release create v0.8.8 --title "v0.8.8" --generate-notes ``` --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 29f4c3dcfa..ca157e9956 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -408,6 +408,9 @@ erDiagram number stickyRoundRobinLimit boolean requireLogin string password_hash + string fallbackStrategy + json rateLimitDefaults + json providerProfiles } PROVIDER_CONNECTION { @@ -750,8 +753,9 @@ Environment variables actively used by code: 3. Request logger writes full headers/body when enabled; treat log directory as sensitive. 4. Cloud behavior depends on correct `NEXT_PUBLIC_BASE_URL` and cloud endpoint reachability. 5. The `open-sse/` directory is published as the `@omniroute/open-sse` **npm workspace package**. Source code imports it via `@omniroute/open-sse/...` (resolved by Next.js `transpilePackages`). File paths in this document still use the directory name `open-sse/` for consistency. -6. Charts in the dashboard use **Recharts** (SVG-based) for accessible, interactive visualizations (bar charts, donut charts). +6. Charts in the dashboard use **Recharts** (SVG-based) for accessible, interactive analytics visualizations (model usage bar charts, provider breakdown tables with success rates). 7. E2E tests use **Playwright** (`tests/e2e/`), run via `npm run test:e2e`. Unit tests use **Node.js test runner** (`tests/unit/`), run via `npm run test:plan3`. Source code under `src/` is **TypeScript** (`.ts`/`.tsx`); the `open-sse/` workspace remains JavaScript (`.js`). +8. Settings page is organized into 5 tabs: Security, Routing (6 global strategies: fill-first, round-robin, p2c, random, least-used, cost-optimized), Resilience (editable rate limits, circuit breaker, policies), AI (thinking budget, system prompt, prompt cache), Advanced (proxy). ## Operational Verification Checklist diff --git a/docs/TASKS.md b/docs/TASKS.md index fff6dd4ead..3ea8b535a5 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -8,24 +8,24 @@ ### 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)` +- [x] `constants.ts` β€” Substituir `COOLDOWN_MS.transient` por `transientInitial` (5s) + `transientMax` (60s) +- [x] `constants.ts` β€” Adicionar `PROVIDER_PROFILES` (oauth / apikey) com cooldowns diferenciados +- [x] `constants.ts` β€” Adicionar `DEFAULT_API_LIMITS` (100 RPM, 200ms minTime) +- [x] `providerRegistry.ts` β€” Criar helper `getProviderCategory(providerId)` β†’ `"oauth"` | `"apikey"` +- [x] `accountFallback.ts` β€” Aceitar `provider` como parΓ’metro em `checkFallbackError` +- [x] `accountFallback.ts` β€” Implementar backoff exponencial para 502/503/504 transientes +- [x] `accountFallback.ts` β€” Calcular cooldown baseado no perfil do provedor +- [x] `accountFallback.ts` β€” Adicionar helper `getProviderProfile(provider)` ### Callers (propagar `provider`) -- [ ] `auth.js` β†’ `markAccountUnavailable` β€” Passar `provider` para `checkFallbackError` -- [ ] `combo.js` β†’ `handleComboChat` / `handleRoundRobinCombo` β€” Passar `provider` nos erros +- [x] `auth.ts` β†’ `markAccountUnavailable` β€” Passar `provider` para `checkFallbackError` +- [x] `combo.ts` β†’ `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` +- [x] Atualizar `rate-limit-enhanced.test.mjs` β€” Teste "transient errors don't increase backoff" β†’ `newBackoffLevel = 1` +- [x] Criar `error-classification.test.mjs` β€” Cooldown exponencial 502, perfis OAuth/API, helper `getProviderCategory` --- @@ -33,15 +33,15 @@ ### 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 +- [x] `combo.ts` β€” Importar `getCircuitBreaker` e `CircuitBreakerOpenError` +- [x] `combo.ts` β€” `handleComboChat` β€” Verificar `breaker.canExecute()` antes de cada modelo +- [x] `combo.ts` β€” `handleRoundRobinCombo` β€” Integrar breaker per-model +- [x] `combo.ts` β€” Marcar `semaphore.markRateLimited` para 502/503/504 (nΓ£o sΓ³ 429) +- [x] `combo.ts` β€” 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 +- [x] Criar `combo-circuit-breaker.test.mjs` β€” Combo skip breaker OPEN, early exit, semΓ‘foro 502 --- @@ -49,13 +49,13 @@ ### 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 +- [x] `rateLimitManager.ts` β€” Auto-enable para `apikey` providers com limites elevados +- [x] `rateLimitManager.ts` β€” Criar limiter com defaults (100 RPM) quando nΓ£o configurado +- [x] `auth.ts` β€” Adicionar mutex na `markAccountUnavailable` para evitar marcaΓ§Γ£o paralela ### Testes -- [ ] Criar `thundering-herd.test.mjs` β€” Mutex, auto-enable, limites nΓ£o restritivos +- [x] Criar `thundering-herd.test.mjs` β€” Mutex, auto-enable, limites nΓ£o restritivos --- @@ -63,30 +63,51 @@ ### Settings Page -- [ ] `settings/page.tsx` β€” Adicionar tab "Resilience" (icon: `health_and_safety`) entre Routing e Pricing +- [x] `settings/page.tsx` β€” Adicionar tab "Resilience" (icon: `health_and_safety`) entre Routing e Pricing ### Novos Componentes -- [ ] Criar `ResilienceTab.tsx` β€” Layout com 3 cards -- [ ] Criar `ProviderProfilesCard.tsx` β€” Toggle OAuth/API Key, inputs para cooldowns -- [ ] Criar `CircuitBreakerCard.tsx` β€” Status real-time per-provider, auto-refresh 5s, botΓ£o reset -- [ ] Criar `RateLimitOverviewCard.tsx` β€” Tabela providers Γ— accounts Γ— cooldown +- [x] Criar `ResilienceTab.tsx` β€” Layout com 4 cards (Provider Profiles β†’ Rate Limiting β†’ Circuit Breakers β†’ Policies) +- [x] Criar `ProviderProfilesCard.tsx` β€” Toggle OAuth/API Key, inputs para cooldowns +- [x] Criar `CircuitBreakerCard.tsx` β€” Status real-time per-provider, auto-refresh 5s, botΓ£o reset +- [x] Criar `RateLimitOverviewCard.tsx` β€” Tabela providers Γ— accounts Γ— cooldown β€” **agora editΓ‘vel com RPM, Min Gap, Max Concurrent** ### API Routes -- [ ] Criar `api/resilience/route.ts` β€” GET (estado completo) + PATCH (salvar perfis) -- [ ] Criar `api/resilience/reset/route.ts` β€” POST (resetar breakers + cooldowns) +- [x] Criar `api/resilience/route.ts` β€” GET (estado completo + defaults mesclados) + PATCH (salvar perfis + defaults) +- [x] Criar `api/resilience/reset/route.ts` β€” POST (resetar breakers + cooldowns) ### MigraΓ§Γ£o -- [ ] Avaliar se `PoliciesPanel.tsx` pode ser removido ou simplificado apΓ³s nova aba +- [x] `PoliciesPanel.tsx` movido de Security para Resilience tab + +--- + +## Fase 5 β€” Settings Page Restructure (v0.9.0) + +### Tab Reorganization + +- [x] **Security** β€” Simplificado para Login/Password + IP Access Control +- [x] **Routing** β€” Expandido para 6 estratΓ©gias globais com descriΓ§Γ΅es +- [x] **Resilience** β€” Reordenado: Provider Profiles β†’ Rate Limiting (editΓ‘vel) β†’ Circuit Breakers β†’ Policies +- [x] **AI** β€” Thinking Budget + System Prompt + Prompt Cache (movido do Advanced) +- [x] **Advanced** β€” Simplificado para apenas Global Proxy + +### Backend Routing Strategies + +- [x] `auth.ts` β€” Implementar `random` (Fisher-Yates shuffle) +- [x] `auth.ts` β€” Implementar `least-used` (sorted by lastUsedAt) +- [x] `auth.ts` β€” Implementar `cost-optimized` (sorted by priority) +- [x] `auth.ts` β€” Corrigir `p2c` (power-of-two-choices com health scoring) +- [x] `settings.ts` β€” Expandir tipo `fallbackStrategy` para 6 valores --- ## 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 +- [x] Rodar todos os testes unitΓ‘rios: `node --test tests/unit/*.test.mjs` +- [x] Build do Next.js: `npm run build` +- [x] Verificar aba Resilience no browser +- [x] Testar persistΓͺncia dos perfis (salvar β†’ reload) +- [x] Testar Reset All Breakers +- [x] Verificar todas as 5 tabs reestruturadas diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 492e644f67..1ec4be1afd 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -513,6 +513,9 @@ Configure via **Dashboard β†’ Settings β†’ Routing**. | **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 | +| **Random** | Randomly selects an account for each request using Fisher-Yates shuffle | +| **Least Used** | Routes to the account with the oldest `lastUsedAt` timestamp, distributing traffic evenly | +| **Cost Optimized** | Routes to the account with the lowest priority value, optimizing for lowest-cost providers | #### Wildcard Model Aliases @@ -542,25 +545,47 @@ Chain: production-fallback Configure via **Dashboard β†’ Settings β†’ Resilience**. -OmniRoute implements provider-level resilience with three components: +OmniRoute implements provider-level resilience with four 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: +1. **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. +2. **Editable Rate Limits** β€” System-level defaults configurable in the dashboard: + - **Requests Per Minute (RPM)** β€” Maximum requests per minute per account + - **Min Time Between Requests** β€” Minimum gap in milliseconds between requests + - **Max Concurrent Requests** β€” Maximum simultaneous requests per account + - Click **Edit** to modify, then **Save** or **Cancel**. Values persist via the resilience API. + +3. **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 + +4. **Policies & Locked Identifiers** β€” Shows circuit breaker status and locked identifiers with force-unlock capability. + +5. **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. --- +### Settings Dashboard + +The settings page is organized into 5 tabs for easy navigation: + +| Tab | Contents | +| -------------- | ---------------------------------------------------------------------------------------------- | +| **Security** | Login/Password settings and IP Access Control (allowlist/blocklist) | +| **Routing** | Global routing strategy (6 options), wildcard model aliases, fallback chains, combo defaults | +| **Resilience** | Provider profiles, editable rate limits, circuit breaker status, policies & locked identifiers | +| **AI** | Thinking budget configuration, global system prompt injection, prompt cache stats | +| **Advanced** | Global proxy configuration (HTTP/SOCKS5) | + +--- + ### Costs & Budget Management Access via **Dashboard β†’ Costs**. diff --git a/package.json b/package.json index 9ea1bf53a4..a35f0d8111 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "0.8.5", + "version": "0.8.8", "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": { diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx index 79d55dc552..89ba789269 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx @@ -29,6 +29,7 @@ export default function CLIToolsPageClient({ machineId }) { const [cloudEnabled, setCloudEnabled] = useState(false); const [apiKeys, setApiKeys] = useState([]); const [toolStatuses, setToolStatuses] = useState({}); + const [statusesLoaded, setStatusesLoaded] = useState(false); useEffect(() => { fetchConnections(); @@ -70,6 +71,8 @@ export default function CLIToolsPageClient({ machineId }) { } } catch (error) { console.log("Error fetching CLI tool statuses:", error); + } finally { + setStatusesLoaded(true); } }; @@ -144,7 +147,7 @@ export default function CLIToolsPageClient({ machineId }) { return "http://localhost:20128"; }; - if (loading) { + if (loading || !statusesLoaded) { return (
@@ -167,6 +170,7 @@ export default function CLIToolsPageClient({ machineId }) { baseUrl: getBaseUrl(), apiKeys, batchStatus: toolStatuses[toolId] || null, + lastConfiguredAt: toolStatuses[toolId]?.lastConfiguredAt || null, }; switch (toolId) { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.tsx index db8905692a..a0a595e2fa 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.tsx @@ -19,6 +19,7 @@ export default function ClaudeToolCard({ apiKeys, cloudEnabled, batchStatus, + lastConfiguredAt, }) { const [claudeStatus, setClaudeStatus] = useState(null); const [checkingClaude, setCheckingClaude] = useState(false); @@ -277,6 +278,7 @@ export default function ClaudeToolCard({

{tool.description}

diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CliStatusBadge.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/CliStatusBadge.tsx index b1e3701779..4048868b3e 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CliStatusBadge.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CliStatusBadge.tsx @@ -4,13 +4,41 @@ * 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. + * Optionally shows last-configured relative timestamp. */ -export default function CliStatusBadge({ effectiveConfigStatus, batchStatus }) { + +function formatRelativeTime(isoDate: string): string { + const now = Date.now(); + const then = new Date(isoDate).getTime(); + const diffMs = now - then; + if (diffMs < 0) return "just now"; + + const seconds = Math.floor(diffMs / 1000); + if (seconds < 60) return "just now"; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + + return `${Math.floor(months / 12)}y ago`; +} + +export default function CliStatusBadge({ + effectiveConfigStatus, + batchStatus, + lastConfiguredAt = null, +}) { // Determine badge from effectiveConfigStatus or batchStatus const status = effectiveConfigStatus || batchStatus?.configStatus || null; - if (!status) return null; - const badges = { configured: { dotClass: "bg-green-500", @@ -39,14 +67,32 @@ export default function CliStatusBadge({ effectiveConfigStatus, batchStatus }) { }, }; - const badge = badges[status] || badges.unknown; + const badge = status ? badges[status] || badges.unknown : null; return ( - - - {badge.text} - + <> + {badge && ( + + + {badge.text} + + )} + {lastConfiguredAt ? ( + + schedule + {formatRelativeTime(lastConfiguredAt)} + + ) : status && status !== "not_installed" ? ( + + schedule + Never + + ) : null} + ); } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx index 08538370b5..9f24dda46b 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx @@ -17,6 +17,7 @@ export default function ClineToolCard({ activeProviders, cloudEnabled, batchStatus, + lastConfiguredAt, }) { const [clineStatus, setClineStatus] = useState(null); const [checkingCline, setCheckingCline] = useState(false); @@ -236,6 +237,7 @@ export default function ClineToolCard({

{tool.description}

@@ -463,11 +465,14 @@ export default function ClineToolCard({ isOpen={showManualConfigModal} onClose={() => setShowManualConfigModal(false)} title="Cline Manual Configuration" - {...{onApply: handleManualConfig, currentConfig: { - model: selectedModel, - apiKey: selectedApiKey, - baseUrl: customBaseUrl || baseUrl, - }} as any} + {...({ + onApply: handleManualConfig, + currentConfig: { + model: selectedModel, + apiKey: selectedApiKey, + baseUrl: customBaseUrl || baseUrl, + }, + } as any)} /> )} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.tsx index bd4881c425..e443bb6698 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.tsx @@ -14,6 +14,7 @@ export default function CodexToolCard({ activeProviders, cloudEnabled, batchStatus, + lastConfiguredAt, }) { const [codexStatus, setCodexStatus] = useState(null); const [checkingCodex, setCheckingCodex] = useState(false); @@ -337,6 +338,7 @@ wire_api = "responses"

{tool.description}

diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.tsx index 2b408544c8..5d2f1caa5e 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.tsx @@ -17,6 +17,7 @@ export default function DroidToolCard({ activeProviders, cloudEnabled, batchStatus, + lastConfiguredAt, }) { const [droidStatus, setDroidStatus] = useState(null); const [checkingDroid, setCheckingDroid] = useState(false); @@ -270,6 +271,7 @@ export default function DroidToolCard({

{tool.description}

diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.tsx index be7e2621d6..48536dadce 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.tsx @@ -17,6 +17,7 @@ export default function KiloToolCard({ activeProviders, cloudEnabled, batchStatus, + lastConfiguredAt, }) { const [kiloStatus, setKiloStatus] = useState(null); const [checkingKilo, setCheckingKilo] = useState(false); @@ -222,6 +223,7 @@ export default function KiloToolCard({

{tool.description}

@@ -448,11 +450,14 @@ export default function KiloToolCard({ isOpen={showManualConfigModal} onClose={() => setShowManualConfigModal(false)} title="Kilo Code Manual Configuration" - {...{onApply: handleManualConfig, currentConfig: { - model: selectedModel, - apiKey: selectedApiKey, - baseUrl: customBaseUrl || baseUrl, - }} as any} + {...({ + onApply: handleManualConfig, + currentConfig: { + model: selectedModel, + apiKey: selectedApiKey, + baseUrl: customBaseUrl || baseUrl, + }, + } as any)} /> )} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.tsx index 1909b98a8c..7fa4f72e34 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.tsx @@ -17,6 +17,7 @@ export default function OpenClawToolCard({ activeProviders, cloudEnabled, batchStatus, + lastConfiguredAt, }) { const [openclawStatus, setOpenclawStatus] = useState(null); const [checkingOpenclaw, setCheckingOpenclaw] = useState(false); @@ -274,6 +275,7 @@ export default function OpenClawToolCard({

{tool.description}

diff --git a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx index 2ac3081126..1b37ac2308 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from "react"; import { Card, Button } from "@/shared/components"; +import { useNotificationStore } from "@/store/notificationStore"; // ─── State colors and labels ────────────────────────────────────────────── const STATE_STYLES = { @@ -28,6 +29,12 @@ const STATE_STYLES = { }, }; +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" }, +}; + function formatMs(ms) { if (!ms || ms <= 0) return "β€”"; if (ms < 1000) return `${ms}ms`; @@ -35,86 +42,6 @@ function formatMs(ms) { 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 ( - -
-
-
- -

Circuit Breakers

-
-
- - {activeBreakers.length > 0 - ? `${activeBreakers.length} tripped` - : `${totalBreakers} healthy`} - - {activeBreakers.length > 0 && ( - - )} -
-
- - {breakers.length === 0 ? ( -

- No circuit breakers active yet. They are created automatically when requests flow - through the combo pipeline. -

- ) : ( -
- {breakers.map((b) => { - const style = STATE_STYLES[b.state] || STATE_STYLES.CLOSED; - return ( -
-
- - {b.name.replace("combo:", "")} -
-
- {b.failureCount > 0 && ( - - {b.failureCount} failure{b.failureCount !== 1 ? "s" : ""} - - )} - - {style.label} - -
-
- ); - })} -
- )} -
-
- ); -} - // ─── Provider Profiles Card ────────────────────────────────────────────── function ProviderProfilesCard({ profiles, onSave, saving }) { const [editMode, setEditMode] = useState(false); @@ -217,16 +144,53 @@ function ProviderProfilesCard({ profiles, onSave, saving }) { ); } -// ─── Rate Limit Overview Card ──────────────────────────────────────────── -function RateLimitOverviewCard({ rateLimitStatus, defaults }) { +// ─── Editable Rate Limit Card ───────────────────────────────────────────── +function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) { + const [editMode, setEditMode] = useState(false); + const [draft, setDraft] = useState(defaults || {}); + + // Sync draft when defaults change from parent (standard prop-to-state sync) + /* eslint-disable react-hooks/set-state-in-effect */ + useEffect(() => { + if (defaults) setDraft(defaults); + }, [defaults]); + /* eslint-enable react-hooks/set-state-in-effect */ + + const handleSave = () => { + onSaveDefaults(draft); + setEditMode(false); + }; + return (
-
- -

Rate Limiting

+
+
+ +

Rate Limiting

+
+ {editMode ? ( +
+ + +
+ ) : ( + + )}

@@ -235,22 +199,34 @@ function RateLimitOverviewCard({ rateLimitStatus, defaults }) {

-

+

Default Safety Net

-
-
{defaults?.requestsPerMinute ?? "β€”"}
-
RPM
-
-
-
{formatMs(defaults?.minTimeBetweenRequests)}
-
Min Gap
-
-
-
{defaults?.concurrentRequests ?? "β€”"}
-
Max Concurrent
-
+ {[ + { key: "requestsPerMinute", label: "RPM", suffix: "" }, + { key: "minTimeBetweenRequests", label: "Min Gap", suffix: "ms", format: formatMs }, + { key: "concurrentRequests", label: "Max Concurrent", suffix: "" }, + ].map(({ key, label, format }) => ( +
+ {editMode ? ( + + setDraft((prev) => ({ ...prev, [key]: parseInt(e.target.value) || 0 })) + } + className="w-full px-2 py-1 text-lg font-bold rounded bg-white/10 border border-white/20" + /> + ) : ( +
+ {format ? format(defaults?.[key]) : (defaults?.[key] ?? "β€”")} +
+ )} +
{label}
+
+ ))}
@@ -281,6 +257,270 @@ function RateLimitOverviewCard({ rateLimitStatus, defaults }) { ); } +// ─── Circuit Breaker Card ──────────────────────────────────────────────── +function CircuitBreakerCard({ breakers, onReset, loading }) { + const activeBreakers = breakers.filter((b) => b.state !== "CLOSED"); + const totalBreakers = breakers.length; + + return ( + +
+
+
+ +

Circuit Breakers

+
+
+ + {activeBreakers.length > 0 + ? `${activeBreakers.length} tripped` + : `${totalBreakers} healthy`} + + {activeBreakers.length > 0 && ( + + )} +
+
+ + {breakers.length === 0 ? ( +

+ No circuit breakers active yet. They are created automatically when requests flow + through the combo pipeline. +

+ ) : ( +
+ {breakers.map((b) => { + const style = STATE_STYLES[b.state] || STATE_STYLES.CLOSED; + return ( +
+
+ + {b.name.replace("combo:", "")} +
+
+ {b.failureCount > 0 && ( + + {b.failureCount} failure{b.failureCount !== 1 ? "s" : ""} + + )} + + {style.label} + +
+
+ ); + })} +
+ )} +
+
+ ); +} + +// ─── Policies Panel (from Security tab) ────────────────────────────────── +function PoliciesCard() { + 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); + } + }; + + const circuitBreakers = data?.circuitBreakers || []; + const lockedIds = data?.lockedIdentifiers || []; + const hasIssues = circuitBreakers.some((cb) => cb.state !== "closed") || lockedIds.length > 0; + + if (loading) { + return ( + +
+ policy + Loading policies... +
+
+ ); + } + + return ( + +
+
+
+ +

Policies & Locked Identifiers

+
+ {hasIssues && ( + + )} +
+ + {!hasIssues ? ( +
+
+ verified_user +
+
+

+ All systems operational β€” no lockouts or tripped breakers +

+
+
+ ) : ( + <> + {/* Circuit Breakers */} + {circuitBreakers.filter((cb) => cb.state !== "closed").length > 0 && ( +
+

Circuit Breakers

+
+ {circuitBreakers + .filter((cb) => cb.state !== "closed") + .map((cb, i) => { + const status = CB_STATUS[cb.state] || CB_STATUS.open; + return ( +
+
+ + {status.icon} + + + {cb.name || cb.provider || "Unknown"} + + + {status.label} + + {cb.failures > 0 && ( + + {cb.failures} failures + + )} +
+
+ ); + })} +
+
+ )} + + {/* Locked Identifiers */} + {lockedIds.length > 0 && ( +
+

Locked Identifiers

+
+ {lockedIds.map((id, i) => { + const identifier = typeof id === "string" ? id : id.identifier || id.id; + return ( +
+
+ + lock + + {identifier} + {typeof id === "object" && id.lockedAt && ( + + since {new Date(id.lockedAt).toLocaleString()} + + )} +
+ +
+ ); + })} +
+
+ )} + + )} +
+
+ ); +} + // ─── Main Resilience Tab ───────────────────────────────────────────────── export default function ResilienceTab() { const [data, setData] = useState(null); @@ -340,6 +580,23 @@ export default function ResilienceTab() { } }; + const handleSaveDefaults = async (defaults) => { + try { + setSaving(true); + const res = await fetch("/api/resilience", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ defaults }), + }); + if (!res.ok) throw new Error("Save failed"); + await loadData(); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + }; + if (loading && !data) { return (
@@ -365,20 +622,27 @@ export default function ResilienceTab() { return (
- + {/* 1. Provider Profiles (resilience settings by auth type) */} - + {/* 3. Circuit Breakers (combo pipeline) */} + + {/* 4. Policies & Locked Identifiers (from previous Security tab) */} +
); } diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index 3336d3ee2b..5c75932dc8 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -13,6 +13,19 @@ const STRATEGIES = [ }, { 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" }, + { value: "random", label: "Random", desc: "Random account each request", icon: "shuffle" }, + { + value: "least-used", + label: "Least Used", + desc: "Pick least recently used account", + icon: "low_priority", + }, + { + value: "cost-optimized", + label: "Cost Opt", + desc: "Prefer cheapest available account", + icon: "savings", + }, ]; export default function RoutingTab() { @@ -76,7 +89,7 @@ export default function RoutingTab() {

Routing Strategy

-
+
{STRATEGIES.map((s) => (
-
); } diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx index cf63a13834..a84e6dc28b 100644 --- a/src/app/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -74,6 +74,7 @@ export default function SettingsPage() {
+
)} @@ -88,12 +89,7 @@ export default function SettingsPage() { {activeTab === "resilience" && } - {activeTab === "advanced" && ( -
- - -
- )} + {activeTab === "advanced" && } {activeTab === "compliance" && }
diff --git a/src/app/api/cli-tools/claude-settings/route.ts b/src/app/api/cli-tools/claude-settings/route.ts index 87f898bcc5..a0cf27ec95 100644 --- a/src/app/api/cli-tools/claude-settings/route.ts +++ b/src/app/api/cli-tools/claude-settings/route.ts @@ -9,6 +9,7 @@ import { getCliRuntimeStatus, } from "@/shared/services/cliRuntime"; import { createBackup } from "@/shared/services/backupService"; +import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState"; // Get claude settings path based on OS const getClaudeSettingsPath = () => getCliPrimaryConfigPath("claude"); @@ -121,6 +122,13 @@ export async function POST(request: Request) { // Write new settings await fs.writeFile(settingsPath, JSON.stringify(newSettings, null, 2)); + // Persist last-configured timestamp + try { + saveCliToolLastConfigured("claude"); + } catch { + /* non-critical */ + } + return NextResponse.json({ success: true, message: "Settings updated successfully", @@ -184,6 +192,13 @@ export async function DELETE() { // Write updated settings await fs.writeFile(settingsPath, JSON.stringify(currentSettings, null, 2)); + // Clear last-configured timestamp + try { + deleteCliToolLastConfigured("claude"); + } catch { + /* non-critical */ + } + return NextResponse.json({ success: true, message: "Settings reset successfully", diff --git a/src/app/api/cli-tools/cline-settings/route.ts b/src/app/api/cli-tools/cline-settings/route.ts index 03e397c1af..418233fe39 100644 --- a/src/app/api/cli-tools/cline-settings/route.ts +++ b/src/app/api/cli-tools/cline-settings/route.ts @@ -6,6 +6,7 @@ import path from "path"; import os from "os"; import { ensureCliConfigWriteAllowed, getCliRuntimeStatus } from "@/shared/services/cliRuntime"; import { createBackup } from "@/shared/services/backupService"; +import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState"; const CLINE_DATA_DIR = path.join(os.homedir(), ".cline", "data"); const GLOBAL_STATE_PATH = path.join(CLINE_DATA_DIR, "globalState.json"); @@ -151,6 +152,13 @@ export async function POST(request: Request) { await fs.writeFile(SECRETS_PATH, JSON.stringify(secrets, null, 2)); + // Persist last-configured timestamp + try { + saveCliToolLastConfigured("cline"); + } catch { + /* non-critical */ + } + return NextResponse.json({ success: true, message: "Cline settings applied successfully!", @@ -210,6 +218,13 @@ export async function DELETE() { delete secrets.openAiApiKey; await fs.writeFile(SECRETS_PATH, JSON.stringify(secrets, null, 2)); + // Clear last-configured timestamp + try { + deleteCliToolLastConfigured("cline"); + } catch { + /* non-critical */ + } + return NextResponse.json({ success: true, message: "OmniRoute settings removed from Cline", diff --git a/src/app/api/cli-tools/codex-settings/route.ts b/src/app/api/cli-tools/codex-settings/route.ts index dd799d992f..bb01c0cb62 100644 --- a/src/app/api/cli-tools/codex-settings/route.ts +++ b/src/app/api/cli-tools/codex-settings/route.ts @@ -9,6 +9,7 @@ import { getCliRuntimeStatus, } from "@/shared/services/cliRuntime"; import { createMultiBackup } from "@/shared/services/backupService"; +import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState"; const getCodexConfigPath = () => getCliConfigPaths("codex").config; const getCodexAuthPath = () => getCliConfigPaths("codex").auth; @@ -201,6 +202,13 @@ export async function POST(request: Request) { authData.OPENAI_API_KEY = apiKey; await fs.writeFile(authPath, JSON.stringify(authData, null, 2)); + // Persist last-configured timestamp + try { + saveCliToolLastConfigured("codex"); + } catch { + /* non-critical */ + } + return NextResponse.json({ success: true, message: "Codex settings applied successfully!", @@ -270,6 +278,13 @@ export async function DELETE() { /* No auth file */ } + // Clear last-configured timestamp + try { + deleteCliToolLastConfigured("codex"); + } catch { + /* non-critical */ + } + return NextResponse.json({ success: true, message: "OmniRoute settings removed successfully", diff --git a/src/app/api/cli-tools/droid-settings/route.ts b/src/app/api/cli-tools/droid-settings/route.ts index e95f95f4c9..de0c72d15c 100644 --- a/src/app/api/cli-tools/droid-settings/route.ts +++ b/src/app/api/cli-tools/droid-settings/route.ts @@ -9,6 +9,7 @@ import { getCliRuntimeStatus, } from "@/shared/services/cliRuntime"; import { createBackup } from "@/shared/services/backupService"; +import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState"; const getDroidSettingsPath = () => getCliPrimaryConfigPath("droid"); const getDroidDir = () => path.dirname(getDroidSettingsPath()); @@ -132,6 +133,13 @@ export async function POST(request: Request) { // Write settings await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); + // Persist last-configured timestamp + try { + saveCliToolLastConfigured("droid"); + } catch { + /* non-critical */ + } + return NextResponse.json({ success: true, message: "Factory Droid settings applied successfully!", @@ -184,6 +192,13 @@ export async function DELETE() { // Write updated settings await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); + // Clear last-configured timestamp + try { + deleteCliToolLastConfigured("droid"); + } catch { + /* non-critical */ + } + return NextResponse.json({ success: true, message: "OmniRoute settings removed successfully", diff --git a/src/app/api/cli-tools/kilo-settings/route.ts b/src/app/api/cli-tools/kilo-settings/route.ts index 359965cfd3..38a1feceda 100644 --- a/src/app/api/cli-tools/kilo-settings/route.ts +++ b/src/app/api/cli-tools/kilo-settings/route.ts @@ -6,6 +6,7 @@ import path from "path"; import os from "os"; import { ensureCliConfigWriteAllowed, getCliRuntimeStatus } from "@/shared/services/cliRuntime"; import { createBackup } from "@/shared/services/backupService"; +import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState"; const KILO_DATA_DIR = path.join(os.homedir(), ".local", "share", "kilo"); const AUTH_PATH = path.join(KILO_DATA_DIR, "auth.json"); @@ -175,6 +176,13 @@ export async function POST(request) { // VS Code settings not writable β€” not a problem for CLI } + // Persist last-configured timestamp + try { + saveCliToolLastConfigured("kilo"); + } catch { + /* non-critical */ + } + return NextResponse.json({ success: true, message: "Kilo Code settings applied successfully!", @@ -233,6 +241,13 @@ export async function DELETE() { /* ignore */ } + // Clear last-configured timestamp + try { + deleteCliToolLastConfigured("kilo"); + } catch { + /* non-critical */ + } + return NextResponse.json({ success: true, message: "OmniRoute settings removed from Kilo Code", diff --git a/src/app/api/cli-tools/openclaw-settings/route.ts b/src/app/api/cli-tools/openclaw-settings/route.ts index 1b9004c22d..ca6d370e1c 100644 --- a/src/app/api/cli-tools/openclaw-settings/route.ts +++ b/src/app/api/cli-tools/openclaw-settings/route.ts @@ -9,6 +9,7 @@ import { getCliRuntimeStatus, } from "@/shared/services/cliRuntime"; import { createBackup } from "@/shared/services/backupService"; +import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState"; const getOpenClawSettingsPath = () => getCliPrimaryConfigPath("openclaw"); const getOpenClawDir = () => path.dirname(getOpenClawSettingsPath()); @@ -132,6 +133,13 @@ export async function POST(request: Request) { // Write settings await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); + // Persist last-configured timestamp + try { + saveCliToolLastConfigured("openclaw"); + } catch { + /* non-critical */ + } + return NextResponse.json({ success: true, message: "Open Claw settings applied successfully!", @@ -189,6 +197,13 @@ export async function DELETE() { // Write updated settings await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); + // Clear last-configured timestamp + try { + deleteCliToolLastConfigured("openclaw"); + } catch { + /* non-critical */ + } + return NextResponse.json({ success: true, message: "OmniRoute settings removed successfully", diff --git a/src/app/api/cli-tools/status/route.ts b/src/app/api/cli-tools/status/route.ts index 8b091d1136..1a535a8628 100644 --- a/src/app/api/cli-tools/status/route.ts +++ b/src/app/api/cli-tools/status/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getCliRuntimeStatus, CLI_TOOL_IDS } from "@/shared/services/cliRuntime"; +import { getAllCliToolLastConfigured } from "@/lib/db/cliToolState"; /** * GET /api/cli-tools/status @@ -58,6 +59,18 @@ export async function GET() { }) ); + // Merge last-configured timestamps from SQLite + try { + const lastConfigured = getAllCliToolLastConfigured(); + for (const [toolId, timestamp] of Object.entries(lastConfigured)) { + if (statuses[toolId]) { + statuses[toolId].lastConfiguredAt = timestamp; + } + } + } catch { + /* non-critical */ + } + return NextResponse.json(statuses); } catch (error) { console.log("Error fetching CLI tool statuses:", error); diff --git a/src/app/api/resilience/route.ts b/src/app/api/resilience/route.ts index d9fc16b1b0..95ec96f26a 100644 --- a/src/app/api/resilience/route.ts +++ b/src/app/api/resilience/route.ts @@ -9,8 +9,7 @@ export async function GET() { // Dynamic imports for open-sse modules const { getAllCircuitBreakerStatuses } = await import("@/../../src/shared/utils/circuitBreaker"); - const { getAllRateLimitStatus } = - await import("@omniroute/open-sse/services/rateLimitManager"); + const { getAllRateLimitStatus } = await import("@omniroute/open-sse/services/rateLimitManager"); const { PROVIDER_PROFILES, DEFAULT_API_LIMITS } = await import("@omniroute/open-sse/config/constants"); @@ -20,7 +19,7 @@ export async function GET() { return NextResponse.json({ profiles: settings.providerProfiles || PROVIDER_PROFILES, - defaults: DEFAULT_API_LIMITS, + defaults: { ...DEFAULT_API_LIMITS, ...(settings.rateLimitDefaults || {}) }, circuitBreakers, rateLimitStatus, }); @@ -34,46 +33,78 @@ export async function GET() { } /** - * PATCH /api/resilience β€” Update provider resilience profiles + * PATCH /api/resilience β€” Update provider resilience profiles and/or rate limit defaults */ export async function PATCH(request) { try { const body = await request.json(); - const { profiles } = body; + const { profiles, defaults } = body; - if (!profiles || typeof profiles !== "object") { - return NextResponse.json({ error: "Invalid profiles payload" }, { status: 400 }); + if (!profiles && !defaults) { + return NextResponse.json({ error: "Must provide profiles or defaults" }, { 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 }); + // Validate profiles if provided + if (profiles) { + if (typeof profiles !== "object") { + return NextResponse.json({ error: "Invalid profiles payload" }, { status: 400 }); } - const required = [ - "transientCooldown", - "rateLimitCooldown", - "maxBackoffLevel", - "circuitBreakerThreshold", - "circuitBreakerReset", - ]; - for (const field of required) { - if (typeof profile[field] !== "number" || profile[field] < 0) { + 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 } + ); + } + } + } + } + + // Validate defaults if provided + if (defaults) { + if (typeof defaults !== "object") { + return NextResponse.json({ error: "Invalid defaults payload" }, { status: 400 }); + } + const validKeys = ["requestsPerMinute", "minTimeBetweenRequests", "concurrentRequests"]; + for (const key of validKeys) { + if ( + defaults[key] !== undefined && + (typeof defaults[key] !== "number" || defaults[key] < 1) + ) { return NextResponse.json( - { error: `Invalid ${key}.${field}: must be a non-negative number` }, + { error: `Invalid defaults.${key}: must be a positive number` }, { status: 400 } ); } } } - await updateSettings({ providerProfiles: profiles }); + const updates: Record = {}; + if (profiles) updates.providerProfiles = profiles; + if (defaults) updates.rateLimitDefaults = defaults; - return NextResponse.json({ ok: true, profiles }); + await updateSettings(updates); + + return NextResponse.json({ + ok: true, + ...(profiles ? { profiles } : {}), + ...(defaults ? { defaults } : {}), + }); } catch (err) { console.error("[API] PATCH /api/resilience error:", err); return NextResponse.json( - { error: err.message || "Failed to save resilience profiles" }, + { error: err.message || "Failed to save resilience settings" }, { status: 500 } ); } diff --git a/src/lib/db/cliToolState.ts b/src/lib/db/cliToolState.ts new file mode 100644 index 0000000000..3bf28fd0b5 --- /dev/null +++ b/src/lib/db/cliToolState.ts @@ -0,0 +1,123 @@ +/** + * CLI Tool State Persistence + * + * Stores last-configured timestamps and initial config snapshots + * for CLI tools in the key_value table. + * + * Namespaces: + * - cliToolLastConfig: ISO timestamp of last configuration + * - cliToolInitialConfig: JSON snapshot of pre-OmniRoute configuration + * + * @module lib/db/cliToolState + */ + +import { getDbInstance, isBuildPhase, isCloud } from "./core"; + +// ──────────────── Last Configured Timestamp ──────────────── + +/** + * Save last-configured timestamp for a CLI tool. + */ +export function saveCliToolLastConfigured( + toolId: string, + timestamp: string = new Date().toISOString() +) { + if (isBuildPhase || isCloud) return; + const db = getDbInstance(); + db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( + "cliToolLastConfig", + toolId, + JSON.stringify(timestamp) + ); +} + +/** + * Get last-configured timestamp for a CLI tool. + * @returns ISO timestamp string or null if never configured. + */ +export function getCliToolLastConfigured(toolId: string): string | null { + if (isBuildPhase || isCloud) return null; + const db = getDbInstance(); + const row: any = db + .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") + .get("cliToolLastConfig", toolId); + return row ? JSON.parse(row.value) : null; +} + +/** + * Get all CLI tool last-configured timestamps. + * @returns Record + */ +export function getAllCliToolLastConfigured(): Record { + if (isBuildPhase || isCloud) return {}; + const db = getDbInstance(); + const rows: any[] = db + .prepare("SELECT key, value FROM key_value WHERE namespace = ?") + .all("cliToolLastConfig"); + const result: Record = {}; + for (const row of rows) { + result[row.key] = JSON.parse(row.value); + } + return result; +} + +/** + * Delete last-configured timestamp for a CLI tool. + */ +export function deleteCliToolLastConfigured(toolId: string) { + if (isBuildPhase || isCloud) return; + const db = getDbInstance(); + db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run( + "cliToolLastConfig", + toolId + ); +} + +// ──────────────── Initial Config Snapshot ──────────────── + +/** + * Save the initial (pre-OmniRoute) config snapshot for a CLI tool. + * Only saves if no snapshot exists yet (first-time only). + * @returns true if saved, false if snapshot already exists. + */ +export function saveCliToolInitialConfig(toolId: string, config: Record): boolean { + if (isBuildPhase || isCloud) return false; + const db = getDbInstance(); + // Only save if not already stored + const existing: any = db + .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") + .get("cliToolInitialConfig", toolId); + if (existing) return false; + + db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( + "cliToolInitialConfig", + toolId, + JSON.stringify(config) + ); + return true; +} + +/** + * Get the initial config snapshot for a CLI tool. + * @returns Config object or null if no snapshot exists. + */ +export function getCliToolInitialConfig(toolId: string): Record | null { + if (isBuildPhase || isCloud) return null; + const db = getDbInstance(); + const row: any = db + .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") + .get("cliToolInitialConfig", toolId); + return row ? JSON.parse(row.value) : null; +} + +/** + * Delete the initial config snapshot for a CLI tool. + */ +export function deleteCliToolInitialConfig(toolId: string) { + if (isBuildPhase || isCloud) return; + const db = getDbInstance(); + db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run( + "cliToolInitialConfig", + toolId + ); +} diff --git a/src/shared/components/UsageAnalytics.tsx b/src/shared/components/UsageAnalytics.tsx index 852743828c..e3345fdb3b 100644 --- a/src/shared/components/UsageAnalytics.tsx +++ b/src/shared/components/UsageAnalytics.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import Card from "./Card"; import { CardSkeleton } from "./Loading"; import { fmtCompact as fmt, fmtFull, fmtCost } from "@/shared/utils/formatting"; @@ -15,6 +15,8 @@ import { WeeklySquares7d, ModelTable, ProviderCostDonut, + ModelOverTimeChart, + ProviderTable, } from "./analytics"; // ============================================================================ @@ -55,11 +57,37 @@ export default function UsageAnalytics() { { value: "all", label: "All" }, ]; + const topModel = useMemo(() => { + const models = analytics?.byModel || []; + return models.length > 0 ? models[0].model : "β€”"; + }, [analytics]); + + const topProvider = useMemo(() => { + const providers = analytics?.byProvider || []; + return providers.length > 0 ? providers[0].provider : "β€”"; + }, [analytics]); + + const busiestDay = useMemo(() => { + const wp = analytics?.weeklyPattern || []; + if (!wp.length) return "β€”"; + const max = wp.reduce((a, b) => (a.avgTokens > b.avgTokens ? a : b), wp[0]); + return max.avgTokens > 0 ? max.day : "β€”"; + }, [analytics]); + + const providerCount = useMemo(() => { + return (analytics?.byProvider || []).length; + }, [analytics]); + if (loading && !analytics) return ; if (error) return Error: {error}; const s = analytics?.summary || {}; + // ── Derived insight values ── + const avgTokensPerReq = s.totalRequests > 0 ? Math.round(s.totalTokens / s.totalRequests) : 0; + const costPerReq = s.totalRequests > 0 ? s.totalCost / s.totalRequests : 0; + const ioRatio = s.completionTokens > 0 ? (s.promptTokens / s.completionTokens).toFixed(1) : "β€”"; + return (
{/* Header + Time Range */} @@ -85,7 +113,7 @@ export default function UsageAnalytics() {
- {/* Summary Cards β€” 7 columns: tokens, input, output, cost, accounts, keys, models */} + {/* Summary Cards β€” Row 1: Core metrics */}
+ {/* Summary Cards β€” Row 2: Derived insights */} +
+ + + + + + + +
+ {/* Activity Heatmap + Weekly Widgets */}
+ {/* Model Usage Over Time (stacked area) */} + + {/* Account Donut + API Key Donut */}
+ {/* Provider Breakdown Table */} + + {/* API Key Table */} {/* Model Breakdown Table */} - - {/* Bottom Stats */} -
- - Requests -
{fmtFull(s.totalRequests)}
-
- - Streak -
{s.streak || 0}d
-
- - Total Tokens -
{fmt(s.totalTokens)}
-
- - Usage Cost -
{fmtCost(s.totalCost)}
-
-
); } diff --git a/src/shared/components/analytics/charts.tsx b/src/shared/components/analytics/charts.tsx index 8c0f2930d5..e63cfb7f8d 100644 --- a/src/shared/components/analytics/charts.tsx +++ b/src/shared/components/analytics/charts.tsx @@ -21,11 +21,23 @@ import { Cell, PieChart, Pie, + AreaChart, + Area, } from "recharts"; // ── Custom Tooltip for dark theme ────────────────────────────────────────── -function DarkTooltip({ active, payload, label, formatter }: { active?: boolean; payload?: any[]; label?: any; formatter?: Function }) { +function DarkTooltip({ + active, + payload, + label, + formatter, +}: { + active?: boolean; + payload?: any[]; + label?: any; + formatter?: Function; +}) { if (!active || !payload?.length) return null; return (
@@ -65,7 +77,19 @@ export function SortIndicator({ active, sortOrder }: { active: boolean; sortOrde // ── StatCard ─────────────────────────────────────────────────────────────── -export function StatCard({ icon, label, value, subValue, color = "text-text-main" }: { icon: any; label: any; value: any; subValue?: any; color?: string }) { +export function StatCard({ + icon, + label, + value, + subValue, + color = "text-text-main", +}: { + icon: any; + label: any; + value: any; + subValue?: any; + color?: string; +}) { return (
@@ -161,7 +185,8 @@ export function ActivityHeatmap({ activityMap }) {

Activity

{Object.keys(activityMap || {}).length} active days Β·{" "} - {fmt(Object.values(activityMap || {}).reduce((a: number, b: number) => a + b, 0))} tokens Β· 365 days + {fmt(Object.values(activityMap || {}).reduce((a: number, b: number) => a + b, 0))} tokens + Β· 365 days
@@ -320,7 +345,15 @@ export function DailyTrendChart({ dailyTrend }) { // ── Cost-aware Tooltip ───────────────────────────────────────────────────── -function CostTooltip({ active, payload, label }: { active?: boolean; payload?: any[]; label?: any }) { +function CostTooltip({ + active, + payload, + label, +}: { + active?: boolean; + payload?: any[]; + label?: any; +}) { if (!active || !payload?.length) return null; return (
@@ -1074,3 +1107,238 @@ export function ProviderCostDonut({ byProvider }) { ); } + +// ── ModelOverTimeChart (Stacked Area) ────────────────────────────────────── + +export function ModelOverTimeChart({ dailyByModel, modelNames }) { + const data = useMemo(() => dailyByModel || [], [dailyByModel]); + const models = useMemo(() => modelNames || [], [modelNames]); + + // Prepare chart data β€” format dates (must be before early return for rules-of-hooks) + const chartData = useMemo(() => { + return data.map((d) => { + const row = { ...d }; + // Short date label + if (d.date) { + const parts = d.date.split("-"); + row.dateLabel = `${parts[1]}/${parts[2]}`; + } + return row; + }); + }, [data]); + + if (!data.length || !models.length) { + return ( + +

+ Model Usage Over Time +

+
No data
+
+ ); + } + + return ( + +

+ Model Usage Over Time +

+ + + + fmt(v)} + axisLine={false} + tickLine={false} + width={50} + /> + } /> + {models.map((m, i) => ( + + ))} + + +
+ {models.map((m, i) => ( + + + {m} + + ))} +
+
+ ); +} + +// ── ProviderTable ────────────────────────────────────────────────────────── + +export function ProviderTable({ byProvider }) { + const [sortBy, setSortBy] = useState("totalTokens"); + const [sortOrder, setSortOrder] = useState("desc"); + + const data = useMemo(() => byProvider || [], [byProvider]); + const totalTokens = useMemo(() => data.reduce((acc, p) => acc + p.totalTokens, 0), [data]); + + const toggleSort = useCallback( + (field) => { + if (sortBy === field) { + setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); + } else { + setSortBy(field); + setSortOrder("desc"); + } + }, + [sortBy] + ); + + const sorted = useMemo(() => { + const arr = [...data]; + arr.sort((a, b) => { + const va = a[sortBy] ?? 0; + const vb = b[sortBy] ?? 0; + if (typeof va === "string") + return sortOrder === "asc" ? va.localeCompare(vb) : vb.localeCompare(va); + return sortOrder === "asc" ? va - vb : vb - va; + }); + return arr; + }, [data, sortBy, sortOrder]); + + if (!data.length) { + return ( + +

+ Provider Breakdown +

+
No data
+
+ ); + } + + return ( + +
+

+ Provider Breakdown +

+
+
+ + + + + + + + + + + + + + {sorted.map((p, i) => { + const pct = totalTokens > 0 ? ((p.totalTokens / totalTokens) * 100).toFixed(1) : "0"; + return ( + + + + + + + + + + ); + })} + +
toggleSort("provider")} + > + Provider + toggleSort("requests")} + > + Requests + toggleSort("promptTokens")} + > + Input + toggleSort("completionTokens")} + > + Output{" "} + + toggleSort("totalTokens")} + > + Total + toggleSort("cost")} + > + Cost + Share
+
+ + {p.provider} +
+
+ {fmtFull(p.requests)} + + {fmt(p.promptTokens)} + + {fmt(p.completionTokens)} + + {fmt(p.totalTokens)} + + {fmtCost(p.cost)} + +
+
+
+
+ + {pct}% + +
+
+
+
+ ); +} diff --git a/src/shared/components/analytics/index.tsx b/src/shared/components/analytics/index.tsx index 6414f683eb..d08a5d778b 100644 --- a/src/shared/components/analytics/index.tsx +++ b/src/shared/components/analytics/index.tsx @@ -22,4 +22,6 @@ export { ModelTable, UsageDetail, ProviderCostDonut, + ModelOverTimeChart, + ProviderTable, } from "./charts"; diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 8b3ec14db3..39a68983d6 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -29,7 +29,10 @@ const markMutexes = new Map>(); * @param {string} provider - Provider name * @param {string|null} excludeConnectionId - Connection ID to exclude (for retry with next account) */ -export async function getProviderCredentials(provider: string, excludeConnectionId: string | null = null) { +export async function getProviderCredentials( + provider: string, + excludeConnectionId: string | null = null +) { // Acquire mutex to prevent race conditions const currentMutex = selectionMutex; let resolveMutex: (() => void) | undefined; @@ -105,7 +108,8 @@ export async function getProviderCredentials(provider: string, excludeConnection (c) => c.rateLimitedUntil && new Date(c.rateLimitedUntil).getTime() > Date.now() ); const earliestConn = rateLimitedConns.sort( - (a: any, b: any) => new Date(a.rateLimitedUntil).getTime() - new Date(b.rateLimitedUntil).getTime() + (a: any, b: any) => + new Date(a.rateLimitedUntil).getTime() - new Date(b.rateLimitedUntil).getTime() )[0]; log.warn( "AUTH", @@ -166,6 +170,41 @@ export async function getProviderCredentials(provider: string, excludeConnection consecutiveUseCount: 1, }); } + } else if (strategy === "p2c") { + // Power of Two Choices: pick 2 random, choose the one with fewer failures + if (availableConnections.length <= 2) { + connection = availableConnections[0]; + } else { + const i = Math.floor(Math.random() * availableConnections.length); + let j = Math.floor(Math.random() * (availableConnections.length - 1)); + if (j >= i) j++; + const a = availableConnections[i]; + const b = availableConnections[j]; + // Prefer the one with fewer consecutive uses / better health + const scoreA = (a.consecutiveUseCount || 0) + (a.lastError ? 10 : 0); + const scoreB = (b.consecutiveUseCount || 0) + (b.lastError ? 10 : 0); + connection = scoreA <= scoreB ? a : b; + } + } else if (strategy === "random") { + // Random: Fisher-Yates-inspired random pick + const idx = Math.floor(Math.random() * availableConnections.length); + connection = availableConnections[idx]; + } else if (strategy === "least-used") { + // Least Used: pick the one with oldest lastUsedAt + const sorted = [...availableConnections].sort((a: any, b: any) => { + if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999); + if (!a.lastUsedAt) return -1; + if (!b.lastUsedAt) return 1; + return new Date(a.lastUsedAt).getTime() - new Date(b.lastUsedAt).getTime(); + }); + connection = sorted[0]; + } else if (strategy === "cost-optimized") { + // Cost Optimized: sort by priority ascending (lower = cheaper/preferred) + // Future: can be enhanced with actual cost data per provider + const sorted = [...availableConnections].sort( + (a: any, b: any) => (a.priority || 999) - (b.priority || 999) + ); + connection = sorted[0]; } else { // Default: fill-first (already sorted by priority in getProviderConnections) connection = availableConnections[0]; diff --git a/src/types/settings.ts b/src/types/settings.ts index 478d202dfa..7c518b41f2 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -4,7 +4,13 @@ export interface Settings { requireLogin: boolean; hasPassword: boolean; - fallbackStrategy: "fill-first" | "round-robin"; + fallbackStrategy: + | "fill-first" + | "round-robin" + | "p2c" + | "random" + | "least-used" + | "cost-optimized"; stickyRoundRobinLimit: number; jwtSecret?: string; }