mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
Merge pull request #67 from diegosouzapw/release/v0.8.8
release(v0.8.8): analytics redesign, settings restructure, routing expansion, editable rate limits
This commit is contained in:
29
CHANGELOG.md
29
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
|
||||
|
||||
68
README.md
68
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -167,6 +170,7 @@ export default function CLIToolsPageClient({ machineId }) {
|
||||
baseUrl: getBaseUrl(),
|
||||
apiKeys,
|
||||
batchStatus: toolStatuses[toolId] || null,
|
||||
lastConfiguredAt: toolStatuses[toolId]?.lastConfiguredAt || null,
|
||||
};
|
||||
|
||||
switch (toolId) {
|
||||
|
||||
@@ -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({
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={batchStatus}
|
||||
lastConfiguredAt={lastConfiguredAt}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
<>
|
||||
{badge && (
|
||||
<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>
|
||||
)}
|
||||
{lastConfiguredAt ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] text-text-muted"
|
||||
title={`Last saved: ${new Date(lastConfiguredAt).toLocaleString()}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">schedule</span>
|
||||
{formatRelativeTime(lastConfiguredAt)}
|
||||
</span>
|
||||
) : status && status !== "not_installed" ? (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] text-text-muted">
|
||||
<span className="material-symbols-outlined text-[12px]">schedule</span>
|
||||
Never
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={batchStatus}
|
||||
lastConfiguredAt={lastConfiguredAt}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -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"
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={batchStatus}
|
||||
lastConfiguredAt={lastConfiguredAt}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
|
||||
@@ -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({
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={batchStatus}
|
||||
lastConfiguredAt={lastConfiguredAt}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
|
||||
@@ -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({
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={batchStatus}
|
||||
lastConfiguredAt={lastConfiguredAt}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -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({
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={batchStatus}
|
||||
lastConfiguredAt={lastConfiguredAt}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
|
||||
@@ -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 (
|
||||
<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);
|
||||
@@ -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 (
|
||||
<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 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">
|
||||
speed
|
||||
</span>
|
||||
<h2 className="text-lg font-bold">Rate Limiting</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">
|
||||
@@ -235,22 +199,34 @@ function RateLimitOverviewCard({ rateLimitStatus, defaults }) {
|
||||
</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">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider mb-3 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>
|
||||
{[
|
||||
{ key: "requestsPerMinute", label: "RPM", suffix: "" },
|
||||
{ key: "minTimeBetweenRequests", label: "Min Gap", suffix: "ms", format: formatMs },
|
||||
{ key: "concurrentRequests", label: "Max Concurrent", suffix: "" },
|
||||
].map(({ key, label, format }) => (
|
||||
<div key={key}>
|
||||
{editMode ? (
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={draft[key] ?? 0}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-lg font-bold">
|
||||
{format ? format(defaults?.[key]) : (defaults?.[key] ?? "—")}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-text-muted">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-2 text-text-muted animate-pulse">
|
||||
<span className="material-symbols-outlined text-[20px]">policy</span>
|
||||
Loading policies...
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
policy
|
||||
</span>
|
||||
<h2 className="text-lg font-bold">Policies & Locked Identifiers</h2>
|
||||
</div>
|
||||
{hasIssues && (
|
||||
<Button size="sm" variant="ghost" onClick={fetchPolicies}>
|
||||
<span className="material-symbols-outlined text-[16px]">refresh</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasIssues ? (
|
||||
<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>
|
||||
<p className="text-sm text-text-muted">
|
||||
All systems operational — no lockouts or tripped breakers
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="flex items-center justify-center py-12 text-text-muted">
|
||||
@@ -365,20 +622,27 @@ export default function ResilienceTab() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<CircuitBreakerCard
|
||||
breakers={data?.circuitBreakers || []}
|
||||
onReset={handleResetBreakers}
|
||||
loading={loading}
|
||||
/>
|
||||
{/* 1. Provider Profiles (resilience settings by auth type) */}
|
||||
<ProviderProfilesCard
|
||||
profiles={data?.profiles || {}}
|
||||
onSave={handleSaveProfiles}
|
||||
saving={saving}
|
||||
/>
|
||||
<RateLimitOverviewCard
|
||||
{/* 2. Rate Limiting (editable defaults + active limiters) */}
|
||||
<RateLimitCard
|
||||
rateLimitStatus={data?.rateLimitStatus || []}
|
||||
defaults={data?.defaults || {}}
|
||||
onSaveDefaults={handleSaveDefaults}
|
||||
saving={saving}
|
||||
/>
|
||||
{/* 3. Circuit Breakers (combo pipeline) */}
|
||||
<CircuitBreakerCard
|
||||
breakers={data?.circuitBreakers || []}
|
||||
onReset={handleResetBreakers}
|
||||
loading={loading}
|
||||
/>
|
||||
{/* 4. Policies & Locked Identifiers (from previous Security tab) */}
|
||||
<PoliciesCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<h3 className="text-lg font-semibold">Routing Strategy</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
<div className="grid grid-cols-3 gap-2 mb-4" style={{ gridAutoRows: "1fr" }}>
|
||||
{STRATEGIES.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
@@ -132,6 +145,12 @@ export default function RoutingTab() {
|
||||
"Using accounts in priority order (Fill First)."}
|
||||
{settings.fallbackStrategy === "p2c" &&
|
||||
"Power of Two Choices: picks 2 random accounts and routes to the healthier one."}
|
||||
{settings.fallbackStrategy === "random" &&
|
||||
"Randomly selects an available account for each request."}
|
||||
{settings.fallbackStrategy === "least-used" &&
|
||||
"Picks the account that was used least recently."}
|
||||
{settings.fallbackStrategy === "cost-optimized" &&
|
||||
"Prefers accounts with the lowest cost (priority-based, extensible with actual cost data)."}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
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 });
|
||||
@@ -147,7 +146,6 @@ export default function SecurityTab() {
|
||||
</div>
|
||||
</Card>
|
||||
<IPFilterSection />
|
||||
<PoliciesPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ export default function SettingsPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<ThinkingBudgetTab />
|
||||
<SystemPromptTab />
|
||||
<CacheStatsCard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -88,12 +89,7 @@ export default function SettingsPage() {
|
||||
|
||||
{activeTab === "resilience" && <ResilienceTab />}
|
||||
|
||||
{activeTab === "advanced" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ProxyTab />
|
||||
<CacheStatsCard />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "advanced" && <ProxyTab />}
|
||||
|
||||
{activeTab === "compliance" && <ComplianceTab />}
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, any> = {};
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
123
src/lib/db/cliToolState.ts
Normal file
123
src/lib/db/cliToolState.ts
Normal file
@@ -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<toolId, ISO timestamp>
|
||||
*/
|
||||
export function getAllCliToolLastConfigured(): Record<string, string> {
|
||||
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<string, string> = {};
|
||||
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<string, any>): 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<string, any> | 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
|
||||
);
|
||||
}
|
||||
@@ -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 <CardSkeleton />;
|
||||
if (error) return <Card className="p-6 text-center text-red-500">Error: {error}</Card>;
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Header + Time Range */}
|
||||
@@ -85,7 +113,7 @@ export default function UsageAnalytics() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards — 7 columns: tokens, input, output, cost, accounts, keys, models */}
|
||||
{/* Summary Cards — Row 1: Core metrics */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-7 gap-3">
|
||||
<StatCard
|
||||
icon="generating_tokens"
|
||||
@@ -116,6 +144,32 @@ export default function UsageAnalytics() {
|
||||
<StatCard icon="model_training" label="Models" value={s.uniqueModels || 0} />
|
||||
</div>
|
||||
|
||||
{/* Summary Cards — Row 2: Derived insights */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-7 gap-3">
|
||||
<StatCard
|
||||
icon="speed"
|
||||
label="Avg Tokens/Req"
|
||||
value={fmt(avgTokensPerReq)}
|
||||
color="text-cyan-500"
|
||||
/>
|
||||
<StatCard
|
||||
icon="request_quote"
|
||||
label="Cost/Request"
|
||||
value={fmtCost(costPerReq)}
|
||||
color="text-orange-500"
|
||||
/>
|
||||
<StatCard
|
||||
icon="compare_arrows"
|
||||
label="I/O Ratio"
|
||||
value={`${ioRatio}x`}
|
||||
color="text-violet-500"
|
||||
/>
|
||||
<StatCard icon="star" label="Top Model" value={topModel} color="text-pink-500" />
|
||||
<StatCard icon="cloud" label="Top Provider" value={topProvider} color="text-teal-500" />
|
||||
<StatCard icon="today" label="Busiest Day" value={busiestDay} color="text-rose-500" />
|
||||
<StatCard icon="dns" label="Providers" value={providerCount} color="text-indigo-500" />
|
||||
</div>
|
||||
|
||||
{/* Activity Heatmap + Weekly Widgets */}
|
||||
<div
|
||||
style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16, alignItems: "stretch" }}
|
||||
@@ -133,37 +187,26 @@ export default function UsageAnalytics() {
|
||||
<ProviderCostDonut byProvider={analytics?.byProvider} />
|
||||
</div>
|
||||
|
||||
{/* Model Usage Over Time (stacked area) */}
|
||||
<ModelOverTimeChart
|
||||
dailyByModel={analytics?.dailyByModel}
|
||||
modelNames={analytics?.modelNames}
|
||||
/>
|
||||
|
||||
{/* Account Donut + API Key Donut */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<AccountDonut byAccount={analytics?.byAccount} />
|
||||
<ApiKeyDonut byApiKey={analytics?.byApiKey} />
|
||||
</div>
|
||||
|
||||
{/* Provider Breakdown Table */}
|
||||
<ProviderTable byProvider={analytics?.byProvider} />
|
||||
|
||||
{/* API Key Table */}
|
||||
<ApiKeyTable byApiKey={analytics?.byApiKey} />
|
||||
|
||||
{/* Model Breakdown Table */}
|
||||
<ModelTable byModel={analytics?.byModel} summary={s} />
|
||||
|
||||
{/* Bottom Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card className="px-4 py-3 text-center">
|
||||
<span className="text-xs text-text-muted uppercase font-semibold">Requests</span>
|
||||
<div className="text-lg font-bold mt-1">{fmtFull(s.totalRequests)}</div>
|
||||
</Card>
|
||||
<Card className="px-4 py-3 text-center">
|
||||
<span className="text-xs text-text-muted uppercase font-semibold">Streak</span>
|
||||
<div className="text-lg font-bold mt-1">{s.streak || 0}d</div>
|
||||
</Card>
|
||||
<Card className="px-4 py-3 text-center">
|
||||
<span className="text-xs text-text-muted uppercase font-semibold">Total Tokens</span>
|
||||
<div className="text-lg font-bold mt-1">{fmt(s.totalTokens)}</div>
|
||||
</Card>
|
||||
<Card className="px-4 py-3 text-center">
|
||||
<span className="text-xs text-text-muted uppercase font-semibold">Usage Cost</span>
|
||||
<div className="text-lg font-bold text-amber-500 mt-1">{fmtCost(s.totalCost)}</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-lg border border-white/10 bg-surface px-3 py-2 text-xs shadow-lg">
|
||||
@@ -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 (
|
||||
<Card className="px-4 py-3 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 text-text-muted text-xs uppercase font-semibold tracking-wider">
|
||||
@@ -161,7 +185,8 @@ export function ActivityHeatmap({ activityMap }) {
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">Activity</h3>
|
||||
<span className="text-xs text-text-muted">
|
||||
{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
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-lg border border-white/10 bg-surface px-3 py-2 text-xs shadow-lg">
|
||||
@@ -1074,3 +1107,238 @@ export function ProviderCostDonut({ byProvider }) {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
Model Usage Over Time
|
||||
</h3>
|
||||
<div className="text-center text-text-muted text-sm py-8">No data</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
Model Usage Over Time
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
|
||||
<XAxis
|
||||
dataKey="dateLabel"
|
||||
tick={{ fontSize: 10, fill: "var(--text-muted)" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: "var(--text-muted)" }}
|
||||
tickFormatter={(v) => fmt(v)}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={50}
|
||||
/>
|
||||
<Tooltip content={<DarkTooltip formatter={fmt} />} />
|
||||
{models.map((m, i) => (
|
||||
<Area
|
||||
key={m}
|
||||
type="monotone"
|
||||
dataKey={m}
|
||||
stackId="1"
|
||||
stroke={getModelColor(i)}
|
||||
fill={getModelColor(i)}
|
||||
fillOpacity={0.4}
|
||||
strokeWidth={1.5}
|
||||
animationDuration={600}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-2 text-[10px] text-text-muted">
|
||||
{models.map((m, i) => (
|
||||
<span key={m} className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: getModelColor(i) }}
|
||||
/>
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
Provider Breakdown
|
||||
</h3>
|
||||
<div className="text-center text-text-muted text-sm py-8">No data</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="p-4 border-b border-border">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">
|
||||
Provider Breakdown
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted uppercase bg-black/[0.02] dark:bg-white/[0.02]">
|
||||
<tr>
|
||||
<th
|
||||
className="px-4 py-2.5 text-left cursor-pointer group"
|
||||
onClick={() => toggleSort("provider")}
|
||||
>
|
||||
Provider <SortIndicator active={sortBy === "provider"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("requests")}
|
||||
>
|
||||
Requests <SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("promptTokens")}
|
||||
>
|
||||
Input <SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("completionTokens")}
|
||||
>
|
||||
Output{" "}
|
||||
<SortIndicator active={sortBy === "completionTokens"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("totalTokens")}
|
||||
>
|
||||
Total <SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("cost")}
|
||||
>
|
||||
Cost <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-right w-36">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{sorted.map((p, i) => {
|
||||
const pct = totalTokens > 0 ? ((p.totalTokens / totalTokens) * 100).toFixed(1) : "0";
|
||||
return (
|
||||
<tr
|
||||
key={p.provider}
|
||||
className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: PROVIDER_COLORS[i % PROVIDER_COLORS.length] }}
|
||||
/>
|
||||
<span className="font-medium capitalize">{p.provider}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-text-muted">
|
||||
{fmtFull(p.requests)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-primary">
|
||||
{fmt(p.promptTokens)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-emerald-500">
|
||||
{fmt(p.completionTokens)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono font-semibold">
|
||||
{fmt(p.totalTokens)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-amber-500">
|
||||
{fmtCost(p.cost)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<div className="w-16 h-1.5 rounded-full bg-white/[0.06] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: PROVIDER_COLORS[i % PROVIDER_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-text-muted w-10 text-right">
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,4 +22,6 @@ export {
|
||||
ModelTable,
|
||||
UsageDetail,
|
||||
ProviderCostDonut,
|
||||
ModelOverTimeChart,
|
||||
ProviderTable,
|
||||
} from "./charts";
|
||||
|
||||
@@ -29,7 +29,10 @@ const markMutexes = new Map<string, Promise<void>>();
|
||||
* @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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user