diff --git a/README.md b/README.md
index ef77b9f4d7..351e7e4785 100644
--- a/README.md
+++ b/README.md
@@ -324,12 +324,13 @@ AI providers can become unstable, return 5xx errors, or hit temporary rate limit
**How OmniRoute solves it:**
-- **Settings-Driven Lock Hierarchy** — Provider profiles control default account/model lockouts, global model quarantine, and provider circuit breakers from one control surface, while explicit upstream `Retry-After` windows still take priority
-- **Exponential Backoff** — Progressive retry delays for both account/model lockouts and higher-level quarantine
+- **Request Queue & Pacing** — Per-connection request buckets smooth bursts before they hit upstream rate caps
+- **Connection Cooldown** — A single connection cools down after retryable failures with optional upstream `Retry-After` hints and exponential backoff
+- **Provider Circuit Breaker** — The provider only trips after fallback is exhausted and the provider request still fails with provider-wide transient errors; connection-scoped `429` rate limits stay in Connection Cooldown
+- **Wait For Cooldown** — The server can wait for the earliest connection cooldown to expire and retry the same client request automatically
- **Anti-Thundering Herd** — Mutex + semaphore protection against concurrent retry storms
- **Combo Fallback Chains** — If the primary provider fails, automatically falls through the chain with no intervention
-- **Combo Circuit Breaker** — Auto-disables failing providers within a combo chain
-- **Health Dashboard** — Uptime monitoring, circuit breaker states, lockouts, cache stats, p50/p95/p99 latency
+- **Health Dashboard** — Uptime monitoring, provider circuit breaker states, cooldowns, cache stats, p50/p95/p99 latency
@@ -470,7 +471,7 @@ As request volume grows, without caching the same questions generate duplicate c
- **Semantic Cache** — Two-tier cache (signature + semantic) reduces cost and latency
- **Request Idempotency** — 5s deduplication window for identical requests
- **Rate Limit Detection** — Per-provider RPM, min gap, and max concurrent tracking
-- **Editable Rate Limits** — Configurable defaults in Settings → Resilience with persistence
+- **Request Queue & Pacing** — Configurable queue, pacing, and concurrency defaults in Settings → Resilience
- **API Key Validation Cache** — 3-tier cache for production performance
- **Health Dashboard with Telemetry** — p50/p95/p99 latency, cache stats, uptime
@@ -567,8 +568,8 @@ Teams need quick runtime changes during incidents or cost events.
**How OmniRoute solves it:**
- Switch combo activation directly from MCP dashboard
-- Apply resilience profiles from pre-defined policy packs
-- Reset circuit breaker state from the same operations panel
+- Tune queue, cooldown, breaker, and wait settings from the dedicated Resilience page
+- Review live provider breaker state from the Health dashboard
@@ -1352,7 +1353,7 @@ OmniRoute v3.6 is built as an operational platform, not just a relay proxy.
| 🔢 **Hybrid Token Counting** | Uses provider-side `/messages/count_tokens` when available; falls back to estimation — accurate usage tracking without guessing |
| 🌱 **Model Alias Auto-Seed** | 30+ cross-proxy dialect aliases normalised at startup — no more routing mismatches |
| 🛡️ **Safe Outbound Fetch** | All provider validation and model discovery go through a guarded fetch layer blocking private/local URLs with retry, timeout, and SSRF protection |
-| 🔄 **Cooldown-Aware Retries** | Chat requests auto-retry on model-scoped cooldowns with configurable `requestRetry` and `maxRetryIntervalSec` |
+| ⏳ **Wait For Cooldown** | Server-side chat retries when every candidate connection is cooling down; configurable `enabled`, `maxRetries`, and `maxRetryWaitSec` |
| 🔍 **Runtime Env Validation** | Startup validates all env vars with Zod schemas — clear errors for missing secrets, invalid URLs, or wrong types |
| 📋 **Compliance Audit Expansion** | Structured audit logs with pagination, request context, auth events, provider CRUD events, and SSRF-blocked validation logging |
| 🔐 **TPS Log Metric** | Log details modal shows Tokens Per Second (TPS) — quick performance at-a-glance for every request |
@@ -1406,7 +1407,7 @@ OmniRoute v3.6 is built as an operational platform, not just a relay proxy.
| 📡 **A2A Task Lifecycle Management** | List/filter tasks, inspect events/artifacts, cancel running tasks |
| 📋 **Agent Card Discovery** | `/.well-known/agent.json` for client auto-discovery |
| 🧪 **Protocol E2E Test Harness** | Real MCP SDK + A2A client flows in `test:protocols:e2e` |
-| ⚙️ **Operational Controls** | Switch combo, apply resilience profiles, reset breakers from one control surface |
+| ⚙️ **Operational Controls** | Switch combos, tune resilience settings, and review breaker state from dedicated Health and Settings surfaces |
### 🧠 Routing & Intelligence
@@ -1446,29 +1447,30 @@ OmniRoute v3.6 is built as an operational platform, not just a relay proxy.
### 🛡️ Resilience, Security & Governance
-| Feature | What It Does |
-| ----------------------------------- | --------------------------------------------------------------------------------------- |
-| 🔌 **Circuit Breakers** | Per-provider and per-model trip/recover with 10-minute cooldowns |
-| 🔒 **Daily Quota Lock** 🆕 | Detects exhaustion signals and locks routing for the specific model until midnight |
-| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
-| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events |
-| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers |
-| ⚡ **Request Idempotency** | Duplicate protection window |
-| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** |
-| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** |
-| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments |
-| 📊 **Editable Rate Limits** | Configurable global/provider-level limits with persistence |
-| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations |
-| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks |
-| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures |
-| 🚪 **Auto-Disable Banned Accounts** | Operational circuit breaker sealing permanently blocked token accounts automatically |
-| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls |
-| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` |
-| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog |
-| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection |
-| 🔄 **Cooldown-Aware Retries** 🆕 | Auto-retry chat on model cooldowns; configurable `requestRetry` / `maxRetryIntervalSec` |
-| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages |
-| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging |
+| Feature | What It Does |
+| ----------------------------------- | ------------------------------------------------------------------------------------------------------- |
+| 🔌 **Provider Circuit Breakers** | Provider-wide trip/recover after fallback exhaustion with configurable thresholds |
+| 🔒 **Daily Quota Lock** 🆕 | Detects exhaustion signals and locks routing for the specific model until midnight |
+| 🎯 **Endpoint-Aware Models** | Custom models declare supported endpoints + API format |
+| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore protections on retry/rate events |
+| 🧠 **Semantic + Signature Cache** | Cost/latency reduction with two cache layers |
+| ⚡ **Request Idempotency** | Duplicate protection window |
+| 🔒 **TLS Fingerprint Spoofing** | Browser-like TLS fingerprint — **reduces bot detection and account flagging** |
+| 🔏 **CLI Fingerprint Matching** | Matches native CLI request signatures — **reduces ban risk while preserving proxy IP** |
+| 🌐 **IP Filtering** | Allowlist/blocklist control for exposed deployments |
+| 🚦 **Request Queue & Pacing** | Configurable per-connection request buckets for RPM, spacing, concurrency, and max wait |
+| 📉 **Graceful Degradation** | Multi-layer capability fallbacks protecting core gateway operations |
+| 📜 **Config Audit Trail** | Diff-based change tracking preventing operational drift with simple rollbacks |
+| ⏳ **Provider Health Sync** | Proactive token expiration monitoring triggering alerts before authorization failures |
+| ❄️ **Connection Cooldown** | Retryable 408/429/5xx failures cool down a single connection with optional upstream hints |
+| 🚪 **Auto-Disable Banned Accounts** | Permanently blocked token accounts can be disabled automatically |
+| 🔑 **API Key Management + Scoping** | Secure key issuance/rotation and model/provider controls |
+| 👁️ **Scoped API Key Reveal** 🆕 | Opt-in recovery of API keys via `ALLOW_API_KEY_REVEAL` |
+| 🛡️ **Protected `/models`** | Optional auth gating and provider hiding for model catalog |
+| 🛡️ **Safe Outbound Fetch** 🆕 | Guarded fetch for provider calls — blocks private/local URLs, retries, SSRF protection |
+| ⏳ **Wait For Cooldown** 🆕 | Auto-retry chat after connection cooldowns; configurable `enabled`, `maxRetries`, and `maxRetryWaitSec` |
+| 🔍 **Runtime Env Validation** 🆕 | Zod-based env schema validation at startup with actionable error messages |
+| 📋 **Compliance Audit v2** 🆕 | Pagination, request context, auth events, provider CRUD, and SSRF-blocked logging |
### 📊 Observability & Analytics
@@ -2283,7 +2285,7 @@ OmniRoute has **218+ features planned** across multiple development phases. Here
| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing |
| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping |
| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model |
-| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing |
+| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, connection cooldowns, multi-account Codex, Copilot quota parsing |
| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API |
| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode |
diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md
index 376ceecacf..087d5619f3 100644
--- a/docs/API_REFERENCE.md
+++ b/docs/API_REFERENCE.md
@@ -284,12 +284,12 @@ GET response includes `agents[]` (id, name, binary, version, installed, protocol
### Resilience & Rate Limits
-| Endpoint | Method | Description |
-| ----------------------- | --------- | ------------------------------- |
-| `/api/resilience` | GET/PATCH | Get/update resilience profiles |
-| `/api/resilience/reset` | POST | Reset circuit breakers |
-| `/api/rate-limits` | GET | Per-account rate limit status |
-| `/api/rate-limit` | GET | Global rate limit configuration |
+| Endpoint | Method | Description |
+| ----------------------- | --------- | ---------------------------------------------------------------------------------- |
+| `/api/resilience` | GET/PATCH | Get/update request queue, connection cooldown, provider breaker, and wait settings |
+| `/api/resilience/reset` | POST | Reset provider circuit breakers |
+| `/api/rate-limits` | GET | Per-account rate limit status |
+| `/api/rate-limit` | GET | Global rate limit configuration |
### Evals
@@ -443,25 +443,6 @@ Content-Type: application/json
}
```
----
-
-## Model Availability
-
-```bash
-# Get real-time model availability across all providers
-GET /api/models/availability
-
-# Check availability for a specific model
-POST /api/models/availability
-Content-Type: application/json
-
-{
- "model": "claude-sonnet-4-5-20250929"
-}
-```
-
----
-
## Request Processing
1. Client sends request to `/v1/*`
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 7900209f28..37c8b1baf0 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -42,7 +42,7 @@ Core capabilities:
- Circuit breaker pattern for provider resilience
- Anti-thundering herd protection with mutex locking
- Signature-based request deduplication cache
-- Domain layer: model availability, cost rules, fallback policy, lockout policy
+- Domain layer: cost rules, fallback policy, lockout policy
- Context Relay: session handoff summaries for account rotation continuity
- Domain state persistence (SQLite write-through cache for fallbacks, budgets, lockouts, circuit breakers)
- Policy engine for centralized request evaluation (lockout → budget → fallback)
@@ -51,7 +51,7 @@ Core capabilities:
- Correlation ID (X-Request-Id) for end-to-end tracing
- Compliance audit logging with opt-out per API key
- Eval framework for LLM quality assurance
-- Resilience UI dashboard with real-time circuit breaker status
+- Health dashboard with real-time provider circuit breaker status
- MCP Server (25 tools) with 3 transports (stdio/SSE/Streamable HTTP)
- A2A Server (JSON-RPC 2.0 + SSE) with skills and task lifecycle
- Memory system (extraction, injection, retrieval, summarization)
@@ -205,10 +205,9 @@ Management domains:
- System prompt: `src/app/api/settings/system-prompt` (GET/PUT)
- Sessions: `src/app/api/sessions` (GET)
- Rate limits: `src/app/api/rate-limits` (GET)
-- Resilience: `src/app/api/resilience` (GET/PATCH) — provider profiles, circuit breaker, rate limit state
-- Resilience reset: `src/app/api/resilience/reset` (POST) — reset breakers + cooldowns
+- Resilience: `src/app/api/resilience` (GET/PATCH) — request queue, connection cooldown, provider breaker, wait-for-cooldown config
+- Resilience reset: `src/app/api/resilience/reset` (POST) — reset provider breakers
- Cache stats: `src/app/api/cache/stats` (GET/DELETE)
-- Model availability: `src/app/api/models/availability` (GET/POST)
- Telemetry: `src/app/api/telemetry/summary` (GET)
- Budget: `src/app/api/usage/budget` (GET/POST)
- Fallback chains: `src/app/api/fallback/chains` (GET/POST/DELETE)
@@ -265,7 +264,6 @@ Services (business logic):
Domain layer modules:
-- Model availability: `src/lib/domain/modelAvailability.ts`
- Cost rules/budgets: `src/lib/domain/costRules.ts`
- Fallback policy: `src/lib/domain/fallbackPolicy.ts`
- Combo resolver: `src/lib/domain/comboResolver.ts`
@@ -794,7 +792,7 @@ legacy compatibility. The current runtime contract uses:
## 1) Account/Provider Availability
-- provider account cooldown on transient/rate/auth errors
+- connection cooldown on retryable upstream failures
- account fallback before failing request
- combo model fallback when current model/provider path is exhausted
@@ -876,7 +874,7 @@ Environment variables actively used by code:
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 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:unit`. 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, **Context Relay** handoff config), AI (thinking budget, system prompt, prompt cache), Advanced (proxy).
+8. Settings page is organized into 7 tabs: General, Appearance, AI, Security, Routing, Resilience, Advanced. The Resilience page only configures request queue, connection cooldown, provider breaker, and wait-for-cooldown behavior; live breaker runtime state is shown on the Health page.
9. **Context Relay** strategy (`context-relay`) is split across two layers: `combo.ts` decides if a handoff should be generated, `chat.ts` injects the handoff after account resolution. Handoff data lives in `context_handoffs` SQLite table. This split is intentional because only `chat.ts` knows whether the actual account changed.
10. **Proxy enforcement** is now comprehensive: `tokenHealthCheck.ts` resolves proxy per connection, `/api/providers/validate` uses `runWithProxyContext`, and `proxyFetch.ts` uses `undici.fetch()` to maintain dispatcher compatibility on Node 22.
11. **Node.js runtime policy detection**: `/api/settings/require-login` returns `nodeVersion` and `nodeCompatible` fields. The login page renders a warning banner when the runtime falls outside the supported secure Node.js lines.
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index a639755c1d..b1f180b531 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -756,35 +756,34 @@ Chain: production-fallback
Configure via **Dashboard → Settings → Resilience**.
-OmniRoute implements provider-level resilience with four components:
+OmniRoute implements provider-level resilience with five components:
-1. **Provider Profiles** — Per-provider configuration for:
- - **Transient Cooldown** — Base cooldown for transient upstream failures
- - **Rate Limit Cooldown** — Base cooldown for `429`-driven lockouts
- - **Max Backoff Level** — Maximum exponential backoff level for repeated failures
- - **CB Threshold** — Failure count before model quarantine / provider circuit breaker escalates
- - **CB Reset Time** — Failure counting window and breaker reset timer
-
-2. **Editable Rate Limits** — System-level defaults configurable in the dashboard:
+1. **Request Queue & Pacing** — System-level request shaping:
- **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 the configured threshold is reached:
+2. **Connection Cooldown** — Per-auth-type configuration for a single connection after retryable failures:
+ - **Base Cooldown** — Default cooldown window for retryable upstream failures
+ - **Use Upstream Retry Hints** — Honors authoritative `Retry-After` or reset hints when provided
+ - **Max Backoff Steps** — Maximum exponential backoff level for repeated failures
+
+3. **Provider Circuit Breaker** — Tracks end-to-end provider failures and automatically opens the breaker when the configured threshold is reached:
+ - **Failure Threshold** — Consecutive provider failures before opening the breaker
+ - **Reset Timeout** — Time window before the provider is tested again
- **CLOSED** (Healthy) — Requests flow normally
- **OPEN** — Provider is temporarily blocked after repeated failures
- **HALF_OPEN** — Testing if provider has recovered
- The same provider profile also drives model-scoped lockouts:
- - Account/model lockouts react immediately to authoritative `429` / `404` signals and use the configured cooldown + backoff values
- - Global provider/model quarantine only activates after repeated exhaustion hits the configured **CB Threshold** within **CB Reset Time**
+ Connection-scoped `429` rate limits stay in **Connection Cooldown** and do not count toward the provider breaker.
-4. **Policies & Locked Identifiers** — Shows circuit breaker status and locked identifiers with force-unlock capability.
+ The provider breaker runtime state is shown on **Dashboard → Health** only.
-5. **Rate Limit Auto-Detection** — Monitors `429` and `Retry-After` headers to proactively avoid hitting provider rate limits. When an upstream provider returns an explicit wait window, that authoritative `Retry-After` value overrides the base cooldown from the provider profile.
+4. **Wait For Cooldown** — If every candidate connection is already cooling down, OmniRoute can wait for the earliest cooldown and retry the same client request automatically.
-**Pro Tip:** Use **Reset All** button to clear all circuit breakers and cooldowns when a provider recovers from an outage.
+5. **Rate Limit Auto-Detection** — When upstream providers return explicit wait windows, those hints override the local connection cooldown when the setting is enabled.
+
+**Pro Tip:** Use the **Health** page to inspect and reset live provider breakers after an outage. The Resilience page only changes configuration.
---
@@ -824,14 +823,14 @@ curl -X POST http://localhost:20128/api/db-backups/import \
The settings page is organized into 6 tabs for easy navigation:
-| Tab | Contents |
-| -------------- | ---------------------------------------------------------------------------------------------- |
-| **General** | System storage tools, appearance settings, theme controls, and per-item sidebar visibility |
-| **Security** | Login/Password settings, IP Access Control, API auth for `/models`, and Provider Blocking |
-| **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) |
+| Tab | Contents |
+| -------------- | -------------------------------------------------------------------------------------------- |
+| **General** | System storage tools, appearance settings, theme controls, and per-item sidebar visibility |
+| **Security** | Login/Password settings, IP Access Control, API auth for `/models`, and Provider Blocking |
+| **Routing** | Global routing strategy (6 options), wildcard model aliases, fallback chains, combo defaults |
+| **Resilience** | Request queue, connection cooldown, provider breaker config, and wait-for-cooldown behavior |
+| **AI** | Thinking budget configuration, global system prompt injection, prompt cache stats |
+| **Advanced** | Global proxy configuration (HTTP/SOCKS5) |
---
@@ -904,9 +903,9 @@ Access via **Dashboard → Health**. Real-time system health overview with 6 car
| Card | What It Shows |
| --------------------- | ----------------------------------------------------------- |
| **System Status** | Uptime, version, memory usage, data directory |
-| **Provider Health** | Per-provider circuit breaker state (Closed/Open/Half-Open) |
-| **Rate Limits** | Active rate limit cooldowns per account with remaining time |
-| **Active Lockouts** | Providers temporarily blocked by the lockout policy |
+| **Provider Health** | Global provider circuit breaker runtime state |
+| **Rate Limits** | Active connection cooldowns per account with remaining time |
+| **Active Lockouts** | Active model-scoped lockouts and temporary exclusions |
| **Signature Cache** | Deduplication cache stats (active keys, hit rate) |
| **Latency Telemetry** | p50/p95/p99 latency aggregation per provider |
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 1a659c0a18..480e803ab2 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -460,22 +460,6 @@ paths:
"200":
description: Complete catalog with all providers
- /api/models/availability:
- get:
- tags: [Models]
- summary: Get model availability status
- description: Returns availability data for all configured models across providers.
- responses:
- "200":
- description: Model availability map
- post:
- tags: [Models]
- summary: Refresh model availability
- description: Triggers a re-check of model availability across all providers.
- responses:
- "200":
- description: Availability refreshed
-
# ─── Management Endpoints ──────────────────────────────────────
/api/providers:
@@ -1844,13 +1828,13 @@ paths:
/api/resilience:
get:
tags: [System]
- summary: Get resilience profiles and circuit breaker states
+ summary: Get resilience configuration
responses:
"200":
- description: Resilience configuration and current states
- put:
+ description: Request queue, connection cooldown, provider breaker, and wait settings
+ patch:
tags: [System]
- summary: Update resilience profiles
+ summary: Update resilience configuration
requestBody:
required: true
content:
@@ -1859,7 +1843,7 @@ paths:
type: object
responses:
"200":
- description: Updated resilience profiles
+ description: Updated resilience configuration
/api/resilience/reset:
post:
diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts
index 8206492282..11dc1b1865 100644
--- a/open-sse/handlers/chatCore.ts
+++ b/open-sse/handlers/chatCore.ts
@@ -14,13 +14,6 @@ import { createRequestLogger } from "../utils/requestLogger.ts";
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
import { resolveModelAlias } from "../services/modelDeprecation.ts";
import { getUnsupportedParams } from "../config/providerRegistry.ts";
-import {
- hasPerModelQuota,
- lockModelIfPerModelQuota,
- isDailyQuotaExhausted,
- getMsUntilTomorrow,
-} from "../services/accountFallback.ts";
-import { COOLDOWN_MS } from "../config/constants.ts";
import {
buildErrorBody,
createErrorResult,
@@ -2080,66 +2073,6 @@ export async function handleChatCore({
console.warn(
`[provider] Node ${connectionId} account deactivated (${statusCode}) — disabling permanently`
);
- } else if (errorType === PROVIDER_ERROR_TYPES.RATE_LIMITED) {
- // For providers with per-model quotas (passthrough providers, Gemini),
- // each model has independent quota. A 429 on one model must NOT lock out
- // the entire connection — other models may still have quota available.
- const rateLimitCooldownMs = retryAfterMs || COOLDOWN_MS.rateLimit;
- if (
- lockModelIfPerModelQuota(
- provider,
- connectionId,
- model,
- "rate_limited",
- rateLimitCooldownMs
- )
- ) {
- console.warn(
- `[provider] Node ${connectionId} model-only rate limited (${statusCode}) for ${model} - ${Math.ceil(rateLimitCooldownMs / 1000)}s (connection stays active)`
- );
- } else {
- const rateLimitedUntil = new Date(Date.now() + rateLimitCooldownMs).toISOString();
- await updateProviderConnection(connectionId, {
- rateLimitedUntil: rateLimitedUntil,
- testStatus: "unavailable",
- lastErrorType: errorType,
- lastError: message,
- errorCode: statusCode,
- healthCheckInterval: null,
- lastHealthCheckAt: null,
- });
- console.warn(
- `[provider] Node ${connectionId} rate limited (${statusCode}) - Next available at ${rateLimitedUntil}`
- );
- }
- } else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) {
- // Providers with per-model quotas — lock the model only, not the connection
- // Daily quota exhausted: lock until tomorrow; otherwise use default cooldown
- const isDailyQuota = isDailyQuotaExhausted(message);
- const quotaCooldownMs = isDailyQuota
- ? getMsUntilTomorrow()
- : retryAfterMs || COOLDOWN_MS.rateLimit;
- if (
- lockModelIfPerModelQuota(
- provider,
- connectionId,
- model,
- isDailyQuota ? "daily_quota_exhausted" : "quota_exhausted",
- quotaCooldownMs
- )
- ) {
- console.warn(
- `[provider] Node ${connectionId} ${isDailyQuota ? "daily " : ""}quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)`
- );
- } else {
- await updateProviderConnection(connectionId, {
- testStatus: "credits_exhausted",
- lastErrorType: errorType,
- lastError: message,
- errorCode: statusCode,
- });
- console.warn(`[provider] Node ${connectionId} exhausted quota (${statusCode})`);
- }
} else if (errorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED) {
await updateProviderConnection(connectionId, {
isActive: false,
diff --git a/open-sse/mcp-server/tools/advancedTools.ts b/open-sse/mcp-server/tools/advancedTools.ts
index 271ee70e8e..655d22bf98 100644
--- a/open-sse/mcp-server/tools/advancedTools.ts
+++ b/open-sse/mcp-server/tools/advancedTools.ts
@@ -112,97 +112,121 @@ interface BudgetGuardState {
let activeBudgetGuard: BudgetGuardState | null = null;
type ResilienceProfileConfig = {
- profiles: {
+ requestQueue: {
+ requestsPerMinute: number;
+ minTimeBetweenRequestsMs: number;
+ concurrentRequests: number;
+ };
+ connectionCooldown: {
oauth: {
- transientCooldown: number;
- rateLimitCooldown: number;
- maxBackoffLevel: number;
- circuitBreakerThreshold: number;
- circuitBreakerReset: number;
+ baseCooldownMs: number;
+ useUpstreamRetryHints: boolean;
+ maxBackoffSteps: number;
};
apikey: {
- transientCooldown: number;
- rateLimitCooldown: number;
- maxBackoffLevel: number;
- circuitBreakerThreshold: number;
- circuitBreakerReset: number;
+ baseCooldownMs: number;
+ useUpstreamRetryHints: boolean;
+ maxBackoffSteps: number;
};
};
- defaults: {
- requestsPerMinute: number;
- minTimeBetweenRequests: number;
- concurrentRequests: number;
+ providerBreaker: {
+ oauth: {
+ failureThreshold: number;
+ resetTimeoutMs: number;
+ };
+ apikey: {
+ failureThreshold: number;
+ resetTimeoutMs: number;
+ };
};
};
const RESILIENCE_PROFILES = {
aggressive: {
- profiles: {
+ requestQueue: {
+ requestsPerMinute: 180,
+ minTimeBetweenRequestsMs: 100,
+ concurrentRequests: 16,
+ },
+ connectionCooldown: {
oauth: {
- transientCooldown: 3000,
- rateLimitCooldown: 30000,
- maxBackoffLevel: 4,
- circuitBreakerThreshold: 2,
- circuitBreakerReset: 30000,
+ baseCooldownMs: 30000,
+ useUpstreamRetryHints: false,
+ maxBackoffSteps: 4,
},
apikey: {
- transientCooldown: 2000,
- rateLimitCooldown: 0,
- maxBackoffLevel: 3,
- circuitBreakerThreshold: 3,
- circuitBreakerReset: 15000,
+ baseCooldownMs: 2000,
+ useUpstreamRetryHints: true,
+ maxBackoffSteps: 3,
},
},
- defaults: {
- requestsPerMinute: 180,
- minTimeBetweenRequests: 100,
- concurrentRequests: 16,
+ providerBreaker: {
+ oauth: {
+ failureThreshold: 2,
+ resetTimeoutMs: 30000,
+ },
+ apikey: {
+ failureThreshold: 3,
+ resetTimeoutMs: 15000,
+ },
},
},
balanced: {
- profiles: {
+ requestQueue: {
+ requestsPerMinute: 100,
+ minTimeBetweenRequestsMs: 200,
+ concurrentRequests: 10,
+ },
+ connectionCooldown: {
oauth: {
- transientCooldown: 5000,
- rateLimitCooldown: 60000,
- maxBackoffLevel: 8,
- circuitBreakerThreshold: 3,
- circuitBreakerReset: 60000,
+ baseCooldownMs: 60000,
+ useUpstreamRetryHints: false,
+ maxBackoffSteps: 8,
},
apikey: {
- transientCooldown: 3000,
- rateLimitCooldown: 0,
- maxBackoffLevel: 5,
- circuitBreakerThreshold: 5,
- circuitBreakerReset: 30000,
+ baseCooldownMs: 3000,
+ useUpstreamRetryHints: true,
+ maxBackoffSteps: 5,
},
},
- defaults: {
- requestsPerMinute: 100,
- minTimeBetweenRequests: 200,
- concurrentRequests: 10,
+ providerBreaker: {
+ oauth: {
+ failureThreshold: 3,
+ resetTimeoutMs: 60000,
+ },
+ apikey: {
+ failureThreshold: 5,
+ resetTimeoutMs: 30000,
+ },
},
},
conservative: {
- profiles: {
+ requestQueue: {
+ requestsPerMinute: 60,
+ minTimeBetweenRequestsMs: 350,
+ concurrentRequests: 6,
+ },
+ connectionCooldown: {
oauth: {
- transientCooldown: 8000,
- rateLimitCooldown: 120000,
- maxBackoffLevel: 10,
- circuitBreakerThreshold: 8,
- circuitBreakerReset: 120000,
+ baseCooldownMs: 120000,
+ useUpstreamRetryHints: false,
+ maxBackoffSteps: 10,
},
apikey: {
- transientCooldown: 5000,
- rateLimitCooldown: 30000,
- maxBackoffLevel: 8,
- circuitBreakerThreshold: 8,
- circuitBreakerReset: 60000,
+ baseCooldownMs: 30000,
+ useUpstreamRetryHints: false,
+ maxBackoffSteps: 8,
},
},
- defaults: {
- requestsPerMinute: 60,
- minTimeBetweenRequests: 350,
- concurrentRequests: 6,
+ providerBreaker: {
+ oauth: {
+ failureThreshold: 8,
+ resetTimeoutMs: 120000,
+ },
+ apikey: {
+ failureThreshold: 8,
+ resetTimeoutMs: 60000,
+ },
},
},
} satisfies Record<"aggressive" | "balanced" | "conservative", ResilienceProfileConfig>;
@@ -460,13 +484,10 @@ export async function handleSetResilienceProfile(args: {
};
}
- // Apply to OmniRoute via API (contract: PATCH + { profiles, defaults })
+ // Apply to OmniRoute via API using the plan-aligned resilience structure.
await apiFetch("/api/resilience", {
method: "PATCH",
- body: JSON.stringify({
- profiles: settings.profiles,
- defaults: settings.defaults,
- }),
+ body: JSON.stringify(settings),
});
const result = { applied: true, profile: args.profile, settings };
diff --git a/open-sse/services/AGENTS.md b/open-sse/services/AGENTS.md
index b5af3cbdd1..9dd5092ef7 100644
--- a/open-sse/services/AGENTS.md
+++ b/open-sse/services/AGENTS.md
@@ -8,9 +8,9 @@
### Combo Routing Engine
-- **`combo.ts`** (800 LOC) — Entry point for multi-model routing. **`handleComboChat()`** iterates through targets in order until success or all fail. **`resolveComboTargets()`** expands combo config into ordered `ResolvedComboTarget[]` (provider + model + account + credentials). Enforces per-target circuit breaker and fallback logic.
+- **`combo.ts`** (800 LOC) — Entry point for multi-model routing. **`handleComboChat()`** iterates through targets in order until success or all fail. **`resolveComboTargets()`** expands combo config into ordered `ResolvedComboTarget[]` (provider + model + account + credentials). Enforces target retry, round-robin slot control, and provider-level resilience gates.
- **Strategies** (13 total): `priority` (ordered list), `weighted` (probabilistic), `fill-first` (fill quota first), `round-robin`, `P2C` (power of two choices), `random`, `least-used`, `cost-optimized`, `strict-random`, `auto`, `lkgp` (last known good provider), `context-optimized`, `context-relay`.
-- **Circuit Breaker**: Per target, tracks consecutive failures; breaks after threshold, reopens on success or timeout.
+- **Provider Breaker Integration**: Combo targets respect the global provider circuit breaker and skip to the next target when a provider is already open.
### Quota & Rate Limiting
@@ -120,7 +120,7 @@ Each service requires unit and integration tests. For authoritative coverage req
- **Combo-first design**: All routing decisions go through combo engine; fallback strategies are combo targets, not ad-hoc logic
- **Service composition**: Small focused modules; combo.ts orchestrates them, not monolithic routing
-- **Circuit breaker per-target**: Failures isolated to specific provider+account combo; other targets unaffected
+- **Provider breaker is global**: Combo targets respect the shared provider circuit breaker; combo does not maintain a second target-local breaker
- **Caching everywhere**: Models, providers, quotas, family fallbacks all pre-cached; invalidated on write
- **13 strategies** over hardcoded logic: Strategy pattern allows new routing logic without touching combo.ts core
@@ -130,6 +130,6 @@ Each service requires unit and integration tests. For authoritative coverage req
- New services must not add blocking I/O to routing hot path
- Combo target resolution under 10ms (measure with benchmarks)
-- Circuit breaker state per-target, not global
+- Combo should not reintroduce a second breaker layer on top of the global provider breaker
- All fallback chains tested (no infinite loops)
- Coverage requirements: See [`CONTRIBUTING.md#running-tests`](../../CONTRIBUTING.md#running-tests) (60% gate enforced in CI)
diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts
index efadc2c25d..d14a3122f0 100644
--- a/open-sse/services/accountFallback.ts
+++ b/open-sse/services/accountFallback.ts
@@ -4,11 +4,30 @@ import {
BACKOFF_STEPS_MS,
RateLimitReason,
HTTP_STATUS,
- PROVIDER_PROFILES,
} from "../config/constants.ts";
import { getPassthroughProviders, getProviderCategory } from "../config/providerRegistry.ts";
+import {
+ DEFAULT_RESILIENCE_SETTINGS,
+ resolveResilienceSettings,
+} from "../../src/lib/resilience/settings";
+import {
+ getAllCircuitBreakerStatuses,
+ getCircuitBreaker,
+ STATE,
+} from "../../src/shared/utils/circuitBreaker";
-type ProviderProfile = (typeof PROVIDER_PROFILES)["oauth"];
+type ProviderProfile = {
+ baseCooldownMs: number;
+ useUpstreamRetryHints: boolean;
+ maxBackoffSteps: number;
+ failureThreshold: number;
+ resetTimeoutMs: number;
+ transientCooldown: number;
+ rateLimitCooldown: number;
+ maxBackoffLevel: number;
+ circuitBreakerThreshold: number;
+ circuitBreakerReset: number;
+};
type JsonRecord = Record;
type ModelLockoutEntry = {
reason: string;
@@ -24,29 +43,10 @@ type ModelFailureState = {
resetAfterMs: number;
};
-// Provider-level failure tracking for circuit breaker behavior
-type ProviderFailureEntry = {
- failureCount: number;
- lastFailureAt: number;
- resetAfterMs: number;
- cooldownUntil: number | null;
-};
-
-// Error codes that count toward provider-level failure threshold
-const PROVIDER_FAILURE_ERROR_CODES = new Set([429, 408, 500, 502, 503, 504]);
-
-// Configuration for provider-level failure tracking
-const PROVIDER_FAILURE_THRESHOLD = 5;
-const PROVIDER_FAILURE_WINDOW_MS = 20 * 60 * 1000; // 20 minutes
-const PROVIDER_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes cooling
-
-// Provider-level failure state map: providerId -> failure entry
-const providerFailureState = new Map();
-// Guard against synchronous re-entrant calls within the same event-loop tick.
-// NOT a true mutex — Node.js is single-threaded, so different SSE streams
-// can interleave across ticks. This Set prevents a single call from recursively
-// re-entering recordProviderFailure within the same synchronous call stack.
-const providerFailureLocks = new Set();
+// Error codes that count toward provider-level failure threshold.
+// Connection-scoped 429 rate limits stay in connection cooldown handling and
+// do not contribute to the shared provider breaker.
+const PROVIDER_FAILURE_ERROR_CODES = new Set([408, 500, 502, 503, 504]);
// T06 (sub2api PR #1037): Signals that indicate permanent account deactivation.
// When a 401 body contains these strings, the account is permanently dead
@@ -139,17 +139,7 @@ export function isOAuthInvalidToken(errorText: string): boolean {
return OAUTH_INVALID_TOKEN_SIGNALS.some((sig) => lower.includes(sig));
}
-// ─── Provider Profile Helper ────────────────────────────────────────────────
-
-/**
- * Get the resilience profile for a provider (oauth or apikey).
- * @param {string} provider - Provider ID or alias
- * @returns {import('../config/constants.js').PROVIDER_PROFILES['oauth']}
- */
-export function getProviderProfile(provider) {
- const category = getProviderCategory(provider);
- return PROVIDER_PROFILES[category] ?? PROVIDER_PROFILES.apikey;
-}
+// ─── Resilience Profile Helper ──────────────────────────────────────────────
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
@@ -162,42 +152,47 @@ function isCompatibleProvider(provider: string | null | undefined): boolean {
);
}
-function mergeProviderProfile(fallback: ProviderProfile, overrides: unknown): ProviderProfile {
- const record = asRecord(overrides);
+function buildProviderProfile(
+ category: "oauth" | "apikey",
+ settings?: Record | null
+) {
+ const resilience = settings ? resolveResilienceSettings(settings) : DEFAULT_RESILIENCE_SETTINGS;
+ const connectionCooldown = resilience.connectionCooldown[category];
+ const providerBreaker = resilience.providerBreaker[category];
+
return {
- transientCooldown:
- typeof record.transientCooldown === "number"
- ? record.transientCooldown
- : fallback.transientCooldown,
- rateLimitCooldown:
- typeof record.rateLimitCooldown === "number"
- ? record.rateLimitCooldown
- : fallback.rateLimitCooldown,
- maxBackoffLevel:
- typeof record.maxBackoffLevel === "number"
- ? record.maxBackoffLevel
- : fallback.maxBackoffLevel,
- circuitBreakerThreshold:
- typeof record.circuitBreakerThreshold === "number"
- ? record.circuitBreakerThreshold
- : fallback.circuitBreakerThreshold,
- circuitBreakerReset:
- typeof record.circuitBreakerReset === "number"
- ? record.circuitBreakerReset
- : fallback.circuitBreakerReset,
- };
+ baseCooldownMs: connectionCooldown.baseCooldownMs,
+ useUpstreamRetryHints: connectionCooldown.useUpstreamRetryHints,
+ maxBackoffSteps: connectionCooldown.maxBackoffSteps,
+ failureThreshold: providerBreaker.failureThreshold,
+ resetTimeoutMs: providerBreaker.resetTimeoutMs,
+ transientCooldown: connectionCooldown.baseCooldownMs,
+ rateLimitCooldown: connectionCooldown.useUpstreamRetryHints
+ ? 0
+ : connectionCooldown.baseCooldownMs,
+ maxBackoffLevel: connectionCooldown.maxBackoffSteps,
+ circuitBreakerThreshold: providerBreaker.failureThreshold,
+ circuitBreakerReset: providerBreaker.resetTimeoutMs,
+ } satisfies ProviderProfile;
+}
+
+/**
+ * Get the resilience profile for a provider (oauth or apikey).
+ * @param {string} provider - Provider ID or alias
+ */
+export function getProviderProfile(provider) {
+ const category = getProviderCategory(provider);
+ return buildProviderProfile(category);
}
export async function getRuntimeProviderProfile(provider: string | null | undefined) {
- const fallback = getProviderProfile(provider);
try {
const { getCachedSettings } = await import("@/lib/db/readCache");
const settings = await getCachedSettings();
- const profiles = asRecord(settings.providerProfiles);
const category = getProviderCategory(provider);
- return mergeProviderProfile(fallback, profiles[category]);
+ return buildProviderProfile(category, settings);
} catch {
- return fallback;
+ return getProviderProfile(provider);
}
}
@@ -211,7 +206,7 @@ function getModelLockKey(provider: string, connectionId: string, model: string)
}
function getFailureWindowMs(profile: ProviderProfile | null = null, fallbackMs = 30 * 60 * 1000) {
- const configured = profile?.circuitBreakerReset;
+ const configured = profile?.resetTimeoutMs;
return typeof configured === "number" && configured > 0 ? configured : fallbackMs;
}
@@ -233,23 +228,13 @@ function getModelLockBaseCooldown(
fallbackCooldownMs: number,
profile: ProviderProfile | null = null
) {
- if (status === HTTP_STATUS.RATE_LIMITED) {
- if (typeof profile?.rateLimitCooldown === "number" && profile.rateLimitCooldown > 0) {
- return profile.rateLimitCooldown;
- }
- if (Number.isFinite(fallbackCooldownMs) && fallbackCooldownMs > 0) {
- return fallbackCooldownMs;
- }
- return getQuotaCooldown(0);
- }
-
- if (typeof profile?.transientCooldown === "number" && profile.transientCooldown > 0) {
- return profile.transientCooldown;
- }
if (Number.isFinite(fallbackCooldownMs) && fallbackCooldownMs > 0) {
return fallbackCooldownMs;
}
- return COOLDOWN_MS.transientInitial;
+ if (typeof profile?.baseCooldownMs === "number" && profile.baseCooldownMs >= 0) {
+ return profile.baseCooldownMs;
+ }
+ return status === HTTP_STATUS.RATE_LIMITED ? getQuotaCooldown(0) : COOLDOWN_MS.transientInitial;
}
function getScaledCooldown(
@@ -333,7 +318,8 @@ export function recordModelLockoutFailure(
reason: string,
status: number,
fallbackCooldownMs: number,
- profile: ProviderProfile | null = null
+ profile: ProviderProfile | null = null,
+ options: { exactCooldownMs?: number | null } = {}
) {
ensureCleanupTimer();
const key = getModelLockKey(provider, connectionId, model);
@@ -351,11 +337,14 @@ export function recordModelLockoutFailure(
});
const baseCooldownMs = getModelLockBaseCooldown(status, fallbackCooldownMs, profile);
- const cooldownMs = getScaledCooldown(
- baseCooldownMs,
- failureCount,
- profile?.maxBackoffLevel ?? BACKOFF_CONFIG.maxLevel
- );
+ const cooldownMs =
+ typeof options.exactCooldownMs === "number" && options.exactCooldownMs > 0
+ ? options.exactCooldownMs
+ : getScaledCooldown(
+ baseCooldownMs,
+ failureCount,
+ profile?.maxBackoffSteps ?? BACKOFF_CONFIG.maxLevel
+ );
lockModel(provider, connectionId, model, reason, cooldownMs, {
failureCount,
@@ -485,113 +474,62 @@ export function getAllModelLockouts() {
return active;
}
-// ─── Provider-Level Failure Tracking ─────────────────────────────────────────
-// Track failures at provider level: when a provider has too many transient failures
-// across all its connections, cooldown the entire provider temporarily.
+// ─── Provider Breaker Compatibility Wrappers ────────────────────────────────
+// Legacy helpers now delegate to the shared provider circuit breaker.
-/**
- * Check if a provider is currently in cooldown due to too many failures
- */
-export function isProviderInCooldown(provider: string | null | undefined): boolean {
- if (!provider) return false;
- const entry = providerFailureState.get(provider);
- if (!entry) return false;
-
- // If in cooldown, check if it has expired
- if (entry.cooldownUntil !== null && Date.now() >= entry.cooldownUntil) {
- providerFailureState.delete(provider);
- return false;
- }
-
- return entry.cooldownUntil !== null;
+function getProviderBreaker(provider: string | null | undefined) {
+ if (!provider) return null;
+ const profile = getProviderProfile(provider);
+ return getCircuitBreaker(provider, {
+ failureThreshold: profile.failureThreshold ?? profile.circuitBreakerThreshold,
+ resetTimeout: profile.resetTimeoutMs ?? profile.circuitBreakerReset,
+ });
}
/**
- * Get remaining cooldown time for a provider
+ * Check if a provider is currently blocked by the shared circuit breaker.
+ */
+export function isProviderInCooldown(provider: string | null | undefined): boolean {
+ const breaker = getProviderBreaker(provider);
+ return breaker ? !breaker.canExecute() : false;
+}
+
+/**
+ * Get remaining retry-after time for a provider breaker.
*/
export function getProviderCooldownRemainingMs(provider: string | null | undefined): number | null {
- if (!provider) return null;
- const entry = providerFailureState.get(provider);
- if (!entry || entry.cooldownUntil === null) return null;
-
- const remaining = entry.cooldownUntil - Date.now();
+ const breaker = getProviderBreaker(provider);
+ if (!breaker || breaker.canExecute()) return null;
+ const remaining = breaker.getRetryAfterMs();
return remaining > 0 ? remaining : null;
}
/**
- * Record a failure for a provider. When threshold is reached within the window,
- * the provider enters cooldown.
+ * Record a provider failure against the shared circuit breaker.
*/
export function recordProviderFailure(
provider: string | null | undefined,
log?: { warn?: (...args: unknown[]) => void }
): void {
- if (!provider) return;
-
- // Guard against concurrent re-entrant calls within the same tick
- if (providerFailureLocks.has(provider)) return;
- providerFailureLocks.add(provider);
-
- try {
- const now = Date.now();
- const entry = providerFailureState.get(provider);
-
- // Check if we're in cooldown period
- if (entry && entry.cooldownUntil !== null && now < entry.cooldownUntil) {
- return; // Already in cooldown, don't record
- }
-
- // Check if failure window has expired
- if (entry && now - entry.lastFailureAt > entry.resetAfterMs) {
- // Window expired, reset count
- providerFailureState.set(provider, {
- failureCount: 1,
- lastFailureAt: now,
- resetAfterMs: PROVIDER_FAILURE_WINDOW_MS,
- cooldownUntil: null,
- });
- return;
- }
-
- // Increment failure count
- const newCount = entry ? entry.failureCount + 1 : 1;
-
- if (newCount >= PROVIDER_FAILURE_THRESHOLD) {
- // Threshold reached, enter cooldown
- const cooldownUntil = now + PROVIDER_COOLDOWN_MS;
- providerFailureState.set(provider, {
- failureCount: newCount,
- lastFailureAt: now,
- resetAfterMs: PROVIDER_FAILURE_WINDOW_MS,
- cooldownUntil,
- });
- log?.warn?.(
- `[ProviderFailure] ${provider}: ${newCount} failures in ${PROVIDER_FAILURE_WINDOW_MS / 1000}s — entering ${PROVIDER_COOLDOWN_MS / 1000}s cooldown`
- );
- } else {
- // Just increment counter
- providerFailureState.set(provider, {
- failureCount: newCount,
- lastFailureAt: now,
- resetAfterMs: PROVIDER_FAILURE_WINDOW_MS,
- cooldownUntil: null,
- });
- }
- } finally {
- providerFailureLocks.delete(provider);
+ const breaker = getProviderBreaker(provider);
+ if (!breaker || !provider) return;
+ breaker._onFailure();
+ const status = breaker.getStatus();
+ if (status.state === STATE.OPEN) {
+ log?.warn?.(`[ProviderBreaker] ${provider}: OPEN after ${status.failureCount} final failures`);
}
}
/**
- * Clear provider failure state (e.g., after successful request)
+ * Reset the shared provider breaker.
*/
export function clearProviderFailure(provider: string | null | undefined): void {
- if (!provider) return;
- providerFailureState.delete(provider);
+ const breaker = getProviderBreaker(provider);
+ breaker?.reset();
}
/**
- * Get all providers currently in cooldown (for debugging/dashboard)
+ * Get all providers currently blocked by the shared breaker.
*/
export function getProvidersInCooldown(): Array<{
provider: string;
@@ -599,22 +537,17 @@ export function getProvidersInCooldown(): Array<{
cooldownRemainingMs: number | null;
lastFailureAt: number;
}> {
- const result = [];
- for (const [provider, entry] of providerFailureState) {
- if (entry.cooldownUntil === null) continue;
- const remaining = entry.cooldownUntil - Date.now();
- if (remaining <= 0) {
- providerFailureState.delete(provider);
- continue;
- }
- result.push({
- provider,
- failureCount: entry.failureCount,
- cooldownRemainingMs: remaining,
- lastFailureAt: entry.lastFailureAt,
- });
- }
- return result;
+ return getAllCircuitBreakerStatuses()
+ .filter((status) => {
+ const breaker = getProviderBreaker(status.name);
+ return Boolean(breaker && !breaker.canExecute());
+ })
+ .map((status) => ({
+ provider: status.name,
+ failureCount: status.failureCount,
+ cooldownRemainingMs: status.retryAfterMs || null,
+ lastFailureAt: status.lastFailureTime,
+ }));
}
/**
@@ -866,16 +799,6 @@ export function getQuotaCooldown(backoffLevel = 0) {
return Math.min(cooldown, BACKOFF_CONFIG.max);
}
-function getRateLimitCooldown(backoffLevel = 0, profile: ProviderProfile | null = null) {
- const maxLevel = profile?.maxBackoffLevel ?? BACKOFF_CONFIG.maxLevel;
- const cappedLevel = Math.min(Math.max(0, backoffLevel), maxLevel);
- const configuredBase = profile?.rateLimitCooldown;
- if (typeof configuredBase === "number" && configuredBase > 0) {
- return configuredBase * Math.pow(2, cappedLevel);
- }
- return getQuotaCooldown(cappedLevel);
-}
-
/**
* Check if error should trigger account fallback (switch to next account)
* @param {number} status - HTTP status code
@@ -889,21 +812,24 @@ export function checkFallbackError(
status,
errorText,
backoffLevel = 0,
- model = null,
+ _model = null,
provider = null,
headers = null,
profileOverride: ProviderProfile | null = null
) {
const errorStr = (errorText || "").toString();
const profile = profileOverride ?? (provider ? getProviderProfile(provider) : null);
+ const maxBackoffSteps = profile?.maxBackoffSteps ?? BACKOFF_CONFIG.maxLevel;
+ const retryableStatuses = new Set([
+ HTTP_STATUS.REQUEST_TIMEOUT,
+ HTTP_STATUS.RATE_LIMITED,
+ HTTP_STATUS.SERVER_ERROR,
+ HTTP_STATUS.BAD_GATEWAY,
+ HTTP_STATUS.SERVICE_UNAVAILABLE,
+ HTTP_STATUS.GATEWAY_TIMEOUT,
+ ]);
- // Track provider-level failures for circuit breaker behavior
- // Only count transient errors that are likely to recover
- if (isProviderFailureCode(status)) {
- recordProviderFailure(provider);
- }
-
- function parseResetFromHeaders(headers, errorStr = "") {
+ function parseResetFromHeaders(headers) {
if (!headers) return null;
// Retry-After header
@@ -935,6 +861,59 @@ export function checkFallbackError(
}
return null;
}
+
+ function getUpstreamRetryHintMs() {
+ if (!profile?.useUpstreamRetryHints) return null;
+ const resetTime = parseResetFromHeaders(headers);
+ if (resetTime) {
+ const waitMs = Math.max(resetTime - Date.now(), 0);
+ if (waitMs > 0) return waitMs;
+ }
+
+ const retryFromErrorText = parseRetryFromErrorText(errorStr);
+ if (retryFromErrorText && retryFromErrorText > 0) {
+ return retryFromErrorText;
+ }
+
+ return null;
+ }
+
+ function getScaledBaseCooldown(reason, level = backoffLevel) {
+ const baseCooldownMs =
+ typeof profile?.baseCooldownMs === "number" && profile.baseCooldownMs >= 0
+ ? profile.baseCooldownMs
+ : COOLDOWN_MS.transientInitial;
+ return {
+ baseCooldownMs,
+ cooldownMs: getScaledCooldown(baseCooldownMs, level + 1, maxBackoffSteps),
+ newBackoffLevel: Math.min(level + 1, maxBackoffSteps),
+ };
+ }
+
+ function buildRetryableFallback(reason) {
+ const upstreamRetryHintMs = getUpstreamRetryHintMs();
+ if (typeof upstreamRetryHintMs === "number" && upstreamRetryHintMs > 0) {
+ return {
+ shouldFallback: true,
+ cooldownMs: upstreamRetryHintMs,
+ baseCooldownMs: upstreamRetryHintMs,
+ newBackoffLevel: 0,
+ usedUpstreamRetryHint: true,
+ reason,
+ };
+ }
+
+ const scaled = getScaledBaseCooldown(reason, backoffLevel);
+ return {
+ shouldFallback: true,
+ cooldownMs: scaled.cooldownMs,
+ baseCooldownMs: scaled.baseCooldownMs,
+ newBackoffLevel: scaled.newBackoffLevel,
+ usedUpstreamRetryHint: false,
+ reason,
+ };
+ }
+
// Check error message FIRST - specific patterns take priority over status codes
if (errorText) {
const lowerError = errorStr.toLowerCase();
@@ -984,6 +963,7 @@ export function checkFallbackError(
return {
shouldFallback: true,
cooldownMs: COOLDOWN_MS.requestNotAllowed,
+ baseCooldownMs: COOLDOWN_MS.requestNotAllowed,
reason: RateLimitReason.RATE_LIMIT_EXCEEDED,
};
}
@@ -999,45 +979,16 @@ export function checkFallbackError(
lowerError.includes("capacity") ||
lowerError.includes("overloaded")
) {
- const resetTime = parseResetFromHeaders(headers);
- if (resetTime) {
- const waitMs = resetTime - Date.now();
- if (waitMs > 60_000) {
- return {
- shouldFallback: true,
- cooldownMs: waitMs,
- newBackoffLevel: 0,
- reason: RateLimitReason.RATE_LIMIT_EXCEEDED,
- };
- }
- }
- const retryFromBody = parseRetryFromErrorText(errorStr);
- if (retryFromBody && retryFromBody > 60_000) {
- return {
- shouldFallback: true,
- cooldownMs: retryFromBody,
- newBackoffLevel: 0,
- reason: RateLimitReason.RATE_LIMIT_EXCEEDED,
- };
- }
- const newLevel = Math.min(
- backoffLevel + 1,
- profile?.maxBackoffLevel ?? BACKOFF_CONFIG.maxLevel
- );
const reason = classifyErrorText(errorStr);
- return {
- shouldFallback: true,
- cooldownMs: getRateLimitCooldown(backoffLevel, profile),
- newBackoffLevel: newLevel,
- reason,
- };
+ return buildRetryableFallback(reason);
}
}
if (status === HTTP_STATUS.UNAUTHORIZED) {
return {
shouldFallback: true,
- cooldownMs: COOLDOWN_MS.unauthorized,
+ cooldownMs: 0,
+ baseCooldownMs: 0,
reason: RateLimitReason.AUTH_ERROR,
};
}
@@ -1045,7 +996,8 @@ export function checkFallbackError(
if (status === HTTP_STATUS.PAYMENT_REQUIRED || status === HTTP_STATUS.FORBIDDEN) {
return {
shouldFallback: true,
- cooldownMs: COOLDOWN_MS.paymentRequired,
+ cooldownMs: 0,
+ baseCooldownMs: 0,
reason: RateLimitReason.QUOTA_EXHAUSTED,
};
}
@@ -1060,64 +1012,11 @@ export function checkFallbackError(
// 429 - Rate limit with exponential backoff
if (status === HTTP_STATUS.RATE_LIMITED) {
- const resetTime = parseResetFromHeaders(headers);
- if (resetTime) {
- const waitMs = resetTime - Date.now();
- if (waitMs > 60_000) {
- return {
- shouldFallback: true,
- cooldownMs: waitMs,
- newBackoffLevel: 0,
- reason: RateLimitReason.RATE_LIMIT_EXCEEDED,
- };
- }
- }
-
- const newLevel = Math.min(
- backoffLevel + 1,
- profile?.maxBackoffLevel ?? BACKOFF_CONFIG.maxLevel
- );
- return {
- shouldFallback: true,
- cooldownMs: getRateLimitCooldown(backoffLevel, profile),
- newBackoffLevel: newLevel,
- reason: RateLimitReason.RATE_LIMIT_EXCEEDED,
- };
+ return buildRetryableFallback(RateLimitReason.RATE_LIMIT_EXCEEDED);
}
- // Transient / server errors — exponential backoff with provider profile
- const transientStatuses = [
- HTTP_STATUS.NOT_ACCEPTABLE,
- HTTP_STATUS.REQUEST_TIMEOUT,
- HTTP_STATUS.SERVER_ERROR,
- HTTP_STATUS.BAD_GATEWAY,
- HTTP_STATUS.SERVICE_UNAVAILABLE,
- HTTP_STATUS.GATEWAY_TIMEOUT,
- ];
- if (transientStatuses.includes(status)) {
- const resetTime = parseResetFromHeaders(headers, errorStr);
- if (resetTime) {
- const waitMs = resetTime - Date.now();
- if (waitMs > 60_000) {
- return {
- shouldFallback: true,
- cooldownMs: waitMs,
- newBackoffLevel: 0,
- reason: RateLimitReason.SERVER_ERROR,
- };
- }
- }
-
- const baseCooldown = profile?.transientCooldown ?? COOLDOWN_MS.transientInitial;
- const maxLevel = profile?.maxBackoffLevel ?? BACKOFF_CONFIG.maxLevel;
- const cooldownMs = Math.min(baseCooldown * Math.pow(2, backoffLevel), COOLDOWN_MS.transientMax);
- const newLevel = Math.min(backoffLevel + 1, maxLevel);
- return {
- shouldFallback: true,
- cooldownMs,
- newBackoffLevel: newLevel,
- reason: RateLimitReason.SERVER_ERROR,
- };
+ if (status === HTTP_STATUS.NOT_ACCEPTABLE || retryableStatuses.has(status)) {
+ return buildRetryableFallback(RateLimitReason.SERVER_ERROR);
}
// 400 — context overflow / malformed request may succeed on another model in the combo
@@ -1140,7 +1039,8 @@ export function checkFallbackError(
// All other errors - fallback with transient cooldown
return {
shouldFallback: true,
- cooldownMs: COOLDOWN_MS.transient,
+ cooldownMs: profile?.baseCooldownMs ?? COOLDOWN_MS.transient,
+ baseCooldownMs: profile?.baseCooldownMs ?? COOLDOWN_MS.transient,
reason: RateLimitReason.UNKNOWN,
};
}
@@ -1232,13 +1132,10 @@ export function applyErrorState(account, status, errorText, provider = null) {
if (!account) return account;
const backoffLevel = account.backoffLevel || 0;
- const { cooldownMs, newBackoffLevel, reason } = checkFallbackError(
- status,
- errorText,
- backoffLevel,
- null,
- provider
- );
+ const fallbackDecision = checkFallbackError(status, errorText, backoffLevel, null, provider);
+ const { cooldownMs, reason } = fallbackDecision;
+ const newBackoffLevel =
+ "newBackoffLevel" in fallbackDecision ? fallbackDecision.newBackoffLevel : undefined;
return {
...account,
diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts
index 219b8afaa4..ea56cb5c60 100644
--- a/open-sse/services/combo.ts
+++ b/open-sse/services/combo.ts
@@ -40,6 +40,16 @@ import {
getComboStepWeight,
normalizeComboStep,
} from "../../src/lib/combos/steps.ts";
+
+function isProviderBreakerOpenResponse(
+ result: Response,
+ errorBody?: { error?: { code?: string | null } } | null
+) {
+ return (
+ result.headers.get("x-omniroute-provider-breaker") === "open" ||
+ errorBody?.error?.code === "provider_circuit_open"
+ );
+}
import {
getConnectionRoutingTags,
matchesRoutingTags,
@@ -47,9 +57,8 @@ import {
type RoutingTagMatchMode,
} from "../../src/domain/tagRouter.ts";
-// Status codes that should mark semaphore + record circuit breaker failures
-// 401, 403 added so Auth errors quickly open the circuit breaker to prevent background request leaks
-const TRANSIENT_FOR_BREAKER = [401, 403, 429, 500, 502, 503, 504];
+// Status codes that should mark round-robin target semaphores as cooling down.
+const TRANSIENT_FOR_SEMAPHORE = [429, 502, 503, 504];
const COMBO_BAD_REQUEST_FALLBACK_PATTERNS = [
/\bprohibited_content\b/i,
/request blocked by .*api/i,
@@ -269,10 +278,6 @@ function getTargetProvider(modelStr: string, providerId?: string | null): string
return providerId || parsed.provider || parsed.providerAlias || "unknown";
}
-function getComboBreakerKey(comboName: string, executionKey: string): string {
- return `combo:${comboName}:${executionKey}`;
-}
-
function toRecordedTarget(target: ResolvedComboTarget) {
return {
executionKey: target.executionKey,
@@ -868,9 +873,7 @@ async function buildAutoCandidates(targets, comboName) {
? Math.max(10, historicalStdDev)
: Math.max(10, p95LatencyMs * 0.1);
- const breakerStateRaw = getCircuitBreaker(
- getComboBreakerKey(comboName, target.executionKey)
- )?.getStatus?.()?.state;
+ const breakerStateRaw = getCircuitBreaker(provider)?.getStatus?.()?.state;
const circuitBreakerState =
breakerStateRaw === "OPEN" || breakerStateRaw === "HALF_OPEN" ? breakerStateRaw : "CLOSED";
@@ -1502,18 +1505,6 @@ export async function handleComboChat({
const modelStr = target.modelStr;
const provider = target.provider;
const profile = await getRuntimeProviderProfile(provider);
- const breakerKey = getComboBreakerKey(combo.name, target.executionKey);
- const breaker = getCircuitBreaker(breakerKey, {
- failureThreshold: profile.circuitBreakerThreshold,
- resetTimeout: profile.circuitBreakerReset,
- });
-
- // Skip model if circuit breaker is OPEN
- if (!breaker.canExecute()) {
- log.info("COMBO", `Skipping ${modelStr}: circuit breaker OPEN for ${provider}`);
- if (i > 0) fallbackCount++;
- continue;
- }
// Pre-check: skip models where all accounts are in cooldown
if (isModelAvailable) {
@@ -1558,7 +1549,6 @@ export async function handleComboChat({
"COMBO",
`Model ${modelStr} returned 200 but failed quality check: ${quality.reason}`
);
- breaker._onFailure();
recordComboRequest(combo.name, modelStr, {
success: false,
latencyMs: Date.now() - startTime,
@@ -1575,7 +1565,6 @@ export async function handleComboChat({
"COMBO",
`Model ${modelStr} succeeded (${latencyMs}ms, ${fallbackCount} fallbacks)`
);
- breaker._onSuccess();
recordComboRequest(combo.name, modelStr, {
success: true,
latencyMs,
@@ -1644,6 +1633,7 @@ export async function handleComboChat({
// Extract error info from response
let errorText = result.statusText || "";
+ let errorBody = null;
let retryAfter = null;
try {
const cloned = result.clone();
@@ -1651,7 +1641,7 @@ export async function handleComboChat({
const text = await cloned.text();
if (text) {
errorText = text.substring(0, 500);
- const errorBody = JSON.parse(text);
+ errorBody = JSON.parse(text);
errorText =
errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText;
retryAfter = errorBody?.retryAfter || null;
@@ -1680,11 +1670,15 @@ export async function handleComboChat({
}
}
- const isAllAccountsRateLimited = isAllAccountsRateLimitedResponse(
- result.status,
- result.headers?.get("content-type") ?? null,
- errorText
- );
+ const providerBreakerOpen = isProviderBreakerOpenResponse(result, errorBody);
+
+ if (providerBreakerOpen) {
+ lastError = errorText || String(result.status);
+ if (!lastStatus) lastStatus = result.status;
+ if (i > 0) fallbackCount++;
+ log.info("COMBO", `Skipping ${modelStr}: provider circuit breaker OPEN for ${provider}`);
+ break;
+ }
const { shouldFallback, cooldownMs } = checkFallbackError(
result.status,
@@ -1697,14 +1691,7 @@ export async function handleComboChat({
);
const comboBadRequestFallback = shouldFallbackComboBadRequest(result.status, errorText);
- // Record failure in circuit breaker for transient errors
- if (TRANSIENT_FOR_BREAKER.includes(result.status)) {
- breaker._onFailure();
- }
-
- if (isAllAccountsRateLimited) {
- log.info("COMBO", `All accounts rate-limited for ${modelStr}, falling back to next model`);
- } else if (!shouldFallback && !comboBadRequestFallback) {
+ if (!shouldFallback && !comboBadRequestFallback) {
log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status });
recordComboRequest(combo.name, modelStr, {
success: false,
@@ -1757,25 +1744,12 @@ export async function handleComboChat({
}
}
- // Early exit: check if all models have breaker OPEN
- const allBreakersOpen = orderedTargets.every((target) => {
- return !getCircuitBreaker(getComboBreakerKey(combo.name, target.executionKey)).canExecute();
- });
-
// All models failed
const latencyMs = Date.now() - startTime;
if (recordedAttempts === 0) {
recordComboRequest(combo.name, null, { success: false, latencyMs, fallbackCount, strategy });
}
- if (allBreakersOpen) {
- log.warn("COMBO", "All models have circuit breaker OPEN — aborting");
- return unavailableResponse(
- 503,
- "All providers temporarily unavailable (circuit breakers open)"
- );
- }
-
if (!lastStatus) {
return new Response(
JSON.stringify({
@@ -1861,19 +1835,7 @@ async function handleRoundRobinCombo({
const modelStr = target.modelStr;
const provider = target.provider;
const profile = await getRuntimeProviderProfile(provider);
- const breakerKey = getComboBreakerKey(combo.name, target.executionKey);
const semaphoreKey = `combo:${combo.name}:${target.executionKey}`;
- const breaker = getCircuitBreaker(breakerKey, {
- failureThreshold: profile.circuitBreakerThreshold,
- resetTimeout: profile.circuitBreakerReset,
- });
-
- // Skip model if circuit breaker is OPEN
- if (!breaker.canExecute()) {
- log.info("COMBO-RR", `Skipping ${modelStr}: circuit breaker OPEN for ${provider}`);
- if (offset > 0) fallbackCount++;
- continue;
- }
// Pre-check availability
if (isModelAvailable) {
@@ -1935,7 +1897,6 @@ async function handleRoundRobinCombo({
"COMBO-RR",
`${modelStr} returned 200 but failed quality check: ${quality.reason}`
);
- breaker._onFailure();
recordComboRequest(combo.name, modelStr, {
success: false,
latencyMs: Date.now() - startTime,
@@ -1952,7 +1913,6 @@ async function handleRoundRobinCombo({
"COMBO-RR",
`${modelStr} succeeded (${latencyMs}ms, ${fallbackCount} fallbacks)`
);
- breaker._onSuccess();
recordComboRequest(combo.name, modelStr, {
success: true,
latencyMs,
@@ -1984,13 +1944,14 @@ async function handleRoundRobinCombo({
// Extract error info
let errorText = result.statusText || "";
let retryAfter = null;
+ let errorBody: { error?: { code?: string | null; message?: string | null } } | null = null;
try {
const cloned = result.clone();
try {
const text = await cloned.text();
if (text) {
errorText = text.substring(0, 500);
- const errorBody = JSON.parse(text);
+ errorBody = JSON.parse(text);
errorText =
errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText;
retryAfter = errorBody?.retryAfter || null;
@@ -2017,6 +1978,17 @@ async function handleRoundRobinCombo({
}
}
+ if (isProviderBreakerOpenResponse(result, errorBody)) {
+ lastError = errorText || String(result.status);
+ if (!lastStatus) lastStatus = result.status;
+ if (offset > 0) fallbackCount++;
+ log.info(
+ "COMBO-RR",
+ `Skipping ${modelStr}: provider circuit breaker OPEN for ${provider}`
+ );
+ break;
+ }
+
const { shouldFallback, cooldownMs } = checkFallbackError(
result.status,
errorText,
@@ -2034,14 +2006,10 @@ async function handleRoundRobinCombo({
errorText
);
- // Transient errors → mark in semaphore AND record circuit breaker failure
- if (TRANSIENT_FOR_BREAKER.includes(result.status) && cooldownMs > 0) {
+ // Transient errors → mark in semaphore so round-robin stops stampeding this target.
+ if (TRANSIENT_FOR_SEMAPHORE.includes(result.status) && cooldownMs > 0) {
semaphore.markRateLimited(semaphoreKey, cooldownMs);
- breaker._onFailure();
- log.warn(
- "COMBO-RR",
- `${modelStr} error ${result.status}, cooldown ${cooldownMs}ms (breaker: ${breaker.getStatus().failureCount}/${profile.circuitBreakerThreshold})`
- );
+ log.warn("COMBO-RR", `${modelStr} error ${result.status}, cooldown ${cooldownMs}ms`);
}
if (isAllAccountsRateLimited) {
@@ -2117,19 +2085,6 @@ async function handleRoundRobinCombo({
});
}
- // Early exit: check if all models have breaker OPEN
- const allBreakersOpen = orderedTargets.every((target) => {
- return !getCircuitBreaker(getComboBreakerKey(combo.name, target.executionKey)).canExecute();
- });
-
- if (allBreakersOpen) {
- log.warn("COMBO-RR", "All models have circuit breaker OPEN — aborting");
- return unavailableResponse(
- 503,
- "All providers temporarily unavailable (circuit breakers open)"
- );
- }
-
if (!lastStatus) {
return new Response(
JSON.stringify({
diff --git a/open-sse/services/comboConfig.ts b/open-sse/services/comboConfig.ts
index 2b3269fd58..af464f1aa8 100644
--- a/open-sse/services/comboConfig.ts
+++ b/open-sse/services/comboConfig.ts
@@ -9,11 +9,8 @@ const DEFAULT_COMBO_CONFIG = {
strategy: "priority",
maxRetries: 1,
retryDelayMs: 2000,
- timeoutMs: 600000,
concurrencyPerModel: 3, // max simultaneous requests per model (round-robin)
queueTimeoutMs: 30000, // max wait time in semaphore queue (round-robin)
- healthCheckEnabled: true,
- healthCheckTimeoutMs: 3000,
handoffThreshold: 0.85,
handoffModel: "",
handoffProviders: ["codex"],
@@ -22,6 +19,12 @@ const DEFAULT_COMBO_CONFIG = {
trackMetrics: true,
};
+const LEGACY_COMBO_RESILIENCE_KEYS = new Set([
+ "timeoutMs",
+ "healthCheckEnabled",
+ "healthCheckTimeoutMs",
+]);
+
/**
* Resolve effective config for a combo, applying cascade:
* DEFAULT_COMBO_CONFIG → settings.comboDefaults → settings.providerOverrides[provider] → combo.config
@@ -38,7 +41,12 @@ export function resolveComboConfig(combo, settings, provider?: string | null) {
// Clean undefined values before spreading
const clean = (obj) =>
- Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null));
+ Object.fromEntries(
+ Object.entries(obj).filter(
+ ([key, value]) =>
+ value !== undefined && value !== null && !LEGACY_COMBO_RESILIENCE_KEYS.has(key)
+ )
+ );
return {
...DEFAULT_COMBO_CONFIG,
diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts
index 35533938de..b4d83961e0 100644
--- a/open-sse/services/rateLimitManager.ts
+++ b/open-sse/services/rateLimitManager.ts
@@ -9,10 +9,14 @@
*/
import Bottleneck from "bottleneck";
-import { parseRetryAfterFromBody, lockModel } from "./accountFallback.ts";
+import { parseRetryAfterFromBody } from "./accountFallback.ts";
import { getProviderCategory } from "../config/providerRegistry.ts";
-import { DEFAULT_API_LIMITS } from "../config/constants.ts";
import { getCodexRateLimitKey } from "../executors/codex.ts";
+import {
+ DEFAULT_RESILIENCE_SETTINGS,
+ resolveResilienceSettings,
+ type RequestQueueSettings,
+} from "../../src/lib/resilience/settings";
interface LearnedLimitEntry {
provider: string;
@@ -24,7 +28,9 @@ interface LearnedLimitEntry {
}
interface LimiterUpdateSettings {
+ maxConcurrent?: number | null;
minTime: number;
+ maxWait?: number | null;
reservoir?: number | null;
reservoirRefreshAmount?: number | null;
reservoirRefreshInterval?: number | null;
@@ -61,20 +67,18 @@ const PERSIST_DEBOUNCE_MS = 60_000; // Debounce persistence to every 60s max
// Track initialization
let initialized = false;
-// Max time (ms) a job can wait in queue before failing with a timeout error.
-// Prevents infinite queuing when all providers are exhausted after a 429.
-// Configurable via RATE_LIMIT_MAX_WAIT_MS env var (default: 2 minutes).
-const MAX_WAIT_MS = parseInt(process.env.RATE_LIMIT_MAX_WAIT_MS || "120000", 10);
+let currentRequestQueueSettings: RequestQueueSettings = DEFAULT_RESILIENCE_SETTINGS.requestQueue;
-// Default conservative settings (before we learn from headers)
-const DEFAULT_SETTINGS = {
- maxConcurrent: 10,
- minTime: 0, // No throttle by default — let headers teach us
- reservoir: null, // No initial reservoir — unlimited until we learn
- reservoirRefreshAmount: null,
- reservoirRefreshInterval: null,
- maxWait: MAX_WAIT_MS, // Fail-fast: don't queue forever on 429 exhaustion
-};
+function buildLimiterDefaults() {
+ return {
+ maxConcurrent: currentRequestQueueSettings.concurrentRequests,
+ minTime: currentRequestQueueSettings.minTimeBetweenRequestsMs,
+ reservoir: currentRequestQueueSettings.requestsPerMinute,
+ reservoirRefreshAmount: currentRequestQueueSettings.requestsPerMinute,
+ reservoirRefreshInterval: 60 * 1000,
+ maxWait: currentRequestQueueSettings.maxWaitMs,
+ };
+}
function trackAsyncOperation(promise: Promise): Promise {
pendingAsyncOperations.add(promise);
@@ -93,11 +97,12 @@ export async function initializeRateLimits() {
initialized = true;
try {
- const { getProviderConnections } = await import("@/lib/localDb");
- const connections = await getProviderConnections();
+ const { getProviderConnections, getSettings } = await import("@/lib/localDb");
+ const [connections, settings] = await Promise.all([getProviderConnections(), getSettings()]);
+ const resilience = resolveResilienceSettings(settings);
+ applyRequestQueueSettings(resilience.requestQueue);
let explicitCount = 0;
let autoCount = 0;
- let customCount = 0;
for (const connRaw of connections as unknown[]) {
const conn = toRecord(connRaw);
@@ -105,38 +110,17 @@ export async function initializeRateLimits() {
const provider = typeof conn.provider === "string" ? conn.provider : "";
const isActive = conn.isActive === true;
const rateLimitProtection = conn.rateLimitProtection === true;
- const customRpm = toNumber(conn.customRpm, 0);
- const customTpm = toNumber(conn.customTpm, 0);
if (!connectionId || !provider) continue;
- // Custom rpm/tpm configured — enable rate limiting with user-defined values (#198)
- if (customRpm > 0 || customTpm > 0) {
- enabledConnections.add(connectionId);
- customCount++;
-
- const key = `${provider}:${connectionId}`;
- const rpm = customRpm > 0 ? customRpm : DEFAULT_API_LIMITS.requestsPerMinute;
- const minTime = Math.max(0, Math.floor(60000 / rpm) - 10);
-
- if (!limiters.has(key)) {
- limiters.set(
- key,
- new Bottleneck({
- maxConcurrent: DEFAULT_API_LIMITS.concurrentRequests,
- minTime,
- reservoir: rpm,
- reservoirRefreshAmount: rpm,
- reservoirRefreshInterval: 60 * 1000,
- maxWait: MAX_WAIT_MS,
- id: key,
- })
- );
- }
- } else if (rateLimitProtection) {
+ if (rateLimitProtection) {
// Explicitly enabled by user
enabledConnections.add(connectionId);
explicitCount++;
- } else if (getProviderCategory(provider) === "apikey" && isActive) {
+ } else if (
+ resilience.requestQueue.autoEnableApiKeyProviders &&
+ getProviderCategory(provider) === "apikey" &&
+ isActive
+ ) {
// Auto-enable for API key providers (safety net)
enabledConnections.add(connectionId);
autoCount++;
@@ -147,12 +131,7 @@ export async function initializeRateLimits() {
limiters.set(
key,
new Bottleneck({
- maxConcurrent: DEFAULT_API_LIMITS.concurrentRequests,
- minTime: DEFAULT_API_LIMITS.minTimeBetweenRequests,
- reservoir: DEFAULT_API_LIMITS.requestsPerMinute,
- reservoirRefreshAmount: DEFAULT_API_LIMITS.requestsPerMinute,
- reservoirRefreshInterval: 60 * 1000, // Refresh every minute
- maxWait: MAX_WAIT_MS,
+ ...buildLimiterDefaults(),
id: key,
})
);
@@ -160,9 +139,9 @@ export async function initializeRateLimits() {
}
}
- if (explicitCount > 0 || autoCount > 0 || customCount > 0) {
+ if (explicitCount > 0 || autoCount > 0) {
console.log(
- `🛡️ [RATE-LIMIT] Loaded ${explicitCount} explicit + ${autoCount} auto-enabled + ${customCount} custom rpm/tpm protection(s)`
+ `🛡️ [RATE-LIMIT] Loaded ${explicitCount} explicit + ${autoCount} auto-enabled protection(s)`
);
}
@@ -173,6 +152,21 @@ export async function initializeRateLimits() {
}
}
+export function applyRequestQueueSettings(nextSettings: RequestQueueSettings) {
+ currentRequestQueueSettings = { ...nextSettings };
+
+ for (const limiter of limiters.values()) {
+ limiter.updateSettings({
+ maxConcurrent: currentRequestQueueSettings.concurrentRequests,
+ minTime: currentRequestQueueSettings.minTimeBetweenRequestsMs,
+ maxWait: currentRequestQueueSettings.maxWaitMs,
+ reservoir: currentRequestQueueSettings.requestsPerMinute,
+ reservoirRefreshAmount: currentRequestQueueSettings.requestsPerMinute,
+ reservoirRefreshInterval: 60 * 1000,
+ });
+ }
+}
+
/**
* Enable rate limit protection for a connection
*/
@@ -222,7 +216,7 @@ function getLimiter(provider, connectionId, model = null) {
if (!limiters.has(key)) {
const limiter = new Bottleneck({
- ...DEFAULT_SETTINGS,
+ ...buildLimiterDefaults(),
id: key,
});
@@ -648,10 +642,5 @@ export function updateFromResponseBody(provider, connectionId, responseBody, sta
reservoirRefreshAmount: 60,
reservoirRefreshInterval: retryAfterMs,
});
-
- // Also apply model-level lockout if model is known
- if (model) {
- lockModel(provider, connectionId, model, reason, retryAfterMs);
- }
}
}
diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts
index 723fff1a84..c59f8c1823 100644
--- a/open-sse/utils/error.ts
+++ b/open-sse/utils/error.ts
@@ -243,6 +243,33 @@ export function unavailableResponse(
});
}
+export function providerCircuitOpenResponse(
+ provider: string,
+ retryAfter?: string | number | Date | null
+) {
+ const retryAfterSec = normalizeRetryAfterSeconds(retryAfter);
+ return new Response(
+ JSON.stringify({
+ error: {
+ message: `Provider ${provider} circuit breaker is open`,
+ type: "server_error",
+ code: "provider_circuit_open",
+ provider,
+ retry_after: retryAfterSec,
+ },
+ }),
+ {
+ status: 503,
+ headers: {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": getCorsOrigin(),
+ "Retry-After": String(retryAfterSec),
+ "X-OmniRoute-Provider-Breaker": "open",
+ },
+ }
+ );
+}
+
export function buildModelCooldownBody({
model,
retryAfterSec,
diff --git a/src/app/(dashboard)/dashboard/combos/IntelligentComboPanel.tsx b/src/app/(dashboard)/dashboard/combos/IntelligentComboPanel.tsx
index 55013eab4d..9a8140d06e 100644
--- a/src/app/(dashboard)/dashboard/combos/IntelligentComboPanel.tsx
+++ b/src/app/(dashboard)/dashboard/combos/IntelligentComboPanel.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useMemo, useState } from "react";
import Button from "@/shared/components/Button";
import Card from "@/shared/components/Card";
import { useNotificationStore } from "@/store/notificationStore";
@@ -8,10 +8,8 @@ import {
FACTOR_LABELS,
MODE_PACK_OPTIONS,
buildIntelligentProviderScores,
- extractIntelligentHealthState,
normalizeIntelligentRoutingConfig,
} from "@/lib/combos/intelligentRouting";
-import ModelStatusBadge from "@/app/(dashboard)/dashboard/providers/components/ModelStatusBadge";
function getI18nOrFallback(t: any, key: string, fallback: string) {
if (typeof t?.has === "function" && t.has(key)) return t(key);
@@ -47,8 +45,6 @@ export default function IntelligentComboPanel({
onComboUpdated?: (combo: any) => void;
}) {
const notify = useNotificationStore();
- const [incidentMode, setIncidentMode] = useState(false);
- const [exclusions, setExclusions] = useState([]);
const [savingModePack, setSavingModePack] = useState(null);
const normalizedConfig = useMemo(
() => normalizeIntelligentRoutingConfig(combo?.config),
@@ -56,35 +52,6 @@ export default function IntelligentComboPanel({
);
const providerScores = useMemo(() => buildIntelligentProviderScores(combo), [combo]);
- const fetchHealth = useCallback(async () => {
- try {
- const response = await fetch("/api/monitoring/health");
- if (!response.ok) {
- setIncidentMode(false);
- setExclusions([]);
- return;
- }
-
- const health = await response.json();
- const nextState = extractIntelligentHealthState(health);
- setIncidentMode(nextState.incidentMode);
- setExclusions(nextState.exclusions);
- } catch {
- setIncidentMode(false);
- setExclusions([]);
- }
- }, []);
-
- useEffect(() => {
- const timeoutId = setTimeout(fetchHealth, 0);
- const intervalId = setInterval(fetchHealth, 30_000);
-
- return () => {
- clearTimeout(timeoutId);
- clearInterval(intervalId);
- };
- }, [fetchHealth]);
-
const handleModePackChange = async (modePackId: string) => {
if (!combo?.id || modePackId === normalizedConfig.modePack) return;
setSavingModePack(modePackId);
@@ -157,19 +124,9 @@ export default function IntelligentComboPanel({
-
-
- {incidentMode ? "warning" : "verified"}
-
- {incidentMode
- ? getI18nOrFallback(t, "incidentMode", "Incident Mode")
- : getI18nOrFallback(t, "normalOperation", "Normal Operation")}
+
+ tune
+ {getI18nOrFallback(t, "configOnlyStatus", "Configuration View")}
@@ -181,22 +138,20 @@ export default function IntelligentComboPanel({
{getI18nOrFallback(t, "statusOverview", "Status Overview")}
- {incidentMode
- ? getI18nOrFallback(
- t,
- "highCircuitBreakerRate",
- "High circuit breaker trip rate detected."
- )
- : getI18nOrFallback(
- t,
- "allProvidersHealthy",
- "Providers are reporting healthy routing conditions."
- )}
+ {getI18nOrFallback(
+ t,
+ "configOnlyHint",
+ "This panel shows routing inputs only. Live breaker state is available on the Health page."
+ )}
-
Exclusions
-
{exclusions.length}
+
+ Candidate Pool
+
+
+ {normalizedConfig.candidatePool.length || activeProviders.length}
+
@@ -284,8 +239,6 @@ export default function IntelligentComboPanel({
{entry.model}
- {/* Model status badge - shows cooldown/error state */}
-
{percentage}%
@@ -320,51 +273,33 @@ export default function IntelligentComboPanel({
- {getI18nOrFallback(t, "excludedProviders", "Excluded Providers")}
+ {getI18nOrFallback(t, "routingInputs", "Routing Inputs")}
{getI18nOrFallback(
t,
- "excludedProvidersHint",
- "Providers with an OPEN circuit breaker are temporarily excluded from routing."
+ "routingInputsHint",
+ "Mode pack and weighting stay here; breaker runtime state stays on the Health page."
)}
-
- {exclusions.length === 0 ? (
-
-
- verified
- {getI18nOrFallback(
- t,
- "noExcludedProviders",
- "No providers are currently excluded."
- )}
-
-
- ) : (
- exclusions.map((exclusion) => (
-
-
-
-
{exclusion.provider}
-
{exclusion.reason}
-
-
- {getI18nOrFallback(t, "cooldownMinutes", "Cooldown: {minutes}m").replace(
- "{minutes}",
- `${Math.ceil(exclusion.cooldownMs / 60000)}`
- )}
-
-
-
- ))
- )}
+
+
+
Mode Pack
+
+ {normalizedConfig.modePack}
+
+
+
+
+ Exploration Rate
+
+
+ {Math.round(normalizedConfig.explorationRate * 100)}%
+
+
diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx
index e5932b019a..6406158e7b 100644
--- a/src/app/(dashboard)/dashboard/combos/page.tsx
+++ b/src/app/(dashboard)/dashboard/combos/page.tsx
@@ -140,12 +140,26 @@ const STRATEGY_GUIDANCE_FALLBACK = {
const ADVANCED_FIELD_HELP_FALLBACK = {
maxRetries: "How many retries are attempted before failing the request.",
retryDelay: "Initial delay between retries. Higher values reduce burst pressure.",
- timeout: "Maximum request time before aborting. Set higher for long generations.",
- healthcheck: "Skips unhealthy models/providers from routing decisions when enabled.",
concurrencyPerModel: "Max simultaneous requests sent to each model in round-robin.",
queueTimeout: "How long a request can wait in queue before timeout in round-robin.",
};
+const LEGACY_COMBO_RESILIENCE_KEYS = new Set([
+ "timeoutMs",
+ "healthCheckEnabled",
+ "healthCheckTimeoutMs",
+]);
+
+function sanitizeComboRuntimeConfig(config) {
+ if (!config || typeof config !== "object") return {};
+ return Object.fromEntries(
+ Object.entries(config).filter(
+ ([key, value]) =>
+ value !== undefined && value !== null && !LEGACY_COMBO_RESILIENCE_KEYS.has(key)
+ )
+ );
+}
+
const STRATEGY_RECOMMENDATIONS_FALLBACK = {
priority: {
title: "Fail-safe baseline",
@@ -334,7 +348,6 @@ const COMBO_TEMPLATES = [
config: {
maxRetries: 3,
retryDelayMs: 500,
- healthCheckEnabled: true,
},
},
{
@@ -349,7 +362,6 @@ const COMBO_TEMPLATES = [
config: {
maxRetries: 2,
retryDelayMs: 1500,
- healthCheckEnabled: true,
},
},
{
@@ -364,7 +376,6 @@ const COMBO_TEMPLATES = [
config: {
maxRetries: 1,
retryDelayMs: 500,
- healthCheckEnabled: true,
},
},
{
@@ -379,7 +390,6 @@ const COMBO_TEMPLATES = [
config: {
maxRetries: 1,
retryDelayMs: 1000,
- healthCheckEnabled: true,
},
},
{
@@ -394,7 +404,6 @@ const COMBO_TEMPLATES = [
config: {
maxRetries: 2,
retryDelayMs: 1000,
- healthCheckEnabled: true,
},
},
];
@@ -740,7 +749,7 @@ export default function CombosPage() {
name: newName,
models: combo.models,
strategy: combo.strategy || "priority",
- config: combo.config || {},
+ config: sanitizeComboRuntimeConfig(combo.config),
};
await handleCreate(data);
@@ -1814,7 +1823,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const [builderError, setBuilderError] = useState("");
const [builderStage, setBuilderStage] = useState(COMBO_BUILDER_STAGES[0]);
const [showAdvanced, setShowAdvanced] = useState(false);
- const [config, setConfig] = useState(combo?.config || {});
+ const [config, setConfig] = useState(sanitizeComboRuntimeConfig(combo?.config));
const [showStrategyNudge, setShowStrategyNudge] = useState(false);
const strategyChangeMountedRef = useRef(false);
// Agent features (#399 / #401 / #454)
@@ -1840,8 +1849,10 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
}
: {};
const nextConfig = nextCombo?.config
- ? { ...nextCombo.config }
- : Object.fromEntries(Object.entries(nextDefaults).filter(([key]) => key !== "strategy"));
+ ? sanitizeComboRuntimeConfig(nextCombo.config)
+ : sanitizeComboRuntimeConfig(
+ Object.fromEntries(Object.entries(nextDefaults).filter(([key]) => key !== "strategy"))
+ );
setName(nextCombo?.name || "");
setModels((nextCombo?.models || []).map((m) => normalizeModelEntry(m)));
@@ -2317,25 +2328,23 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const applyStrategyRecommendations = () => {
const strategyDefaults = {
- priority: { maxRetries: 2, retryDelayMs: 1500, healthCheckEnabled: true },
- weighted: { maxRetries: 1, retryDelayMs: 1000, healthCheckEnabled: true },
+ priority: { maxRetries: 2, retryDelayMs: 1500 },
+ weighted: { maxRetries: 1, retryDelayMs: 1000 },
"round-robin": {
maxRetries: 1,
retryDelayMs: 750,
- healthCheckEnabled: true,
concurrencyPerModel: 3,
queueTimeoutMs: 30000,
},
"context-relay": {
maxRetries: 1,
retryDelayMs: 750,
- healthCheckEnabled: true,
handoffThreshold: 0.85,
maxMessagesForSummary: 30,
},
- random: { maxRetries: 1, retryDelayMs: 1000, healthCheckEnabled: true },
- "least-used": { maxRetries: 1, retryDelayMs: 1000, healthCheckEnabled: true },
- "cost-optimized": { maxRetries: 1, retryDelayMs: 500, healthCheckEnabled: true },
+ random: { maxRetries: 1, retryDelayMs: 1000 },
+ "least-used": { maxRetries: 1, retryDelayMs: 1000 },
+ "cost-optimized": { maxRetries: 1, retryDelayMs: 500 },
};
const defaults = strategyDefaults[strategy] || strategyDefaults.priority;
@@ -2466,7 +2475,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
};
// Include config only if any values are set
- const configToSave = { ...config };
+ const configToSave = sanitizeComboRuntimeConfig(config);
// Add round-robin specific fields to config
if (strategy === "round-robin") {
if (config.concurrencyPerModel !== undefined)
@@ -3225,48 +3234,6 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none"
/>
-
-
-
- setConfig({
- ...config,
- timeoutMs: e.target.value ? Number(e.target.value) : undefined,
- })
- }
- className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none"
- />
-
-
-
-
- setConfig({ ...config, healthCheckEnabled: e.target.checked })
- }
- className="accent-primary"
- />
-
{strategy === "round-robin" && (
diff --git a/src/app/(dashboard)/dashboard/endpoint/components/MCPDashboard.tsx b/src/app/(dashboard)/dashboard/endpoint/components/MCPDashboard.tsx
index 2bbd3df717..e4bd6de419 100644
--- a/src/app/(dashboard)/dashboard/endpoint/components/MCPDashboard.tsx
+++ b/src/app/(dashboard)/dashboard/endpoint/components/MCPDashboard.tsx
@@ -69,72 +69,90 @@ const AUDIT_PAGE_SIZE = 20;
const RESILIENCE_PRESETS = {
aggressive: {
- profiles: {
+ requestQueue: {
+ requestsPerMinute: 180,
+ minTimeBetweenRequestsMs: 100,
+ concurrentRequests: 16,
+ },
+ connectionCooldown: {
oauth: {
- transientCooldown: 3000,
- rateLimitCooldown: 30000,
- maxBackoffLevel: 4,
- circuitBreakerThreshold: 2,
- circuitBreakerReset: 30000,
+ baseCooldownMs: 30000,
+ useUpstreamRetryHints: false,
+ maxBackoffSteps: 4,
},
apikey: {
- transientCooldown: 2000,
- rateLimitCooldown: 0,
- maxBackoffLevel: 3,
- circuitBreakerThreshold: 3,
- circuitBreakerReset: 15000,
+ baseCooldownMs: 2000,
+ useUpstreamRetryHints: true,
+ maxBackoffSteps: 3,
},
},
- defaults: {
- requestsPerMinute: 180,
- minTimeBetweenRequests: 100,
- concurrentRequests: 16,
+ providerBreaker: {
+ oauth: {
+ failureThreshold: 2,
+ resetTimeoutMs: 30000,
+ },
+ apikey: {
+ failureThreshold: 3,
+ resetTimeoutMs: 15000,
+ },
},
},
balanced: {
- profiles: {
+ requestQueue: {
+ requestsPerMinute: 100,
+ minTimeBetweenRequestsMs: 200,
+ concurrentRequests: 10,
+ },
+ connectionCooldown: {
oauth: {
- transientCooldown: 5000,
- rateLimitCooldown: 60000,
- maxBackoffLevel: 8,
- circuitBreakerThreshold: 3,
- circuitBreakerReset: 60000,
+ baseCooldownMs: 60000,
+ useUpstreamRetryHints: false,
+ maxBackoffSteps: 8,
},
apikey: {
- transientCooldown: 3000,
- rateLimitCooldown: 0,
- maxBackoffLevel: 5,
- circuitBreakerThreshold: 5,
- circuitBreakerReset: 30000,
+ baseCooldownMs: 3000,
+ useUpstreamRetryHints: true,
+ maxBackoffSteps: 5,
},
},
- defaults: {
- requestsPerMinute: 100,
- minTimeBetweenRequests: 200,
- concurrentRequests: 10,
+ providerBreaker: {
+ oauth: {
+ failureThreshold: 3,
+ resetTimeoutMs: 60000,
+ },
+ apikey: {
+ failureThreshold: 5,
+ resetTimeoutMs: 30000,
+ },
},
},
conservative: {
- profiles: {
+ requestQueue: {
+ requestsPerMinute: 60,
+ minTimeBetweenRequestsMs: 350,
+ concurrentRequests: 6,
+ },
+ connectionCooldown: {
oauth: {
- transientCooldown: 8000,
- rateLimitCooldown: 120000,
- maxBackoffLevel: 10,
- circuitBreakerThreshold: 8,
- circuitBreakerReset: 120000,
+ baseCooldownMs: 120000,
+ useUpstreamRetryHints: false,
+ maxBackoffSteps: 10,
},
apikey: {
- transientCooldown: 5000,
- rateLimitCooldown: 30000,
- maxBackoffLevel: 8,
- circuitBreakerThreshold: 8,
- circuitBreakerReset: 60000,
+ baseCooldownMs: 30000,
+ useUpstreamRetryHints: false,
+ maxBackoffSteps: 8,
},
},
- defaults: {
- requestsPerMinute: 60,
- minTimeBetweenRequests: 350,
- concurrentRequests: 6,
+ providerBreaker: {
+ oauth: {
+ failureThreshold: 8,
+ resetTimeoutMs: 120000,
+ },
+ apikey: {
+ failureThreshold: 8,
+ resetTimeoutMs: 60000,
+ },
},
},
} as const;
diff --git a/src/app/(dashboard)/dashboard/health/page.tsx b/src/app/(dashboard)/dashboard/health/page.tsx
index 9838f9721d..54efd143e9 100644
--- a/src/app/(dashboard)/dashboard/health/page.tsx
+++ b/src/app/(dashboard)/dashboard/health/page.tsx
@@ -809,6 +809,9 @@ export default function HealthPage() {
{cb.failures === 1
? t("failures", { count: cb.failures })
: t("failuresPlural", { count: cb.failures })}
+ {Number(cb.retryAfterMs) > 0 && (
+
· retry in {fmtMs(cb.retryAfterMs)}
+ )}
{cb.lastFailure && (
· {t("lastFailure")}:{" "}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx
index 9164e05718..c0febc8234 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx
@@ -53,7 +53,6 @@ import {
getCodexRequestDefaults as _getCodexRequestDefaults,
} from "@/lib/providers/requestDefaults";
import { resolveDashboardProviderInfo } from "../providerPageUtils";
-import ModelStatusBadge from "@/app/(dashboard)/dashboard/providers/components/ModelStatusBadge";
type CompatByProtocolMap = Partial<
Record<
@@ -3362,7 +3361,6 @@ function ModelRow({
{fullModel}
-
onCopy(fullModel, `model-${model.id}`)}
diff --git a/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.tsx b/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.tsx
deleted file mode 100644
index 4b29f1b208..0000000000
--- a/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.tsx
+++ /dev/null
@@ -1,200 +0,0 @@
-"use client";
-
-/**
- * ModelAvailabilityBadge — compact inline status indicator
- *
- * Replaces the full ModelAvailabilityPanel card with a small badge
- * that shows green when all models are operational, or amber/red
- * when there are issues, with a hover popover for details.
- */
-
-import { useState, useEffect, useCallback, useRef } from "react";
-import { useTranslations } from "next-intl";
-import { Button } from "@/shared/components";
-import { useNotificationStore } from "@/store/notificationStore";
-
-export default function ModelAvailabilityBadge() {
- const t = useTranslations("providers");
- const tc = useTranslations("common");
-
- const STATUS_CONFIG = {
- available: { icon: "check_circle", color: "#22c55e", label: t("available") },
- cooldown: { icon: "schedule", color: "#f59e0b", label: t("cooldown") },
- unavailable: { icon: "error", color: "#ef4444", label: t("unavailable") },
- unknown: { icon: "help", color: "#6b7280", label: t("unknown") },
- };
-
- const [data, setData] = useState(null);
- const [loading, setLoading] = useState(true);
- const [expanded, setExpanded] = useState(false);
- const [clearing, setClearing] = useState(null);
- const ref = useRef(null);
- const notify = useNotificationStore();
-
- const fetchStatus = useCallback(async () => {
- try {
- const res = await fetch("/api/models/availability");
- if (res.ok) {
- const json = await res.json();
- setData(json);
- }
- } catch {
- // silent fail — will retry
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- fetchStatus();
- const interval = setInterval(fetchStatus, 30000);
- return () => clearInterval(interval);
- }, [fetchStatus]);
-
- // Close popover on outside click
- useEffect(() => {
- const handleClick = (e: MouseEvent) => {
- if (ref.current && !ref.current.contains(e.target as Node)) {
- setExpanded(false);
- }
- };
- if (expanded) document.addEventListener("mousedown", handleClick);
- return () => document.removeEventListener("mousedown", handleClick);
- }, [expanded]);
-
- const handleClearCooldown = async (provider: string, model: string) => {
- setClearing(`${provider}:${model}`);
- try {
- const res = await fetch("/api/models/availability", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ action: "clearCooldown", provider, model }),
- });
- if (res.ok) {
- notify.success(t("cooldownCleared", { model }));
- await fetchStatus();
- } else {
- notify.error(t("failedClearCooldown"));
- }
- } catch {
- notify.error(t("failedClearCooldown"));
- } finally {
- setClearing(null);
- }
- };
-
- if (loading) return null;
-
- const models = data?.models || [];
- const unavailableCount =
- data?.unavailableCount || models.filter((m: any) => m.status !== "available").length;
- const isHealthy = unavailableCount === 0;
-
- // Group unhealthy models by provider
- const byProvider: Record = {};
- models.forEach((m: any) => {
- if (m.status === "available") return;
- const key = m.provider || "unknown";
- if (!byProvider[key]) byProvider[key] = [];
- byProvider[key].push(m);
- });
-
- return (
-
-
setExpanded(!expanded)}
- className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
- isHealthy
- ? "bg-emerald-500/10 border-emerald-500/20 text-emerald-500 hover:bg-emerald-500/15"
- : "bg-amber-500/10 border-amber-500/20 text-amber-500 hover:bg-amber-500/15"
- }`}
- >
-
- {isHealthy ? "verified" : "warning"}
-
- {isHealthy ? t("allModelsOperational") : t("modelsWithIssues", { count: unavailableCount })}
-
-
- {/* Expanded popover */}
- {expanded && (
-
-
-
-
- {isHealthy ? "verified" : "warning"}
-
- {t("modelStatus")}
-
-
-
- refresh
-
-
-
-
-
- {isHealthy ? (
-
{t("allModelsNormal")}
- ) : (
-
- {Object.entries(byProvider).map(([provider, provModels]) => (
-
-
- {provider}
-
-
- {provModels.map((m) => {
- const status =
- STATUS_CONFIG[m.status as keyof typeof STATUS_CONFIG] ||
- STATUS_CONFIG.unknown;
- const isClearing = clearing === `${m.provider}:${m.model}`;
- return (
-
-
-
- {status.icon}
-
-
- {m.model}
-
-
- {m.status === "cooldown" && (
-
handleClearCooldown(m.provider, m.model)}
- disabled={isClearing}
- className="text-[10px] px-1.5! py-0.5! ml-2"
- >
- {isClearing ? t("clearing") : t("clearCooldown")}
-
- )}
-
- );
- })}
-
-
- ))}
-
- )}
-
-
- )}
-
- );
-}
diff --git a/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityPanel.tsx b/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityPanel.tsx
deleted file mode 100644
index 2ede152ba5..0000000000
--- a/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityPanel.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-"use client";
-
-/**
- * ModelAvailabilityPanel — Batch B
- *
- * Shows real-time model availability and cooldown status.
- * Fetched from /api/models/availability.
- */
-
-import { useState, useEffect, useCallback } from "react";
-import { useTranslations } from "next-intl";
-import { Card, Button } from "@/shared/components";
-import { useNotificationStore } from "@/store/notificationStore";
-
-export default function ModelAvailabilityPanel() {
- const t = useTranslations("providers");
- const tc = useTranslations("common");
-
- const STATUS_CONFIG = {
- available: { icon: "check_circle", color: "#22c55e", label: t("available") },
- cooldown: { icon: "schedule", color: "#f59e0b", label: t("cooldown") },
- unavailable: { icon: "error", color: "#ef4444", label: t("unavailable") },
- unknown: { icon: "help", color: "#6b7280", label: t("unknown") },
- };
-
- const [data, setData] = useState(null);
- const [loading, setLoading] = useState(true);
- const [clearing, setClearing] = useState(null);
- const notify = useNotificationStore();
-
- const fetchStatus = useCallback(async () => {
- try {
- const res = await fetch("/api/models/availability");
- if (res.ok) {
- const json = await res.json();
- setData(json);
- }
- } catch {
- // silent fail — will retry
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- fetchStatus();
- const interval = setInterval(fetchStatus, 30000);
- return () => clearInterval(interval);
- }, [fetchStatus]);
-
- const handleClearCooldown = async (provider: string, model: string) => {
- setClearing(`${provider}:${model}`);
- try {
- const res = await fetch("/api/models/availability", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ action: "clearCooldown", provider, model }),
- });
- if (res.ok) {
- notify.success(t("cooldownCleared", { model }));
- await fetchStatus();
- } else {
- notify.error(t("failedClearCooldown"));
- }
- } catch {
- notify.error(t("failedClearCooldown"));
- } finally {
- setClearing(null);
- }
- };
-
- if (loading) {
- return (
-
-
-
- monitoring
-
- {t("loadingAvailability")}
-
-
- );
- }
-
- const models = data?.models || [];
- const unavailableCount =
- data?.unavailableCount || models.filter((m: any) => m.status !== "available").length;
-
- if (models.length === 0 || unavailableCount === 0) {
- return (
-
-
-
-
- verified
-
-
-
-
{t("modelAvailability")}
-
{t("allModelsOperational")}
-
-
-
- );
- }
-
- // Group by provider
- const byProvider: Record = {};
- models.forEach((m: any) => {
- if (m.status === "available") return;
- const key = m.provider || "unknown";
- if (!byProvider[key]) byProvider[key] = [];
- byProvider[key].push(m);
- });
-
- return (
-
-
-
-
-
- warning
-
-
-
-
{t("modelAvailability")}
-
- {t("modelsWithIssues", { count: unavailableCount })}
-
-
-
-
-
- refresh
-
-
-
-
-
- {Object.entries(byProvider).map(([provider, provModels]) => (
-
-
{provider}
-
- {provModels.map((m) => {
- const status =
- STATUS_CONFIG[m.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.unknown;
- const isClearing = clearing === `${m.provider}:${m.model}`;
- return (
-
-
-
- {status.icon}
-
- {m.model}
-
- {status.label}
-
- {m.cooldownUntil && (
-
- {t("until", { time: new Date(m.cooldownUntil).toLocaleTimeString() })}
-
- )}
-
- {m.status === "cooldown" && (
-
handleClearCooldown(m.provider, m.model)}
- disabled={isClearing}
- className="text-xs"
- >
- {isClearing ? t("clearing") : t("clearCooldown")}
-
- )}
-
- );
- })}
-
-
- ))}
-
-
- );
-}
diff --git a/src/app/(dashboard)/dashboard/providers/components/ModelStatusBadge.tsx b/src/app/(dashboard)/dashboard/providers/components/ModelStatusBadge.tsx
deleted file mode 100644
index 82538a6d8d..0000000000
--- a/src/app/(dashboard)/dashboard/providers/components/ModelStatusBadge.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-"use client";
-
-/**
- * ModelStatusBadge — compact single-model status indicator
- *
- * Shows a small badge with status icon (cooldown/unavailable/error)
- * with a tooltip containing additional details like remaining cooldown time
- * or last error message.
- * Only renders for non-available models to keep the UI clean.
- *
- * Uses shared polling via ModelStatusContext to avoid redundant API calls.
- */
-
-import { useState, useEffect, useRef } from "react";
-import { useTranslations } from "next-intl";
-import Tooltip from "@/shared/components/Tooltip";
-import { useModelStatus } from "./ModelStatusContext";
-
-interface ModelStatusBadgeProps {
- provider: string;
- model: string;
- size?: "sm" | "md";
- className?: string;
-}
-
-function formatRemainingTime(ms: number): string {
- if (ms <= 0) return "";
- const seconds = Math.ceil(ms / 1000);
- if (seconds < 60) return `${seconds}s`;
- const minutes = Math.floor(seconds / 60);
- const remainingSeconds = seconds % 60;
- if (minutes < 60) {
- return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
- }
- const hours = Math.floor(minutes / 60);
- const remainingMinutes = minutes % 60;
- return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
-}
-
-export default function ModelStatusBadge({
- provider,
- model,
- size = "sm",
- className = "",
-}: ModelStatusBadgeProps) {
- const t = useTranslations("providers");
- const status = useModelStatus(provider, model);
-
- // Store the latest remaining time calculated by the interval
- const [displayRemainingMs, setDisplayRemainingMs] = useState(null);
-
- // Use ref to track server-provided initial cooldown duration for countdown calculation
- const initialCooldownMsRef = useRef(null);
- // Track when we started counting down
- const countdownStartRef = useRef(null);
-
- // Set up countdown timer when cooldown begins
- useEffect(() => {
- if (status?.status !== "cooldown" || !status.remainingMs) {
- // Reset refs when not in cooldown (no setState here)
- initialCooldownMsRef.current = null;
- countdownStartRef.current = null;
- return;
- }
-
- // Initialize timing refs (no setState here)
- initialCooldownMsRef.current = status.remainingMs;
- countdownStartRef.current = Date.now();
-
- // Update countdown every second via interval callback (setState allowed here)
- const interval = setInterval(() => {
- if (!initialCooldownMsRef.current || !countdownStartRef.current) {
- return;
- }
-
- const elapsed = Date.now() - countdownStartRef.current;
- const newRemaining = Math.max(0, initialCooldownMsRef.current - elapsed);
-
- setDisplayRemainingMs(newRemaining);
-
- // Stop updating when countdown reaches zero
- if (newRemaining === 0) {
- clearInterval(interval);
- }
- }, 1000);
-
- return () => clearInterval(interval);
- }, [status?.status, status?.remainingMs]);
-
- // Derive the displayed remaining time: use countdown value if active, else use status value
- const remainingMs =
- status?.status === "cooldown" ? (displayRemainingMs ?? status.remainingMs ?? null) : null;
-
- // Don't render badge for available models (keep UI clean)
- if (!status || status.status === "available" || status.status === "unknown") {
- return null;
- }
-
- const getStatusColor = () => {
- switch (status.status) {
- case "cooldown":
- return "#f59e0b";
- case "unavailable":
- case "error":
- return "#ef4444";
- default:
- return "#6b7280";
- }
- };
-
- const getStatusIcon = () => {
- switch (status.status) {
- case "cooldown":
- return "schedule";
- case "unavailable":
- case "error":
- return "error";
- default:
- return "help";
- }
- };
-
- const getTooltipText = () => {
- switch (status.status) {
- case "cooldown": {
- const remaining = remainingMs !== null ? formatRemainingTime(remainingMs) : "";
- const reason = status.reason ? ` (${status.reason})` : "";
- const remainingText = remaining ? ` - ${remaining}` : "";
- return `${t("cooldown")}${reason}${remainingText}`;
- }
- case "unavailable":
- return `${t("unavailable")}${status.reason ? `: ${status.reason}` : ""}`;
- case "error":
- return `${t("error")}${status.lastError ? `: ${status.lastError}` : ""}`;
- default:
- return "";
- }
- };
-
- const color = getStatusColor();
- const sizeClasses = size === "sm" ? "px-1.5 py-0.5" : "px-2 py-1";
- const iconSize = size === "sm" ? "text-[12px]" : "text-[14px]";
-
- return (
-
-
-
- {getStatusIcon()}
-
- {status.status === "cooldown" && remainingMs !== null && (
- {formatRemainingTime(remainingMs)}
- )}
-
-
- );
-}
diff --git a/src/app/(dashboard)/dashboard/providers/components/ModelStatusContext.tsx b/src/app/(dashboard)/dashboard/providers/components/ModelStatusContext.tsx
deleted file mode 100644
index 3605a9defd..0000000000
--- a/src/app/(dashboard)/dashboard/providers/components/ModelStatusContext.tsx
+++ /dev/null
@@ -1,186 +0,0 @@
-"use client";
-
-/**
- * ModelStatusContext — shared polling for model availability
- *
- * Prevents redundant API calls by having all ModelStatusBadge components
- * share a single polling interval. Only one request is made every 15 seconds
- * regardless of how many badges are on the page.
- */
-
-import React, { createContext, useContext, useEffect, useRef, useCallback, useMemo } from "react";
-
-export interface ModelStatus {
- status: "available" | "cooldown" | "unavailable" | "error" | "unknown";
- reason?: string;
- remainingMs?: number;
- lastError?: string;
-}
-
-interface ModelStatusContextValue {
- getStatus: (provider: string, model: string) => ModelStatus | null;
- registerModel: (key: string, provider: string, model: string) => void;
- unregisterModel: (key: string) => void;
-}
-
-const ModelStatusContext = createContext(null);
-
-// Global map of model key -> status
-let modelStatusMap = new Map();
-// Global set of registered model keys
-let registeredModels = new Set();
-// Polling interval ref (singleton)
-let pollIntervalRef: NodeJS.Timeout | null = null;
-
-function getModelKey(provider: string, model: string): string {
- return `${provider}/${model}`;
-}
-
-async function fetchModelStatus(): Promise {
- try {
- const res = await fetch("/api/models/availability");
- if (!res.ok) return;
-
- const json = await res.json();
- const models = json?.models || [];
-
- // Update all registered models with fresh data
- const now = Date.now();
- registeredModels.forEach((key) => {
- const [provider, model] = key.split("/");
- // Use exact matching first to avoid gpt-4 matching gpt-4-turbo incorrectly
- const modelEntry =
- models.find((m: any) => m.provider === provider && m.model === model) ||
- models.find(
- // Fallback to prefix matching only for models that contain the registered key
- // This handles cases like "gpt-4o" matching badge for "gpt-4"
- (m: any) =>
- m.provider === provider &&
- m.model &&
- model &&
- (m.model.startsWith(model + "-") || model.startsWith(m.model + "-"))
- );
-
- if (modelEntry) {
- const newStatus: ModelStatus = {
- status: modelEntry.status || "unknown",
- reason: modelEntry.reason,
- remainingMs: modelEntry.remainingMs,
- lastError: modelEntry.lastError,
- };
-
- // For cooldown status, calculate remaining time based on server-provided value
- if (modelEntry.status === "cooldown" && modelEntry.remainingMs) {
- newStatus.remainingMs = modelEntry.remainingMs;
- }
-
- modelStatusMap.set(key, newStatus);
- } else {
- modelStatusMap.set(key, { status: "available" });
- }
- });
-
- // Trigger re-render by dispatching custom event
- window.dispatchEvent(new CustomEvent("model-status-update"));
- } catch {
- // Best-effort polling
- }
-}
-
-function ensurePolling(): void {
- if (pollIntervalRef) return;
-
- // Initial fetch
- fetchModelStatus();
-
- // Poll every 15 seconds
- pollIntervalRef = setInterval(fetchModelStatus, 15000);
-}
-
-function stopPolling(): void {
- if (pollIntervalRef) {
- clearInterval(pollIntervalRef);
- pollIntervalRef = null;
- }
-}
-
-export function ModelStatusProvider({ children }: { children: React.ReactNode }) {
- const [, forceUpdate] = React.useState(0);
-
- // Listen for status updates from the global poller
- useEffect(() => {
- const handleUpdate = () => forceUpdate((n) => n + 1);
- window.addEventListener("model-status-update", handleUpdate);
- return () => window.removeEventListener("model-status-update", handleUpdate);
- }, []);
-
- // Cleanup on unmount — stop polling only when no models remain registered
- useEffect(() => {
- return () => {
- if (registeredModels.size === 0) {
- stopPolling();
- }
- };
- }, []);
-
- const getStatus = useCallback((provider: string, model: string): ModelStatus | null => {
- const key = getModelKey(provider, model);
- return modelStatusMap.get(key) || null;
- }, []);
-
- const registerModel = useCallback((key: string, provider: string, model: string): void => {
- const wasEmpty = registeredModels.size === 0;
- registeredModels.add(key);
-
- // Start polling when first model registers
- if (wasEmpty) {
- ensurePolling();
- }
-
- // Immediately fetch if no data yet
- if (!modelStatusMap.has(key)) {
- fetchModelStatus();
- }
- }, []);
-
- const unregisterModel = useCallback((key: string): void => {
- registeredModels.delete(key);
- modelStatusMap.delete(key);
-
- // Stop polling when last model unregisters
- if (registeredModels.size === 0) {
- stopPolling();
- }
- }, []);
-
- const value = useMemo(
- () => ({
- getStatus,
- registerModel,
- unregisterModel,
- }),
- [getStatus, registerModel, unregisterModel]
- );
-
- return {children} ;
-}
-
-export function useModelStatus(provider: string, model: string): ModelStatus | null {
- const context = useContext(ModelStatusContext);
-
- if (!context) {
- throw new Error("useModelStatus must be used within a ModelStatusProvider");
- }
-
- const key = useMemo(() => getModelKey(provider, model), [provider, model]);
-
- // Register/unregister on mount/unmount
- useEffect(() => {
- context.registerModel(key, provider, model);
- return () => context.unregisterModel(key);
- }, [context, key, provider, model]);
-
- return context.getStatus(provider, model);
-}
-
-export default ModelStatusContext;
diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx
index de86e770cb..e509322fa6 100644
--- a/src/app/(dashboard)/dashboard/providers/page.tsx
+++ b/src/app/(dashboard)/dashboard/providers/page.tsx
@@ -26,7 +26,6 @@ import { getErrorCode, getRelativeTime } from "@/shared/utils";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { useNotificationStore } from "@/store/notificationStore";
-import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge";
import { useTranslations } from "next-intl";
import {
buildMergedOAuthProviderEntries,
@@ -496,7 +495,6 @@ export default function ProvidersPage() {
-
= {
"context-relay": "Context Relay",
};
+const LEGACY_COMBO_RESILIENCE_KEYS = new Set([
+ "timeoutMs",
+ "healthCheckEnabled",
+ "healthCheckTimeoutMs",
+]);
+
function translateOrFallback(
t: ReturnType,
key: string,
@@ -18,14 +24,31 @@ function translateOrFallback(
return typeof t.has === "function" && t.has(key) ? t(key) : fallback;
}
+function sanitizeComboRuntimeConfig(config?: Record | null) {
+ if (!config || typeof config !== "object") return {};
+ return Object.fromEntries(
+ Object.entries(config).filter(
+ ([key, value]) =>
+ value !== undefined && value !== null && !LEGACY_COMBO_RESILIENCE_KEYS.has(key)
+ )
+ );
+}
+
+function sanitizeProviderOverrides(overrides?: Record | null) {
+ if (!overrides || typeof overrides !== "object") return {};
+ return Object.fromEntries(
+ Object.entries(overrides).map(([providerId, config]) => [
+ providerId,
+ sanitizeComboRuntimeConfig(config),
+ ])
+ );
+}
+
export default function ComboDefaultsTab() {
const [comboDefaults, setComboDefaults] = useState({
strategy: "priority",
maxRetries: 1,
retryDelayMs: 2000,
- timeoutMs: 120000,
- healthCheckEnabled: true,
- healthCheckTimeoutMs: 3000,
maxComboDepth: 3,
trackMetrics: true,
handoffThreshold: 0.85,
@@ -54,7 +77,6 @@ export default function ComboDefaultsTab() {
const numericSettings = [
{ key: "maxRetries", label: t("maxRetriesLabel"), min: 0, max: 5 },
{ key: "retryDelayMs", label: t("retryDelayLabel"), min: 500, max: 10000, step: 500 },
- { key: "timeoutMs", label: t("timeoutLabel"), min: 5000, step: 5000 },
{ key: "maxComboDepth", label: t("maxNestingDepth"), min: 1, max: 10 },
];
@@ -66,7 +88,7 @@ export default function ComboDefaultsTab() {
.then(([comboData, settingsData]) => {
setComboDefaults((prev) => ({
...prev,
- ...(comboData.comboDefaults || {}),
+ ...sanitizeComboRuntimeConfig(comboData.comboDefaults),
strategy:
settingsData.fallbackStrategy ?? comboData.comboDefaults?.strategy ?? prev.strategy,
stickyRoundRobinLimit:
@@ -74,7 +96,9 @@ export default function ComboDefaultsTab() {
comboData.comboDefaults?.stickyRoundRobinLimit ??
prev.stickyRoundRobinLimit,
}));
- if (comboData.providerOverrides) setProviderOverrides(comboData.providerOverrides);
+ if (comboData.providerOverrides) {
+ setProviderOverrides(sanitizeProviderOverrides(comboData.providerOverrides));
+ }
})
.catch((err) => console.error("Failed to fetch combo defaults:", err));
}, []);
@@ -116,7 +140,10 @@ export default function ComboDefaultsTab() {
const comboDefaultsRes = await fetch("/api/settings/combo-defaults", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ comboDefaults: comboDefaultsPayload, providerOverrides }),
+ body: JSON.stringify({
+ comboDefaults: sanitizeComboRuntimeConfig(comboDefaultsPayload),
+ providerOverrides: sanitizeProviderOverrides(providerOverrides),
+ }),
});
if (!comboDefaultsRes.ok) {
@@ -136,7 +163,7 @@ export default function ComboDefaultsTab() {
const addProviderOverride = () => {
const name = newOverrideProvider.trim().toLowerCase();
if (!name || providerOverrides[name]) return;
- setProviderOverrides((prev) => ({ ...prev, [name]: { maxRetries: 1, timeoutMs: 120000 } }));
+ setProviderOverrides((prev) => ({ ...prev, [name]: { maxRetries: 1 } }));
setNewOverrideProvider("");
};
@@ -381,21 +408,6 @@ export default function ComboDefaultsTab() {
{/* Toggles */}
-
-
-
{t("healthCheck")}
-
{t("healthCheckDesc")}
-
-
- setComboDefaults((prev) => ({
- ...prev,
- healthCheckEnabled: !prev.healthCheckEnabled,
- }))
- }
- />
-
{t("trackMetrics")}
@@ -436,24 +448,6 @@ export default function ComboDefaultsTab() {
aria-label={t("providerMaxRetriesAria", { provider })}
/>
{t("retries")}
-
- setProviderOverrides((prev) => ({
- ...prev,
- [provider]: {
- ...prev[provider],
- timeoutMs: parseInt(e.target.value) || 120000,
- },
- }))
- }
- className="text-xs w-24"
- aria-label={t("providerTimeoutAria", { provider })}
- />
-
{t("ms")}
removeProviderOverride(provider)}
className="ml-auto text-red-400 hover:text-red-500 transition-colors"
diff --git a/src/app/(dashboard)/dashboard/settings/components/PoliciesPanel.tsx b/src/app/(dashboard)/dashboard/settings/components/PoliciesPanel.tsx
index 2ac4c6e451..7f980c1d86 100644
--- a/src/app/(dashboard)/dashboard/settings/components/PoliciesPanel.tsx
+++ b/src/app/(dashboard)/dashboard/settings/components/PoliciesPanel.tsx
@@ -3,22 +3,16 @@
/**
* PoliciesPanel — Batch E
*
- * Shows circuit breaker states and locked identifiers.
+ * Shows locked identifiers managed by policy-related safeguards.
* Allows force-unlocking locked identifiers.
* API: /api/policies
*/
import { useState, useEffect, useCallback } from "react";
-import { Card, Button, EmptyState } from "@/shared/components";
+import { Card, Button } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
-const CB_STATUS = {
- closed: { icon: "check_circle", color: "#22c55e", label: "Closed" },
- "half-open": { icon: "pending", color: "#f59e0b", label: "Half-Open" },
- open: { icon: "error", color: "#ef4444", label: "Open" },
-};
-
export default function PoliciesPanel() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
@@ -78,11 +72,9 @@ export default function PoliciesPanel() {
);
}
- const circuitBreakers = data?.circuitBreakers || [];
const lockedIds = data?.lockedIdentifiers || [];
- const hasIssues = circuitBreakers.some((cb) => cb.state !== "closed") || lockedIds.length > 0;
- if (!hasIssues) {
+ if (lockedIds.length === 0) {
return (
@@ -90,7 +82,7 @@ export default function PoliciesPanel() {
verified_user
-
{t("policiesCircuitBreakers")}
+
{t("policiesLocked")}
{t("allOperational")}
@@ -106,7 +98,7 @@ export default function PoliciesPanel() {
gpp_maybe
-
{t("policiesCircuitBreakers")}
+
{t("policiesLocked")}
{t("activeIssuesDetected")}
@@ -115,50 +107,6 @@ export default function PoliciesPanel() {
- {/* Circuit Breakers */}
- {circuitBreakers.filter((cb) => cb.state !== "closed").length > 0 && (
-
-
{t("circuitBreakers")}
-
- {circuitBreakers
- .filter((cb) => cb.state !== "closed")
- .map((cb, i) => {
- const status = CB_STATUS[cb.state] || CB_STATUS.open;
- return (
-
-
-
- {status.icon}
-
-
- {cb.name || cb.provider || "Unknown"}
-
-
- {status.label}
-
- {cb.failures > 0 && (
- {cb.failures} failures
- )}
-
-
- );
- })}
-
-
- )}
-
{/* Locked Identifiers */}
{lockedIds.length > 0 && (
diff --git a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx
index e286af443b..f3ca6f7105 100644
--- a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx
+++ b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx
@@ -1,549 +1,316 @@
"use client";
-import { useState, useEffect, useCallback } from "react";
-import { Card, Button } from "@/shared/components";
+import { type ReactNode, useEffect, useState } from "react";
+import { Button, Card } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
-import { useLocale, useTranslations } from "next-intl";
-import AutoDisableCard from "./AutoDisableCard";
+import { useTranslations } from "next-intl";
-// ─── State colors and labels ──────────────────────────────────────────────
-const STATE_STYLES = {
- CLOSED: {
- bg: "bg-emerald-500/15",
- text: "text-emerald-400",
- border: "border-emerald-500/30",
- icon: "check_circle",
- },
- OPEN: {
- bg: "bg-red-500/15",
- text: "text-red-400",
- border: "border-red-500/30",
- icon: "error",
- },
- HALF_OPEN: {
- bg: "bg-amber-500/15",
- text: "text-amber-400",
- border: "border-amber-500/30",
- icon: "warning",
- },
+type RequestQueueSettings = {
+ autoEnableApiKeyProviders: boolean;
+ requestsPerMinute: number;
+ minTimeBetweenRequestsMs: number;
+ concurrentRequests: number;
+ maxWaitMs: number;
};
-const CB_STATUS = {
- closed: { icon: "check_circle", color: "#22c55e" },
- "half-open": { icon: "pending", color: "#f59e0b" },
- open: { icon: "error", color: "#ef4444" },
+type ConnectionCooldownProfileSettings = {
+ baseCooldownMs: number;
+ useUpstreamRetryHints: boolean;
+ maxBackoffSteps: number;
};
-function getBreakerStateLabel(state, t) {
- const normalized = String(state || "closed")
- .toLowerCase()
- .replaceAll("_", "-");
- if (normalized === "open") return t("breakerStateOpen");
- if (normalized === "half-open") return t("breakerStateHalfOpen");
- return t("breakerStateClosed");
+type ProviderBreakerProfileSettings = {
+ failureThreshold: number;
+ resetTimeoutMs: number;
+};
+
+type WaitForCooldownSettings = {
+ enabled: boolean;
+ maxRetries: number;
+ maxRetryWaitSec: number;
+};
+
+type ResilienceResponse = {
+ requestQueue: RequestQueueSettings;
+ connectionCooldown: {
+ oauth: ConnectionCooldownProfileSettings;
+ apikey: ConnectionCooldownProfileSettings;
+ };
+ providerBreaker: {
+ oauth: ProviderBreakerProfileSettings;
+ apikey: ProviderBreakerProfileSettings;
+ };
+ waitForCooldown: WaitForCooldownSettings;
+};
+
+function formatMs(value: number | null | undefined) {
+ if (typeof value !== "number") return "—";
+ return `${value}ms`;
}
-function formatMs(ms) {
- if (!ms || ms <= 0) return "—";
- if (ms < 1000) return `${ms}ms`;
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
- return `${(ms / 60000).toFixed(1)}m`;
+function SectionDescription({
+ scope,
+ trigger,
+ effect,
+}: {
+ scope: string;
+ trigger: string;
+ effect: string;
+}) {
+ return (
+
+
+ Scope: {scope}
+
+
+ Trigger: {trigger}
+
+
+ Effect: {effect}
+
+
+ );
}
-function getErrorMessage(err, fallback) {
- return err instanceof Error && err.message ? err.message : fallback;
+function NumberField({
+ label,
+ value,
+ suffix,
+ min = 0,
+ onChange,
+}: {
+ label: string;
+ value: number;
+ suffix?: string;
+ min?: number;
+ onChange: (value: number) => void;
+}) {
+ return (
+
+ {label}
+
+ {
+ if (event.target.value === "") return;
+ const nextValue = Number(event.target.value);
+ if (Number.isFinite(nextValue)) {
+ onChange(nextValue);
+ }
+ }}
+ className="w-full rounded-lg border border-border bg-bg-subtle px-3 py-2 text-sm"
+ />
+ {suffix ? {suffix} : null}
+
+
+ );
}
-// ─── Provider Profiles Card ──────────────────────────────────────────────
-function ProviderProfilesCard({ profiles, onSave, saving }) {
- const [editMode, setEditMode] = useState(false);
- const [draft, setDraft] = useState(profiles);
- const t = useTranslations("settings");
+function BooleanField({
+ label,
+ description,
+ checked,
+ onChange,
+}: {
+ label: string;
+ description: string;
+ checked: boolean;
+ onChange: (value: boolean) => void;
+}) {
+ return (
+
+
+
{label}
+
{description}
+
+ onChange(event.target.checked)}
+ className="mt-1 size-4 rounded border-border"
+ />
+
+ );
+}
+
+function ProfileColumn({
+ title,
+ icon,
+ children,
+}: {
+ title: string;
+ icon: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {icon}
+
{title}
+
+
{children}
+
+ );
+}
+
+function ActionRow({
+ editing,
+ saving,
+ onEdit,
+ onCancel,
+ onSave,
+}: {
+ editing: boolean;
+ saving: boolean;
+ onEdit: () => void;
+ onCancel: () => void;
+ onSave: () => void;
+}) {
const tc = useTranslations("common");
-
- useEffect(() => {
- setDraft(profiles);
- }, [profiles]);
-
- const formatMsRaw = (value) => (value == null ? "—" : `${value}${t("ms")}`);
- const fields = [
- { key: "transientCooldown", label: t("transientCooldown"), format: formatMsRaw },
- { key: "rateLimitCooldown", label: t("rateLimitCooldown"), format: formatMsRaw },
- { key: "maxBackoffLevel", label: t("maxBackoffLevel") },
- {
- key: "circuitBreakerThreshold",
- label: t("cbThreshold"),
- format: (value) => (value == null ? "—" : t("failures", { count: value })),
- },
- { key: "circuitBreakerReset", label: t("cbResetTime"), format: formatMsRaw },
- ];
-
- const handleSave = () => {
- // Only send 'oauth' and 'apikey' — the API schema rejects any other keys (e.g. 'local')
- const { oauth, apikey } = draft ?? {};
- onSave({ ...(oauth ? { oauth } : {}), ...(apikey ? { apikey } : {}) });
- setEditMode(false);
- };
-
- return (
-
-
-
-
-
- tune
-
-
{t("providerProfiles")}
-
- {editMode ? (
-
- setEditMode(false)}>
- {tc("cancel")}
-
-
- {tc("save")}
-
-
- ) : (
-
setEditMode(true)}>
- {tc("edit")}
-
- )}
-
-
-
{t("providerProfilesDesc")}
-
-
- {["oauth", "apikey"].map((type) => (
-
-
-
- {type === "oauth" ? "lock" : "key"}
-
- {type === "oauth" ? t("oauthProviders") : t("apiKeyProviders")}
-
-
- {fields.map(({ key, label, format }) => (
-
- {label}
- {editMode ? (
-
- setDraft({
- ...draft,
- [type]: { ...draft[type], [key]: Number(e.target.value) },
- })
- }
- className="w-24 px-2 py-1 text-xs rounded bg-white/10 border border-white/20 text-right"
- />
- ) : (
-
- {format
- ? format(profiles?.[type]?.[key])
- : (profiles?.[type]?.[key] ?? "—")}
-
- )}
-
- ))}
-
-
- ))}
-
-
-
- );
-}
-
-// ─── Editable Rate Limit Card ─────────────────────────────────────────────
-function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
- const [editMode, setEditMode] = useState(false);
- const [draft, setDraft] = useState(defaults || {});
- const t = useTranslations("settings");
- const tc = useTranslations("common");
-
- // 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 (
-
-
-
-
-
- speed
-
-
{t("rateLimiting")}
-
- {editMode ? (
-
- setEditMode(false)}>
- {tc("cancel")}
-
-
- {tc("save")}
-
-
- ) : (
-
setEditMode(true)}>
- {tc("edit")}
-
- )}
-
-
-
{t("rateLimitingDesc")}
-
-
-
- {t("defaultSafetyNet")}
-
-
- {[
- { key: "requestsPerMinute", label: t("rpm") },
- { key: "minTimeBetweenRequests", label: t("minGap"), format: formatMs },
- { key: "concurrentRequests", label: t("maxConcurrent") },
- ].map(({ key, label, format }) => (
-
- {editMode ? (
-
- setDraft((prev) => ({ ...prev, [key]: parseInt(e.target.value) || 0 }))
- }
- className="w-full px-2 py-1 text-lg font-bold rounded bg-white/10 border border-white/20"
- />
- ) : (
-
- {format ? format(defaults?.[key]) : (defaults?.[key] ?? "—")}
-
- )}
-
{label}
-
- ))}
-
-
-
- {rateLimitStatus && rateLimitStatus.length > 0 ? (
-
-
- {t("activeLimiters")}
-
- {rateLimitStatus.map((rl, i) => (
-
-
{rl.provider || rl.key}
-
- {rl.reservoir != null && (
-
- {t("reservoir")}: {rl.reservoir}
-
- )}
- {rl.running != null && (
-
- {t("running")}: {rl.running}
-
- )}
- {rl.queued != null && (
-
- {t("queued")}: {rl.queued}
-
- )}
-
-
- ))}
-
- ) : (
-
{t("noActiveLimiters")}
- )}
-
-
- );
-}
-
-// ─── Circuit Breaker Card ────────────────────────────────────────────────
-function CircuitBreakerCard({ breakers, onReset, loading }) {
- const activeBreakers = breakers.filter((b) => b.state !== "CLOSED");
- const totalBreakers = breakers.length;
- const t = useTranslations("settings");
-
- return (
-
-
-
-
-
- electrical_services
-
-
{t("circuitBreakers")}
-
-
-
- {activeBreakers.length > 0
- ? t("tripped", { count: activeBreakers.length })
- : t("healthy", { count: totalBreakers })}
-
- {activeBreakers.length > 0 && (
-
- {t("resetAll")}
-
- )}
-
-
-
- {breakers.length === 0 ? (
-
{t("noCircuitBreakers")}
- ) : (
-
- {breakers.map((b) => {
- const style = STATE_STYLES[b.state] || STATE_STYLES.CLOSED;
- return (
-
-
-
- {style.icon}
-
- {b.name.replace("combo:", "")}
-
-
- {b.failureCount > 0 && (
-
- {t("failures", { count: b.failureCount })}
-
- )}
-
- {getBreakerStateLabel(b.state, t)}
-
-
-
- );
- })}
-
- )}
-
-
- );
-}
-
-// ─── 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 locale = useLocale();
- const t = useTranslations("settings");
-
- 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(t("unlockedIdentifier", { identifier }));
- await fetchPolicies();
- } else {
- notify.error(t("failedUnlock"));
- }
- } catch {
- notify.error(t("failedUnlock"));
- } finally {
- setUnlocking(null);
- }
- };
-
- const circuitBreakers = data?.circuitBreakers || [];
- const lockedIds = data?.lockedIdentifiers || [];
- const hasIssues = circuitBreakers.some((cb) => cb.state !== "closed") || lockedIds.length > 0;
-
- if (loading) {
+ if (editing) {
return (
-
-
- policy
- {t("loadingPolicies")}
-
-
+
+
+ {tc("cancel")}
+
+
+ {tc("save")}
+
+
);
}
return (
-
-
-
-
-
- policy
-
-
{t("policiesLocked")}
-
- {hasIssues && (
-
- refresh
-
- )}
-
+
+ {tc("edit")}
+
+ );
+}
- {!hasIssues ? (
-
-
- verified_user
-
-
-
{t("allOperational")}
-
+function RequestQueueCard({
+ value,
+ onSave,
+ saving,
+}: {
+ value: RequestQueueSettings;
+ onSave: (next: RequestQueueSettings) => Promise
;
+ saving: boolean;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(value);
+
+ useEffect(() => {
+ setDraft(value);
+ }, [value]);
+
+ return (
+
+
+
+
+ speed
+
Request Queue & Pacing
+
+
+
setEditing(true)}
+ onCancel={() => {
+ setDraft(value);
+ setEditing(false);
+ }}
+ onSave={async () => {
+ await onSave(draft);
+ setEditing(false);
+ }}
+ />
+
+
+
+ This layer only controls queueing and pacing. It does not write cooldowns or open breakers.
+
+
+
+ {editing ? (
+ <>
+
+ setDraft((prev) => ({ ...prev, autoEnableApiKeyProviders }))
+ }
+ />
+ setDraft((prev) => ({ ...prev, requestsPerMinute }))}
+ />
+
+ setDraft((prev) => ({ ...prev, minTimeBetweenRequestsMs }))
+ }
+ />
+
+ setDraft((prev) => ({ ...prev, concurrentRequests }))
+ }
+ />
+ setDraft((prev) => ({ ...prev, maxWaitMs }))}
+ />
+ >
) : (
<>
- {/* Circuit Breakers */}
- {circuitBreakers.filter((cb) => cb.state !== "closed").length > 0 && (
-
-
{t("circuitBreakers")}
-
- {circuitBreakers
- .filter((cb) => cb.state !== "closed")
- .map((cb, i) => {
- const status = CB_STATUS[cb.state] || CB_STATUS.open;
- return (
-
-
-
- {status.icon}
-
-
- {cb.name || cb.provider || t("unknown")}
-
-
- {getBreakerStateLabel(cb.state, t)}
-
- {cb.failures > 0 && (
-
- {t("failures", { count: cb.failures })}
-
- )}
-
-
- );
- })}
-
+
+
Auto-enable for API key providers
+
+ {value.autoEnableApiKeyProviders ? "Enabled" : "Disabled"}
- )}
-
- {/* Locked Identifiers */}
- {lockedIds.length > 0 && (
-
-
{t("lockedIdentifiers")}
-
- {lockedIds.map((id, i) => {
- const identifier = typeof id === "string" ? id : id.identifier || id.id;
- return (
-
-
-
- lock
-
- {identifier}
- {typeof id === "object" && id.lockedAt && (
-
- {t("sinceDate", {
- date: new Date(id.lockedAt).toLocaleString(locale),
- })}
-
- )}
-
-
handleUnlock(identifier)}
- disabled={unlocking === identifier}
- className="text-xs"
- >
- {unlocking === identifier ? t("unlocking") : t("forceUnlock")}
-
-
- );
- })}
-
+
+
+
Requests per minute
+
+ {value.requestsPerMinute}
- )}
+
+
+
Min time between requests
+
+ {formatMs(value.minTimeBetweenRequestsMs)}
+
+
+
+
Concurrent requests
+
+ {value.concurrentRequests}
+
+
+
+
Max queue wait
+
+ {formatMs(value.maxWaitMs)}
+
+
>
)}
@@ -551,131 +318,435 @@ function PoliciesCard() {
);
}
-// ─── Main Resilience Tab ─────────────────────────────────────────────────
-export default function ResilienceTab() {
- const [data, setData] = useState(null);
- const [loading, setLoading] = useState(true);
- const [saving, setSaving] = useState(false);
- const [error, setError] = useState(null);
- const t = useTranslations("settings");
-
- const loadData = useCallback(async () => {
- try {
- setLoading(true);
- const res = await fetch("/api/resilience");
- if (!res.ok) throw new Error(t("failedLoadWithStatus", { status: res.status }));
- const json = await res.json();
- setData(json);
- setError(null);
- } catch (err) {
- setError(getErrorMessage(err, t("failedLoadResilience")));
- } finally {
- setLoading(false);
- }
- }, [t]);
+function ConnectionCooldownCard({
+ value,
+ onSave,
+ saving,
+}: {
+ value: ResilienceResponse["connectionCooldown"];
+ onSave: (next: ResilienceResponse["connectionCooldown"]) => Promise
;
+ saving: boolean;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(value);
useEffect(() => {
- loadData();
- // Auto-refresh every 10s
- const interval = setInterval(loadData, 10000);
- return () => clearInterval(interval);
- }, [loadData]);
+ setDraft(value);
+ }, [value]);
- const handleResetBreakers = async () => {
- try {
- setLoading(true);
- const res = await fetch("/api/resilience/reset", { method: "POST" });
- if (!res.ok) throw new Error(t("resetFailed"));
- await loadData();
- } catch (err) {
- setError(getErrorMessage(err, t("resetFailed")));
- } finally {
- setLoading(false);
- }
+ const renderProfile = (key: "oauth" | "apikey", title: string, icon: string) => {
+ const current = editing ? draft[key] : value[key];
+ return (
+
+ {editing ? (
+ <>
+
+ setDraft((prev) => ({ ...prev, [key]: { ...prev[key], baseCooldownMs } }))
+ }
+ />
+
+ setDraft((prev) => ({
+ ...prev,
+ [key]: { ...prev[key], useUpstreamRetryHints },
+ }))
+ }
+ />
+
+ setDraft((prev) => ({ ...prev, [key]: { ...prev[key], maxBackoffSteps } }))
+ }
+ />
+ >
+ ) : (
+ <>
+
+ Base cooldown
+ {formatMs(current.baseCooldownMs)}
+
+
+ Use upstream retry hints
+
+ {current.useUpstreamRetryHints ? "Yes" : "No"}
+
+
+
+ Max backoff steps
+ {current.maxBackoffSteps}
+
+ >
+ )}
+
+ );
};
- const handleSaveProfiles = async (profiles) => {
- try {
- setSaving(true);
- const res = await fetch("/api/resilience", {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ profiles }),
- });
- if (!res.ok) throw new Error(t("saveFailed"));
- await loadData();
- } catch (err) {
- setError(getErrorMessage(err, t("saveFailed")));
- } finally {
- setSaving(false);
- }
+ return (
+
+
+
+
+ timer_off
+
Connection Cooldown
+
+
+
+
setEditing(true)}
+ onCancel={() => {
+ setDraft(value);
+ setEditing(false);
+ }}
+ onSave={async () => {
+ await onSave(draft);
+ setEditing(false);
+ }}
+ />
+
+
+
+ Base cooldown covers retryable connection failures. When upstream retry hints are enabled,
+ explicit provider wait windows override the local base cooldown.
+
+
+
+ {renderProfile("oauth", "OAuth Providers", "lock")}
+ {renderProfile("apikey", "API Key Providers", "key")}
+
+
+ );
+}
+
+function ProviderBreakerCard({
+ value,
+ onSave,
+ saving,
+}: {
+ value: ResilienceResponse["providerBreaker"];
+ onSave: (next: ResilienceResponse["providerBreaker"]) => Promise;
+ saving: boolean;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(value);
+
+ useEffect(() => {
+ setDraft(value);
+ }, [value]);
+
+ const renderProfile = (key: "oauth" | "apikey", title: string, icon: string) => {
+ const current = editing ? draft[key] : value[key];
+ return (
+
+ {editing ? (
+ <>
+
+ setDraft((prev) => ({ ...prev, [key]: { ...prev[key], failureThreshold } }))
+ }
+ />
+
+ setDraft((prev) => ({ ...prev, [key]: { ...prev[key], resetTimeoutMs } }))
+ }
+ />
+ >
+ ) : (
+ <>
+
+ Failure threshold
+ {current.failureThreshold}
+
+
+ Reset timeout
+ {formatMs(current.resetTimeoutMs)}
+
+ >
+ )}
+
+ );
};
- const handleSaveDefaults = async (defaults) => {
+ return (
+
+
+
+
+
+ electrical_services
+
+
Provider Circuit Breaker
+
+
+
+
setEditing(true)}
+ onCancel={() => {
+ setDraft(value);
+ setEditing(false);
+ }}
+ onSave={async () => {
+ await onSave(draft);
+ setEditing(false);
+ }}
+ />
+
+
+
+ Breaker runtime state is shown only on the Health page. Connection-scoped 429 rate limits
+ stay in Connection Cooldown and do not trip the provider breaker.
+
+
+
+ {renderProfile("oauth", "OAuth Providers", "lock")}
+ {renderProfile("apikey", "API Key Providers", "key")}
+
+
+ );
+}
+
+function WaitForCooldownCard({
+ value,
+ onSave,
+ saving,
+}: {
+ value: WaitForCooldownSettings;
+ onSave: (next: WaitForCooldownSettings) => Promise;
+ saving: boolean;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(value);
+
+ useEffect(() => {
+ setDraft(value);
+ }, [value]);
+
+ return (
+
+
+
+
+ hourglass_top
+
Wait For Cooldown
+
+
+
+
setEditing(true)}
+ onCancel={() => {
+ setDraft(value);
+ setEditing(false);
+ }}
+ onSave={async () => {
+ await onSave(draft);
+ setEditing(false);
+ }}
+ />
+
+
+
+ This only affects the current request. It does not write connection or provider state.
+
+
+
+ {editing ? (
+ <>
+
setDraft((prev) => ({ ...prev, enabled }))}
+ />
+ setDraft((prev) => ({ ...prev, maxRetries }))}
+ />
+ setDraft((prev) => ({ ...prev, maxRetryWaitSec }))}
+ />
+ >
+ ) : (
+ <>
+
+
Enable server-side waiting
+
+ {value.enabled ? "Enabled" : "Disabled"}
+
+
+
+
Max retries
+
{value.maxRetries}
+
+
+
Max retry wait
+
+ {value.maxRetryWaitSec}s
+
+
+ >
+ )}
+
+
+ );
+}
+
+export default function ResilienceTab() {
+ const notify = useNotificationStore();
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [savingSection, setSavingSection] = useState(null);
+
+ useEffect(() => {
+ let mounted = true;
+
+ const load = async () => {
+ try {
+ const response = await fetch("/api/resilience");
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ const json = await response.json();
+ if (!mounted) return;
+ setData({
+ requestQueue: json.requestQueue,
+ connectionCooldown: json.connectionCooldown,
+ providerBreaker: json.providerBreaker,
+ waitForCooldown: json.waitForCooldown,
+ });
+ } catch (error) {
+ notify.error(error instanceof Error ? error.message : "Failed to load resilience settings");
+ } finally {
+ if (mounted) setLoading(false);
+ }
+ };
+
+ void load();
+ return () => {
+ mounted = false;
+ };
+ }, [notify]);
+
+ const savePatch = async (section: string, payload: Record) => {
+ setSavingSection(section);
try {
- setSaving(true);
- const res = await fetch("/api/resilience", {
+ const response = await fetch("/api/resilience", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ defaults }),
+ body: JSON.stringify(payload),
});
- if (!res.ok) throw new Error(t("saveFailed"));
- await loadData();
- } catch (err) {
- setError(getErrorMessage(err, t("saveFailed")));
+ const json = await response.json();
+ if (!response.ok) {
+ throw new Error(json?.error?.message || json?.error || `HTTP ${response.status}`);
+ }
+ setData({
+ requestQueue: json.requestQueue,
+ connectionCooldown: json.connectionCooldown,
+ providerBreaker: json.providerBreaker,
+ waitForCooldown: json.waitForCooldown,
+ });
+ notify.success("Resilience settings updated.");
+ } catch (error) {
+ notify.error(error instanceof Error ? error.message : "Failed to save resilience settings");
+ throw error;
} finally {
- setSaving(false);
+ setSavingSection(null);
}
};
if (loading && !data) {
return (
-
- hourglass_empty
- {t("loadingResilience")}
-
+
+
+ progress_activity
+ Loading resilience settings...
+
+
);
}
- if (error && !data) {
+ if (!data) {
return (
-
- error
- {error}
-
-
- {t("retry")}
-
+ Unable to load resilience settings.
);
}
return (
-
- {/* 1. Provider Profiles (resilience settings by auth type) */}
-
+
+
+
info
+
+
Resilience Structure
+
+ This page only configures behavior. Live breaker state is shown on the Health page.
+ Combo-specific retry and round-robin slot control remain on combo settings.
+
+
+
+
+
+ savePatch("requestQueue", { requestQueue })}
/>
- {/* 1.5 Auto Disable Banned Accounts */}
-
- {/* 2. Rate Limiting (editable defaults + active limiters) */}
- savePatch("connectionCooldown", { connectionCooldown })}
/>
- {/* 3. Circuit Breakers (combo pipeline) */}
- savePatch("providerBreaker", { providerBreaker })}
+ />
+ savePatch("waitForCooldown", { waitForCooldown })}
/>
- {/* 4. Policies & Locked Identifiers (from previous Security tab) */}
-
);
}
diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx
index 8237e8eb34..e640e17628 100644
--- a/src/app/(dashboard)/layout.tsx
+++ b/src/app/(dashboard)/layout.tsx
@@ -1,10 +1,5 @@
import { DashboardLayout } from "@/shared/components";
-import { ModelStatusProvider } from "./dashboard/providers/components/ModelStatusContext";
export default function DashboardRootLayout({ children }) {
- return (
-
- {children}
-
- );
+ return {children} ;
}
diff --git a/src/app/api/models/availability/route.ts b/src/app/api/models/availability/route.ts
deleted file mode 100644
index 9ef7418c54..0000000000
--- a/src/app/api/models/availability/route.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { NextResponse } from "next/server";
-import {
- getAvailabilityReport,
- clearModelUnavailability,
- getUnavailableCount,
-} from "@/domain/modelAvailability";
-import { clearModelAvailabilitySchema } from "@/shared/validation/schemas";
-import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
-
-export async function GET() {
- try {
- const report = getAvailabilityReport();
- const count = getUnavailableCount();
- return NextResponse.json({ unavailableCount: count, models: report });
- } catch (error) {
- console.error("Error getting model availability:", error);
- return NextResponse.json({ error: "Failed to get model availability" }, { status: 500 });
- }
-}
-
-export async function POST(request) {
- let rawBody;
- try {
- rawBody = await request.json();
- } catch {
- return NextResponse.json(
- {
- error: {
- message: "Invalid request",
- details: [{ field: "body", message: "Invalid JSON body" }],
- },
- },
- { status: 400 }
- );
- }
-
- try {
- const validation = validateBody(clearModelAvailabilitySchema, rawBody);
- if (isValidationFailure(validation)) {
- return NextResponse.json({ error: validation.error }, { status: 400 });
- }
- const { provider, model } = validation.data;
-
- const removed = clearModelUnavailability(provider, model);
- return NextResponse.json({ success: true, removed });
- } catch (error) {
- console.error("Error clearing model availability:", error);
- return NextResponse.json({ error: "Failed to clear model availability" }, { status: 500 });
- }
-}
diff --git a/src/app/api/policies/route.ts b/src/app/api/policies/route.ts
index dc74eb76f6..47f0e3dfa5 100644
--- a/src/app/api/policies/route.ts
+++ b/src/app/api/policies/route.ts
@@ -1,14 +1,12 @@
import { NextResponse } from "next/server";
-import { getAllCircuitBreakerStatuses } from "@/shared/utils/circuitBreaker";
import { getLockedIdentifiers, forceUnlock } from "@/domain/lockoutPolicy";
import { policyActionSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function GET() {
try {
- const circuitBreakers = getAllCircuitBreakerStatuses();
const lockedIdentifiers = getLockedIdentifiers();
- return NextResponse.json({ circuitBreakers, lockedIdentifiers });
+ return NextResponse.json({ lockedIdentifiers });
} catch (error) {
console.error("Error loading policies:", error);
return NextResponse.json({ error: "Failed to load policies" }, { status: 500 });
diff --git a/src/app/api/resilience/reset/route.ts b/src/app/api/resilience/reset/route.ts
index 3ee4e94aea..8c21960732 100644
--- a/src/app/api/resilience/reset/route.ts
+++ b/src/app/api/resilience/reset/route.ts
@@ -1,11 +1,10 @@
import { NextResponse } from "next/server";
/**
- * POST /api/resilience/reset — Reset all circuit breakers and clear cooldowns
+ * POST /api/resilience/reset — Reset all provider circuit breakers
*/
export async function POST() {
try {
- // Reset all circuit breakers
const { getAllCircuitBreakerStatuses, getCircuitBreaker } =
await import("@/shared/utils/circuitBreaker");
diff --git a/src/app/api/resilience/route.ts b/src/app/api/resilience/route.ts
index d1bea74e33..9436aae3cf 100644
--- a/src/app/api/resilience/route.ts
+++ b/src/app/api/resilience/route.ts
@@ -1,5 +1,12 @@
import { NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
+import {
+ buildLegacyResilienceCompat,
+ mergeResilienceSettings,
+ resolveResilienceSettings,
+ type ResilienceSettings,
+ type ResilienceSettingsPatch,
+} from "@/lib/resilience/settings";
import { updateResilienceSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -13,41 +20,130 @@ function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback;
}
+function normalizeLegacyPatch(body: JsonRecord): ResilienceSettingsPatch {
+ const profiles = asRecord(body.profiles);
+ const defaults = asRecord(body.defaults);
+ const oauth = asRecord(profiles.oauth);
+ const apikey = asRecord(profiles.apikey);
+
+ const patch: ResilienceSettingsPatch = {};
+
+ if (Object.keys(defaults).length > 0) {
+ patch.requestQueue = {
+ ...(typeof defaults.requestsPerMinute === "number"
+ ? { requestsPerMinute: defaults.requestsPerMinute }
+ : {}),
+ ...(typeof defaults.minTimeBetweenRequests === "number"
+ ? { minTimeBetweenRequestsMs: defaults.minTimeBetweenRequests }
+ : {}),
+ ...(typeof defaults.concurrentRequests === "number"
+ ? { concurrentRequests: defaults.concurrentRequests }
+ : {}),
+ };
+ }
+
+ if (Object.keys(oauth).length > 0 || Object.keys(apikey).length > 0) {
+ const buildLegacyCooldownPatch = (profile: JsonRecord) => {
+ const cooldownCandidates = [
+ typeof profile.transientCooldown === "number" ? profile.transientCooldown : null,
+ typeof profile.rateLimitCooldown === "number" && profile.rateLimitCooldown > 0
+ ? profile.rateLimitCooldown
+ : null,
+ ].filter((value): value is number => typeof value === "number");
+
+ return {
+ ...(cooldownCandidates.length > 0
+ ? { baseCooldownMs: Math.max(...cooldownCandidates) }
+ : {}),
+ ...(typeof profile.rateLimitCooldown === "number"
+ ? { useUpstreamRetryHints: profile.rateLimitCooldown === 0 }
+ : {}),
+ ...(typeof profile.maxBackoffLevel === "number"
+ ? { maxBackoffSteps: profile.maxBackoffLevel }
+ : {}),
+ };
+ };
+
+ patch.connectionCooldown = {
+ ...(Object.keys(oauth).length > 0
+ ? {
+ oauth: buildLegacyCooldownPatch(oauth),
+ }
+ : {}),
+ ...(Object.keys(apikey).length > 0
+ ? {
+ apikey: buildLegacyCooldownPatch(apikey),
+ }
+ : {}),
+ };
+
+ patch.providerBreaker = {
+ ...(Object.keys(oauth).length > 0
+ ? {
+ oauth: {
+ ...(typeof oauth.circuitBreakerThreshold === "number"
+ ? { failureThreshold: oauth.circuitBreakerThreshold }
+ : {}),
+ ...(typeof oauth.circuitBreakerReset === "number"
+ ? { resetTimeoutMs: oauth.circuitBreakerReset }
+ : {}),
+ },
+ }
+ : {}),
+ ...(Object.keys(apikey).length > 0
+ ? {
+ apikey: {
+ ...(typeof apikey.circuitBreakerThreshold === "number"
+ ? { failureThreshold: apikey.circuitBreakerThreshold }
+ : {}),
+ ...(typeof apikey.circuitBreakerReset === "number"
+ ? { resetTimeoutMs: apikey.circuitBreakerReset }
+ : {}),
+ },
+ }
+ : {}),
+ };
+ }
+
+ return patch;
+}
+
+async function syncRuntimeSettings(resilienceSettings: ResilienceSettings) {
+ const { applyRequestQueueSettings } =
+ await import("@omniroute/open-sse/services/rateLimitManager");
+ applyRequestQueueSettings(resilienceSettings.requestQueue);
+}
+
/**
- * GET /api/resilience — Get current resilience configuration and status
+ * GET /api/resilience — Get current resilience configuration
*/
export async function GET() {
try {
- // Dynamic imports for open-sse modules
- const { getAllCircuitBreakerStatuses } = await import("@/shared/utils/circuitBreaker");
- const { getAllRateLimitStatus } = await import("@omniroute/open-sse/services/rateLimitManager");
- const { PROVIDER_PROFILES, DEFAULT_API_LIMITS } =
- await import("@omniroute/open-sse/config/constants");
-
const settings = await getSettings();
- const circuitBreakers = getAllCircuitBreakerStatuses();
- const rateLimitStatus = getAllRateLimitStatus();
+ const resilience = resolveResilienceSettings(settings);
return NextResponse.json({
- profiles: settings.providerProfiles || PROVIDER_PROFILES,
- defaults: {
- ...DEFAULT_API_LIMITS,
- ...asRecord(settings.rateLimitDefaults),
+ requestQueue: resilience.requestQueue,
+ connectionCooldown: resilience.connectionCooldown,
+ providerBreaker: resilience.providerBreaker,
+ waitForCooldown: {
+ enabled: resilience.waitForCooldown.enabled,
+ maxRetries: resilience.waitForCooldown.maxRetries,
+ maxRetryWaitSec: resilience.waitForCooldown.maxRetryWaitSec,
},
- circuitBreakers,
- rateLimitStatus,
+ legacy: buildLegacyResilienceCompat(resilience),
});
} catch (err: unknown) {
console.error("[API] GET /api/resilience error:", err);
return NextResponse.json(
- { error: getErrorMessage(err, "Failed to load resilience status") },
+ { error: getErrorMessage(err, "Failed to load resilience settings") },
{ status: 500 }
);
}
}
/**
- * PATCH /api/resilience — Update provider resilience profiles and/or rate limit defaults
+ * PATCH /api/resilience — Update resilience configuration
*/
export async function PATCH(request) {
let rawBody;
@@ -70,18 +166,47 @@ export async function PATCH(request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
- const { profiles, defaults } = validation.data;
- const updates: Record = {};
- if (profiles) updates.providerProfiles = profiles;
- if (defaults) updates.rateLimitDefaults = defaults;
+ const body = validation.data as JsonRecord;
+ const currentSettings = await getSettings();
+ const currentResilience = resolveResilienceSettings(currentSettings);
+ const nextResilience = mergeResilienceSettings(currentResilience, {
+ ...(body.requestQueue
+ ? { requestQueue: body.requestQueue as ResilienceSettingsPatch["requestQueue"] }
+ : {}),
+ ...(body.connectionCooldown
+ ? {
+ connectionCooldown:
+ body.connectionCooldown as ResilienceSettingsPatch["connectionCooldown"],
+ }
+ : {}),
+ ...(body.providerBreaker
+ ? { providerBreaker: body.providerBreaker as ResilienceSettingsPatch["providerBreaker"] }
+ : {}),
+ ...(body.waitForCooldown
+ ? { waitForCooldown: body.waitForCooldown as ResilienceSettingsPatch["waitForCooldown"] }
+ : {}),
+ ...normalizeLegacyPatch(body),
+ });
- await updateSettings(updates);
+ await updateSettings({
+ resilienceSettings: nextResilience,
+ requestRetry: nextResilience.waitForCooldown.maxRetries,
+ maxRetryIntervalSec: nextResilience.waitForCooldown.maxRetryWaitSec,
+ });
+ await syncRuntimeSettings(nextResilience);
return NextResponse.json({
ok: true,
- ...(profiles ? { profiles } : {}),
- ...(defaults ? { defaults } : {}),
+ requestQueue: nextResilience.requestQueue,
+ connectionCooldown: nextResilience.connectionCooldown,
+ providerBreaker: nextResilience.providerBreaker,
+ waitForCooldown: {
+ enabled: nextResilience.waitForCooldown.enabled,
+ maxRetries: nextResilience.waitForCooldown.maxRetries,
+ maxRetryWaitSec: nextResilience.waitForCooldown.maxRetryWaitSec,
+ },
+ legacy: buildLegacyResilienceCompat(nextResilience),
});
} catch (err: unknown) {
console.error("[API] PATCH /api/resilience error:", err);
diff --git a/src/app/api/settings/combo-defaults/route.ts b/src/app/api/settings/combo-defaults/route.ts
index fb2df51cda..49c01285b4 100644
--- a/src/app/api/settings/combo-defaults/route.ts
+++ b/src/app/api/settings/combo-defaults/route.ts
@@ -3,6 +3,32 @@ import { getSettings, updateSettings } from "@/lib/localDb";
import { updateComboDefaultsSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
+const LEGACY_COMBO_RESILIENCE_KEYS = new Set([
+ "timeoutMs",
+ "healthCheckEnabled",
+ "healthCheckTimeoutMs",
+]);
+
+function sanitizeComboRuntimeConfig(config?: Record | null) {
+ if (!config || typeof config !== "object") return {};
+ return Object.fromEntries(
+ Object.entries(config).filter(
+ ([key, value]) =>
+ value !== undefined && value !== null && !LEGACY_COMBO_RESILIENCE_KEYS.has(key)
+ )
+ );
+}
+
+function sanitizeProviderOverrides(overrides?: Record | null) {
+ if (!overrides || typeof overrides !== "object") return {};
+ return Object.fromEntries(
+ Object.entries(overrides).map(([providerId, config]) => [
+ providerId,
+ sanitizeComboRuntimeConfig(config),
+ ])
+ );
+}
+
/**
* GET /api/settings/combo-defaults
* Returns the current combo global defaults and provider overrides
@@ -10,21 +36,23 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function GET() {
try {
const settings: any = await getSettings();
+ const comboDefaults = sanitizeComboRuntimeConfig(settings.comboDefaults);
+ const providerOverrides = sanitizeProviderOverrides(settings.providerOverrides);
return NextResponse.json({
- comboDefaults: settings.comboDefaults || {
- strategy: "priority",
- maxRetries: 1,
- retryDelayMs: 2000,
- timeoutMs: 120000,
- healthCheckEnabled: true,
- healthCheckTimeoutMs: 3000,
- handoffThreshold: 0.85,
- handoffModel: "",
- maxMessagesForSummary: 30,
- maxComboDepth: 3,
- trackMetrics: true,
- },
- providerOverrides: settings.providerOverrides || {},
+ comboDefaults:
+ Object.keys(comboDefaults).length > 0
+ ? comboDefaults
+ : {
+ strategy: "priority",
+ maxRetries: 1,
+ retryDelayMs: 2000,
+ handoffThreshold: 0.85,
+ handoffModel: "",
+ maxMessagesForSummary: 30,
+ maxComboDepth: 3,
+ trackMetrics: true,
+ },
+ providerOverrides,
});
} catch (error) {
console.log("Error fetching combo defaults:", error);
@@ -63,16 +91,16 @@ export async function PATCH(request) {
const updates: Record = {};
if (body.comboDefaults) {
- updates.comboDefaults = body.comboDefaults;
+ updates.comboDefaults = sanitizeComboRuntimeConfig(body.comboDefaults);
}
if (body.providerOverrides) {
- updates.providerOverrides = body.providerOverrides;
+ updates.providerOverrides = sanitizeProviderOverrides(body.providerOverrides);
}
const settings: any = await updateSettings(updates);
return NextResponse.json({
- comboDefaults: settings.comboDefaults || {},
- providerOverrides: settings.providerOverrides || {},
+ comboDefaults: sanitizeComboRuntimeConfig(settings.comboDefaults),
+ providerOverrides: sanitizeProviderOverrides(settings.providerOverrides),
});
} catch (error) {
console.log("Error updating combo defaults:", error);
diff --git a/src/domain/modelAvailability.ts b/src/domain/modelAvailability.ts
deleted file mode 100644
index 9510c0ea73..0000000000
--- a/src/domain/modelAvailability.ts
+++ /dev/null
@@ -1,319 +0,0 @@
-/**
- * Model Availability — Domain Layer (T-19)
- *
- * Tracks model availability per provider with TTL-based cooldowns.
- * When a model becomes unavailable (rate-limited, erroring), it is
- * marked with a cooldown period. The availability report powers
- * the dashboard health view.
- *
- * @module domain/modelAvailability
- */
-
-/**
- * @typedef {Object} UnavailableEntry
- * @property {string} provider
- * @property {string} model
- * @property {number} unavailableSince - timestamp
- * @property {number} cooldownMs
- * @property {string} [reason]
- */
-
-/** @type {Map} */
-const unavailable = new Map();
-
-/**
- * @typedef {Object} FailureState
- * @property {number} failureCount
- * @property {number} lastFailureAt
- * @property {number} resetAfterMs
- */
-
-/**
- * @typedef {Object} ProviderProfile
- * @property {number} [transientCooldown]
- * @property {number} [rateLimitCooldown]
- * @property {number} [maxBackoffLevel]
- * @property {number} [circuitBreakerThreshold]
- * @property {number} [circuitBreakerReset]
- */
-
-/** @type {Map} */
-const failureState = new Map();
-
-const FAILURE_WINDOW_MS = 30 * 60 * 1000;
-
-const PROBLEMATIC_STATUS_COOLDOWNS = {
- 429: 5 * 60 * 1000,
- 408: 60 * 1000,
- 500: 2 * 60 * 1000,
- 502: 2 * 60 * 1000,
- 503: 2 * 60 * 1000,
- 504: 2 * 60 * 1000,
-};
-
-const MIN_PROBLEMATIC_COOLDOWN_MS = 60 * 1000;
-const MAX_PROBLEMATIC_COOLDOWN_MS = 30 * 60 * 1000;
-
-function toPositiveNumber(value) {
- return Number.isFinite(value) && Number(value) > 0 ? Number(value) : null;
-}
-
-function toNonNegativeNumber(value) {
- return Number.isFinite(value) && Number(value) >= 0 ? Number(value) : null;
-}
-
-/**
- * The first layer already reacts immediately to authoritative model/account failures.
- * Global provider/model quarantine is the escalation layer, so its failure window and
- * threshold are only customized when a runtime provider profile is supplied.
- *
- * @param {ProviderProfile | null | undefined} profile
- * @returns {number}
- */
-function getFailureWindowMs(profile) {
- return toPositiveNumber(profile?.circuitBreakerReset) ?? FAILURE_WINDOW_MS;
-}
-
-/**
- * Without a runtime profile we preserve legacy behavior: quarantine on the first failure.
- *
- * @param {ProviderProfile | null | undefined} profile
- * @returns {number}
- */
-function getFailureThreshold(profile) {
- return toPositiveNumber(profile?.circuitBreakerThreshold) ?? 1;
-}
-
-function getLegacyStatusCooldown(status) {
- return status && Object.prototype.hasOwnProperty.call(PROBLEMATIC_STATUS_COOLDOWNS, status)
- ? PROBLEMATIC_STATUS_COOLDOWNS[status]
- : 0;
-}
-
-/**
- * @param {number | null} status
- * @param {ProviderProfile | null | undefined} profile
- * @returns {number}
- */
-function getProfileStatusCooldown(status, profile) {
- if (!profile) return 0;
- if (status === 429) {
- return toPositiveNumber(profile.rateLimitCooldown) ?? 0;
- }
- return toPositiveNumber(profile.transientCooldown) ?? 0;
-}
-
-/**
- * @param {number} baseCooldownMs
- * @param {number} failureCount
- * @param {ProviderProfile | null | undefined} profile
- * @returns {number}
- */
-function getScaledCooldown(baseCooldownMs, failureCount, profile) {
- const safeBase = toPositiveNumber(baseCooldownMs) ?? 1000;
- if (!profile) {
- return Math.min(
- Math.max(safeBase, MIN_PROBLEMATIC_COOLDOWN_MS) * Math.pow(2, Math.max(0, failureCount - 1)),
- MAX_PROBLEMATIC_COOLDOWN_MS
- );
- }
-
- const maxBackoffLevel = Math.max(
- 0,
- Math.trunc(toNonNegativeNumber(profile.maxBackoffLevel) ?? 0)
- );
- const exponent = Math.min(Math.max(0, failureCount - 1), maxBackoffLevel);
- return safeBase * Math.pow(2, exponent);
-}
-
-/**
- * Build a composite key for provider+model.
- * @param {string} provider
- * @param {string} model
- * @returns {string}
- */
-function makeKey(provider, model) {
- return `${provider}::${model}`;
-}
-
-/**
- * Check if a model is currently available.
- *
- * @param {string} provider - Provider ID (e.g. "openai", "anthropic")
- * @param {string} model - Model ID (e.g. "gpt-4o", "claude-sonnet-4-20250514")
- * @returns {boolean} true if model is available (not in cooldown)
- */
-export function isModelAvailable(provider, model) {
- const key = makeKey(provider, model);
- const entry = unavailable.get(key);
- if (!entry) return true;
-
- // Check if cooldown has expired
- if (Date.now() - entry.unavailableSince >= entry.cooldownMs) {
- unavailable.delete(key);
- return true;
- }
-
- return false;
-}
-
-/**
- * Get remaining cooldown information for a model, if it is currently unavailable.
- *
- * @param {string} provider
- * @param {string} model
- * @returns {{ provider: string, model: string, reason: string, remainingMs: number, unavailableSince: string } | null}
- */
-export function getModelCooldownInfo(provider, model) {
- const key = makeKey(provider, model);
- const entry = unavailable.get(key);
- if (!entry) return null;
-
- const elapsed = Date.now() - entry.unavailableSince;
- if (elapsed >= entry.cooldownMs) {
- unavailable.delete(key);
- return null;
- }
-
- return {
- provider: entry.provider,
- model: entry.model,
- reason: entry.reason || "unknown",
- remainingMs: entry.cooldownMs - elapsed,
- unavailableSince: new Date(entry.unavailableSince).toISOString(),
- };
-}
-
-/**
- * Mark a model as temporarily unavailable.
- *
- * @param {string} provider
- * @param {string} model
- * @param {number} [cooldownMs=60000] - Cooldown in milliseconds (default 60s)
- * @param {string} [reason] - Optional reason for unavailability
- */
-export function setModelUnavailable(provider, model, cooldownMs = 60000, reason) {
- const key = makeKey(provider, model);
- const now = Date.now();
- const safeCooldownMs = Number.isFinite(cooldownMs) && cooldownMs > 0 ? cooldownMs : 60000;
- const existing = unavailable.get(key);
- const existingRemainingMs =
- existing && Date.now() - existing.unavailableSince < existing.cooldownMs
- ? existing.cooldownMs - (Date.now() - existing.unavailableSince)
- : 0;
- const effectiveCooldownMs = Math.max(safeCooldownMs, existingRemainingMs);
-
- unavailable.set(key, {
- provider,
- model,
- unavailableSince: now,
- cooldownMs: effectiveCooldownMs,
- reason: reason || "unknown",
- });
-}
-
-/**
- * Marca provider/model como problemático com cooldown adaptativo.
- * Mantém retrocompatibilidade: não altera o comportamento de setModelUnavailable,
- * apenas oferece uma estratégia mais agressiva para falhas recorrentes.
- *
- * @param {string} provider
- * @param {string} model
- * @param {{ status?: number, baseCooldownMs?: number, reason?: string, profile?: ProviderProfile | null }} [options]
- * @returns {{ cooldownMs: number, failureCount: number, quarantined: boolean, threshold: number, resetAfterMs: number }}
- */
-export function markModelAsProblematic(provider, model, options = {}) {
- const key = makeKey(provider, model);
- const now = Date.now();
- const status = Number.isFinite(options.status) ? Number(options.status) : null;
- const profile = options.profile || null;
- const explicitBaseCooldownMs =
- Number.isFinite(options.baseCooldownMs) && Number(options.baseCooldownMs) > 0
- ? Number(options.baseCooldownMs)
- : 0;
- const statusBaseCooldown = profile
- ? getProfileStatusCooldown(status, profile)
- : getLegacyStatusCooldown(status);
- const baseCooldownMs = Math.max(explicitBaseCooldownMs, statusBaseCooldown);
-
- const prev = failureState.get(key);
- const resetAfterMs = getFailureWindowMs(profile);
- const withinFailureWindow = prev && now - prev.lastFailureAt <= prev.resetAfterMs;
- const failureCount = withinFailureWindow ? prev.failureCount + 1 : 1;
- failureState.set(key, { failureCount, lastFailureAt: now, resetAfterMs });
-
- const threshold = getFailureThreshold(profile);
- const cooldownMs = getScaledCooldown(baseCooldownMs, failureCount, profile);
- const quarantined = failureCount >= threshold;
-
- if (quarantined) {
- setModelUnavailable(provider, model, cooldownMs, options.reason || "problematic_model");
- }
-
- return {
- cooldownMs,
- failureCount,
- quarantined,
- threshold,
- resetAfterMs,
- };
-}
-
-/**
- * Clear unavailability for a model (e.g. after manual reset).
- *
- * @param {string} provider
- * @param {string} model
- * @returns {boolean} true if entry existed and was removed
- */
-export function clearModelUnavailability(provider, model) {
- const key = makeKey(provider, model);
- failureState.delete(key);
- return unavailable.delete(key);
-}
-
-/**
- * Get a report of all currently unavailable models.
- *
- * @returns {Array<{ provider: string, model: string, reason: string, remainingMs: number, unavailableSince: string }>}
- */
-export function getAvailabilityReport() {
- const now = Date.now();
- const report = [];
-
- for (const [key, entry] of unavailable.entries()) {
- const elapsed = now - entry.unavailableSince;
- if (elapsed >= entry.cooldownMs) {
- unavailable.delete(key);
- continue;
- }
-
- report.push({
- provider: entry.provider,
- model: entry.model,
- reason: entry.reason || "unknown",
- remainingMs: entry.cooldownMs - elapsed,
- unavailableSince: new Date(entry.unavailableSince).toISOString(),
- });
- }
-
- return report;
-}
-
-/**
- * Get total count of unavailable models.
- * @returns {number}
- */
-export function getUnavailableCount() {
- // Prune expired entries first
- getAvailabilityReport();
- return unavailable.size;
-}
-
-/**
- * Reset all availability states (for testing or admin).
- */
-export function resetAllAvailability() {
- unavailable.clear();
- failureState.clear();
-}
diff --git a/src/lib/combos/intelligentRouting.ts b/src/lib/combos/intelligentRouting.ts
index 1f2a285a6a..7f42f5a0cb 100644
--- a/src/lib/combos/intelligentRouting.ts
+++ b/src/lib/combos/intelligentRouting.ts
@@ -31,13 +31,6 @@ export type IntelligentProviderScore = {
factors: IntelligentRoutingWeights;
};
-export type IntelligentExclusionEntry = {
- provider: string;
- excludedAt: string;
- cooldownMs: number;
- reason: string;
-};
-
export const DEFAULT_INTELLIGENT_WEIGHTS: IntelligentRoutingWeights = {
quota: 0.2,
health: 0.25,
@@ -161,50 +154,3 @@ export function buildIntelligentProviderScores(combo: {
factors: weights,
}));
}
-
-export function extractIntelligentHealthState(health: unknown): {
- incidentMode: boolean;
- exclusions: IntelligentExclusionEntry[];
-} {
- const healthRecord = isRecord(health) ? health : {};
- const providerHealth = isRecord(healthRecord.providerHealth) ? healthRecord.providerHealth : {};
- const providerBreakers = Object.entries(providerHealth).map(([provider, status]) => {
- const statusRecord = isRecord(status) ? status : {};
- return {
- provider,
- state: typeof statusRecord.state === "string" ? statusRecord.state : "CLOSED",
- lastFailure: typeof statusRecord.lastFailure === "string" ? statusRecord.lastFailure : null,
- };
- });
- const breakersFromArray = Array.isArray(healthRecord.circuitBreakers)
- ? healthRecord.circuitBreakers
- .map((entry) => {
- const breaker = isRecord(entry) ? entry : {};
- const provider =
- typeof breaker.provider === "string"
- ? breaker.provider
- : typeof breaker.name === "string"
- ? breaker.name
- : "unknown";
- return {
- provider,
- state: typeof breaker.state === "string" ? breaker.state : "CLOSED",
- lastFailure: typeof breaker.lastFailure === "string" ? breaker.lastFailure : null,
- };
- })
- .filter((entry) => typeof entry.provider === "string")
- : [];
-
- const breakers = breakersFromArray.length > 0 ? breakersFromArray : providerBreakers;
- const openBreakers = breakers.filter((breaker) => breaker.state === "OPEN");
-
- return {
- incidentMode: openBreakers.length / Math.max(breakers.length, 1) > 0.5,
- exclusions: openBreakers.map((breaker) => ({
- provider: breaker.provider,
- excludedAt: breaker.lastFailure || new Date().toISOString(),
- cooldownMs: 5 * 60 * 1000,
- reason: "Circuit breaker OPEN",
- })),
- };
-}
diff --git a/src/lib/dataPaths.js b/src/lib/dataPaths.js
deleted file mode 100644
index 3595429936..0000000000
--- a/src/lib/dataPaths.js
+++ /dev/null
@@ -1,70 +0,0 @@
-"use strict";
-var __importDefault = (this && this.__importDefault) || function (mod) {
- return (mod && mod.__esModule) ? mod : { "default": mod };
-};
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.APP_NAME = void 0;
-exports.getLegacyDotDataDir = getLegacyDotDataDir;
-exports.getDefaultDataDir = getDefaultDataDir;
-exports.resolveDataDir = resolveDataDir;
-exports.isSamePath = isSamePath;
-const path_1 = __importDefault(require("path"));
-const os_1 = __importDefault(require("os"));
-exports.APP_NAME = "omniroute";
-function fallbackHomeDir() {
- const envHome = process.env.HOME || process.env.USERPROFILE;
- if (typeof envHome === "string" && envHome.trim().length > 0) {
- return path_1.default.resolve(envHome);
- }
- return os_1.default.tmpdir();
-}
-function safeHomeDir() {
- try {
- return os_1.default.homedir();
- }
- catch {
- return fallbackHomeDir();
- }
-}
-function normalizeConfiguredPath(dir) {
- if (typeof dir !== "string")
- return null;
- const trimmed = dir.trim();
- if (!trimmed)
- return null;
- return path_1.default.resolve(trimmed);
-}
-function getLegacyDotDataDir() {
- return path_1.default.join(safeHomeDir(), `.${exports.APP_NAME}`);
-}
-function getDefaultDataDir() {
- const homeDir = safeHomeDir();
- if (process.platform === "win32") {
- const appData = process.env.APPDATA || path_1.default.join(homeDir, "AppData", "Roaming");
- return path_1.default.join(appData, exports.APP_NAME);
- }
- // Support XDG on Linux/macOS when explicitly configured.
- const xdgConfigHome = normalizeConfiguredPath(process.env.XDG_CONFIG_HOME);
- if (xdgConfigHome) {
- return path_1.default.join(xdgConfigHome, exports.APP_NAME);
- }
- return getLegacyDotDataDir();
-}
-function resolveDataDir({ isCloud = false } = {}) {
- if (isCloud)
- return "/tmp";
- const configured = normalizeConfiguredPath(process.env.DATA_DIR);
- if (configured)
- return configured;
- return getDefaultDataDir();
-}
-function isSamePath(a, b) {
- if (!a || !b)
- return false;
- const normalizedA = path_1.default.resolve(a);
- const normalizedB = path_1.default.resolve(b);
- if (process.platform === "win32") {
- return normalizedA.toLowerCase() === normalizedB.toLowerCase();
- }
- return normalizedA === normalizedB;
-}
diff --git a/src/lib/monitoring/observability.ts b/src/lib/monitoring/observability.ts
index 8feb1cdcc7..0088fd5192 100644
--- a/src/lib/monitoring/observability.ts
+++ b/src/lib/monitoring/observability.ts
@@ -4,7 +4,8 @@ interface CircuitBreakerStatus {
name: string;
state: string;
failureCount?: number;
- lastFailureTime?: string | null;
+ lastFailureTime?: number | string | null;
+ retryAfterMs?: number;
}
interface SessionSnapshot {
@@ -155,13 +156,31 @@ export function buildHealthPayload({
platform: process.platform,
};
+ const providerBreakers = circuitBreakers
+ .filter((cb) => !cb.name.startsWith("test-") && !cb.name.startsWith("test_"))
+ .map((cb) => {
+ const lastFailure =
+ typeof cb.lastFailureTime === "number" && Number.isFinite(cb.lastFailureTime)
+ ? new Date(cb.lastFailureTime).toISOString()
+ : typeof cb.lastFailureTime === "string"
+ ? cb.lastFailureTime
+ : null;
+ return {
+ provider: cb.name,
+ state: cb.state,
+ failureCount: cb.failureCount || 0,
+ lastFailure,
+ retryAfterMs: cb.retryAfterMs || 0,
+ };
+ });
+
const providerHealth: Record = {};
- for (const cb of circuitBreakers) {
- if (cb.name.startsWith("test-") || cb.name.startsWith("test_")) continue;
- providerHealth[cb.name] = {
- state: cb.state,
- failures: cb.failureCount || 0,
- lastFailure: cb.lastFailureTime || null,
+ for (const breaker of providerBreakers) {
+ providerHealth[breaker.provider] = {
+ state: breaker.state,
+ failures: breaker.failureCount,
+ lastFailure: breaker.lastFailure,
+ retryAfterMs: breaker.retryAfterMs,
};
}
@@ -197,6 +216,7 @@ export function buildHealthPayload({
...breakerCounts,
total: breakerCounts.open + breakerCounts.halfOpen + breakerCounts.closed,
},
+ providerBreakers,
providerHealth,
providerSummary: {
catalogCount,
diff --git a/src/lib/resilience/settings.ts b/src/lib/resilience/settings.ts
new file mode 100644
index 0000000000..f6f65e8faf
--- /dev/null
+++ b/src/lib/resilience/settings.ts
@@ -0,0 +1,419 @@
+import { DEFAULT_API_LIMITS, PROVIDER_PROFILES } from "@omniroute/open-sse/config/constants";
+
+type JsonRecord = Record;
+type AuthCategory = "oauth" | "apikey";
+
+export interface RequestQueueSettings {
+ autoEnableApiKeyProviders: boolean;
+ requestsPerMinute: number;
+ minTimeBetweenRequestsMs: number;
+ concurrentRequests: number;
+ maxWaitMs: number;
+}
+
+export interface ConnectionCooldownProfileSettings {
+ baseCooldownMs: number;
+ useUpstreamRetryHints: boolean;
+ maxBackoffSteps: number;
+}
+
+export interface ProviderBreakerProfileSettings {
+ failureThreshold: number;
+ resetTimeoutMs: number;
+}
+
+export interface WaitForCooldownSettings {
+ enabled: boolean;
+ maxRetries: number;
+ maxRetryWaitSec: number;
+ maxRetryWaitMs: number;
+}
+
+export interface ResilienceSettings {
+ requestQueue: RequestQueueSettings;
+ connectionCooldown: Record;
+ providerBreaker: Record;
+ waitForCooldown: WaitForCooldownSettings;
+}
+
+export interface ResilienceSettingsPatch {
+ requestQueue?: Partial;
+ connectionCooldown?: Partial>>;
+ providerBreaker?: Partial>>;
+ waitForCooldown?: Partial;
+}
+
+function asRecord(value: unknown): JsonRecord {
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
+}
+
+function toInteger(
+ value: unknown,
+ fallback: number,
+ options: { min?: number; max?: number } = {}
+): number {
+ const min = options.min ?? 0;
+ const max = options.max ?? Number.MAX_SAFE_INTEGER;
+ const parsed =
+ typeof value === "number"
+ ? value
+ : typeof value === "string" && value.trim().length > 0
+ ? Number(value)
+ : Number.NaN;
+
+ if (!Number.isFinite(parsed)) {
+ return fallback;
+ }
+
+ return Math.min(max, Math.max(min, Math.trunc(parsed)));
+}
+
+function toBoolean(value: unknown, fallback: boolean): boolean {
+ return typeof value === "boolean" ? value : fallback;
+}
+
+export const DEFAULT_REQUEST_QUEUE_MAX_WAIT_MS = (() => {
+ const parsed = Number(process.env.RATE_LIMIT_MAX_WAIT_MS || "120000");
+ return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : 120000;
+})();
+
+export const DEFAULT_RESILIENCE_SETTINGS: ResilienceSettings = {
+ requestQueue: {
+ autoEnableApiKeyProviders: true,
+ requestsPerMinute: DEFAULT_API_LIMITS.requestsPerMinute,
+ minTimeBetweenRequestsMs: DEFAULT_API_LIMITS.minTimeBetweenRequests,
+ concurrentRequests: DEFAULT_API_LIMITS.concurrentRequests,
+ maxWaitMs: DEFAULT_REQUEST_QUEUE_MAX_WAIT_MS,
+ },
+ connectionCooldown: {
+ oauth: {
+ baseCooldownMs: PROVIDER_PROFILES.oauth.transientCooldown,
+ useUpstreamRetryHints: PROVIDER_PROFILES.oauth.rateLimitCooldown === 0,
+ maxBackoffSteps: PROVIDER_PROFILES.oauth.maxBackoffLevel,
+ },
+ apikey: {
+ baseCooldownMs: PROVIDER_PROFILES.apikey.transientCooldown,
+ useUpstreamRetryHints: PROVIDER_PROFILES.apikey.rateLimitCooldown === 0,
+ maxBackoffSteps: PROVIDER_PROFILES.apikey.maxBackoffLevel,
+ },
+ },
+ providerBreaker: {
+ oauth: {
+ failureThreshold: PROVIDER_PROFILES.oauth.circuitBreakerThreshold,
+ resetTimeoutMs: PROVIDER_PROFILES.oauth.circuitBreakerReset,
+ },
+ apikey: {
+ failureThreshold: PROVIDER_PROFILES.apikey.circuitBreakerThreshold,
+ resetTimeoutMs: PROVIDER_PROFILES.apikey.circuitBreakerReset,
+ },
+ },
+ waitForCooldown: {
+ enabled: true,
+ maxRetries: 3,
+ maxRetryWaitSec: 30,
+ maxRetryWaitMs: 30000,
+ },
+};
+
+function normalizeRequestQueueSettings(
+ next: unknown,
+ fallback: RequestQueueSettings
+): RequestQueueSettings {
+ const record = asRecord(next);
+ const requestsPerMinute = toInteger(record.requestsPerMinute, fallback.requestsPerMinute, {
+ min: 1,
+ max: 1_000_000,
+ });
+ const minTimeBetweenRequestsMs = toInteger(
+ record.minTimeBetweenRequestsMs,
+ fallback.minTimeBetweenRequestsMs,
+ { min: 0, max: 60 * 60 * 1000 }
+ );
+ const concurrentRequests = toInteger(record.concurrentRequests, fallback.concurrentRequests, {
+ min: 1,
+ max: 10_000,
+ });
+ const maxWaitMs = toInteger(record.maxWaitMs, fallback.maxWaitMs, {
+ min: 1,
+ max: 24 * 60 * 60 * 1000,
+ });
+
+ return {
+ autoEnableApiKeyProviders: toBoolean(
+ record.autoEnableApiKeyProviders,
+ fallback.autoEnableApiKeyProviders
+ ),
+ requestsPerMinute,
+ minTimeBetweenRequestsMs,
+ concurrentRequests,
+ maxWaitMs,
+ };
+}
+
+function normalizeConnectionCooldownProfile(
+ next: unknown,
+ fallback: ConnectionCooldownProfileSettings
+): ConnectionCooldownProfileSettings {
+ const record = asRecord(next);
+ return {
+ baseCooldownMs: toInteger(record.baseCooldownMs, fallback.baseCooldownMs, {
+ min: 0,
+ max: 24 * 60 * 60 * 1000,
+ }),
+ useUpstreamRetryHints: toBoolean(record.useUpstreamRetryHints, fallback.useUpstreamRetryHints),
+ maxBackoffSteps: toInteger(record.maxBackoffSteps, fallback.maxBackoffSteps, {
+ min: 0,
+ max: 32,
+ }),
+ };
+}
+
+function normalizeLegacyConnectionCooldownProfile(
+ next: unknown,
+ fallback: ConnectionCooldownProfileSettings
+): ConnectionCooldownProfileSettings {
+ const record = asRecord(next);
+ const transientCooldown = toInteger(record.transientCooldown, fallback.baseCooldownMs, {
+ min: 0,
+ max: 24 * 60 * 60 * 1000,
+ });
+ const legacyRateLimitCooldown = toInteger(record.rateLimitCooldown, transientCooldown, {
+ min: 0,
+ max: 24 * 60 * 60 * 1000,
+ });
+ const useUpstreamRetryHints =
+ typeof record.rateLimitCooldown === "number"
+ ? record.rateLimitCooldown === 0
+ : fallback.useUpstreamRetryHints;
+
+ return {
+ baseCooldownMs: useUpstreamRetryHints
+ ? transientCooldown
+ : Math.max(transientCooldown, legacyRateLimitCooldown),
+ useUpstreamRetryHints,
+ maxBackoffSteps: toInteger(record.maxBackoffLevel, fallback.maxBackoffSteps, {
+ min: 0,
+ max: 32,
+ }),
+ };
+}
+
+function normalizeProviderBreakerProfile(
+ next: unknown,
+ fallback: ProviderBreakerProfileSettings
+): ProviderBreakerProfileSettings {
+ const record = asRecord(next);
+ return {
+ failureThreshold: toInteger(record.failureThreshold, fallback.failureThreshold, {
+ min: 1,
+ max: 1000,
+ }),
+ resetTimeoutMs: toInteger(record.resetTimeoutMs, fallback.resetTimeoutMs, {
+ min: 1000,
+ max: 24 * 60 * 60 * 1000,
+ }),
+ };
+}
+
+function normalizeWaitForCooldownSettings(
+ next: unknown,
+ fallback: WaitForCooldownSettings
+): WaitForCooldownSettings {
+ const record = asRecord(next);
+ const maxRetryWaitSec = toInteger(record.maxRetryWaitSec, fallback.maxRetryWaitSec, {
+ min: 0,
+ max: 300,
+ });
+ const maxRetries = toInteger(record.maxRetries, fallback.maxRetries, { min: 0, max: 10 });
+ const enabled =
+ toBoolean(record.enabled, fallback.enabled) && maxRetries > 0 && maxRetryWaitSec > 0;
+
+ return {
+ enabled,
+ maxRetries,
+ maxRetryWaitSec,
+ maxRetryWaitMs: maxRetryWaitSec * 1000,
+ };
+}
+
+function buildLegacyFallback(settings: JsonRecord): ResilienceSettings {
+ const profiles = asRecord(settings.providerProfiles);
+ const defaults = asRecord(settings.rateLimitDefaults);
+
+ const oauthLegacy = asRecord(profiles.oauth);
+ const apikeyLegacy = asRecord(profiles.apikey);
+
+ const waitMaxRetrySec = toInteger(
+ settings.maxRetryIntervalSec,
+ DEFAULT_RESILIENCE_SETTINGS.waitForCooldown.maxRetryWaitSec,
+ { min: 0, max: 300 }
+ );
+ const waitMaxRetries = toInteger(
+ settings.requestRetry,
+ DEFAULT_RESILIENCE_SETTINGS.waitForCooldown.maxRetries,
+ { min: 0, max: 10 }
+ );
+
+ return {
+ requestQueue: {
+ autoEnableApiKeyProviders: DEFAULT_RESILIENCE_SETTINGS.requestQueue.autoEnableApiKeyProviders,
+ requestsPerMinute: toInteger(
+ defaults.requestsPerMinute,
+ DEFAULT_RESILIENCE_SETTINGS.requestQueue.requestsPerMinute,
+ { min: 1, max: 1_000_000 }
+ ),
+ minTimeBetweenRequestsMs: toInteger(
+ defaults.minTimeBetweenRequests,
+ DEFAULT_RESILIENCE_SETTINGS.requestQueue.minTimeBetweenRequestsMs,
+ { min: 0, max: 60 * 60 * 1000 }
+ ),
+ concurrentRequests: toInteger(
+ defaults.concurrentRequests,
+ DEFAULT_RESILIENCE_SETTINGS.requestQueue.concurrentRequests,
+ { min: 1, max: 10_000 }
+ ),
+ maxWaitMs: DEFAULT_RESILIENCE_SETTINGS.requestQueue.maxWaitMs,
+ },
+ connectionCooldown: {
+ oauth: normalizeLegacyConnectionCooldownProfile(
+ oauthLegacy,
+ DEFAULT_RESILIENCE_SETTINGS.connectionCooldown.oauth
+ ),
+ apikey: normalizeLegacyConnectionCooldownProfile(
+ apikeyLegacy,
+ DEFAULT_RESILIENCE_SETTINGS.connectionCooldown.apikey
+ ),
+ },
+ providerBreaker: {
+ oauth: {
+ failureThreshold: toInteger(
+ oauthLegacy.circuitBreakerThreshold,
+ DEFAULT_RESILIENCE_SETTINGS.providerBreaker.oauth.failureThreshold,
+ { min: 1, max: 1000 }
+ ),
+ resetTimeoutMs: toInteger(
+ oauthLegacy.circuitBreakerReset,
+ DEFAULT_RESILIENCE_SETTINGS.providerBreaker.oauth.resetTimeoutMs,
+ { min: 1000, max: 24 * 60 * 60 * 1000 }
+ ),
+ },
+ apikey: {
+ failureThreshold: toInteger(
+ apikeyLegacy.circuitBreakerThreshold,
+ DEFAULT_RESILIENCE_SETTINGS.providerBreaker.apikey.failureThreshold,
+ { min: 1, max: 1000 }
+ ),
+ resetTimeoutMs: toInteger(
+ apikeyLegacy.circuitBreakerReset,
+ DEFAULT_RESILIENCE_SETTINGS.providerBreaker.apikey.resetTimeoutMs,
+ { min: 1000, max: 24 * 60 * 60 * 1000 }
+ ),
+ },
+ },
+ waitForCooldown: {
+ enabled: waitMaxRetries > 0 && waitMaxRetrySec > 0,
+ maxRetries: waitMaxRetries,
+ maxRetryWaitSec: waitMaxRetrySec,
+ maxRetryWaitMs: waitMaxRetrySec * 1000,
+ },
+ };
+}
+
+export function resolveResilienceSettings(
+ settings: Record | null | undefined
+): ResilienceSettings {
+ const record = asRecord(settings);
+ const current = asRecord(record.resilienceSettings);
+ const fallback = buildLegacyFallback(record);
+
+ return {
+ requestQueue: normalizeRequestQueueSettings(current.requestQueue, fallback.requestQueue),
+ connectionCooldown: {
+ oauth: normalizeConnectionCooldownProfile(
+ asRecord(current.connectionCooldown).oauth,
+ fallback.connectionCooldown.oauth
+ ),
+ apikey: normalizeConnectionCooldownProfile(
+ asRecord(current.connectionCooldown).apikey,
+ fallback.connectionCooldown.apikey
+ ),
+ },
+ providerBreaker: {
+ oauth: normalizeProviderBreakerProfile(
+ asRecord(current.providerBreaker).oauth,
+ fallback.providerBreaker.oauth
+ ),
+ apikey: normalizeProviderBreakerProfile(
+ asRecord(current.providerBreaker).apikey,
+ fallback.providerBreaker.apikey
+ ),
+ },
+ waitForCooldown: normalizeWaitForCooldownSettings(
+ current.waitForCooldown,
+ fallback.waitForCooldown
+ ),
+ };
+}
+
+export function mergeResilienceSettings(
+ current: ResilienceSettings,
+ updates: ResilienceSettingsPatch
+): ResilienceSettings {
+ return {
+ requestQueue: normalizeRequestQueueSettings(updates.requestQueue, current.requestQueue),
+ connectionCooldown: {
+ oauth: normalizeConnectionCooldownProfile(
+ updates.connectionCooldown?.oauth,
+ current.connectionCooldown.oauth
+ ),
+ apikey: normalizeConnectionCooldownProfile(
+ updates.connectionCooldown?.apikey,
+ current.connectionCooldown.apikey
+ ),
+ },
+ providerBreaker: {
+ oauth: normalizeProviderBreakerProfile(
+ updates.providerBreaker?.oauth,
+ current.providerBreaker.oauth
+ ),
+ apikey: normalizeProviderBreakerProfile(
+ updates.providerBreaker?.apikey,
+ current.providerBreaker.apikey
+ ),
+ },
+ waitForCooldown: normalizeWaitForCooldownSettings(
+ updates.waitForCooldown,
+ current.waitForCooldown
+ ),
+ };
+}
+
+export function buildLegacyResilienceCompat(settings: ResilienceSettings) {
+ return {
+ profiles: {
+ oauth: {
+ transientCooldown: settings.connectionCooldown.oauth.baseCooldownMs,
+ rateLimitCooldown: settings.connectionCooldown.oauth.useUpstreamRetryHints
+ ? 0
+ : settings.connectionCooldown.oauth.baseCooldownMs,
+ maxBackoffLevel: settings.connectionCooldown.oauth.maxBackoffSteps,
+ circuitBreakerThreshold: settings.providerBreaker.oauth.failureThreshold,
+ circuitBreakerReset: settings.providerBreaker.oauth.resetTimeoutMs,
+ },
+ apikey: {
+ transientCooldown: settings.connectionCooldown.apikey.baseCooldownMs,
+ rateLimitCooldown: settings.connectionCooldown.apikey.useUpstreamRetryHints
+ ? 0
+ : settings.connectionCooldown.apikey.baseCooldownMs,
+ maxBackoffLevel: settings.connectionCooldown.apikey.maxBackoffSteps,
+ circuitBreakerThreshold: settings.providerBreaker.apikey.failureThreshold,
+ circuitBreakerReset: settings.providerBreaker.apikey.resetTimeoutMs,
+ },
+ },
+ defaults: {
+ requestsPerMinute: settings.requestQueue.requestsPerMinute,
+ minTimeBetweenRequests: settings.requestQueue.minTimeBetweenRequestsMs,
+ concurrentRequests: settings.requestQueue.concurrentRequests,
+ },
+ };
+}
diff --git a/src/shared/utils/circuitBreaker.ts b/src/shared/utils/circuitBreaker.ts
index 3f9e0e332a..3700bba31c 100644
--- a/src/shared/utils/circuitBreaker.ts
+++ b/src/shared/utils/circuitBreaker.ts
@@ -180,6 +180,7 @@ export class CircuitBreaker {
state: this.state,
failureCount: this.failureCount,
lastFailureTime: this.lastFailureTime,
+ retryAfterMs: this.getRetryAfterMs(),
};
}
diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts
index ae120f3da7..fc6d83101e 100644
--- a/src/shared/validation/schemas.ts
+++ b/src/shared/validation/schemas.ts
@@ -616,11 +616,6 @@ export const updateModelAliasSchema = z.object({
alias: z.string().trim().min(1, "Alias is required").max(200),
});
-export const clearModelAvailabilitySchema = z.object({
- provider: z.string().trim().min(1, "provider is required").max(120),
- model: modelIdSchema,
-});
-
/** Align with `sanitizeUpstreamHeadersMap` — allow non-ASCII names; reject Host / hop-by-hop / whitespace / ":". */
const upstreamHeaderNameSchema = z
.string()
@@ -709,7 +704,7 @@ export const toggleRateLimitSchema = z.object({
enabled: z.boolean(),
});
-const resilienceProfileSchema = z.object({
+const legacyResilienceProfileSchema = z.object({
transientCooldown: z.number().min(0),
rateLimitCooldown: z.number().min(0),
maxBackoffLevel: z.number().int().min(0),
@@ -717,30 +712,87 @@ const resilienceProfileSchema = z.object({
circuitBreakerReset: z.number().min(0),
});
-const resilienceDefaultsSchema = z
+const legacyResilienceDefaultsSchema = z
.object({
requestsPerMinute: z.number().int().min(1).optional(),
- minTimeBetweenRequests: z.number().int().min(1).optional(),
+ minTimeBetweenRequests: z.number().int().min(0).optional(),
concurrentRequests: z.number().int().min(1).optional(),
})
.strict();
+const requestQueueSettingsSchema = z
+ .object({
+ autoEnableApiKeyProviders: z.boolean().optional(),
+ requestsPerMinute: z.number().int().min(1).optional(),
+ minTimeBetweenRequestsMs: z.number().int().min(0).optional(),
+ concurrentRequests: z.number().int().min(1).optional(),
+ maxWaitMs: z.number().int().min(1).optional(),
+ })
+ .strict();
+
+const connectionCooldownProfileSchema = z
+ .object({
+ baseCooldownMs: z.number().int().min(0).optional(),
+ useUpstreamRetryHints: z.boolean().optional(),
+ maxBackoffSteps: z.number().int().min(0).optional(),
+ })
+ .strict();
+
+const providerBreakerProfileSchema = z
+ .object({
+ failureThreshold: z.number().int().min(1).optional(),
+ resetTimeoutMs: z.number().int().min(1000).optional(),
+ })
+ .strict();
+
+const waitForCooldownSettingsSchema = z
+ .object({
+ enabled: z.boolean().optional(),
+ maxRetries: z.number().int().min(0).max(10).optional(),
+ maxRetryWaitSec: z.number().int().min(0).max(300).optional(),
+ })
+ .strict();
+
export const updateResilienceSchema = z
.object({
- profiles: z
+ requestQueue: requestQueueSettingsSchema.optional(),
+ connectionCooldown: z
.object({
- oauth: resilienceProfileSchema.optional(),
- apikey: resilienceProfileSchema.optional(),
+ oauth: connectionCooldownProfileSchema.optional(),
+ apikey: connectionCooldownProfileSchema.optional(),
})
.strict()
.optional(),
- defaults: resilienceDefaultsSchema.optional(),
+ providerBreaker: z
+ .object({
+ oauth: providerBreakerProfileSchema.optional(),
+ apikey: providerBreakerProfileSchema.optional(),
+ })
+ .strict()
+ .optional(),
+ waitForCooldown: waitForCooldownSettingsSchema.optional(),
+ profiles: z
+ .object({
+ oauth: legacyResilienceProfileSchema.optional(),
+ apikey: legacyResilienceProfileSchema.optional(),
+ })
+ .strict()
+ .optional(),
+ defaults: legacyResilienceDefaultsSchema.optional(),
})
+ .strict()
.superRefine((value, ctx) => {
- if (!value.profiles && !value.defaults) {
+ if (
+ !value.requestQueue &&
+ !value.connectionCooldown &&
+ !value.providerBreaker &&
+ !value.waitForCooldown &&
+ !value.profiles &&
+ !value.defaults
+ ) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: "Must provide profiles or defaults",
+ message: "Must provide resilience settings to update",
path: [],
});
}
diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts
index 70f546be1b..b66d1ea534 100644
--- a/src/sse/handlers/chat.ts
+++ b/src/sse/handlers/chat.ts
@@ -42,11 +42,6 @@ import {
// Pipeline integration — wired modules
import { getCircuitBreaker } from "../../shared/utils/circuitBreaker";
-import {
- isModelAvailable,
- markModelAsProblematic,
- clearModelUnavailability,
-} from "../../domain/modelAvailability";
import { markAccountExhaustedFrom429 } from "../../domain/quotaCache";
import { RequestTelemetry, recordTelemetry } from "../../shared/utils/requestTelemetry";
import { generateRequestId } from "../../shared/utils/requestId";
@@ -107,6 +102,8 @@ function intersectAllowedConnectionIds(primary: unknown, secondary: unknown): st
return first || second || null;
}
+const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]);
+
/**
* Handle chat completion request
* Supports: OpenAI, Claude, Gemini, OpenAI Responses API formats
@@ -324,14 +321,6 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
return false;
}
- // Fixed-account combo steps must bypass the provider/model cooldown gate here.
- // A previous account failure can quarantine the model globally, but the next
- // step may intentionally pin a different connection for the same model.
- if (!hasForcedConnection && !isModelAvailable(provider, resolvedModel)) {
- log.debug("AVAILABILITY", `${provider}/${modelInfo.model} in cooldown, skipping`);
- return false;
- }
-
const creds = await getProviderCredentialsWithQuotaPreflight(
provider,
null,
@@ -524,7 +513,7 @@ async function handleSingleModelChat(
? "fixed combo step connection"
: undefined;
- // 2. Pipeline gates (availability + circuit breaker)
+ // 2. Pipeline gates (availability + provider circuit breaker)
const providerProfile = await getRuntimeProviderProfile(provider);
const gate = await checkPipelineGates(provider, model, {
ignoreCircuitBreaker: forceLiveComboTest || hasForcedConnection,
@@ -535,8 +524,8 @@ async function handleSingleModelChat(
if (gate) return gate;
const breaker = getCircuitBreaker(provider, {
- failureThreshold: providerProfile.circuitBreakerThreshold,
- resetTimeout: providerProfile.circuitBreakerReset,
+ failureThreshold: providerProfile.failureThreshold,
+ resetTimeout: providerProfile.resetTimeoutMs,
onStateChange: (name: string, from: string, to: string) =>
log.info("CIRCUIT", `${name}: ${from} → ${to}`),
});
@@ -549,9 +538,10 @@ async function handleSingleModelChat(
const retrySettings = disableCooldownAwareRetry
? {
...baseRetrySettings,
- requestRetry: 0,
- maxRetryIntervalSec: 0,
- maxRetryIntervalMs: 0,
+ enabled: false,
+ maxRetries: 0,
+ maxRetryWaitSec: 0,
+ maxRetryWaitMs: 0,
}
: baseRetrySettings;
const requestSignal = request?.signal ?? null;
@@ -597,26 +587,6 @@ async function handleSingleModelChat(
);
if (!credentials || "allRateLimited" in credentials) {
- if ([408, 429, 500, 502, 503, 504].includes(Number(lastStatus))) {
- const quarantine = markModelAsProblematic(provider, model, {
- status: Number(lastStatus),
- baseCooldownMs: lastCooldownMs,
- reason: `HTTP ${lastStatus}`,
- profile: providerProfile,
- });
- if (quarantine.quarantined) {
- log.info(
- "AVAILABILITY",
- `${provider}/${model} marked unavailable — all accounts exhausted (HTTP ${lastStatus}, cooldown ${Math.ceil(quarantine.cooldownMs / 1000)}s, failureCount ${quarantine.failureCount}/${quarantine.threshold})`
- );
- } else {
- log.info(
- "AVAILABILITY",
- `${provider}/${model} recorded exhaustion failure ${quarantine.failureCount}/${quarantine.threshold} (HTTP ${lastStatus}, cooldown basis ${Math.ceil(quarantine.cooldownMs / 1000)}s)`
- );
- }
- }
-
if (credentials?.allRateLimited) {
const retryDecision = getCooldownAwareRetryDecision({
retryAfter: credentials.retryAfter,
@@ -628,7 +598,7 @@ async function handleSingleModelChat(
const waitSec = Math.max(Math.ceil(retryDecision.waitMs / 1000), 0);
log.info(
"COOLDOWN_RETRY",
- `${provider}/${model} all accounts cooling down (${retryDecision.retryAfterHuman || `retry in ${waitSec}s`}) — waiting ${waitSec}s before retry ${requestRetryAttempt + 1}/${retrySettings.requestRetry}`
+ `${provider}/${model} all connections cooling down (${retryDecision.retryAfterHuman || `retry in ${waitSec}s`}) — waiting ${waitSec}s before retry ${requestRetryAttempt + 1}/${retrySettings.maxRetries}`
);
const completed = await waitForCooldownAwareRetry(retryDecision.waitMs, requestSignal);
@@ -643,12 +613,21 @@ async function handleSingleModelChat(
requestRetryAttempt += 1;
log.info(
"COOLDOWN_RETRY",
- `${provider}/${model} cooldown elapsed — restarting request attempt ${requestRetryAttempt}/${retrySettings.requestRetry}`
+ `${provider}/${model} cooldown elapsed — restarting request attempt ${requestRetryAttempt}/${retrySettings.maxRetries}`
);
continue requestAttemptLoop;
}
}
+ const breakerFailureStatus = Number(lastStatus ?? credentials?.lastErrorCode);
+ if (
+ !forceLiveComboTest &&
+ credentials?.allRateLimited &&
+ PROVIDER_BREAKER_FAILURE_STATUSES.has(breakerFailureStatus)
+ ) {
+ breaker._onFailure();
+ }
+
return handleNoCredentials(
credentials,
excludedConnectionIds.size > 0 ? Array.from(excludedConnectionIds)[0] : null,
@@ -717,11 +696,9 @@ async function handleSingleModelChat(
const proxyInfo = await safeResolveProxy(credentials.connectionId);
const proxyStartTime = Date.now();
- // 4. Execute chat via core (with circuit breaker + optional TLS)
+ // 4. Execute chat via core after breaker gate checks (with optional TLS tracking)
if (telemetry) telemetry.startPhase("connect");
const { result, tlsFingerprintUsed } = await executeChatWithBreaker({
- bypassCircuitBreaker: forceLiveComboTest,
- breaker,
body: requestBody,
provider,
model,
@@ -765,7 +742,9 @@ async function handleSingleModelChat(
if (result.success) {
clearModelLock(provider, credentials.connectionId, model);
- clearModelUnavailability(provider, model);
+ if (!forceLiveComboTest) {
+ breaker._onSuccess();
+ }
if (injectedHandoff && runtimeOptions.sessionId && comboName) {
deleteHandoff(runtimeOptions.sessionId, comboName);
}
@@ -873,6 +852,10 @@ async function handleSingleModelChat(
continue;
}
+ if (!forceLiveComboTest && PROVIDER_BREAKER_FAILURE_STATUSES.has(Number(result.status))) {
+ breaker._onFailure();
+ }
+
return result.response;
}
}
diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts
index ef5dd9213e..8099889ed3 100644
--- a/src/sse/handlers/chatHelpers.ts
+++ b/src/sse/handlers/chatHelpers.ts
@@ -14,6 +14,7 @@ import { handleChatCore } from "@omniroute/open-sse/handlers/chatCore.ts";
import {
errorResponse,
modelCooldownResponse,
+ providerCircuitOpenResponse,
unavailableResponse,
} from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
@@ -23,14 +24,10 @@ import {
isTlsFingerprintActive,
} from "@omniroute/open-sse/utils/proxyFetch.ts";
import { resolveProxyForConnection } from "@/lib/localDb";
-import { getCircuitBreaker, CircuitBreakerOpenError } from "../../shared/utils/circuitBreaker";
-import { getModelCooldownInfo, isModelAvailable } from "../../domain/modelAvailability";
+import { getCircuitBreaker } from "../../shared/utils/circuitBreaker";
import { logProxyEvent } from "../../lib/proxyLogger";
import { logTranslationEvent } from "../../lib/translatorEvents";
-import {
- getRuntimeProviderProfile,
- clearProviderFailure,
-} from "@omniroute/open-sse/services/accountFallback.ts";
+import { getRuntimeProviderProfile } from "@omniroute/open-sse/services/accountFallback.ts";
export async function resolveModelOrError(modelStr: string, body: any, endpointPath: string = "") {
const modelInfo = await getModelInfo(modelStr);
@@ -79,30 +76,16 @@ export async function checkPipelineGates(
providerProfile?: {
circuitBreakerThreshold?: number;
circuitBreakerReset?: number;
+ failureThreshold?: number;
+ resetTimeoutMs?: number;
} | null;
} = {}
) {
const bypassReason = options.bypassReason || "pipeline override";
- const modelAvailable = isModelAvailable(provider, model);
- if (!modelAvailable && options.ignoreModelCooldown) {
- log.info("AVAILABILITY", `${provider}/${model} cooldown bypassed (${bypassReason})`);
- } else if (!modelAvailable) {
- const cooldownInfo = getModelCooldownInfo(provider, model);
- const retryAfterSec = cooldownInfo
- ? Math.max(Math.ceil(cooldownInfo.remainingMs / 1000), 1)
- : 1;
- log.warn("AVAILABILITY", `${provider}/${model} is in cooldown, rejecting request`);
- return unavailableResponse(
- HTTP_STATUS.SERVICE_UNAVAILABLE,
- `Model ${provider}/${model} is temporarily unavailable (cooldown)`,
- retryAfterSec
- );
- }
-
const providerProfile = options.providerProfile ?? (await getRuntimeProviderProfile(provider));
const breaker = getCircuitBreaker(provider, {
- failureThreshold: providerProfile.circuitBreakerThreshold,
- resetTimeout: providerProfile.circuitBreakerReset,
+ failureThreshold: providerProfile.failureThreshold ?? providerProfile.circuitBreakerThreshold,
+ resetTimeout: providerProfile.resetTimeoutMs ?? providerProfile.circuitBreakerReset,
onStateChange: (name: string, from: string, to: string) =>
log.info("CIRCUIT", `${name}: ${from} → ${to}`),
});
@@ -112,19 +95,13 @@ export async function checkPipelineGates(
const retryAfterMs = breaker.getRetryAfterMs();
const retryAfterSec = Math.max(Math.ceil(retryAfterMs / 1000), 1);
log.warn("CIRCUIT", `Circuit breaker OPEN for ${provider}, rejecting request`);
- return unavailableResponse(
- HTTP_STATUS.SERVICE_UNAVAILABLE,
- `Provider ${provider} circuit breaker is open`,
- retryAfterSec
- );
+ return providerCircuitOpenResponse(provider, retryAfterSec);
}
return null;
}
export async function executeChatWithBreaker({
- bypassCircuitBreaker,
- breaker,
body,
provider,
model,
@@ -173,46 +150,18 @@ export async function executeChatWithBreaker({
},
onRequestSuccess: async () => {
await clearAccountError(credentials.connectionId, credentials);
- // Clear provider-level failure state on successful request
- clearProviderFailure(provider);
},
})
);
- if (bypassCircuitBreaker) {
- if (!proxyInfo?.proxy && isTlsFingerprintActive()) {
- const tracked = await runWithTlsTracking(chatFn);
- return { result: tracked.result, tlsFingerprintUsed: tracked.tlsFingerprintUsed };
- }
-
- const result = await chatFn();
- return { result, tlsFingerprintUsed: false };
- }
-
if (!proxyInfo?.proxy && isTlsFingerprintActive()) {
- const tracked = await breaker.execute(async () => runWithTlsTracking(chatFn));
+ const tracked = await runWithTlsTracking(chatFn);
return { result: tracked.result, tlsFingerprintUsed: tracked.tlsFingerprintUsed };
}
- const result = await breaker.execute(chatFn);
+ const result = await chatFn();
return { result, tlsFingerprintUsed: false };
} catch (cbErr: any) {
- if (cbErr instanceof CircuitBreakerOpenError) {
- log.warn("CIRCUIT", `${provider} circuit open during retry: ${cbErr.message}`);
- return {
- result: {
- success: false,
- response: unavailableResponse(
- HTTP_STATUS.SERVICE_UNAVAILABLE,
- `Provider ${provider} circuit breaker is open`,
- Math.ceil(cbErr.retryAfterMs / 1000)
- ),
- status: HTTP_STATUS.SERVICE_UNAVAILABLE,
- },
- tlsFingerprintUsed: false,
- };
- }
-
if (cbErr?.code === "PROXY_UNREACHABLE" || /proxy unreachable/i.test(cbErr?.message || "")) {
const detail = cbErr?.message || "Proxy unreachable";
log.warn("PROXY", detail);
diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts
index ec601a3810..59e64366ad 100644
--- a/src/sse/services/auth.ts
+++ b/src/sse/services/auth.ts
@@ -19,12 +19,14 @@ import {
hasPerModelQuota,
getRuntimeProviderProfile,
recordModelLockoutFailure,
- isProviderInCooldown,
- getProviderCooldownRemainingMs,
} from "@omniroute/open-sse/services/accountFallback.ts";
import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts";
import { COOLDOWN_MS } from "@omniroute/open-sse/config/constants.ts";
import { preflightQuota } from "@omniroute/open-sse/services/quotaPreflight.ts";
+import {
+ classifyProviderError,
+ PROVIDER_ERROR_TYPES,
+} from "@omniroute/open-sse/services/errorClassifier.ts";
import { getCodexModelScope } from "@omniroute/open-sse/executors/codex.ts";
import { getProviderAlias, resolveProviderId } from "@/shared/constants/providers";
import { isModelExcludedByConnection } from "@/domain/connectionModelRules";
@@ -253,6 +255,31 @@ function isTerminalConnectionStatus(connection: ProviderConnectionView): boolean
return status === "credits_exhausted" || status === "banned" || status === "expired";
}
+function resolveTerminalConnectionStatus(
+ status: number,
+ result: { permanent?: boolean; creditsExhausted?: boolean },
+ providerErrorType: string | null = null
+): string | null {
+ if (result.creditsExhausted || status === 402) return "credits_exhausted";
+ if (
+ providerErrorType === PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR ||
+ providerErrorType === PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN
+ ) {
+ return null;
+ }
+ if (result.permanent || providerErrorType === PROVIDER_ERROR_TYPES.FORBIDDEN || status === 403) {
+ return "banned";
+ }
+ if (
+ providerErrorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED ||
+ providerErrorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED ||
+ status === 401
+ ) {
+ return "expired";
+ }
+ return null;
+}
+
export function resolveQuotaLimitPolicy(
provider: string,
providerSpecificData: JsonRecord
@@ -684,22 +711,6 @@ export async function getProviderCredentials(
return true;
});
- // Check if the entire provider is in cooldown (too many transient failures)
- if (isProviderInCooldown(provider)) {
- const cooldownRemaining = getProviderCooldownRemainingMs(provider);
- log.warn(
- "AUTH",
- `${provider} | provider in cooldown for ${Math.ceil((cooldownRemaining || 0) / 1000)}s (${availableConnections.length} connections bypassed)`
- );
- return {
- allRateLimited: true,
- retryAfter: new Date(Date.now() + (cooldownRemaining || 0)).toISOString(),
- retryAfterHuman: formatRetryAfter(
- new Date(Date.now() + (cooldownRemaining || 0)).toISOString()
- ),
- };
- }
-
log.debug(
"AUTH",
`${provider} | available: ${availableConnections.length}/${connections.length}`
@@ -1206,6 +1217,15 @@ export async function markAccountUnavailable(
const effectiveProviderProfile =
providerProfile || (provider ? await getRuntimeProviderProfile(provider) : null);
+ const fallbackResult = checkFallbackError(
+ status,
+ errorText,
+ backoffLevel,
+ model,
+ provider,
+ null,
+ effectiveProviderProfile
+ );
// Read passthroughModels from connection config (user-configured per-model quota)
const connProviderSpecificData = (conn?.providerSpecificData as Record) || {};
@@ -1216,18 +1236,20 @@ export async function markAccountUnavailable(
const isPerModelQuotaProvider = hasPerModelQuota(provider, model, connectionPassthroughModels);
if (isPerModelQuotaProvider && provider && model && (status === 404 || status === 429)) {
const reason = status === 404 ? "not_found" : "rate_limited";
- const fallbackCooldown =
- status === 404
- ? (effectiveProviderProfile?.transientCooldown ?? COOLDOWN_MS.notFoundLocal)
- : 0;
const lockout = recordModelLockoutFailure(
provider,
connectionId,
model,
reason,
status,
- fallbackCooldown,
- effectiveProviderProfile
+ status === 404
+ ? (effectiveProviderProfile?.baseCooldownMs ?? COOLDOWN_MS.notFoundLocal)
+ : (fallbackResult.baseCooldownMs ?? effectiveProviderProfile?.baseCooldownMs ?? 0),
+ effectiveProviderProfile,
+ {
+ exactCooldownMs:
+ fallbackResult.usedUpstreamRetryHint === true ? fallbackResult.cooldownMs : null,
+ }
);
// Update last error for observability (without changing terminal status)
updateProviderConnection(connectionId, {
@@ -1242,18 +1264,12 @@ export async function markAccountUnavailable(
);
return { shouldFallback: true, cooldownMs: lockout.cooldownMs };
}
-
- const result = checkFallbackError(
- status,
- errorText,
- backoffLevel,
- model,
- provider,
- null,
- effectiveProviderProfile
- );
- const { shouldFallback, cooldownMs, newBackoffLevel, reason } = result;
+ const result = fallbackResult;
+ const { shouldFallback, cooldownMs: rawCooldownMs, newBackoffLevel, reason } = result;
if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 };
+ const providerErrorType = classifyProviderError(status, errorText);
+ const terminalStatus = resolveTerminalConnectionStatus(status, result, providerErrorType);
+ const cooldownMs = terminalStatus ? 0 : rawCooldownMs;
// ── 404 model-only lockout: connection stays active ──
// For local providers (detected by URL), a 404 means the specific model
@@ -1286,7 +1302,6 @@ export async function markAccountUnavailable(
return { shouldFallback: true, cooldownMs: lockout.cooldownMs };
}
- const rateLimitedUntil = getUnavailableUntil(cooldownMs);
const errorMsg = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
// T09: Codex per-scope lockout (do not block the whole account globally).
@@ -1294,7 +1309,7 @@ export async function markAccountUnavailable(
const scope = getCodexModelScope(model);
const existingScopeMap = asRecord(conn.providerSpecificData.codexScopeRateLimitedUntil);
const persistedScopeUntil = getCodexScopeRateLimitedUntil(conn.providerSpecificData, model);
- const scopeRateLimitedUntil = persistedScopeUntil || rateLimitedUntil;
+ const scopeRateLimitedUntil = persistedScopeUntil || getUnavailableUntil(cooldownMs);
const scopeCooldownMs = Math.max(new Date(scopeRateLimitedUntil).getTime() - Date.now(), 0);
await updateProviderConnection(connectionId, {
@@ -1323,14 +1338,27 @@ export async function markAccountUnavailable(
return { shouldFallback: true, cooldownMs: scopeCooldownMs };
}
- await updateProviderConnection(connectionId, {
- rateLimitedUntil,
- testStatus: "unavailable",
+ const baseUpdate = {
lastError: errorMsg,
+ lastErrorType: providerErrorType,
errorCode: status,
lastErrorAt: new Date().toISOString(),
backoffLevel: newBackoffLevel ?? backoffLevel,
- });
+ };
+
+ if (cooldownMs > 0) {
+ await updateProviderConnection(connectionId, {
+ ...baseUpdate,
+ rateLimitedUntil: getUnavailableUntil(cooldownMs),
+ testStatus: "unavailable",
+ });
+ } else {
+ await updateProviderConnection(connectionId, {
+ ...baseUpdate,
+ rateLimitedUntil: null,
+ ...(terminalStatus ? { testStatus: terminalStatus } : {}),
+ });
+ }
// T-AUTODISABLE: If auto-disable setting is enabled and error is permanent/terminal,
// mark account as inactive so it is never retried again.
@@ -1354,11 +1382,6 @@ export async function markAccountUnavailable(
}
}
- // Per-model lockout: lock the specific model if known
- if (provider && model && cooldownMs > 0) {
- lockModel(provider, connectionId, model, reason || "unknown", cooldownMs);
- }
-
if (provider && status && errorMsg) {
console.error(`❌ ${provider} [${status}]: ${errorMsg}`);
}
diff --git a/src/sse/services/cooldownAwareRetry.ts b/src/sse/services/cooldownAwareRetry.ts
index 99f1ef5f4a..14a95c605d 100644
--- a/src/sse/services/cooldownAwareRetry.ts
+++ b/src/sse/services/cooldownAwareRetry.ts
@@ -1,14 +1,14 @@
import { formatRetryAfter } from "@omniroute/open-sse/services/accountFallback.ts";
+import { resolveResilienceSettings } from "@/lib/resilience/settings";
-const DEFAULT_REQUEST_RETRY = 3;
-const DEFAULT_MAX_RETRY_INTERVAL_SEC = 30;
const MAX_REQUEST_RETRY = 10;
const MAX_RETRY_INTERVAL_SEC = 300;
export interface CooldownAwareRetrySettings {
- requestRetry: number;
- maxRetryIntervalSec: number;
- maxRetryIntervalMs: number;
+ enabled: boolean;
+ maxRetries: number;
+ maxRetryWaitSec: number;
+ maxRetryWaitMs: number;
}
function normalizeInteger(
@@ -35,20 +35,26 @@ function normalizeInteger(
export function resolveCooldownAwareRetrySettings(
settings: Record | null | undefined
): CooldownAwareRetrySettings {
- const requestRetry = normalizeInteger(settings?.requestRetry, DEFAULT_REQUEST_RETRY, {
+ const waitForCooldown = resolveResilienceSettings(settings).waitForCooldown;
+ const maxRetries = normalizeInteger(waitForCooldown.maxRetries, waitForCooldown.maxRetries, {
min: 0,
max: MAX_REQUEST_RETRY,
});
- const maxRetryIntervalSec = normalizeInteger(
- settings?.maxRetryIntervalSec,
- DEFAULT_MAX_RETRY_INTERVAL_SEC,
- { min: 0, max: MAX_RETRY_INTERVAL_SEC }
+ const maxRetryWaitSec = normalizeInteger(
+ waitForCooldown.maxRetryWaitSec,
+ waitForCooldown.maxRetryWaitSec,
+ {
+ min: 0,
+ max: MAX_RETRY_INTERVAL_SEC,
+ }
);
+ const enabled = Boolean(waitForCooldown.enabled) && maxRetries > 0 && maxRetryWaitSec > 0;
return {
- requestRetry,
- maxRetryIntervalSec,
- maxRetryIntervalMs: maxRetryIntervalSec * 1000,
+ enabled,
+ maxRetries,
+ maxRetryWaitSec,
+ maxRetryWaitMs: maxRetryWaitSec * 1000,
};
}
@@ -90,9 +96,10 @@ export function getCooldownAwareRetryDecision({
} {
const closest = computeClosestRetryAfter(retryAfter);
if (
- settings.requestRetry <= 0 ||
- settings.maxRetryIntervalMs <= 0 ||
- attempt >= settings.requestRetry ||
+ !settings.enabled ||
+ settings.maxRetries <= 0 ||
+ settings.maxRetryWaitMs <= 0 ||
+ attempt >= settings.maxRetries ||
closest.waitMs === null
) {
return {
@@ -103,7 +110,7 @@ export function getCooldownAwareRetryDecision({
};
}
- if (closest.waitMs > settings.maxRetryIntervalMs) {
+ if (closest.waitMs > settings.maxRetryWaitMs) {
return {
shouldRetry: false,
retryAfter: closest.retryAfter,
diff --git a/src/types/settings.ts b/src/types/settings.ts
index 9f4f32b38e..273b5bf941 100644
--- a/src/types/settings.ts
+++ b/src/types/settings.ts
@@ -1,4 +1,5 @@
import type { HideableSidebarItemId } from "@/shared/constants/sidebarVisibility";
+import type { ResilienceSettings } from "@/lib/resilience/settings";
/**
* Application settings stored in SQLite key-value pairs.
@@ -20,15 +21,13 @@ export interface Settings {
jwtSecret?: string;
hideHealthCheckLogs?: boolean;
hiddenSidebarItems?: HideableSidebarItemId[];
+ resilienceSettings?: ResilienceSettings;
}
export interface ComboDefaults {
strategy: "priority" | "weighted" | "round-robin" | "context-relay";
maxRetries: number;
retryDelayMs: number;
- timeoutMs: number;
- healthCheckEnabled: boolean;
- healthCheckTimeoutMs: number;
maxComboDepth: number;
trackMetrics: boolean;
concurrencyPerModel?: number;
diff --git a/tests/e2e/resilience-plan-alignment.spec.ts b/tests/e2e/resilience-plan-alignment.spec.ts
new file mode 100644
index 0000000000..817ebcf5bc
--- /dev/null
+++ b/tests/e2e/resilience-plan-alignment.spec.ts
@@ -0,0 +1,374 @@
+import { expect, test, type Page } from "@playwright/test";
+import { gotoDashboardRoute } from "./helpers/dashboardAuth";
+
+const resilienceSettings = {
+ requestQueue: {
+ autoEnableApiKeyProviders: true,
+ requestsPerMinute: 100,
+ minTimeBetweenRequestsMs: 200,
+ concurrentRequests: 10,
+ maxWaitMs: 120000,
+ },
+ connectionCooldown: {
+ oauth: {
+ baseCooldownMs: 60000,
+ useUpstreamRetryHints: false,
+ maxBackoffSteps: 8,
+ },
+ apikey: {
+ baseCooldownMs: 3000,
+ useUpstreamRetryHints: true,
+ maxBackoffSteps: 5,
+ },
+ },
+ providerBreaker: {
+ oauth: {
+ failureThreshold: 3,
+ resetTimeoutMs: 60000,
+ },
+ apikey: {
+ failureThreshold: 5,
+ resetTimeoutMs: 30000,
+ },
+ },
+ waitForCooldown: {
+ enabled: true,
+ maxRetries: 3,
+ maxRetryWaitSec: 30,
+ },
+};
+
+async function mockResilienceSettings(page: Page) {
+ await page.route("**/api/resilience", async (route) => {
+ const method = route.request().method();
+ if (method === "GET") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(resilienceSettings),
+ });
+ return;
+ }
+
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ ok: true,
+ ...resilienceSettings,
+ }),
+ });
+ });
+}
+
+async function mockHealthPageApis(page: Page) {
+ const now = new Date().toISOString();
+
+ await page.route("**/api/monitoring/health", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ status: "error",
+ system: {
+ uptime: 3723,
+ version: "3.7.0",
+ nodeVersion: "22.12.0",
+ memoryUsage: {
+ rss: 64 * 1024 * 1024,
+ heapUsed: 24 * 1024 * 1024,
+ heapTotal: 48 * 1024 * 1024,
+ },
+ },
+ providerHealth: {
+ openai: {
+ state: "OPEN",
+ failures: 3,
+ retryAfterMs: 15000,
+ lastFailure: now,
+ },
+ gemini: {
+ state: "HALF_OPEN",
+ failures: 1,
+ retryAfterMs: 5000,
+ lastFailure: now,
+ },
+ groq: {
+ state: "CLOSED",
+ failures: 0,
+ retryAfterMs: 0,
+ lastFailure: null,
+ },
+ },
+ providerSummary: {
+ configuredCount: 3,
+ activeCount: 2,
+ monitoredCount: 3,
+ },
+ rateLimitStatus: {},
+ lockouts: {},
+ sessions: {
+ activeCount: 0,
+ stickyBoundCount: 0,
+ byApiKey: {},
+ top: [],
+ },
+ quotaMonitor: {
+ active: 0,
+ alerting: 0,
+ exhausted: 0,
+ errors: 0,
+ byProvider: {},
+ monitors: [],
+ },
+ }),
+ });
+ });
+
+ await page.route("**/api/telemetry/summary", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ p50: 120, p95: 240, p99: 450, totalRequests: 18 }),
+ });
+ });
+
+ await page.route("**/api/cache/stats", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ size: 3, maxSize: 100, hitRate: 50, hits: 2, misses: 2 }),
+ });
+ });
+
+ await page.route("**/api/rate-limits", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ cacheStats: {
+ defaultCount: 0,
+ tool: { entries: 0, patterns: 0 },
+ family: { entries: 0, patterns: 0 },
+ session: { entries: 0, patterns: 0 },
+ },
+ }),
+ });
+ });
+
+ await page.route("**/api/health/degradation", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ summary: { full: 0, reduced: 0, minimal: 0, default: 0 },
+ features: [],
+ }),
+ });
+ });
+
+ await page.route("**/api/v1/db/health", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ isHealthy: true,
+ issues: [],
+ repairedCount: 0,
+ backupCreated: false,
+ }),
+ });
+ });
+}
+
+async function mockProvidersPageApis(page: Page) {
+ await page.route("**/api/providers", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ connections: [
+ {
+ id: "conn-openai-main",
+ provider: "openai",
+ authType: "apikey",
+ name: "OpenAI Main",
+ testStatus: "active",
+ },
+ {
+ id: "conn-gemini-main",
+ provider: "gemini",
+ authType: "apikey",
+ name: "Gemini Main",
+ testStatus: "active",
+ },
+ ],
+ }),
+ });
+ });
+
+ await page.route("**/api/provider-nodes", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ nodes: [], ccCompatibleProviderEnabled: false }),
+ });
+ });
+
+ await page.route("**/api/providers/expiration", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({}),
+ });
+ });
+
+ await page.route("**/api/system/env/repair", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ available: false, missingCount: 0 }),
+ });
+ });
+}
+
+async function mockIntelligentCombosPageApis(page: Page) {
+ await page.route("**/api/combos", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ combos: [
+ {
+ id: "combo-auto",
+ name: "combo-auto",
+ models: ["openai/gpt-4o-mini", "gemini/gemini-2.5-pro"],
+ strategy: "auto",
+ config: {
+ candidatePool: ["openai", "gemini", "anthropic"],
+ modePack: "ship-fast",
+ explorationRate: 0.15,
+ },
+ isActive: true,
+ },
+ ],
+ }),
+ });
+ });
+
+ await page.route("**/api/combos/metrics", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ metrics: {} }),
+ });
+ });
+
+ await page.route("**/api/providers", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ connections: [
+ { id: "conn-openai", provider: "openai", name: "OpenAI", testStatus: "active" },
+ { id: "conn-gemini", provider: "gemini", name: "Gemini", testStatus: "active" },
+ {
+ id: "conn-anthropic",
+ provider: "anthropic",
+ name: "Anthropic",
+ testStatus: "active",
+ },
+ ],
+ }),
+ });
+ });
+
+ await page.route("**/api/provider-nodes", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ nodes: [] }),
+ });
+ });
+
+ await page.route("**/api/settings/proxy", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ combos: {} }),
+ });
+ });
+}
+
+test.describe("Resilience Plan Alignment", () => {
+ test("resilience settings page only shows the plan-aligned cooldown fields", async ({ page }) => {
+ await mockResilienceSettings(page);
+
+ await gotoDashboardRoute(page, "/dashboard/settings?tab=resilience");
+ await expect(page.getByText("Connection Cooldown")).toBeVisible({ timeout: 15000 });
+ await expect(page.getByText("Base cooldown", { exact: true }).first()).toBeVisible();
+ await expect(page.getByText("Use upstream retry hints", { exact: true }).first()).toBeVisible();
+ await expect(page.getByText("Max backoff steps", { exact: true }).first()).toBeVisible();
+ await expect(page.getByText(/Rate-limit fallback/i)).toHaveCount(0);
+ });
+
+ test("health page renders provider breaker runtime state for multiple providers", async ({
+ page,
+ }) => {
+ await mockHealthPageApis(page);
+
+ await gotoDashboardRoute(page, "/dashboard/health");
+ await expect(page.getByText("Provider Health")).toBeVisible({ timeout: 15000 });
+ await expect(page.getByText("OpenAI")).toBeVisible();
+ await expect(page.getByText("Gemini")).toBeVisible();
+ await expect(page.getByText("Groq")).toBeVisible();
+ await expect(page.getByText("Recovering", { exact: true }).first()).toBeVisible();
+ await expect(page.getByText("Down", { exact: true }).first()).toBeVisible();
+ });
+
+ test("providers page no longer requests legacy model availability data", async ({ page }) => {
+ let availabilityRequests = 0;
+
+ await mockProvidersPageApis(page);
+ await page.route("**/api/models/availability", async (route) => {
+ availabilityRequests += 1;
+ await route.fulfill({
+ status: 410,
+ contentType: "application/json",
+ body: JSON.stringify({ error: "removed" }),
+ });
+ });
+
+ await gotoDashboardRoute(page, "/dashboard/providers");
+ await page.waitForLoadState("networkidle");
+
+ expect(availabilityRequests).toBe(0);
+ await expect(page.getByText("OpenAI").first()).toBeVisible();
+ await expect(page.getByText(/Model Availability/i)).toHaveCount(0);
+ });
+
+ test("intelligent combo panel stays config-only and does not fetch breaker runtime state", async ({
+ page,
+ }) => {
+ let monitoringHealthRequests = 0;
+
+ await mockIntelligentCombosPageApis(page);
+ await page.route("**/api/monitoring/health", async (route) => {
+ monitoringHealthRequests += 1;
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ providerHealth: {} }),
+ });
+ });
+
+ await gotoDashboardRoute(page, "/dashboard/combos?filter=intelligent");
+ await page.waitForLoadState("networkidle");
+
+ expect(monitoringHealthRequests).toBe(0);
+ await expect(page.getByText("Intelligent Routing Dashboard")).toBeVisible({ timeout: 15000 });
+ await expect(page.getByText("Routing Inputs", { exact: true })).toBeVisible();
+ await expect(page.getByText(/Excluded Providers/i)).toHaveCount(0);
+ await expect(page.getByText(/Incident Mode/i)).toHaveCount(0);
+ });
+});
diff --git a/tests/integration/_chatPipelineHarness.ts b/tests/integration/_chatPipelineHarness.ts
index d5a7cdbad1..57b2d745ed 100644
--- a/tests/integration/_chatPipelineHarness.ts
+++ b/tests/integration/_chatPipelineHarness.ts
@@ -33,8 +33,6 @@ export async function createChatPipelineHarness(prefix) {
const { initTranslators } = await import("../../open-sse/translator/index.ts");
const { clearInflight } = await import("../../open-sse/services/requestDedup.ts");
const { BaseExecutor } = await import("../../open-sse/executors/base.ts");
- const { resetAllAvailability, setModelUnavailable } =
- await import("../../src/domain/modelAvailability.ts");
const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts");
const originalFetch = globalThis.fetch;
@@ -209,7 +207,6 @@ export async function createChatPipelineHarness(prefix) {
clearInflight();
idempotencyLayerModule.clearIdempotency();
semanticCacheModule.clearCache();
- resetAllAvailability();
resetAllCircuitBreakers();
apiKeysDb.resetApiKeyState();
readCacheDb.invalidateDbCache();
@@ -229,7 +226,6 @@ export async function createChatPipelineHarness(prefix) {
idempotencyLayerModule.clearIdempotency();
semanticCacheModule.clearCache();
clearSkillState();
- resetAllAvailability();
resetAllCircuitBreakers();
core.resetDbInstance();
fs.rmSync(testDataDir, { recursive: true, force: true });
@@ -292,7 +288,6 @@ export async function createChatPipelineHarness(prefix) {
semanticCacheModule,
seedApiKey,
seedConnection,
- setModelUnavailable,
settingsDb,
skillByIdRouteModule,
skillExecutor,
diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts
index 10f5d366f4..ce6f4641b3 100644
--- a/tests/integration/chat-pipeline.test.ts
+++ b/tests/integration/chat-pipeline.test.ts
@@ -23,9 +23,8 @@ const { handleChat } = await import("../../src/sse/handlers/chat.ts");
const { initTranslators } = await import("../../open-sse/translator/index.ts");
const { clearInflight } = await import("../../open-sse/services/requestDedup.ts");
const { BaseExecutor } = await import("../../open-sse/executors/base.ts");
-const { resetAllAvailability, setModelUnavailable } =
- await import("../../src/domain/modelAvailability.ts");
-const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts");
+const { getCircuitBreaker, resetAllCircuitBreakers } =
+ await import("../../src/shared/utils/circuitBreaker.ts");
const { clearProviderFailure } = await import("../../open-sse/services/accountFallback.ts");
const originalFetch = globalThis.fetch;
@@ -295,7 +294,6 @@ async function resetStorage() {
globalThis.fetch = originalFetch;
process.env.REQUIRE_API_KEY = "false";
clearInflight();
- resetAllAvailability();
resetAllCircuitBreakers();
apiKeysDb.resetApiKeyState();
readCacheDb.invalidateDbCache();
@@ -442,7 +440,6 @@ test.after(async () => {
BaseExecutor.RETRY_CONFIG.delayMs = originalRetryDelayMs;
globalThis.fetch = originalFetch;
clearInflight();
- resetAllAvailability();
resetAllCircuitBreakers();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
@@ -909,26 +906,6 @@ test("chat pipeline returns current no-credentials contract when no provider con
assert.match(json.error.message, /No credentials for provider: openai/);
});
-test("chat pipeline returns 503 when the requested model is temporarily unavailable", async () => {
- await seedConnection("openai", { apiKey: "sk-openai-unavailable" });
- setModelUnavailable("openai", "gpt-4o-mini", 60000, "test cooldown");
-
- const response = await handleChat(
- buildRequest({
- body: {
- model: "openai/gpt-4o-mini",
- stream: false,
- messages: [{ role: "user", content: "Provider unavailable" }],
- },
- })
- );
-
- const json = await response.json();
- assert.equal(response.status, 503);
- assert.ok(Number(response.headers.get("Retry-After")) >= 1);
- assert.match(json.error.message, /temporarily unavailable/i);
-});
-
test("chat pipeline surfaces upstream 500 responses as structured errors", async () => {
await seedConnection("openai", { apiKey: "sk-openai-500" });
@@ -993,6 +970,46 @@ test("chat pipeline returns 429 with Retry-After when the upstream rate-limits t
assert.match(json.error.message, /\[openai\/gpt-4o-mini\]/);
});
+test("chat pipeline keeps provider breaker closed for repeated connection-scoped 429s", async () => {
+ await seedConnection("openai", { apiKey: "sk-openai-429-breaker" });
+ await settingsDb.updateSettings({
+ requestRetry: 0,
+ maxRetryIntervalSec: 0,
+ });
+
+ globalThis.fetch = async () =>
+ new Response(
+ JSON.stringify({
+ error: {
+ message: "Rate limit exceeded. Your quota will reset after 30s.",
+ },
+ }),
+ {
+ status: 429,
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+
+ for (let i = 0; i < 3; i += 1) {
+ const response = await handleChat(
+ buildRequest({
+ body: {
+ model: "openai/gpt-4o-mini",
+ stream: false,
+ messages: [{ role: "user", content: `Trigger 429 #${i + 1}` }],
+ },
+ })
+ );
+ assert.equal(response.status, 429);
+ }
+
+ const breaker = getCircuitBreaker("openai");
+ const status = breaker.getStatus();
+
+ assert.equal(status.state, "CLOSED");
+ assert.equal(status.failureCount, 0);
+});
+
test("chat pipeline maps upstream timeouts to 504 responses", async () => {
await seedConnection("openai", { apiKey: "sk-openai-timeout" });
diff --git a/tests/integration/chatcore-compression-integration.test.ts b/tests/integration/chatcore-compression-integration.test.ts
index 13adcd92e6..2ab34d8fa0 100644
--- a/tests/integration/chatcore-compression-integration.test.ts
+++ b/tests/integration/chatcore-compression-integration.test.ts
@@ -15,14 +15,12 @@ const readCacheDb = await import("../../src/lib/db/readCache.ts");
const combosDb = await import("../../src/lib/db/combos.ts");
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
const { estimateTokens, getTokenLimit } = await import("../../open-sse/services/contextManager.ts");
-const { resetAllAvailability } = await import("../../src/domain/modelAvailability.ts");
const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts");
const originalFetch = globalThis.fetch;
async function resetStorage() {
globalThis.fetch = originalFetch;
- resetAllAvailability();
resetAllCircuitBreakers();
readCacheDb.invalidateDbCache();
await new Promise((resolve) => setTimeout(resolve, 20));
diff --git a/tests/integration/integration-wiring.test.ts b/tests/integration/integration-wiring.test.ts
index a9e99a6809..4ecd2da607 100644
--- a/tests/integration/integration-wiring.test.ts
+++ b/tests/integration/integration-wiring.test.ts
@@ -84,8 +84,8 @@ describe("Pipeline Wiring — sse chat handler", () => {
assert.match(src, /getCircuitBreaker|CircuitBreakerOpenError/);
});
- it("should import model availability integration", () => {
- assert.match(src, /isModelAvailable|setModelUnavailable/);
+ it("should use credential preflight instead of global model quarantine gates", () => {
+ assert.match(src, /getProviderCredentialsWithQuotaPreflight/);
});
it("should import request telemetry integration", () => {
@@ -129,7 +129,6 @@ describe("Pipeline Wiring — middleware proxy", () => {
describe("API Routes — existence check", () => {
const routes = [
"src/app/api/cache/stats/route.ts",
- "src/app/api/models/availability/route.ts",
"src/app/api/telemetry/summary/route.ts",
"src/app/api/usage/budget/route.ts",
"src/app/api/usage/quota/route.ts",
@@ -152,10 +151,6 @@ describe("API Routes — export HTTP methods", () => {
assertRouteMethods("src/app/api/cache/stats/route.ts", ["GET", "DELETE"]);
});
- it("/api/models/availability should export GET and POST", () => {
- assertRouteMethods("src/app/api/models/availability/route.ts", ["GET", "POST"]);
- });
-
it("/api/telemetry/summary should export GET", () => {
assertRouteMethods("src/app/api/telemetry/summary/route.ts", ["GET"]);
});
diff --git a/tests/integration/proxy-pipeline.test.ts b/tests/integration/proxy-pipeline.test.ts
index 847c27cb4a..42540df355 100644
--- a/tests/integration/proxy-pipeline.test.ts
+++ b/tests/integration/proxy-pipeline.test.ts
@@ -88,25 +88,25 @@ describe("Chat Pipeline — combo fallback support", () => {
assert.match(src, /handleSingleModel.*handleSingleModelChat/s);
});
- it("should check model availability before attempting combo models", () => {
- assert.match(src, /isModelAvailable/);
+ it("should preflight provider credentials before attempting combo models", () => {
+ assert.match(src, /getProviderCredentialsWithQuotaPreflight/);
});
});
describe("Chat Pipeline — circuit breaker integration", () => {
const helpersSrc = readSrc("sse/handlers/chatHelpers.ts");
- it("should import CircuitBreakerOpenError", () => {
+ it("should import providerCircuitOpenResponse", () => {
assert.ok(helpersSrc, "chatHelpers.ts should exist");
- assert.match(helpersSrc, /CircuitBreakerOpenError/);
+ assert.match(helpersSrc, /providerCircuitOpenResponse/);
});
- it("should handle CircuitBreakerOpenError with retry-after", () => {
+ it("should handle circuit-open responses with retry-after", () => {
assert.match(helpersSrc, /retryAfterMs/);
});
- it("should reject requests when circuit is open", () => {
- assert.match(helpersSrc, /circuit breaker is open/i);
+ it("should reject requests when circuit is open via structured provider breaker response", () => {
+ assert.match(helpersSrc, /providerCircuitOpenResponse\(provider,\s*retryAfterSec\)/);
});
});
diff --git a/tests/integration/resilience-http-e2e.test.ts b/tests/integration/resilience-http-e2e.test.ts
new file mode 100644
index 0000000000..f6ea094ab2
--- /dev/null
+++ b/tests/integration/resilience-http-e2e.test.ts
@@ -0,0 +1,746 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import fsp from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import http from "node:http";
+import net from "node:net";
+import { spawn } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-resilience-http-e2e-"));
+const DASHBOARD_PORT = await getFreePort();
+const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
+
+process.env.DATA_DIR = TEST_DATA_DIR;
+process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
+process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "resilience-http-e2e-secret-123456";
+process.env.REQUIRE_API_KEY = "false";
+
+const core = await import("../../src/lib/db/core.ts");
+const providersDb = await import("../../src/lib/db/providers.ts");
+const combosDb = await import("../../src/lib/db/combos.ts");
+const settingsDb = await import("../../src/lib/db/settings.ts");
+
+function getFreePort() {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ server.close();
+ reject(new Error("Failed to allocate a free port"));
+ return;
+ }
+ const { port } = address;
+ server.close((closeError) => {
+ if (closeError) reject(closeError);
+ else resolve(port);
+ });
+ });
+ });
+}
+
+function sleep(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function buildCompletion(content: string) {
+ return {
+ status: 200,
+ body: {
+ id: `chatcmpl_${Math.random().toString(16).slice(2, 8)}`,
+ object: "chat.completion",
+ choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
+ usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 },
+ },
+ };
+}
+
+function buildError(status: number, message: string, headers: Record = {}) {
+ return {
+ status,
+ headers,
+ body: { error: { message } },
+ };
+}
+
+type PlannedResponse = {
+ status: number;
+ body: Record;
+ headers?: Record;
+ delayMs?: number;
+};
+
+type TokenBehavior = {
+ defaultResponse: PlannedResponse;
+ queue: PlannedResponse[];
+ hits: number;
+ startedAt: number[];
+ bodies: Array>;
+};
+
+function createFakeOpenAiRelay() {
+ const behaviors = new Map();
+ let server: http.Server | null = null;
+ let baseUrl = "";
+
+ const handleRequest = async (req: http.IncomingMessage, res: http.ServerResponse) => {
+ const chunks: Buffer[] = [];
+ req.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
+ req.on("end", async () => {
+ const authHeader = String(req.headers.authorization || "");
+ const token = authHeader.replace(/^Bearer\s+/i, "").trim();
+ const rawBody = Buffer.concat(chunks).toString("utf8");
+ const parsedBody = rawBody ? JSON.parse(rawBody) : {};
+
+ if (req.method === "GET" && req.url?.startsWith("/v1/models")) {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ data: [{ id: "test-model", object: "model" }] }));
+ return;
+ }
+
+ if (req.method !== "POST" || !req.url?.startsWith("/v1/chat/completions")) {
+ res.writeHead(404, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: { message: `Unhandled path: ${req.method} ${req.url}` } }));
+ return;
+ }
+
+ const behavior = behaviors.get(token);
+ if (!behavior) {
+ res.writeHead(401, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: { message: `Unknown token: ${token || "missing"}` } }));
+ return;
+ }
+
+ behavior.hits += 1;
+ behavior.startedAt.push(Date.now());
+ behavior.bodies.push(parsedBody as Record);
+
+ const planned = behavior.queue.shift() || behavior.defaultResponse;
+ if (planned.delayMs && planned.delayMs > 0) {
+ await sleep(planned.delayMs);
+ }
+
+ const headers = { "Content-Type": "application/json", ...(planned.headers || {}) };
+ res.writeHead(planned.status, headers);
+ res.end(JSON.stringify(planned.body));
+ });
+ };
+
+ return {
+ async start() {
+ const port = await getFreePort();
+ await new Promise((resolve, reject) => {
+ server = http.createServer((req, res) => {
+ void handleRequest(req, res);
+ });
+ server.once("error", reject);
+ server.listen(port, "127.0.0.1", () => resolve());
+ });
+ baseUrl = `http://127.0.0.1:${port}/v1`;
+ return baseUrl;
+ },
+ getBaseUrl() {
+ if (!baseUrl) throw new Error("Fake relay has not started yet");
+ return baseUrl;
+ },
+ configureToken(
+ token: string,
+ config: { defaultResponse: PlannedResponse; queue?: PlannedResponse[] }
+ ) {
+ behaviors.set(token, {
+ defaultResponse: config.defaultResponse,
+ queue: [...(config.queue || [])],
+ hits: 0,
+ startedAt: [],
+ bodies: [],
+ });
+ },
+ getState(token: string) {
+ const state = behaviors.get(token);
+ if (!state) throw new Error(`Unknown token state for ${token}`);
+ return state;
+ },
+ resetState(token: string, queue?: PlannedResponse[]) {
+ const state = behaviors.get(token);
+ if (!state) throw new Error(`Unknown token state for ${token}`);
+ state.hits = 0;
+ state.startedAt = [];
+ state.bodies = [];
+ state.queue = [...(queue || [])];
+ },
+ async stop() {
+ if (!server) return;
+ await new Promise((resolve) => server?.close(() => resolve()));
+ server = null;
+ },
+ };
+}
+
+function createServerProcess(dataDir: string, port: number) {
+ const stdoutLines: string[] = [];
+ const stderrLines: string[] = [];
+ const child = spawn(process.execPath, ["scripts/run-next.mjs", "dev"], {
+ cwd: REPO_ROOT,
+ env: {
+ ...process.env,
+ DATA_DIR: dataDir,
+ PORT: String(port),
+ DASHBOARD_PORT: String(port),
+ API_PORT: String(port),
+ HOST: "127.0.0.1",
+ REQUIRE_API_KEY: "false",
+ API_KEY_SECRET: process.env.API_KEY_SECRET || "resilience-http-e2e-secret-123456",
+ DISABLE_SQLITE_AUTO_BACKUP: "true",
+ INITIAL_PASSWORD: "",
+ NEXT_TELEMETRY_DISABLED: "1",
+ OMNIROUTE_DISABLE_BACKGROUND_SERVICES: "true",
+ OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: "true",
+ OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: "true",
+ OMNIROUTE_HIDE_HEALTHCHECK_LOGS: "true",
+ },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+
+ child.stdout.on("data", (chunk) => {
+ const lines = String(chunk).split(/\r?\n/).filter(Boolean);
+ stdoutLines.push(...lines);
+ if (stdoutLines.length > 200) stdoutLines.splice(0, stdoutLines.length - 200);
+ });
+ child.stderr.on("data", (chunk) => {
+ const lines = String(chunk).split(/\r?\n/).filter(Boolean);
+ stderrLines.push(...lines);
+ if (stderrLines.length > 200) stderrLines.splice(0, stderrLines.length - 200);
+ });
+
+ return {
+ child,
+ stdoutLines,
+ stderrLines,
+ baseUrl: `http://127.0.0.1:${port}`,
+ };
+}
+
+async function waitForServer(
+ baseUrl: string,
+ logs: { stdoutLines: string[]; stderrLines: string[] }
+) {
+ const startedAt = Date.now();
+ let lastError = "";
+ while (Date.now() - startedAt < 120_000) {
+ try {
+ const response = await fetch(`${baseUrl}/api/monitoring/health`, {
+ signal: AbortSignal.timeout(5_000),
+ });
+ if (response.ok) return;
+ lastError = `HTTP ${response.status}`;
+ } catch (error) {
+ lastError = error instanceof Error ? error.message : String(error);
+ }
+ await sleep(500);
+ }
+
+ throw new Error(
+ [
+ `Timed out waiting for OmniRoute to start: ${lastError}`,
+ "--- stdout ---",
+ ...logs.stdoutLines.slice(-40),
+ "--- stderr ---",
+ ...logs.stderrLines.slice(-40),
+ ].join("\n")
+ );
+}
+
+async function stopProcess(child: ReturnType) {
+ if (child.killed) return;
+ child.kill("SIGTERM");
+ const exited = await Promise.race([
+ new Promise((resolve) => child.once("exit", () => resolve(true))),
+ sleep(5_000).then(() => false),
+ ]);
+ if (!exited && !child.killed) {
+ child.kill("SIGKILL");
+ await new Promise((resolve) => child.once("exit", () => resolve()));
+ }
+}
+
+async function seedCompatibleProvider(prefix: string, apiKey: string, baseUrl: string) {
+ const providerId = `openai-compatible-chat-e2e-${prefix}`;
+ await providersDb.createProviderNode({
+ id: providerId,
+ type: "openai-compatible",
+ name: `E2E ${prefix}`,
+ prefix,
+ apiType: "chat",
+ baseUrl,
+ });
+ await providersDb.createProviderConnection({
+ provider: providerId,
+ authType: "apikey",
+ name: `conn-${prefix}`,
+ apiKey,
+ isActive: true,
+ testStatus: "active",
+ providerSpecificData: {
+ baseUrl,
+ apiType: "chat",
+ },
+ });
+ return { providerId, model: `${prefix}/test-model`, apiKey };
+}
+
+function buildResilienceConfig(overrides: Record = {}) {
+ const base = {
+ requestQueue: {
+ autoEnableApiKeyProviders: true,
+ requestsPerMinute: 120,
+ minTimeBetweenRequestsMs: 0,
+ concurrentRequests: 4,
+ maxWaitMs: 2_000,
+ },
+ connectionCooldown: {
+ oauth: {
+ baseCooldownMs: 500,
+ useUpstreamRetryHints: true,
+ maxBackoffSteps: 3,
+ },
+ apikey: {
+ baseCooldownMs: 300,
+ useUpstreamRetryHints: false,
+ maxBackoffSteps: 0,
+ },
+ },
+ providerBreaker: {
+ oauth: {
+ failureThreshold: 3,
+ resetTimeoutMs: 2_000,
+ },
+ apikey: {
+ failureThreshold: 2,
+ resetTimeoutMs: 1_500,
+ },
+ },
+ waitForCooldown: {
+ enabled: false,
+ maxRetries: 0,
+ maxRetryWaitSec: 0,
+ },
+ };
+
+ return {
+ ...base,
+ ...overrides,
+ requestQueue: {
+ ...base.requestQueue,
+ ...((overrides.requestQueue as Record) || {}),
+ },
+ connectionCooldown: {
+ oauth: {
+ ...base.connectionCooldown.oauth,
+ ...(((overrides.connectionCooldown as Record)?.oauth as Record<
+ string,
+ unknown
+ >) || {}),
+ },
+ apikey: {
+ ...base.connectionCooldown.apikey,
+ ...(((overrides.connectionCooldown as Record)?.apikey as Record<
+ string,
+ unknown
+ >) || {}),
+ },
+ },
+ providerBreaker: {
+ oauth: {
+ ...base.providerBreaker.oauth,
+ ...(((overrides.providerBreaker as Record)?.oauth as Record<
+ string,
+ unknown
+ >) || {}),
+ },
+ apikey: {
+ ...base.providerBreaker.apikey,
+ ...(((overrides.providerBreaker as Record)?.apikey as Record<
+ string,
+ unknown
+ >) || {}),
+ },
+ },
+ waitForCooldown: {
+ ...base.waitForCooldown,
+ ...((overrides.waitForCooldown as Record) || {}),
+ },
+ };
+}
+
+async function patchResilience(baseUrl: string, config: Record) {
+ const response = await fetch(`${baseUrl}/api/resilience`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(config),
+ signal: AbortSignal.timeout(10_000),
+ });
+ const payload = await response.json();
+ assert.equal(response.status, 200, JSON.stringify(payload));
+ return payload;
+}
+
+async function getJson(url: string) {
+ const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
+ const json = await response.json();
+ return { response, json };
+}
+
+async function postChat(baseUrl: string, model: string, content: string) {
+ const response = await fetch(`${baseUrl}/api/v1/chat/completions`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model,
+ stream: false,
+ messages: [{ role: "user", content }],
+ }),
+ signal: AbortSignal.timeout(20_000),
+ });
+ const text = await response.text();
+ const json = text ? JSON.parse(text) : {};
+ return { response, json };
+}
+
+const relay = createFakeOpenAiRelay();
+let app:
+ | {
+ child: ReturnType;
+ stdoutLines: string[];
+ stderrLines: string[];
+ baseUrl: string;
+ }
+ | undefined;
+
+const TOKENS = {
+ p1: "sk-e2e-p1",
+ p2: "sk-e2e-p2",
+ p3: "sk-e2e-p3",
+ p4: "sk-e2e-p4",
+ p5: "sk-e2e-p5",
+ p6: "sk-e2e-p6",
+ p7: "sk-e2e-p7",
+ p8: "sk-e2e-p8",
+};
+
+test.before(async () => {
+ const fakeBaseUrl = await relay.start();
+
+ relay.configureToken(TOKENS.p1, {
+ defaultResponse: buildCompletion("primary healthy again"),
+ queue: [buildError(503, "primary transient failure")],
+ });
+ relay.configureToken(TOKENS.p2, {
+ defaultResponse: buildCompletion("secondary stable"),
+ });
+ relay.configureToken(TOKENS.p3, {
+ defaultResponse: buildCompletion("wait-for-cooldown via upstream hint"),
+ queue: [buildError(429, "rate limited, retry after 1 second", { "Retry-After": "1" })],
+ });
+ relay.configureToken(TOKENS.p4, {
+ defaultResponse: buildCompletion("ignored upstream retry hint"),
+ queue: [buildError(429, "rate limited, retry after 5 seconds", { "Retry-After": "5" })],
+ });
+ relay.configureToken(TOKENS.p5, {
+ defaultResponse: buildCompletion("breaker target recovered"),
+ queue: [buildError(503, "breaker failure #1"), buildError(503, "breaker failure #2")],
+ });
+ relay.configureToken(TOKENS.p6, {
+ defaultResponse: buildCompletion("round robin A"),
+ });
+ relay.configureToken(TOKENS.p7, {
+ defaultResponse: buildCompletion("round robin B"),
+ });
+ relay.configureToken(TOKENS.p8, {
+ defaultResponse: {
+ ...buildCompletion("queued connection request"),
+ delayMs: 250,
+ },
+ });
+
+ await seedCompatibleProvider("p1", TOKENS.p1, fakeBaseUrl);
+ await seedCompatibleProvider("p2", TOKENS.p2, fakeBaseUrl);
+ await seedCompatibleProvider("p3", TOKENS.p3, fakeBaseUrl);
+ await seedCompatibleProvider("p4", TOKENS.p4, fakeBaseUrl);
+ await seedCompatibleProvider("p5", TOKENS.p5, fakeBaseUrl);
+ await seedCompatibleProvider("p6", TOKENS.p6, fakeBaseUrl);
+ await seedCompatibleProvider("p7", TOKENS.p7, fakeBaseUrl);
+ await seedCompatibleProvider("p8", TOKENS.p8, fakeBaseUrl);
+
+ await combosDb.createCombo({
+ name: "res-priority-fallback",
+ strategy: "priority",
+ config: { maxRetries: 0, retryDelayMs: 0 },
+ models: ["p1/test-model", "p2/test-model"],
+ });
+ await combosDb.createCombo({
+ name: "res-breaker-combo",
+ strategy: "priority",
+ config: { maxRetries: 0, retryDelayMs: 0 },
+ models: ["p5/test-model", "p2/test-model"],
+ });
+ await combosDb.createCombo({
+ name: "res-rr",
+ strategy: "round-robin",
+ config: { maxRetries: 0, retryDelayMs: 0, concurrencyPerModel: 1, queueTimeoutMs: 800 },
+ models: ["p6/test-model", "p7/test-model"],
+ });
+
+ await settingsDb.updateSettings({
+ resilienceSettings: buildResilienceConfig(),
+ requestRetry: 0,
+ maxRetryIntervalSec: 0,
+ requireLogin: false,
+ setupComplete: true,
+ });
+
+ core.closeDbInstance();
+
+ app = createServerProcess(TEST_DATA_DIR, DASHBOARD_PORT);
+ await waitForServer(app.baseUrl, app);
+
+ await patchResilience(app.baseUrl, buildResilienceConfig());
+
+ const warmup = await postChat(app.baseUrl, "p2/test-model", "warm up chat route");
+ assert.equal(warmup.response.status, 200, JSON.stringify(warmup.json));
+ relay.resetState(TOKENS.p2);
+});
+
+test.after(async () => {
+ if (app) {
+ await stopProcess(app.child);
+ }
+ await relay.stop();
+ core.closeDbInstance();
+ await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true });
+});
+
+test("resilience API only exposes configuration, not runtime breaker state", async () => {
+ assert.ok(app);
+ const { response, json } = await getJson(`${app.baseUrl}/api/resilience`);
+
+ assert.equal(response.status, 200);
+ assert.deepEqual(Object.keys(json).sort(), [
+ "connectionCooldown",
+ "legacy",
+ "providerBreaker",
+ "requestQueue",
+ "waitForCooldown",
+ ]);
+ assert.equal("providerBreakers" in json, false);
+ assert.equal("runtime" in json, false);
+});
+
+test("request queue serializes concurrent requests on the same connection", async () => {
+ assert.ok(app);
+ await patchResilience(
+ app.baseUrl,
+ buildResilienceConfig({
+ requestQueue: {
+ concurrentRequests: 1,
+ maxWaitMs: 1_500,
+ },
+ })
+ );
+ relay.resetState(TOKENS.p8);
+
+ const startedAt = Date.now();
+ const [first, second] = await Promise.all([
+ postChat(app.baseUrl, "p8/test-model", "queue-one"),
+ postChat(app.baseUrl, "p8/test-model", "queue-two"),
+ ]);
+ const elapsed = Date.now() - startedAt;
+ const state = relay.getState(TOKENS.p8);
+
+ assert.equal(first.response.status, 200, JSON.stringify(first.json));
+ assert.equal(second.response.status, 200, JSON.stringify(second.json));
+ assert.equal(state.hits, 2);
+ assert.ok(elapsed >= 450, `expected queued elapsed >= 450ms, got ${elapsed}ms`);
+ assert.ok(
+ state.startedAt[1] - state.startedAt[0] >= 180,
+ `expected second request to be delayed by queue, got ${state.startedAt[1] - state.startedAt[0]}ms`
+ );
+});
+
+test("priority combo falls back on 503 and skips the cooled-down primary on the next request", async () => {
+ assert.ok(app);
+ await patchResilience(app.baseUrl, buildResilienceConfig());
+ relay.resetState(TOKENS.p1, [buildError(503, "primary transient failure")]);
+ relay.resetState(TOKENS.p2);
+
+ const first = await postChat(app.baseUrl, "res-priority-fallback", "priority fallback request");
+ assert.equal(first.response.status, 200, JSON.stringify(first.json));
+ assert.equal(first.json.choices[0].message.content, "secondary stable");
+ assert.equal(relay.getState(TOKENS.p1).hits, 1);
+ assert.equal(relay.getState(TOKENS.p2).hits, 1);
+
+ const second = await postChat(app.baseUrl, "res-priority-fallback", "priority fallback again");
+ assert.equal(second.response.status, 200, JSON.stringify(second.json));
+ assert.equal(second.json.choices[0].message.content, "secondary stable");
+ assert.equal(relay.getState(TOKENS.p1).hits, 1);
+ assert.equal(relay.getState(TOKENS.p2).hits, 2);
+});
+
+test("wait-for-cooldown honors upstream Retry-After when enabled", async () => {
+ assert.ok(app);
+ await patchResilience(
+ app.baseUrl,
+ buildResilienceConfig({
+ connectionCooldown: {
+ apikey: {
+ useUpstreamRetryHints: true,
+ baseCooldownMs: 200,
+ },
+ },
+ waitForCooldown: {
+ enabled: true,
+ maxRetries: 1,
+ maxRetryWaitSec: 2,
+ },
+ })
+ );
+ relay.resetState(TOKENS.p3);
+ const warmup = await postChat(app.baseUrl, "p3/test-model", "warm provider-specific route");
+ assert.equal(warmup.response.status, 200, JSON.stringify(warmup.json));
+ relay.resetState(TOKENS.p3, [
+ buildError(429, "rate limited, retry after 1 second", { "Retry-After": "1" }),
+ ]);
+
+ const startedAt = Date.now();
+ const result = await postChat(app.baseUrl, "p3/test-model", "wait for cooldown via upstream");
+ const elapsed = Date.now() - startedAt;
+
+ assert.equal(result.response.status, 200, JSON.stringify(result.json));
+ assert.equal(result.json.choices[0].message.content, "wait-for-cooldown via upstream hint");
+ assert.equal(relay.getState(TOKENS.p3).hits, 2);
+ assert.ok(elapsed >= 800, `expected upstream wait >= 800ms, got ${elapsed}ms`);
+});
+
+test("connection cooldown can ignore upstream Retry-After and use the configured local cooldown", async () => {
+ assert.ok(app);
+ await patchResilience(
+ app.baseUrl,
+ buildResilienceConfig({
+ connectionCooldown: {
+ apikey: {
+ useUpstreamRetryHints: false,
+ baseCooldownMs: 200,
+ },
+ },
+ waitForCooldown: {
+ enabled: true,
+ maxRetries: 1,
+ maxRetryWaitSec: 2,
+ },
+ })
+ );
+ relay.resetState(TOKENS.p4);
+ const warmup = await postChat(app.baseUrl, "p4/test-model", "warm provider-specific route");
+ assert.equal(warmup.response.status, 200, JSON.stringify(warmup.json));
+ relay.resetState(TOKENS.p4, [
+ buildError(429, "rate limited, retry after 30 seconds", { "Retry-After": "30" }),
+ ]);
+
+ const startedAt = Date.now();
+ const result = await postChat(app.baseUrl, "p4/test-model", "ignore upstream retry-after");
+ const elapsed = Date.now() - startedAt;
+
+ assert.equal(result.response.status, 200, JSON.stringify(result.json));
+ assert.equal(result.json.choices[0].message.content, "ignored upstream retry hint");
+ assert.equal(relay.getState(TOKENS.p4).hits, 2);
+ assert.ok(
+ elapsed < 5_000,
+ `expected ignored upstream hint to avoid a 30s wait, got ${elapsed}ms`
+ );
+});
+
+test("provider circuit breaker opens after repeated final failures and Health reports it", async () => {
+ assert.ok(app);
+ await patchResilience(
+ app.baseUrl,
+ buildResilienceConfig({
+ waitForCooldown: {
+ enabled: false,
+ maxRetries: 0,
+ maxRetryWaitSec: 0,
+ },
+ providerBreaker: {
+ apikey: {
+ failureThreshold: 2,
+ resetTimeoutMs: 1_500,
+ },
+ },
+ })
+ );
+ relay.resetState(TOKENS.p5, [
+ buildError(503, "breaker failure #1"),
+ buildError(503, "breaker failure #2"),
+ ]);
+
+ const first = await postChat(app.baseUrl, "p5/test-model", "breaker first failure");
+ const second = await postChat(app.baseUrl, "p5/test-model", "breaker second failure");
+ const third = await postChat(app.baseUrl, "p5/test-model", "breaker should now be open");
+
+ assert.equal(first.response.status, 503);
+ assert.equal(second.response.status, 503);
+ assert.equal(third.response.status, 503);
+ assert.match(String(second.json.error?.message || ""), /reset after/i);
+ assert.match(String(third.json.error?.message || ""), /circuit breaker is open/i);
+ assert.equal(relay.getState(TOKENS.p5).hits, 1);
+
+ const health = await getJson(`${app.baseUrl}/api/monitoring/health`);
+ assert.equal(health.response.status, 200);
+ const breakerEntry = (health.json.providerBreakers || []).find(
+ (entry: Record) => entry.provider === "openai-compatible-chat-e2e-p5"
+ );
+ assert.ok(breakerEntry, "expected provider breaker entry for p5");
+ assert.equal(breakerEntry.state, "OPEN");
+ assert.ok(Number(breakerEntry.failureCount) >= 2);
+ assert.ok(Number(breakerEntry.retryAfterMs) > 0);
+ assert.ok(
+ (health.json.providerBreakers || []).every(
+ (entry: Record) => !String(entry.provider || "").includes(":")
+ )
+ );
+});
+
+test("combo respects the global provider breaker and falls through without a combo-local breaker", async () => {
+ assert.ok(app);
+ relay.resetState(TOKENS.p2);
+
+ const result = await postChat(
+ app.baseUrl,
+ "res-breaker-combo",
+ "combo should skip broken provider"
+ );
+
+ assert.equal(result.response.status, 200, JSON.stringify(result.json));
+ assert.equal(result.json.choices[0].message.content, "secondary stable");
+ assert.equal(relay.getState(TOKENS.p5).hits, 1);
+ assert.equal(relay.getState(TOKENS.p2).hits, 1);
+});
+
+test("round-robin combo still alternates healthy providers after combo breaker removal", async () => {
+ assert.ok(app);
+ await patchResilience(app.baseUrl, buildResilienceConfig());
+ relay.resetState(TOKENS.p6);
+ relay.resetState(TOKENS.p7);
+
+ const first = await postChat(app.baseUrl, "res-rr", "round robin one");
+ const second = await postChat(app.baseUrl, "res-rr", "round robin two");
+
+ assert.equal(first.response.status, 200, JSON.stringify(first.json));
+ assert.equal(second.response.status, 200, JSON.stringify(second.json));
+ assert.equal(first.json.choices[0].message.content, "round robin A");
+ assert.equal(second.json.choices[0].message.content, "round robin B");
+ assert.equal(relay.getState(TOKENS.p6).hits, 1);
+ assert.equal(relay.getState(TOKENS.p7).hits, 1);
+});
diff --git a/tests/integration/security-hardening.test.ts b/tests/integration/security-hardening.test.ts
index df01b34c2f..cb6060e247 100644
--- a/tests/integration/security-hardening.test.ts
+++ b/tests/integration/security-hardening.test.ts
@@ -232,7 +232,6 @@ test("T06 route payload validation uses validateBody in critical endpoints", ()
"src/app/api/policies/route.ts",
"src/app/api/fallback/chains/route.ts",
"src/app/api/models/route.ts",
- "src/app/api/models/availability/route.ts",
"src/app/api/provider-models/route.ts",
"src/app/api/pricing/route.ts",
"src/app/api/rate-limits/route.ts",
diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts
index 912aa9836e..2095057770 100644
--- a/tests/unit/account-fallback-service.test.ts
+++ b/tests/unit/account-fallback-service.test.ts
@@ -207,8 +207,37 @@ test("lockModelIfPerModelQuota only locks supported providers and real models",
});
test("getProviderProfile differentiates oauth and api-key providers", () => {
- assert.deepEqual(getProviderProfile("claude"), PROVIDER_PROFILES.oauth);
- assert.deepEqual(getProviderProfile("openai"), PROVIDER_PROFILES.apikey);
+ const oauthProfile = getProviderProfile("claude");
+ assert.equal(oauthProfile.transientCooldown, PROVIDER_PROFILES.oauth.transientCooldown);
+ assert.equal(
+ oauthProfile.rateLimitCooldown,
+ oauthProfile.useUpstreamRetryHints ? 0 : oauthProfile.baseCooldownMs
+ );
+ assert.equal(oauthProfile.maxBackoffLevel, PROVIDER_PROFILES.oauth.maxBackoffLevel);
+ assert.equal(
+ oauthProfile.circuitBreakerThreshold,
+ PROVIDER_PROFILES.oauth.circuitBreakerThreshold
+ );
+ assert.equal(oauthProfile.circuitBreakerReset, PROVIDER_PROFILES.oauth.circuitBreakerReset);
+ assert.equal(oauthProfile.baseCooldownMs, PROVIDER_PROFILES.oauth.transientCooldown);
+ assert.equal(oauthProfile.failureThreshold, PROVIDER_PROFILES.oauth.circuitBreakerThreshold);
+ assert.equal(oauthProfile.resetTimeoutMs, PROVIDER_PROFILES.oauth.circuitBreakerReset);
+
+ const apiKeyProfile = getProviderProfile("openai");
+ assert.equal(apiKeyProfile.transientCooldown, PROVIDER_PROFILES.apikey.transientCooldown);
+ assert.equal(
+ apiKeyProfile.rateLimitCooldown,
+ apiKeyProfile.useUpstreamRetryHints ? 0 : apiKeyProfile.baseCooldownMs
+ );
+ assert.equal(apiKeyProfile.maxBackoffLevel, PROVIDER_PROFILES.apikey.maxBackoffLevel);
+ assert.equal(
+ apiKeyProfile.circuitBreakerThreshold,
+ PROVIDER_PROFILES.apikey.circuitBreakerThreshold
+ );
+ assert.equal(apiKeyProfile.circuitBreakerReset, PROVIDER_PROFILES.apikey.circuitBreakerReset);
+ assert.equal(apiKeyProfile.baseCooldownMs, PROVIDER_PROFILES.apikey.transientCooldown);
+ assert.equal(apiKeyProfile.failureThreshold, PROVIDER_PROFILES.apikey.circuitBreakerThreshold);
+ assert.equal(apiKeyProfile.resetTimeoutMs, PROVIDER_PROFILES.apikey.circuitBreakerReset);
});
test("shouldMarkAccountExhaustedFrom429 skips connection poisoning for compatible providers", () => {
@@ -229,11 +258,11 @@ test("recordModelLockoutFailure uses provider profile cooldowns, backoff, and re
const compatibleProvider = "openai-compatible-custom-node";
const compatibleModel = "custom-model-a";
const profile = {
- transientCooldown: 250,
- rateLimitCooldown: 125,
- maxBackoffLevel: 2,
- circuitBreakerThreshold: 60,
- circuitBreakerReset: 500,
+ baseCooldownMs: 125,
+ useUpstreamRetryHints: false,
+ maxBackoffSteps: 2,
+ failureThreshold: 60,
+ resetTimeoutMs: 500,
};
const first = recordModelLockoutFailure(
@@ -298,8 +327,8 @@ test("recordModelLockoutFailure uses provider profile cooldowns, backoff, and re
});
// Provider-level failure circuit breaker tests
-test("isProviderFailureCode correctly identifies transient error codes", () => {
- assert.equal(isProviderFailureCode(429), true);
+test("isProviderFailureCode correctly identifies provider-wide transient error codes", () => {
+ assert.equal(isProviderFailureCode(429), false);
assert.equal(isProviderFailureCode(408), true);
assert.equal(isProviderFailureCode(500), true);
assert.equal(isProviderFailureCode(502), true);
@@ -360,62 +389,16 @@ test("recordProviderFailure tracks failures and triggers cooldown after threshol
}
});
-test("recordProviderFailure resets counter after failure window expires", () => {
- const originalNow = Date.now;
- let now = 1_700_000_000_000;
- Date.now = () => now;
+test("checkFallbackError no longer mutates provider breaker state on per-connection failures", () => {
+ const provider = "test-provider-check";
+ clearProviderFailure(provider);
- try {
- const provider = "test-provider-window";
- clearProviderFailure(provider);
-
- // Record 3 failures
- for (let i = 0; i < 3; i++) {
- recordProviderFailure(provider);
- now += 1000;
- }
- assert.equal(isProviderInCooldown(provider), false);
-
- // Wait for failure window to expire (20 minutes + 1 second)
- now += 20 * 60 * 1000 + 1000;
-
- // Next failure should reset counter, not trigger cooldown
- recordProviderFailure(provider);
- assert.equal(isProviderInCooldown(provider), false);
-
- // Need 4 more failures to trigger cooldown
- for (let i = 0; i < 4; i++) {
- recordProviderFailure(provider);
- now += 1000;
- }
- assert.equal(isProviderInCooldown(provider), true);
- } finally {
- Date.now = originalNow;
- clearProviderFailure("test-provider-window");
+ for (let i = 0; i < 5; i++) {
+ checkFallbackError(429, "rate limited", 0, null, provider);
}
-});
-test("checkFallbackError records provider failure for transient errors", () => {
- const originalNow = Date.now;
- let now = 1_700_000_000_000;
- Date.now = () => now;
-
- try {
- const provider = "test-provider-check";
- clearProviderFailure(provider);
-
- // Simulate 5 transient errors through checkFallbackError
- for (let i = 0; i < 5; i++) {
- checkFallbackError(429, "rate limited", 0, null, provider);
- now += 1000;
- }
-
- // Provider should now be in cooldown
- assert.equal(isProviderInCooldown(provider), true);
- } finally {
- Date.now = originalNow;
- clearProviderFailure("test-provider-check");
- }
+ assert.equal(isProviderInCooldown(provider), false);
+ clearProviderFailure(provider);
});
test("checkFallbackError does not record provider failure for non-transient errors", () => {
diff --git a/tests/unit/auth-terminal-status.test.ts b/tests/unit/auth-terminal-status.test.ts
index a62c4ba788..e3cad8eea0 100644
--- a/tests/unit/auth-terminal-status.test.ts
+++ b/tests/unit/auth-terminal-status.test.ts
@@ -109,3 +109,129 @@ test("markAccountUnavailable does not overwrite terminal status", async () => {
const after = await providersDb.getProviderConnectionById(conn.id);
assert.equal(after.testStatus, "credits_exhausted");
});
+
+test("markAccountUnavailable marks 401 connections as expired without adding cooldown", async () => {
+ await resetStorage();
+
+ const conn = await providersDb.createProviderConnection({
+ provider: "openai",
+ authType: "apikey",
+ apiKey: "sk-expired",
+ isActive: true,
+ testStatus: "active",
+ });
+
+ const result = await auth.markAccountUnavailable(
+ conn.id,
+ 401,
+ "unauthorized",
+ "openai",
+ "gpt-4.1"
+ );
+ const after = await providersDb.getProviderConnectionById(conn.id);
+
+ assert.equal(result.shouldFallback, true);
+ assert.equal(result.cooldownMs, 0);
+ assert.equal(after.testStatus, "expired");
+ assert.ok(!after.rateLimitedUntil);
+});
+
+test("markAccountUnavailable marks 402 connections as credits_exhausted without adding cooldown", async () => {
+ await resetStorage();
+
+ const conn = await providersDb.createProviderConnection({
+ provider: "openai",
+ authType: "apikey",
+ apiKey: "sk-credits",
+ isActive: true,
+ testStatus: "active",
+ });
+
+ const result = await auth.markAccountUnavailable(
+ conn.id,
+ 402,
+ "payment required",
+ "openai",
+ "gpt-4.1"
+ );
+ const after = await providersDb.getProviderConnectionById(conn.id);
+
+ assert.equal(result.shouldFallback, true);
+ assert.equal(result.cooldownMs, 0);
+ assert.equal(after.testStatus, "credits_exhausted");
+ assert.ok(!after.rateLimitedUntil);
+});
+
+test("markAccountUnavailable marks 403 connections as banned without adding cooldown", async () => {
+ await resetStorage();
+
+ const conn = await providersDb.createProviderConnection({
+ provider: "openai",
+ authType: "apikey",
+ apiKey: "sk-banned",
+ isActive: true,
+ testStatus: "active",
+ });
+
+ const result = await auth.markAccountUnavailable(conn.id, 403, "forbidden", "openai", "gpt-4.1");
+ const after = await providersDb.getProviderConnectionById(conn.id);
+
+ assert.equal(result.shouldFallback, true);
+ assert.equal(result.cooldownMs, 0);
+ assert.equal(after.testStatus, "banned");
+ assert.ok(!after.rateLimitedUntil);
+});
+
+test("markAccountUnavailable keeps project-route 403 errors non-terminal", async () => {
+ await resetStorage();
+
+ const conn = await providersDb.createProviderConnection({
+ provider: "openai",
+ authType: "apikey",
+ apiKey: "sk-project-route",
+ isActive: true,
+ testStatus: "active",
+ });
+
+ const result = await auth.markAccountUnavailable(
+ conn.id,
+ 403,
+ "The service has not been used in project",
+ "openai",
+ "gpt-4.1"
+ );
+ const after = await providersDb.getProviderConnectionById(conn.id);
+
+ assert.equal(result.shouldFallback, true);
+ assert.equal(result.cooldownMs, 0);
+ assert.equal(after.testStatus, "active");
+ assert.equal(after.lastErrorType, "project_route_error");
+ assert.ok(!after.rateLimitedUntil);
+});
+
+test("markAccountUnavailable keeps oauth-invalid 401 errors non-terminal", async () => {
+ await resetStorage();
+
+ const conn = await providersDb.createProviderConnection({
+ provider: "openai",
+ authType: "apikey",
+ apiKey: "sk-oauth-invalid",
+ isActive: true,
+ testStatus: "active",
+ });
+
+ const result = await auth.markAccountUnavailable(
+ conn.id,
+ 401,
+ "Invalid authentication credentials provided",
+ "openai",
+ "gpt-4.1"
+ );
+ const after = await providersDb.getProviderConnectionById(conn.id);
+
+ assert.equal(result.shouldFallback, true);
+ assert.equal(result.cooldownMs, 0);
+ assert.equal(after.testStatus, "active");
+ assert.equal(after.lastErrorType, "oauth_invalid_token");
+ assert.ok(!after.rateLimitedUntil);
+});
diff --git a/tests/unit/autocombo-unification.test.ts b/tests/unit/autocombo-unification.test.ts
index a622651fdc..4ff04fa2dc 100644
--- a/tests/unit/autocombo-unification.test.ts
+++ b/tests/unit/autocombo-unification.test.ts
@@ -128,29 +128,40 @@ test("sidebar visibility excludes the removed auto-combo item", async () => {
assert.deepEqual(sidebarVisibility.normalizeHiddenSidebarItems(["auto-combo", "home"]), ["home"]);
});
-test("intelligent routing helpers normalize config and health state", () => {
+test("intelligent routing helpers normalize config and build provider scores", () => {
const normalizedConfig = intelligentRouting.normalizeIntelligentRoutingConfig({
candidatePool: ["openai", "anthropic"],
explorationRate: "0.25",
+ modePack: "",
+ routerStrategy: "",
weights: { quota: 0.4 },
});
assert.deepEqual(normalizedConfig.candidatePool, ["openai", "anthropic"]);
assert.equal(normalizedConfig.explorationRate, 0.25);
+ assert.equal(normalizedConfig.modePack, "ship-fast");
+ assert.equal(normalizedConfig.routerStrategy, "rules");
assert.equal(normalizedConfig.weights.quota, 0.4);
assert.equal(
normalizedConfig.weights.health,
intelligentRouting.DEFAULT_INTELLIGENT_WEIGHTS.health
);
- const healthState = intelligentRouting.extractIntelligentHealthState({
- circuitBreakers: [
- { provider: "openai", state: "OPEN", lastFailure: "2026-04-12T12:00:00Z" },
- { provider: "anthropic", state: "OPEN", lastFailure: "2026-04-12T12:01:00Z" },
- { provider: "google", state: "CLOSED" },
- ],
+ const providerScores = intelligentRouting.buildIntelligentProviderScores({
+ config: normalizedConfig,
});
- assert.equal(healthState.incidentMode, true);
- assert.equal(healthState.exclusions.length, 2);
+ assert.equal(providerScores.length, 2);
+ assert.deepEqual(
+ providerScores.map((entry) => ({
+ provider: entry.provider,
+ model: entry.model,
+ score: entry.score,
+ quotaWeight: entry.factors.quota,
+ })),
+ [
+ { provider: "openai", model: "auto", score: 0.5, quotaWeight: 0.4 },
+ { provider: "anthropic", model: "auto", score: 0.5, quotaWeight: 0.4 },
+ ]
+ );
});
diff --git a/tests/unit/batch-a-domain.test.ts b/tests/unit/batch-a-domain.test.ts
index 5efb40bf48..fd7e1237b7 100644
--- a/tests/unit/batch-a-domain.test.ts
+++ b/tests/unit/batch-a-domain.test.ts
@@ -1,166 +1,13 @@
/**
* Batch A — Domain Layer + Infrastructure Tests
*
- * Tests for: modelAvailability, costRules, fallbackPolicy,
+ * Tests for: costRules, fallbackPolicy,
* errorCodes, requestId, fetchTimeout
*/
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
-// ──────────────── T-19: Model Availability ────────────────
-
-import {
- isModelAvailable,
- setModelUnavailable,
- markModelAsProblematic,
- clearModelUnavailability,
- getAvailabilityReport,
- getUnavailableCount,
- resetAllAvailability,
-} from "../../src/domain/modelAvailability.ts";
-
-describe("modelAvailability", () => {
- before(() => resetAllAvailability());
- after(() => resetAllAvailability());
-
- it("should report model as available by default", () => {
- assert.equal(isModelAvailable("openai", "gpt-4o"), true);
- });
-
- it("should mark model as unavailable", () => {
- setModelUnavailable("openai", "gpt-4o", 60000, "rate limited");
- assert.equal(isModelAvailable("openai", "gpt-4o"), false);
- });
-
- it("should report unavailable models", () => {
- const report = getAvailabilityReport();
- assert.equal(report.length, 1);
- assert.equal(report[0].provider, "openai");
- assert.equal(report[0].model, "gpt-4o");
- assert.equal(report[0].reason, "rate limited");
- assert.ok(report[0].remainingMs > 0);
- });
-
- it("should count unavailable models", () => {
- assert.equal(getUnavailableCount(), 1);
- });
-
- it("should clear model unavailability", () => {
- clearModelUnavailability("openai", "gpt-4o");
- assert.equal(isModelAvailable("openai", "gpt-4o"), true);
- assert.equal(getUnavailableCount(), 0);
- });
-
- it("should auto-expire after cooldown", () => {
- setModelUnavailable("anthropic", "claude-sonnet-4-20250514", 1, "test");
- // Wait 2ms for expiry
- const start = Date.now();
- while (Date.now() - start < 5) {} // spin wait
- assert.equal(isModelAvailable("anthropic", "claude-sonnet-4-20250514"), true);
- });
-
- it("should apply adaptive quarantine for repeated problematic failures", () => {
- const first = markModelAsProblematic("nvidia", "z-ai/glm4.7", {
- status: 502,
- baseCooldownMs: 3000,
- reason: "HTTP 502",
- });
- const second = markModelAsProblematic("nvidia", "z-ai/glm4.7", {
- status: 502,
- baseCooldownMs: 3000,
- reason: "HTTP 502",
- });
-
- assert.equal(first.failureCount, 1);
- assert.equal(first.cooldownMs, 120000);
- assert.equal(second.failureCount, 2);
- assert.equal(second.cooldownMs, 240000);
-
- const report = getAvailabilityReport();
- const entry = report.find((r) => r.provider === "nvidia" && r.model === "z-ai/glm4.7");
- assert.ok(entry, "nvidia model should be quarantined");
- assert.ok(entry.remainingMs >= 200000, "cooldown should be preserved/escalated");
- });
-
- it("should reset failure history after model recovery", () => {
- clearModelUnavailability("nvidia", "z-ai/glm4.7");
- const afterReset = markModelAsProblematic("nvidia", "z-ai/glm4.7", {
- status: 502,
- baseCooldownMs: 3000,
- reason: "HTTP 502",
- });
-
- assert.equal(afterReset.failureCount, 1);
- assert.equal(afterReset.cooldownMs, 120000);
- });
-
- it("should use provider profile threshold, cooldowns, and reset window for global quarantine", () => {
- const originalNow = Date.now;
- let now = 10_000;
- Date.now = () => now;
-
- try {
- const profile = {
- transientCooldown: 200,
- rateLimitCooldown: 100,
- maxBackoffLevel: 2,
- circuitBreakerThreshold: 3,
- circuitBreakerReset: 500,
- };
-
- const first = markModelAsProblematic("openai", "gpt-4o-mini", {
- status: 429,
- reason: "HTTP 429",
- profile,
- });
- const second = markModelAsProblematic("openai", "gpt-4o-mini", {
- status: 429,
- reason: "HTTP 429",
- profile,
- });
-
- assert.equal(first.failureCount, 1);
- assert.equal(first.cooldownMs, 100);
- assert.equal(first.quarantined, false);
- assert.equal(isModelAvailable("openai", "gpt-4o-mini"), true);
-
- assert.equal(second.failureCount, 2);
- assert.equal(second.cooldownMs, 200);
- assert.equal(second.quarantined, false);
- assert.equal(isModelAvailable("openai", "gpt-4o-mini"), true);
-
- now += 10;
- const third = markModelAsProblematic("openai", "gpt-4o-mini", {
- status: 429,
- reason: "HTTP 429",
- profile,
- });
-
- assert.equal(third.failureCount, 3);
- assert.equal(third.cooldownMs, 400);
- assert.equal(third.quarantined, true);
- assert.equal(isModelAvailable("openai", "gpt-4o-mini"), false);
-
- now += 600;
- assert.equal(isModelAvailable("openai", "gpt-4o-mini"), true);
-
- const afterWindow = markModelAsProblematic("openai", "gpt-4o-mini", {
- status: 429,
- reason: "HTTP 429",
- profile,
- });
-
- assert.equal(afterWindow.failureCount, 1);
- assert.equal(afterWindow.cooldownMs, 100);
- assert.equal(afterWindow.quarantined, false);
- } finally {
- Date.now = originalNow;
- clearModelUnavailability("openai", "gpt-4o-mini");
- }
- });
-});
-
// ──────────────── T-19: Cost Rules ────────────────
import {
diff --git a/tests/unit/chat-combo-live-test.test.ts b/tests/unit/chat-combo-live-test.test.ts
index 107d601ccd..31c99f7517 100644
--- a/tests/unit/chat-combo-live-test.test.ts
+++ b/tests/unit/chat-combo-live-test.test.ts
@@ -12,8 +12,6 @@ const providersDb = await import("../../src/lib/db/providers.ts");
const chatRoute = await import("../../src/app/api/v1/chat/completions/route.ts");
const { generateSignature, invalidateBySignature, setCachedResponse } =
await import("../../src/lib/semanticCache.ts");
-const { clearModelUnavailability, resetAllAvailability, setModelUnavailable } =
- await import("../../src/domain/modelAvailability.ts");
const { getCircuitBreaker, resetAllCircuitBreakers, STATE } =
await import("../../src/shared/utils/circuitBreaker.ts");
@@ -28,7 +26,6 @@ async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
- resetAllAvailability();
resetAllCircuitBreakers();
}
@@ -79,27 +76,20 @@ test.beforeEach(async () => {
test.afterEach(async () => {
await flushBackgroundWork();
globalThis.fetch = originalFetch;
- resetAllAvailability();
resetAllCircuitBreakers();
});
test.after(async () => {
await flushBackgroundWork();
globalThis.fetch = originalFetch;
- resetAllAvailability();
resetAllCircuitBreakers();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
-test("combo live test bypasses local cooldown and breaker state to perform a real upstream request", async () => {
+test("combo live test bypasses connection cooldown and breaker state to perform a real upstream request", async () => {
const created = await seedSuppressedConnection();
- setModelUnavailable("openai", "gpt-4o-mini", 60_000, "test cooldown");
- const breaker = getCircuitBreaker("openai");
- breaker.state = STATE.OPEN;
- breaker.lastFailureTime = Date.now();
-
const fetchCalls = [];
globalThis.fetch = async (url, init = {}) => {
fetchCalls.push({ url: String(url), init });
@@ -120,7 +110,10 @@ test("combo live test bypasses local cooldown and breaker state to perform a rea
assert.equal(blockedByCooldown.status, 503);
assert.equal(fetchCalls.length, 0);
- clearModelUnavailability("openai", "gpt-4o-mini");
+ const breaker = getCircuitBreaker("openai");
+ breaker.state = STATE.OPEN;
+ breaker.lastFailureTime = Date.now();
+ breaker.resetTimeout = 60_000;
const blockedByBreaker = await chatRoute.POST(makeRequest());
assert.equal(blockedByBreaker.status, 503);
diff --git a/tests/unit/chat-helpers.test.ts b/tests/unit/chat-helpers.test.ts
index 64726e297d..102ccfd84c 100644
--- a/tests/unit/chat-helpers.test.ts
+++ b/tests/unit/chat-helpers.test.ts
@@ -18,13 +18,10 @@ const {
safeLogEvents,
withSessionHeader,
} = await import("../../src/sse/handlers/chatHelpers.ts");
-const { getModelCooldownInfo, setModelUnavailable, resetAllAvailability } =
- await import("../../src/domain/modelAvailability.ts");
-const { getCircuitBreaker, resetAllCircuitBreakers, CircuitBreakerOpenError, STATE } =
+const { getCircuitBreaker, resetAllCircuitBreakers, STATE } =
await import("../../src/shared/utils/circuitBreaker.ts");
async function resetStorage() {
- resetAllAvailability();
resetAllCircuitBreakers();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
@@ -92,22 +89,6 @@ test("resolveModelOrError rejects malformed model strings", async () => {
assert.match(json.error.message, /Invalid model format/i);
});
-test("checkPipelineGates blocks models in cooldown", async () => {
- setModelUnavailable("openai", "gpt-4o-mini", 12_000, "cooldown");
-
- const response = await checkPipelineGates("openai", "gpt-4o-mini");
- const json = await response.json();
- const cooldownInfo = getModelCooldownInfo("openai", "gpt-4o-mini");
- const retryAfter = Number(response.headers.get("Retry-After"));
-
- assert.equal(response.status, 503);
- assert.ok(cooldownInfo);
- assert.ok(retryAfter >= 1);
- assert.ok(retryAfter <= 12);
- assert.ok(retryAfter >= Math.ceil((cooldownInfo?.remainingMs || 0) / 1000) - 1);
- assert.match(json.error.message, /temporarily unavailable/i);
-});
-
test("checkPipelineGates blocks providers with an open circuit breaker", async () => {
const breaker = getCircuitBreaker("openai");
breaker.state = STATE.OPEN;
@@ -116,8 +97,8 @@ test("checkPipelineGates blocks providers with an open circuit breaker", async (
const response = await checkPipelineGates("openai", "gpt-4o-mini", {
providerProfile: {
- circuitBreakerThreshold: 5,
- circuitBreakerReset: 5_000,
+ failureThreshold: 5,
+ resetTimeoutMs: 5_000,
},
});
const json = await response.json();
@@ -127,6 +108,8 @@ test("checkPipelineGates blocks providers with an open circuit breaker", async (
assert.ok(retryAfter >= 4);
assert.ok(retryAfter <= 5);
assert.match(json.error.message, /circuit breaker is open/i);
+ assert.equal(json.error.code, "provider_circuit_open");
+ assert.equal(response.headers.get("X-OmniRoute-Provider-Breaker"), "open");
});
test("checkPipelineGates reapplies runtime breaker settings to existing breakers", async () => {
@@ -139,8 +122,8 @@ test("checkPipelineGates reapplies runtime breaker settings to existing breakers
const response = await checkPipelineGates("openai", "gpt-4o-mini", {
providerProfile: {
- circuitBreakerThreshold: 60,
- circuitBreakerReset: 5_000,
+ failureThreshold: 60,
+ resetTimeoutMs: 5_000,
},
});
@@ -233,60 +216,44 @@ test("safeResolveProxy returns the direct route when no proxy config is present"
});
});
-test("executeChatWithBreaker converts circuit-open and proxy-fast-fail errors", async () => {
- const credentials = { connectionId: "conn_helper" };
- const openResult = await executeChatWithBreaker({
- bypassCircuitBreaker: false,
- breaker: {
- execute: async () => {
- throw new CircuitBreakerOpenError("already open", "openai", 5_000);
- },
- },
- body: { model: "openai/gpt-4o-mini" },
- provider: "openai",
- model: "gpt-4o-mini",
- refreshedCredentials: credentials,
- proxyInfo: null,
- log: console,
- clientRawRequest: null,
- credentials,
- apiKeyInfo: null,
- userAgent: "",
- comboName: null,
- comboStrategy: null,
- isCombo: false,
- extendedContext: false,
- });
+test("executeChatWithBreaker converts proxy fast-fail errors", async () => {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async () => {
+ const error = new Error("Proxy unreachable");
+ (error as Error & { code?: string }).code = "PROXY_UNREACHABLE";
+ throw error;
+ };
- const proxyResult = await executeChatWithBreaker({
- bypassCircuitBreaker: false,
- breaker: {
- execute: async () => {
- const error = new Error("Proxy unreachable");
- error.code = "PROXY_UNREACHABLE";
- throw error;
- },
- },
- body: { model: "openai/gpt-4o-mini" },
- provider: "openai",
- model: "gpt-4o-mini",
- refreshedCredentials: credentials,
- proxyInfo: null,
- log: console,
- clientRawRequest: null,
- credentials,
- apiKeyInfo: null,
- userAgent: "",
- comboName: null,
- comboStrategy: null,
- isCombo: false,
- extendedContext: false,
- });
+ try {
+ const credentials = {
+ connectionId: "conn_helper",
+ apiKey: "sk-openai-helper",
+ providerSpecificData: {},
+ };
+ const proxyResult = await executeChatWithBreaker({
+ body: { model: "openai/gpt-4o-mini" },
+ provider: "openai",
+ model: "gpt-4o-mini",
+ refreshedCredentials: credentials,
+ proxyInfo: null,
+ log: console,
+ clientRawRequest: null,
+ credentials,
+ apiKeyInfo: null,
+ userAgent: "",
+ comboName: null,
+ comboStrategy: null,
+ isCombo: false,
+ extendedContext: false,
+ comboStepId: null,
+ comboExecutionKey: null,
+ });
- assert.equal(openResult.result.status, 503);
- assert.equal(openResult.result.response.status, 503);
- assert.equal(proxyResult.result.status, 503);
- assert.equal(proxyResult.result.error, "Proxy unreachable");
+ assert.equal(proxyResult.result.status, 502);
+ assert.match(String(proxyResult.result.error || ""), /Proxy unreachable/);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
});
test("safeLogEvents tolerates success and timeout payloads", () => {
diff --git a/tests/unit/chat-route-coverage.test.ts b/tests/unit/chat-route-coverage.test.ts
index 2c7b4ca71d..f38085ff94 100644
--- a/tests/unit/chat-route-coverage.test.ts
+++ b/tests/unit/chat-route-coverage.test.ts
@@ -14,13 +14,11 @@ const {
resetStorage,
seedApiKey,
seedConnection,
- setModelUnavailable,
settingsDb,
toPlainHeaders,
} = harness;
const { getCircuitBreaker, STATE } = await import("../../src/shared/utils/circuitBreaker.ts");
-const { clearModelUnavailability } = await import("../../src/domain/modelAvailability.ts");
const { clearProviderFailure } = await import("../../open-sse/services/accountFallback.ts");
const { getDefaultTaskModelMap, resetTaskRoutingStats, setTaskRoutingConfig } =
await import("../../open-sse/services/taskAwareRouter.ts");
@@ -345,9 +343,11 @@ test("handleChat returns 400 when no provider credentials exist", async () => {
assert.match(json.error.message, /No credentials for provider: openai/);
});
-test("handleChat returns 503 for cooled-down models and open circuit breakers", async () => {
- await seedConnection("openai", { apiKey: "sk-openai-breaker" });
- setModelUnavailable("openai", "gpt-4o-mini", 60_000, "test cooldown");
+test("handleChat returns 503 for cooled-down connections and 503 for open circuit breakers", async () => {
+ await seedConnection("openai", {
+ apiKey: "sk-openai-breaker",
+ rateLimitedUntil: new Date(Date.now() + 60_000).toISOString(),
+ });
const cooldownResponse = await handleChat(
buildRequest({
@@ -360,15 +360,13 @@ test("handleChat returns 503 for cooled-down models and open circuit breakers",
);
const cooldownJson = await cooldownResponse.json();
assert.equal(cooldownResponse.status, 503);
- assert.match(cooldownJson.error.message, /temporarily unavailable/i);
-
- clearModelUnavailability("openai", "gpt-4o-mini");
- const freshBreaker = getCircuitBreaker("openai");
- freshBreaker.reset();
+ assert.ok(Number(cooldownResponse.headers.get("Retry-After")) >= 1);
+ assert.match(cooldownJson.error.message, /\[openai\/gpt-4o-mini\]/i);
const breaker = getCircuitBreaker("openai");
breaker.state = STATE.OPEN;
breaker.lastFailureTime = Date.now();
+ breaker.resetTimeout = 60_000;
const breakerBlocked = await handleChat(
buildRequest({
@@ -382,6 +380,8 @@ test("handleChat returns 503 for cooled-down models and open circuit breakers",
const breakerJson = await breakerBlocked.json();
assert.equal(breakerBlocked.status, 503);
+ assert.equal(breakerBlocked.headers.get("X-OmniRoute-Provider-Breaker"), "open");
+ assert.equal(breakerJson.error.code, "provider_circuit_open");
assert.match(breakerJson.error.message, /circuit breaker is open/i);
});
diff --git a/tests/unit/combo-circuit-breaker.test.ts b/tests/unit/combo-circuit-breaker.test.ts
deleted file mode 100644
index 5b06a3a848..0000000000
--- a/tests/unit/combo-circuit-breaker.test.ts
+++ /dev/null
@@ -1,230 +0,0 @@
-import test from "node:test";
-import assert from "node:assert/strict";
-
-// Reset circuit breaker registry between tests
-const { CircuitBreaker, getCircuitBreaker, getAllCircuitBreakerStatuses, STATE } =
- await import("../../src/shared/utils/circuitBreaker.ts");
-
-const { handleComboChat, getComboFromData } = await import("../../open-sse/services/combo.ts");
-const { normalizeComboStep } = await import("../../src/lib/combos/steps.ts");
-
-const { PROVIDER_PROFILES } = await import("../../open-sse/config/constants.ts");
-
-// ─── Helpers ────────────────────────────────────────────────────────────────
-
-/** Create a mock logger */
-function mockLog() {
- const entries = [];
- return {
- info: (tag, msg) => entries.push({ level: "info", tag, msg }),
- warn: (tag, msg) => entries.push({ level: "warn", tag, msg }),
- error: (tag, msg) => entries.push({ level: "error", tag, msg }),
- entries,
- };
-}
-
-/** Create a handleSingleModel that returns given status codes in sequence */
-function mockHandler(statusSequence) {
- let callIndex = 0;
- return async (body, modelStr) => {
- const status = statusSequence[callIndex] ?? statusSequence[statusSequence.length - 1] ?? 200;
- callIndex++;
- if (status === 200) {
- return new Response(JSON.stringify({ ok: true }), { status: 200 });
- }
- return new Response(JSON.stringify({ error: { message: `Error ${status}` } }), {
- status,
- statusText: `Error ${status}`,
- });
- };
-}
-
-function getComboTargetBreakerKey(combo, index = 0) {
- const step = normalizeComboStep(combo.models[index], {
- comboName: combo.name,
- index,
- });
- return `combo:${combo.name}:${step.id}`;
-}
-
-// ─── Circuit Breaker Integration Tests ──────────────────────────────────────
-// NOTE: combo.ts uses the full model string (e.g. "combo:groq/llama-3.3-70b")
-// as the circuit breaker key, not just the provider prefix.
-
-test("handleComboChat: circuit breaker opens after repeated 502 errors", async () => {
- const combo = {
- name: "test-combo",
- models: [{ model: "groq/llama-3.3-70b", weight: 0 }],
- strategy: "priority",
- config: { maxRetries: 0 },
- };
- const breakerKey = getComboTargetBreakerKey(combo);
- const breaker = getCircuitBreaker(breakerKey, {
- failureThreshold: PROVIDER_PROFILES.apikey.circuitBreakerThreshold,
- resetTimeout: 60000,
- });
- breaker.reset();
-
- const log = mockLog();
-
- // Send requests that all fail with 502 until the API-key profile threshold is reached.
- for (let i = 0; i < PROVIDER_PROFILES.apikey.circuitBreakerThreshold; i++) {
- await handleComboChat({
- body: {},
- combo,
- handleSingleModel: mockHandler([502]),
- isModelAvailable: () => true,
- log,
- settings: null,
- allCombos: null,
- });
- }
-
- // Breaker should now be OPEN
- const status = breaker.getStatus();
- console.log("=== BREAKER STATUS AFTER THRESHOLD CALLS ===", status);
- assert.equal(
- status.state,
- STATE.OPEN,
- "Breaker should be OPEN after the API-key failure threshold is reached"
- );
- assert.equal(
- status.failureCount,
- PROVIDER_PROFILES.apikey.circuitBreakerThreshold,
- "Failure count should match the API-key profile threshold"
- );
-});
-
-test("handleComboChat: skips models with open circuit breaker", async () => {
- // Set up: groq breaker is OPEN, fireworks breaker is CLOSED
- const combo = {
- name: "test-skip-combo",
- models: [
- { model: "groq/llama-3.3-70b", weight: 0 },
- { model: "fireworks/deepseek-v3p1", weight: 0 },
- ],
- strategy: "priority",
- };
- const groqBreakerKey = getComboTargetBreakerKey(combo, 0);
- const groqBreaker = getCircuitBreaker(groqBreakerKey, {
- failureThreshold: 3,
- resetTimeout: 60000,
- });
- groqBreaker.reset();
- // Force open the breaker
- groqBreaker._onFailure();
- groqBreaker._onFailure();
- groqBreaker._onFailure();
- assert.equal(groqBreaker.getStatus().state, STATE.OPEN);
-
- const fireworksBreakerKey = getComboTargetBreakerKey(combo, 1);
- const fireworksBreaker = getCircuitBreaker(fireworksBreakerKey, {
- failureThreshold: 5,
- resetTimeout: 30000,
- });
- fireworksBreaker.reset();
-
- const log = mockLog();
-
- const result = await handleComboChat({
- body: {},
- combo,
- handleSingleModel: mockHandler([200]), // fireworks will succeed
- isModelAvailable: () => true,
- log,
- settings: null,
- allCombos: null,
- });
-
- assert.equal(result.ok, true, "Should succeed via fireworks fallback");
-
- // Check logs show groq was skipped
- const skipLog = log.entries.find(
- (e) => e.msg.includes("circuit breaker OPEN") && e.msg.includes("groq")
- );
- assert.ok(skipLog, "Should log that groq was skipped due to breaker");
-});
-
-test("handleComboChat: returns 503 when all breakers are open", async () => {
- const combo = {
- name: "test-all-open",
- models: [
- { model: "groq/llama-3.3-70b", weight: 0 },
- { model: "fireworks/deepseek-v3p1", weight: 0 },
- ],
- strategy: "priority",
- };
-
- // Open both breakers explicitly before invoking the combo.
- const groqBreaker = getCircuitBreaker(getComboTargetBreakerKey(combo, 0), {
- failureThreshold: 3,
- resetTimeout: 60000,
- });
- groqBreaker.reset();
- groqBreaker._onFailure();
- groqBreaker._onFailure();
- groqBreaker._onFailure();
- assert.equal(groqBreaker.getStatus().state, STATE.OPEN);
-
- const fireworksBreaker = getCircuitBreaker(getComboTargetBreakerKey(combo, 1), {
- failureThreshold: 5,
- resetTimeout: 30000,
- });
- fireworksBreaker.reset();
- for (let i = 0; i < 5; i++) fireworksBreaker._onFailure();
- assert.equal(fireworksBreaker.getStatus().state, STATE.OPEN);
-
- const log = mockLog();
-
- const result = await handleComboChat({
- body: {},
- combo,
- handleSingleModel: mockHandler([200]), // Won't be called
- isModelAvailable: () => true,
- log,
- settings: null,
- allCombos: null,
- });
-
- assert.equal(result.status, 503, "Should return 503");
- const body = await result.json();
- assert.ok(body.error.message.includes("circuit breakers open"), "Should mention breakers");
-});
-
-test("handleComboChat: 429 errors also trigger circuit breaker", async () => {
- const combo = {
- name: "test-429",
- models: [{ model: "cerebras/llama-3.3-70b", weight: 0 }],
- strategy: "priority",
- };
- const breakerKey = getComboTargetBreakerKey(combo);
- const breaker = getCircuitBreaker(breakerKey, {
- failureThreshold: 5,
- resetTimeout: 30000,
- });
- breaker.reset();
-
- const log = mockLog();
-
- // 5 x 429 should open breaker
- for (let i = 0; i < 5; i++) {
- await handleComboChat({
- body: {},
- combo,
- handleSingleModel: mockHandler([429]),
- isModelAvailable: () => true,
- log,
- settings: null,
- allCombos: null,
- });
- }
-
- assert.equal(breaker.getStatus().state, STATE.OPEN, "429s should open breaker");
-});
-
-test("circuit breaker uses provider profile thresholds", () => {
- // OAuth providers (e.g. claude) should have lower threshold
- assert.equal(PROVIDER_PROFILES.oauth.circuitBreakerThreshold, 3);
- // API providers (e.g. groq) should have higher threshold
- assert.equal(PROVIDER_PROFILES.apikey.circuitBreakerThreshold, 5);
-});
diff --git a/tests/unit/combo-config.test.ts b/tests/unit/combo-config.test.ts
index 014ff4e44a..0c39a25993 100644
--- a/tests/unit/combo-config.test.ts
+++ b/tests/unit/combo-config.test.ts
@@ -13,7 +13,9 @@ test("getDefaultComboConfig returns a fresh copy of the defaults", () => {
assert.notEqual(first, second);
assert.equal(first.strategy, "priority");
assert.equal(first.maxRetries, 1);
- assert.equal(first.timeoutMs, 600000);
+ assert.equal(first.retryDelayMs, 2000);
+ assert.ok(!("timeoutMs" in first));
+ assert.ok(!("healthCheckEnabled" in first));
assert.equal(first.handoffThreshold, 0.85);
assert.equal(first.maxMessagesForSummary, 30);
assert.deepEqual(first.handoffProviders, ["codex"]);
@@ -27,7 +29,6 @@ test("resolveComboConfig applies the full cascade from defaults to combo overrid
{
config: {
maxRetries: 4,
- timeoutMs: 45000,
},
},
{
@@ -47,12 +48,12 @@ test("resolveComboConfig applies the full cascade from defaults to combo overrid
assert.equal(result.strategy, "round-robin");
assert.equal(result.retryDelayMs, 500);
- assert.equal(result.timeoutMs, 45000);
assert.equal(result.maxRetries, 4);
- assert.equal(result.healthCheckEnabled, true);
+ assert.ok(!("timeoutMs" in result));
+ assert.ok(!("healthCheckEnabled" in result));
});
-test("resolveComboConfig ignores null and undefined overrides", () => {
+test("resolveComboConfig ignores null, undefined, and legacy resilience overrides", () => {
const result = resolveComboConfig(
{
config: {
@@ -75,7 +76,7 @@ test("resolveComboConfig ignores null and undefined overrides", () => {
"openai"
);
- assert.equal(result.timeoutMs, 600000);
+ assert.ok(!("timeoutMs" in result));
assert.equal(result.queueTimeoutMs, 15000);
assert.equal(result.concurrencyPerModel, 9);
assert.equal(result.trackMetrics, false);
diff --git a/tests/unit/combo-context-relay.test.ts b/tests/unit/combo-context-relay.test.ts
index 7675f05848..b3aa09f78d 100644
--- a/tests/unit/combo-context-relay.test.ts
+++ b/tests/unit/combo-context-relay.test.ts
@@ -13,12 +13,10 @@ const handoffDb = await import("../../src/lib/db/contextHandoffs.ts");
const { registerCodexConnection } = await import("../../open-sse/services/codexQuotaFetcher.ts");
const { clearSessions, touchSession } = await import("../../open-sse/services/sessionManager.ts");
const { resetAllComboMetrics } = await import("../../open-sse/services/comboMetrics.ts");
-const { resetAllCircuitBreakers, getCircuitBreaker } =
- await import("../../src/shared/utils/circuitBreaker.ts");
+const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts");
const { resetAll: resetAllSemaphores } =
await import("../../open-sse/services/rateLimitSemaphore.ts");
const { _resetAllDecks } = await import("../../src/shared/utils/shuffleDeck.ts");
-const { normalizeComboStep } = await import("../../src/lib/combos/steps.ts");
const originalFetch = globalThis.fetch;
@@ -39,6 +37,24 @@ function okResponse(body = { choices: [{ message: { content: "ok" } }] }) {
});
}
+function providerBreakerOpenResponse() {
+ return new Response(
+ JSON.stringify({
+ error: {
+ message: "Provider circuit breaker is open",
+ code: "provider_circuit_open",
+ },
+ }),
+ {
+ status: 503,
+ headers: {
+ "content-type": "application/json",
+ "x-omniroute-provider-breaker": "open",
+ },
+ }
+ );
+}
+
function buildQuotaResponse(usedPercent, resetAfterSeconds = 3600) {
return new Response(
JSON.stringify({
@@ -161,23 +177,13 @@ test("handleComboChat context-relay skips unavailable models and falls through t
assert.deepEqual(calls, ["openai/gpt-4o-mini"]);
});
-test("handleComboChat context-relay skips models with an open circuit breaker", async () => {
+test("handleComboChat context-relay skips targets that report an open provider circuit breaker", async () => {
const combo = {
name: "relay-breaker",
strategy: "context-relay",
models: ["codex/gpt-5.4", "openai/gpt-4o-mini"],
config: { maxRetries: 0 },
};
- const firstStep = normalizeComboStep(combo.models[0], {
- comboName: combo.name,
- index: 0,
- });
- const breaker = getCircuitBreaker(`combo:${combo.name}:${firstStep.id}`, {
- failureThreshold: 1,
- resetTimeout: 60000,
- });
- breaker._onFailure();
-
const log = createLog();
const calls = [];
@@ -188,6 +194,9 @@ test("handleComboChat context-relay skips models with an open circuit breaker",
combo,
handleSingleModel: async (_body, modelStr) => {
calls.push(modelStr);
+ if (modelStr === "codex/gpt-5.4") {
+ return providerBreakerOpenResponse();
+ }
return okResponse();
},
isModelAvailable: async () => true,
@@ -197,8 +206,10 @@ test("handleComboChat context-relay skips models with an open circuit breaker",
});
assert.equal(result.ok, true);
- assert.deepEqual(calls, ["openai/gpt-4o-mini"]);
- assert.ok(log.entries.some((entry) => entry.msg.includes("circuit breaker OPEN")));
+ assert.deepEqual(calls, ["codex/gpt-5.4", "openai/gpt-4o-mini"]);
+ assert.ok(
+ log.entries.some((entry) => String(entry.msg).includes("provider circuit breaker OPEN"))
+ );
});
test("handleComboChat context-relay persists a handoff when codex quota reaches the warning threshold", async () => {
diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts
index 823dfbc3bb..c8dd07e2ac 100644
--- a/tests/unit/combo-routing-engine.test.ts
+++ b/tests/unit/combo-routing-engine.test.ts
@@ -24,8 +24,7 @@ const { saveModelsDevCapabilities, clearModelsDevCapabilities } =
await import("../../src/lib/modelsDevSync.ts");
const { getComboMetrics, recordComboRequest, resetAllComboMetrics } =
await import("../../open-sse/services/comboMetrics.ts");
-const { getCircuitBreaker, resetAllCircuitBreakers } =
- await import("../../src/shared/utils/circuitBreaker.ts");
+const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts");
const { acquire: acquireSemaphore, resetAll: resetAllSemaphores } =
await import("../../open-sse/services/rateLimitSemaphore.ts");
const { _resetAllDecks } = await import("../../src/shared/utils/shuffleDeck.ts");
@@ -54,6 +53,24 @@ function errorResponse(status: number, message: string = `Error ${status}`) {
});
}
+function providerBreakerOpenResponse() {
+ return new Response(
+ JSON.stringify({
+ error: {
+ message: "Provider circuit breaker is open",
+ code: "provider_circuit_open",
+ },
+ }),
+ {
+ status: 503,
+ headers: {
+ "content-type": "application/json",
+ "x-omniroute-provider-breaker": "open",
+ },
+ }
+ );
+}
+
function streamResponse(chunks: any[]) {
return new Response(chunks.join(""), {
status: 200,
@@ -83,7 +100,7 @@ function capabilityEntry(limitContext: any) {
};
}
-function getComboTargetBreakerKey(comboName: string, index: number, stepInput: any) {
+function getComboTargetExecutionKey(comboName: string, index: number, stepInput: any) {
const step = normalizeComboStep(stepInput, { comboName, index });
if (!step) throw new Error(`Failed to normalize combo step for ${comboName}#${index}`);
return `combo:${comboName}:${step.id}`;
@@ -1038,7 +1055,7 @@ test("handleComboChat round-robin returns 404 when no models are configured", as
test("handleComboChat round-robin falls through semaphore timeouts and malformed success payloads", async () => {
const release = await acquireSemaphore(
- getComboTargetBreakerKey("rr-timeout-fallback", 0, "model-a"),
+ getComboTargetExecutionKey("rr-timeout-fallback", 0, "model-a"),
{
maxConcurrency: 1,
timeoutMs: 100,
@@ -1376,38 +1393,36 @@ test("handleComboChat returns a 503 when every model is unavailable before execu
assert.equal(payload.error.code, "ALL_ACCOUNTS_INACTIVE");
});
-test("handleComboChat returns the circuit-breaker unavailable response when all breakers are open", async () => {
- for (const [index, modelStr] of ["openai/model-a", "openai/model-b"].entries()) {
- const breaker = getCircuitBreaker(
- getComboTargetBreakerKey("all-breakers-open", index, modelStr),
- {
- failureThreshold: 1,
- resetTimeout: 60000,
- }
- );
- breaker._onFailure();
- }
-
+test("handleComboChat falls through targets that return provider circuit breaker open responses", async () => {
+ const calls = [];
+ const log = createLog();
const result = await handleComboChat({
body: {},
combo: {
- name: "all-breakers-open",
+ name: "provider-breaker-open",
strategy: "priority",
models: ["openai/model-a", "openai/model-b"],
},
- handleSingleModel: async () => {
- throw new Error("handleSingleModel should not run when all breakers are open");
+ handleSingleModel: async (_body, modelStr) => {
+ calls.push(modelStr);
+ if (modelStr === "openai/model-a") {
+ return providerBreakerOpenResponse();
+ }
+ return okResponse();
},
isModelAvailable: async () => true,
- log: createLog(),
+ log,
settings: null,
relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
- assert.equal(result.status, 503);
- assert.match((await result.json()).error.message, /circuit breakers open/);
+ assert.equal(result.ok, true);
+ assert.deepEqual(calls, ["openai/model-a", "openai/model-b"]);
+ assert.ok(
+ log.entries.some((entry) => String(entry.msg).includes("provider circuit breaker OPEN"))
+ );
});
test("handleComboChat auto strategy honors LKGP after filtering to tool-capable models", async () => {
@@ -1799,39 +1814,37 @@ test("handleComboChat round-robin resolves nested combos and returns inactive wh
assert.equal(payload.error.code, "ALL_ACCOUNTS_INACTIVE");
});
-test("handleComboChat round-robin returns circuit-breaker unavailable when every model is open", async () => {
- for (const [index, modelStr] of ["openai/model-a", "openai/model-b"].entries()) {
- const breaker = getCircuitBreaker(
- getComboTargetBreakerKey("rr-breakers-open", index, modelStr),
- {
- failureThreshold: 1,
- resetTimeout: 60000,
- }
- );
- breaker._onFailure();
- }
-
+test("handleComboChat round-robin skips targets that return provider circuit breaker open responses", async () => {
+ const calls = [];
+ const log = createLog();
const result = await handleComboChat({
body: {},
combo: {
- name: "rr-breakers-open",
+ name: "rr-provider-breaker-open",
strategy: "round-robin",
models: ["openai/model-a", "openai/model-b"],
config: { maxRetries: 0 },
},
- handleSingleModel: async () => {
- throw new Error("round-robin should not execute when all breakers are open");
+ handleSingleModel: async (_body, modelStr) => {
+ calls.push(modelStr);
+ if (modelStr === "openai/model-a") {
+ return providerBreakerOpenResponse();
+ }
+ return okResponse();
},
isModelAvailable: async () => true,
- log: createLog(),
+ log,
settings: null,
relayOptions: null as any,
allCombos: null,
relayOptions: null,
});
- assert.equal(result.status, 503);
- assert.match((await result.json()).error.message, /circuit breakers open/);
+ assert.equal(result.ok, true);
+ assert.deepEqual(calls, ["openai/model-a", "openai/model-b"]);
+ assert.ok(
+ log.entries.some((entry) => String(entry.msg).includes("provider circuit breaker OPEN"))
+ );
});
test("handleComboChat round-robin retries a transient failure on the same model before succeeding", async () => {
diff --git a/tests/unit/domain-branch-hardening.test.ts b/tests/unit/domain-branch-hardening.test.ts
index 63d8e387d7..8f6071dbee 100644
--- a/tests/unit/domain-branch-hardening.test.ts
+++ b/tests/unit/domain-branch-hardening.test.ts
@@ -11,7 +11,6 @@ const core = await import("../../src/lib/db/core.ts");
const costRules = await import("../../src/domain/costRules.ts");
const fallbackPolicy = await import("../../src/domain/fallbackPolicy.ts");
const lockoutPolicy = await import("../../src/domain/lockoutPolicy.ts");
-const modelAvailability = await import("../../src/domain/modelAvailability.ts");
const providerExpiration = await import("../../src/domain/providerExpiration.ts");
const quotaCache = await import("../../src/domain/quotaCache.ts");
const comboResolver = await import("../../src/domain/comboResolver.ts");
@@ -28,7 +27,6 @@ function isoFromNow(offsetMs) {
async function resetStorage() {
costRules.resetCostData();
fallbackPolicy.resetAllFallbacks();
- modelAvailability.resetAllAvailability();
providerExpiration.resetExpirations();
quotaCache.stopBackgroundRefresh();
core.resetDbInstance();
@@ -63,7 +61,6 @@ test.after(async () => {
quotaCache.stopBackgroundRefresh();
costRules.resetCostData();
fallbackPolicy.resetAllFallbacks();
- modelAvailability.resetAllAvailability();
providerExpiration.resetExpirations();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
@@ -192,30 +189,6 @@ test("resolveComboModel also covers implicit defaults and missing optional field
assert.deepEqual(comboResolver.getComboFallbacks({}, 0), []);
});
-test("modelAvailability tracks missing, active and expired cooldowns", () => {
- let now = 1_000;
- Date.now = () => now;
-
- assert.equal(modelAvailability.isModelAvailable("openai", "gpt-4o"), true);
-
- modelAvailability.setModelUnavailable("openai", "gpt-4o", 100, undefined);
- modelAvailability.setModelUnavailable("anthropic", "claude-sonnet", 500, "capacity");
-
- assert.equal(modelAvailability.isModelAvailable("openai", "gpt-4o"), false);
- assert.equal(modelAvailability.getUnavailableCount(), 2);
-
- const report = modelAvailability.getAvailabilityReport();
- assert.equal(report.length, 2);
- assert.equal(report[0].reason, "unknown");
- assert.equal(report[1].reason, "capacity");
-
- now += 150;
- assert.equal(modelAvailability.isModelAvailable("openai", "gpt-4o"), true);
- assert.equal(modelAvailability.clearModelUnavailability("openai", "gpt-4o"), false);
- assert.equal(modelAvailability.clearModelUnavailability("anthropic", "claude-sonnet"), true);
- assert.equal(modelAvailability.getUnavailableCount(), 0);
-});
-
test("providerExpiration derives status, sorting, summary and header-based expiration hints", () => {
const expired = providerExpiration.setExpiration(
"conn-expired",
diff --git a/tests/unit/error-classification.test.ts b/tests/unit/error-classification.test.ts
index 9fb24d7181..0f6be74c08 100644
--- a/tests/unit/error-classification.test.ts
+++ b/tests/unit/error-classification.test.ts
@@ -6,7 +6,7 @@ const { checkFallbackError, getProviderProfile, parseRetryFromErrorText } =
const { getProviderCategory } = await import("../../open-sse/config/providerRegistry.ts");
-const { COOLDOWN_MS, PROVIDER_PROFILES, RateLimitReason } =
+const { BACKOFF_CONFIG, COOLDOWN_MS, PROVIDER_PROFILES, RateLimitReason } =
await import("../../open-sse/config/constants.ts");
// ─── Provider Category Tests ────────────────────────────────────────────────
@@ -41,12 +41,36 @@ test("getProviderCategory: unknown provider defaults to 'apikey'", () => {
test("getProviderProfile: OAuth provider returns oauth profile", () => {
const profile = getProviderProfile("claude");
- assert.deepEqual(profile, PROVIDER_PROFILES.oauth);
+ assert.equal(profile.baseCooldownMs, PROVIDER_PROFILES.oauth.transientCooldown);
+ assert.equal(profile.useUpstreamRetryHints, false);
+ assert.equal(profile.maxBackoffSteps, PROVIDER_PROFILES.oauth.maxBackoffLevel);
+ assert.equal(profile.failureThreshold, PROVIDER_PROFILES.oauth.circuitBreakerThreshold);
+ assert.equal(profile.resetTimeoutMs, PROVIDER_PROFILES.oauth.circuitBreakerReset);
+ assert.equal(profile.transientCooldown, PROVIDER_PROFILES.oauth.transientCooldown);
+ assert.equal(
+ profile.rateLimitCooldown,
+ profile.useUpstreamRetryHints ? 0 : profile.baseCooldownMs
+ );
+ assert.equal(profile.maxBackoffLevel, PROVIDER_PROFILES.oauth.maxBackoffLevel);
+ assert.equal(profile.circuitBreakerThreshold, PROVIDER_PROFILES.oauth.circuitBreakerThreshold);
+ assert.equal(profile.circuitBreakerReset, PROVIDER_PROFILES.oauth.circuitBreakerReset);
});
test("getProviderProfile: API provider returns apikey profile", () => {
const profile = getProviderProfile("groq");
- assert.deepEqual(profile, PROVIDER_PROFILES.apikey);
+ assert.equal(profile.baseCooldownMs, PROVIDER_PROFILES.apikey.transientCooldown);
+ assert.equal(profile.useUpstreamRetryHints, true);
+ assert.equal(profile.maxBackoffSteps, PROVIDER_PROFILES.apikey.maxBackoffLevel);
+ assert.equal(profile.failureThreshold, PROVIDER_PROFILES.apikey.circuitBreakerThreshold);
+ assert.equal(profile.resetTimeoutMs, PROVIDER_PROFILES.apikey.circuitBreakerReset);
+ assert.equal(profile.transientCooldown, PROVIDER_PROFILES.apikey.transientCooldown);
+ assert.equal(
+ profile.rateLimitCooldown,
+ profile.useUpstreamRetryHints ? 0 : profile.baseCooldownMs
+ );
+ assert.equal(profile.maxBackoffLevel, PROVIDER_PROFILES.apikey.maxBackoffLevel);
+ assert.equal(profile.circuitBreakerThreshold, PROVIDER_PROFILES.apikey.circuitBreakerThreshold);
+ assert.equal(profile.circuitBreakerReset, PROVIDER_PROFILES.apikey.circuitBreakerReset);
});
test("getProviderProfile: profiles have different thresholds", () => {
@@ -64,7 +88,7 @@ test("getProviderProfile: profiles have different thresholds", () => {
// ─── Exponential Backoff for Transient Errors ───────────────────────────────
-test("502 transient: exponential backoff 5s → 10s → 20s → 40s → 60s (capped)", () => {
+test("502 transient: exponential backoff doubles until the configured max backoff step", () => {
const cooldowns = [];
for (let level = 0; level < 6; level++) {
const result = checkFallbackError(502, "", level, null, null);
@@ -73,14 +97,14 @@ test("502 transient: exponential backoff 5s → 10s → 20s → 40s → 60s (cap
assert.equal(result.newBackoffLevel, level + 1);
assert.equal(result.reason, RateLimitReason.SERVER_ERROR);
}
- // Without provider: uses COOLDOWN_MS.transientInitial (5s) as base
- assert.equal(cooldowns[0], COOLDOWN_MS.transientInitial); // 5s
- assert.equal(cooldowns[1], COOLDOWN_MS.transientInitial * 2); // 10s
- assert.equal(cooldowns[2], COOLDOWN_MS.transientInitial * 4); // 20s
- assert.equal(cooldowns[3], COOLDOWN_MS.transientInitial * 8); // 40s
- // Level 4: 5s * 16 = 80s → capped at 60s
- assert.equal(cooldowns[4], COOLDOWN_MS.transientMax); // 60s
- assert.equal(cooldowns[5], COOLDOWN_MS.transientMax); // 60s (stays capped)
+ assert.deepEqual(cooldowns, [
+ COOLDOWN_MS.transientInitial,
+ COOLDOWN_MS.transientInitial * 2,
+ COOLDOWN_MS.transientInitial * 4,
+ COOLDOWN_MS.transientInitial * 8,
+ COOLDOWN_MS.transientInitial * 16,
+ COOLDOWN_MS.transientInitial * 32,
+ ]);
});
test("502 with OAuth provider: uses oauth profile transientCooldown", () => {
@@ -116,11 +140,13 @@ test("429 rate limit: still uses quota-based exponential backoff", () => {
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
});
-test("401 auth error: still uses flat cooldown, no backoff", () => {
+test("401 auth error: returns terminal auth semantics without connection cooldown", () => {
const result = checkFallbackError(401, "", 0, null, "groq");
assert.equal(result.shouldFallback, true);
- assert.equal(result.cooldownMs, COOLDOWN_MS.unauthorized);
+ assert.equal(result.cooldownMs, 0);
+ assert.equal(result.baseCooldownMs, 0);
assert.equal(result.newBackoffLevel, undefined);
+ assert.equal(result.reason, RateLimitReason.AUTH_ERROR);
});
test("400 bad request: still returns shouldFallback false", () => {
@@ -162,7 +188,7 @@ test("parseRetryFromErrorText: parses will reset after variant", () => {
// ─── T06: Keyword Matching for Long Cooldowns ────────────────────────────────
-test("quota will reset keyword triggers long cooldown from body", () => {
+test("quota reset text is ignored when upstream retry hints are disabled", () => {
const result = checkFallbackError(
429,
"Your quota will reset after 27h41m36s",
@@ -172,19 +198,31 @@ test("quota will reset keyword triggers long cooldown from body", () => {
null
);
assert.equal(result.shouldFallback, true);
- assert.ok(result.cooldownMs > 60_000, "cooldownMs should be > 60s");
- assert.equal(result.newBackoffLevel, 0, "backoffLevel should reset to 0");
+ assert.equal(result.cooldownMs, PROVIDER_PROFILES.oauth.transientCooldown);
+ assert.equal(result.newBackoffLevel, 1);
+ assert.equal(result.usedUpstreamRetryHint, false);
});
-test("exhausted your capacity keyword triggers long cooldown", () => {
+test("quota reset text is honored when upstream retry hints are enabled", () => {
const result = checkFallbackError(
429,
"You have exhausted your capacity. Your quota will reset after 2h",
0,
null,
- "antigravity",
+ "groq",
null
);
assert.equal(result.shouldFallback, true);
- assert.ok(result.cooldownMs > 60_000);
+ assert.equal(result.cooldownMs, 2 * 60 * 60 * 1000);
+ assert.equal(result.newBackoffLevel, 0);
+ assert.equal(result.usedUpstreamRetryHint, true);
+});
+
+test("high transient backoff levels clamp to the configured maxBackoffSteps", () => {
+ const result = checkFallbackError(502, "", BACKOFF_CONFIG.maxLevel + 5, null, null);
+ assert.equal(result.newBackoffLevel, BACKOFF_CONFIG.maxLevel);
+ assert.equal(
+ result.cooldownMs,
+ COOLDOWN_MS.transientInitial * Math.pow(2, BACKOFF_CONFIG.maxLevel)
+ );
});
diff --git a/tests/unit/rate-limit-manager.test.ts b/tests/unit/rate-limit-manager.test.ts
index cb1aa96b6a..e61d523103 100644
--- a/tests/unit/rate-limit-manager.test.ts
+++ b/tests/unit/rate-limit-manager.test.ts
@@ -157,9 +157,14 @@ test("rate limit manager parses retry hints from response bodies and locks model
"gpt-4o"
);
- const lockout = accountFallback.getModelLockoutInfo("openai", "conn-body", "gpt-4o");
- assert.equal(lockout.reason, "rate_limit_exceeded");
- assert.ok(lockout.remainingMs > 0);
+ assert.equal(accountFallback.getModelLockoutInfo("openai", "conn-body", "gpt-4o"), null);
+ const limiterState = await rateLimitManager.__getLimiterStateForTests(
+ "openai",
+ "conn-body",
+ "gpt-4o"
+ );
+ assert.equal(limiterState?.key, "openai:conn-body");
+ assert.equal(rateLimitManager.getRateLimitStatus("openai", "conn-body").active, true);
rateLimitManager.updateFromResponseBody(
"openai",
diff --git a/tests/unit/sse-auth.test.ts b/tests/unit/sse-auth.test.ts
index 98b31f54c8..04d558a7c2 100644
--- a/tests/unit/sse-auth.test.ts
+++ b/tests/unit/sse-auth.test.ts
@@ -14,6 +14,7 @@ const settingsDb = await import("../../src/lib/db/settings.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const auth = await import("../../src/sse/services/auth.ts");
const quotaCache = await import("../../src/domain/quotaCache.ts");
+const { COOLDOWN_MS } = await import("../../open-sse/config/constants.ts");
async function resetStorage() {
core.resetDbInstance();
@@ -760,7 +761,7 @@ test("getProviderCredentials exposes copilotToken when present in providerSpecif
assert.equal(selected.copilotToken, "copilot-token-value");
});
-test("markAccountUnavailable uses configured cooldowns for local 404 model lockouts", async () => {
+test("markAccountUnavailable keeps local 404 failures model-scoped with the local not-found cooldown", async () => {
await settingsDb.updateSettings({
providerProfiles: {
apikey: {
@@ -790,7 +791,7 @@ test("markAccountUnavailable uses configured cooldowns for local 404 model locko
const updated = await providersDb.getProviderConnectionById(connection.id);
assert.equal(result.shouldFallback, true);
- assert.equal(result.cooldownMs, 250);
+ assert.equal(result.cooldownMs, COOLDOWN_MS.notFoundLocal);
assert.equal(updated.testStatus, "active");
assert.equal(updated.rateLimitedUntil, undefined);
assert.equal(updated.lastErrorType, "not_found");
@@ -843,7 +844,7 @@ test("markAccountUnavailable applies a model-only lockout for compatible provide
assert.equal(Number(updated.errorCode), 429);
});
-test("markAccountUnavailable honors configured api-key rate-limit cooldowns", async () => {
+test("markAccountUnavailable uses the unified configured api-key connection cooldown", async () => {
await settingsDb.updateSettings({
providerProfiles: {
apikey: {
@@ -869,7 +870,7 @@ test("markAccountUnavailable honors configured api-key rate-limit cooldowns", as
);
assert.equal(result.shouldFallback, true);
- assert.equal(result.cooldownMs, 125);
+ assert.equal(result.cooldownMs, 200);
});
test("markAccountUnavailable stores Codex scope-specific cooldowns without a global rate limit", async () => {
@@ -1021,7 +1022,7 @@ test("markAccountUnavailable auto-disables permanently banned accounts when the
assert.equal(result.shouldFallback, true);
assert.equal(updated.isActive, false);
- assert.equal(updated.testStatus, "unavailable");
+ assert.equal(updated.testStatus, "banned");
});
test("markAccountUnavailable leaves permanently banned accounts active when auto-disable is disabled", async () => {
@@ -1041,7 +1042,7 @@ test("markAccountUnavailable leaves permanently banned accounts active when auto
assert.equal(result.shouldFallback, true);
assert.equal(updated.isActive, true);
- assert.equal(updated.testStatus, "unavailable");
+ assert.equal(updated.testStatus, "banned");
});
test("markAccountUnavailable swallows auto-disable persistence errors", async () => {
@@ -1085,7 +1086,7 @@ test("markAccountUnavailable swallows auto-disable persistence errors", async ()
assert.equal(result.shouldFallback, true);
assert.equal(updated.isActive, true);
- assert.equal(updated.testStatus, "unavailable");
+ assert.equal(updated.testStatus, "banned");
} finally {
db.prepare = originalPrepare;
}
diff --git a/tests/unit/thundering-herd.test.ts b/tests/unit/thundering-herd.test.ts
index aa1c28b5c2..e3af4f81f1 100644
--- a/tests/unit/thundering-herd.test.ts
+++ b/tests/unit/thundering-herd.test.ts
@@ -9,7 +9,7 @@ import assert from "node:assert/strict";
const { checkFallbackError, getProviderProfile } =
await import("../../open-sse/services/accountFallback.ts");
-const { PROVIDER_PROFILES, DEFAULT_API_LIMITS, COOLDOWN_MS, RateLimitReason } =
+const { BACKOFF_CONFIG, PROVIDER_PROFILES, DEFAULT_API_LIMITS, COOLDOWN_MS, RateLimitReason } =
await import("../../open-sse/config/constants.ts");
const { getProviderCategory } = await import("../../open-sse/config/providerRegistry.ts");
@@ -36,10 +36,13 @@ test("API profile has shorter transient cooldown", () => {
// ─── Backoff Ceiling Tests (prevents infinite growth) ───────────────────────
-test("Exponential backoff is capped at transientMax for high backoff levels", () => {
- // Level 20 → 5s * 2^20 = 5.2M ms, but capped at 60s
+test("Exponential backoff clamps to the configured maxBackoffLevel", () => {
const result = checkFallbackError(502, "", 20, null, null);
- assert.equal(result.cooldownMs, COOLDOWN_MS.transientMax);
+ assert.equal(result.newBackoffLevel, BACKOFF_CONFIG.maxLevel);
+ assert.equal(
+ result.cooldownMs,
+ COOLDOWN_MS.transientInitial * Math.pow(2, BACKOFF_CONFIG.maxLevel)
+ );
});
test("API provider backoff level caps at profile maxBackoffLevel", () => {