refactor: unify resilience controls - #1449
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors the resilience configuration system by centralizing settings into a new ResilienceSettings structure, removing legacy model-level availability tracking, and replacing the per-target circuit breaker logic with a global provider-level breaker. While the changes improve resilience management, the new implementation introduces tight coupling by allowing the open-sse workspace package to import directly from the host application's internal modules (@/lib/db/readCache and @/lib/localDb). This violates the intended isolation of the workspace package. Additionally, the NumberField component in the resilience settings UI prevents users from clearing input fields, which should be addressed to improve usability.
| export async function getRuntimeProviderProfile(provider: string | null | undefined) { | ||
| const fallback = getProviderProfile(provider); | ||
| try { | ||
| const { getCachedSettings } = await import("@/lib/db/readCache"); |
There was a problem hiding this comment.
The workspace package @omniroute/open-sse is using a host-application alias (@/lib/db/readCache) in its imports. Since this directory is intended to be a standalone workspace package (as noted in ARCHITECTURE.md), it should not depend on the internal modules of the application that consumes it. This creates a circular dependency and prevents the package from being used independently or published correctly. Consider refactoring this to inject the settings dependency or use a shared interface.
| try { | ||
| const { getProviderConnections } = await import("@/lib/localDb"); | ||
| const connections = await getProviderConnections(); | ||
| const { getProviderConnections, getSettings } = await import("@/lib/localDb"); |
There was a problem hiding this comment.
This workspace package is importing directly from the application layer (@/lib/localDb). This tight coupling violates package boundaries and makes the @omniroute/open-sse package non-portable. Dependencies on the host application's database or configuration should be injected at runtime rather than being hardcoded via application-level aliases.
| if (event.target.value === "") return; | ||
| const nextValue = Number(event.target.value); |
There was a problem hiding this comment.
The NumberField component returns early when the input is an empty string, which prevents users from clearing the field to type a new value (the controlled input will immediately revert to its previous state). Removing this check allows the state to be updated correctly when the field is cleared, improving the user experience during configuration tuning.
| if (event.target.value === "") return; | |
| const nextValue = Number(event.target.value); | |
| const nextValue = Number(event.target.value); |
There was a problem hiding this comment.
Pull request overview
Refactors OmniRoute’s resilience model to a unified, plan-aligned stack (request queue → connection cooldown → provider breaker → wait-for-cooldown), removing legacy global model availability/quarantine surfaces and aligning runtime, dashboard, API, MCP, and docs for the 3.7.0 release.
Changes:
- Introduces
ResilienceSettingswith normalization/legacy-compat wiring and updates/api/resilience+ dashboard/MCP presets to the new shape. - Removes legacy model availability/quarantine domain + API/UI, shifting runtime health to provider breaker + connection cooldowns.
- Updates combo routing and chat pipeline behavior/tests to respect provider breaker responses and the unified cooldown-aware retry semantics.
Reviewed changes
Copilot reviewed 68 out of 68 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/thundering-herd.test.ts | Updates backoff ceiling expectations to use BACKOFF_CONFIG clamping. |
| tests/unit/sse-auth.test.ts | Aligns auth/cooldown expectations with unified cooldown constants and terminal statuses. |
| tests/unit/rate-limit-manager.test.ts | Updates assertions to reflect new limiter state exposure vs model lockout behavior. |
| tests/unit/error-classification.test.ts | Expands provider profile assertions and updates backoff/upstream-hint semantics. |
| tests/unit/domain-branch-hardening.test.ts | Removes modelAvailability reset/coverage now that the domain module is removed. |
| tests/unit/combo-routing-engine.test.ts | Updates combo tests to skip provider-breaker-open responses and renames execution key helper. |
| tests/unit/combo-context-relay.test.ts | Updates context-relay behavior to skip provider-breaker-open responses instead of per-target breaker state. |
| tests/unit/combo-config.test.ts | Removes legacy combo resilience fields (timeoutMs, healthcheck) from defaults/config resolution. |
| tests/unit/combo-circuit-breaker.test.ts | Removes legacy per-target combo circuit-breaker integration tests. |
| tests/unit/chat-route-coverage.test.ts | Shifts 503 coverage from model cooldown to connection cooldown and adds provider breaker response headers/code assertions. |
| tests/unit/chat-helpers.test.ts | Updates pipeline gate checks to provider breaker response and simplifies breaker execution path. |
| tests/unit/chat-combo-live-test.test.ts | Updates “live test” semantics to bypass connection cooldown and sets breaker timing fields explicitly. |
| tests/unit/batch-a-domain.test.ts | Removes model availability domain tests from Batch A suite. |
| tests/unit/autocombo-unification.test.ts | Updates intelligent routing helper test to config-only scoring (no health extraction). |
| tests/unit/auth-terminal-status.test.ts | Adds coverage for terminal/non-terminal auth classifications without adding cooldown. |
| tests/unit/account-fallback-service.test.ts | Updates profile expectations and removes provider-cooldown mutation behavior assertions. |
| tests/integration/security-hardening.test.ts | Removes legacy /api/models/availability route from endpoint validation checks. |
| tests/integration/proxy-pipeline.test.ts | Updates pipeline wiring assertions to new breaker response helper and credential preflight usage. |
| tests/integration/integration-wiring.test.ts | Removes /api/models/availability existence/method checks; updates wiring expectations. |
| tests/integration/chatcore-compression-integration.test.ts | Removes modelAvailability resets from integration harness reset logic. |
| tests/integration/chat-pipeline.test.ts | Removes global model-unavailable integration test and associated resets. |
| tests/integration/_chatPipelineHarness.ts | Removes modelAvailability helpers from harness surface and reset flow. |
| tests/e2e/resilience-plan-alignment.spec.ts | Adds Playwright coverage ensuring UI surfaces align to the new resilience plan and stop calling legacy endpoints. |
| src/types/settings.ts | Adds resilienceSettings?: ResilienceSettings and removes legacy combo defaults fields. |
| src/sse/services/cooldownAwareRetry.ts | Reads wait-for-cooldown behavior from resolveResilienceSettings and renames settings fields. |
| src/sse/services/auth.ts | Refactors markAccountUnavailable to unify cooldown decisions, terminal statuses, and provider error classification. |
| src/sse/handlers/chatHelpers.ts | Uses providerCircuitOpenResponse for breaker gating; removes model availability gating and breaker.execute usage. |
| src/sse/handlers/chat.ts | Removes model availability quarantine integration and updates provider breaker handling + wait-for-cooldown semantics. |
| src/shared/validation/schemas.ts | Adds plan-aligned resilience schemas while keeping legacy profile/default schemas for compatibility. |
| src/shared/utils/circuitBreaker.ts | Extends breaker status payload with retryAfterMs. |
| src/lib/resilience/settings.ts | New module defining defaults, normalization, merge, and legacy compatibility mapping for resilience settings. |
| src/lib/monitoring/observability.ts | Normalizes breaker status and exposes providerBreakers array + retryAfterMs in health payload. |
| src/lib/dataPaths.js | Removes legacy compiled JS dataPaths artifact. |
| src/lib/combos/intelligentRouting.ts | Removes health extraction; keeps provider scoring based on config only. |
| src/domain/modelAvailability.ts | Removes legacy global model availability/quarantine domain module. |
| src/app/api/settings/combo-defaults/route.ts | Sanitizes legacy combo resilience keys out of persisted defaults/overrides. |
| src/app/api/resilience/route.ts | Implements plan-aligned GET/PATCH, legacy patch normalization, persistence, and runtime sync. |
| src/app/api/resilience/reset/route.ts | Updates endpoint semantics/docs to “reset provider circuit breakers” only. |
| src/app/api/policies/route.ts | Removes circuit breaker states from policies API payload (locked identifiers only). |
| src/app/api/models/availability/route.ts | Removes legacy model availability API route. |
| src/app/(dashboard)/layout.tsx | Removes ModelStatusProvider wrapper now that model availability UI is removed. |
| src/app/(dashboard)/dashboard/settings/components/PoliciesPanel.tsx | Removes circuit breaker UI from Policies panel; focuses on locked identifiers. |
| src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx | Removes legacy combo resilience fields and sanitizes legacy keys on load/save. |
| src/app/(dashboard)/dashboard/providers/page.tsx | Removes ModelAvailabilityBadge from providers page header. |
| src/app/(dashboard)/dashboard/providers/components/ModelStatusContext.tsx | Removes legacy shared polling context for model availability. |
| src/app/(dashboard)/dashboard/providers/components/ModelStatusBadge.tsx | Removes per-model status badge component. |
| src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityPanel.tsx | Removes legacy model availability panel. |
| src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.tsx | Removes legacy model availability badge/popup. |
| src/app/(dashboard)/dashboard/providers/[id]/page.tsx | Removes per-model status badge in provider model rows. |
| src/app/(dashboard)/dashboard/health/page.tsx | Displays breaker retryAfterMs in provider health UI. |
| src/app/(dashboard)/dashboard/endpoint/components/MCPDashboard.tsx | Updates resilience presets to new plan-aligned structure. |
| src/app/(dashboard)/dashboard/combos/page.tsx | Sanitizes legacy combo config keys and removes legacy advanced fields from templates/UI. |
| src/app/(dashboard)/dashboard/combos/IntelligentComboPanel.tsx | Removes health polling/exclusions UI; makes panel explicitly config-only. |
| open-sse/utils/error.ts | Adds structured providerCircuitOpenResponse (503 + code + headers). |
| open-sse/services/rateLimitManager.ts | Integrates request queue settings from ResilienceSettings and applies them across Bottleneck limiters. |
| open-sse/services/comboConfig.ts | Filters legacy combo resilience keys during config cascade resolution. |
| open-sse/services/combo.ts | Removes per-target breaker logic; skips targets when provider breaker open responses are returned. |
| open-sse/services/accountFallback.ts | Reworks provider profile derivation to use ResilienceSettings and maps legacy “provider cooldown” helpers to shared breaker. |
| open-sse/services/AGENTS.md | Updates architecture notes to reflect provider-breaker/global model. |
| open-sse/mcp-server/tools/advancedTools.ts | Updates MCP resilience profile payloads to new structure. |
| open-sse/handlers/chatCore.ts | Removes legacy per-model quota lockout handling in favor of shared auth/fallback path. |
| docs/openapi.yaml | Removes /api/models/availability and updates /api/resilience to GET/PATCH config semantics. |
| docs/USER_GUIDE.md | Updates resilience documentation to the new multi-layer model and surface responsibilities. |
| docs/ARCHITECTURE.md | Updates architecture references to remove model availability and reflect new resilience surfaces. |
| docs/API_REFERENCE.md | Removes model availability API docs and updates resilience section wording. |
| README.md | Updates resilience feature description to match the new plan-aligned model. |
| settings: null, | ||
| relayOptions: null as any, | ||
| allCombos: null, | ||
| relayOptions: null, | ||
| }); |
There was a problem hiding this comment.
Object literal passed to handleComboChat defines relayOptions twice. In TypeScript this is a compile error (duplicate property name) and the first value is silently overwritten at runtime. Remove the duplicate and keep a single relayOptions entry (and do the same for other call sites in this file).
|
Great refactor! This unified resilience model is much cleaner than the fragmented approach we had before. I have a suggestion: Consider removing 429 from PROVIDER_FAILURE_ERROR_CODES. Rate limiting (429) is expected behavior that's already handled at model-level and account-level cooldowns. Including it in the provider-wide circuit breaker causes premature cooldown of the entire provider, reducing availability for legitimate traffic that could be routed to other accounts or models. const PROVIDER_FAILURE_ERROR_CODES = new Set([408, 500, 502, 503, 504]); I had a related PR (#1442) that addressed this and added configurable thresholds. Since your refactor already covers the threshold configuration via providerBreaker.failureThreshold, I'm happy to close mine and let this proceed. The only remaining item would be the 429 removal and a small test isolation fix in settings-api.test.ts that I can submit separately after this merges. Thanks for the comprehensive cleanup! |
|
@clousky2020 Good catch. I adopted this in the latest update.
I also updated the Resilience settings copy and the related tests/docs so the UI, runtime behavior, and validation all match this rule. Thanks for calling it out. |
… + sibling #1444 growth)
… + sibling #1449 growth)
… + sibling #1444 growth)
… + sibling #1449 growth)
… + sibling #1444 growth)
…4347) * fix(providers): bound OAuth connection-test probe with a timeout (port from 9router#1449) The OAuth path of "Test Connection One-by-One" called bare fetch() with no AbortController/signal, so a provider probe that accepted the socket but never responded wedged the test queue forever. Both the initial probe and the post-refresh retry are now bounded with AbortSignal.timeout(30s) — matching the API-key path's existing budget — and a timed-out probe resolves as a failure with a clear "Test timed out after 30s" message in the route's normal error shape. Reported-by: ntdung6868 (decolua/9router#1449) Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> * chore(quality): rebaseline providers test route file-size to 887 (#1449 + sibling #1444 growth) --------- Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com>
…4397) * chore(release): open v3.8.31 development cycle * fix(mitm): exact host membership in MITM hosts test (CodeQL false positive) (#4386) getMitmToolHosts returns string[], so .includes(host) is Array.prototype.includes (exact membership). CodeQL's js/incomplete-url-substring-sanitization heuristic misreads it as a String.includes() URL-substring sanitization check and raises a HIGH alert. Switch to .some(h => h === host) — identical semantics, explicit intent, no flagged pattern. Surfaced post-v3.8.30 (#4325) once the test landed on main. Test-only change (no runtime behavior); the suite still passes and the CodeQL re-scan on merge clears the alert. * fix(codex): request reasoning summaries (#4359) Adds reasoning.summary=auto + reasoning.encrypted_content include for Codex. Thanks @xz-dev. * fix(embeddings): inject NVIDIA NIM input_type for asymmetric embed models (#4341) NVIDIA NIM asymmetric embedding models (e.g. nvidia/nv-embedqa-e5-v5) reject requests without an `input_type` ("query" | "passage") with 400 "'input_type' parameter is required". The embedding registry now carries a model-level default param for the asymmetric NVIDIA model, and the embeddings handler injects a model's default params into the upstream body only when the client omitted them, leaving a client-supplied value untouched. Reported-by: hydraromania (decolua/9router#1378) Co-authored-by: hydraromania <252583922+hydraromania@users.noreply.github.com> * fix(api): migrate deprecated Codex [features].codex_hooks to [features].hooks (#4342) Codex renamed the `codex_hooks` feature flag to `hooks`; recent Codex CLI versions ignore the old key and warn. When OmniRoute rewrites an existing config.toml (configure/reset Codex provider) it now renames [features].codex_hooks -> [features].hooks, preserving the value and never clobbering an already-present `hooks`, then drops the deprecated key. The migration is a no-op when the flag is absent and runs on both the POST and DELETE config paths. Reported-by: Bian-Sh (decolua/9router#1327) Co-authored-by: Bian-Sh <24520547+Bian-Sh@users.noreply.github.com> * fix(translator): drop the null flush on the same-format response path (#4344) The streaming response translator's same-format fast path returned `[chunk]` unconditionally, so the end-of-stream null/flush signal (chunk === null) propagated as a literal `[null]`. Downstream this surfaced as an empty `data: null` SSE event between chunks and crashed strict clients (e.g. Factory Droid BYOK on /v1/responses). The fast path now returns `[]` for the null flush while still passing real chunks through unchanged. Reported-by: thaitryhand (decolua/9router#1052) Co-authored-by: thaitryhand <248103256+thaitryhand@users.noreply.github.com> * fix(translator): strip assistant echo fields on the OpenAI target path (Mistral 422) (#4350) Strict OpenAI-compatible upstreams (e.g. mistral/codestral-latest) reject client-only assistant echo fields sent back as input with 422 extra_forbidden (the report hit messages[].assistant.reasoning_content via Codex /responses). Only reasoning_content was stripped on the OpenAI target path; the sibling fields reasoning / refusal / annotations / cache_control leaked through. They are now all dropped on the non-reasoner OpenAI target path. `audio` is intentionally preserved (OpenAI audio models reference a prior assistant audio response by id; Mistral never emits audio). Reported-by: xxy9468615 (decolua/9router#1649) Co-authored-by: xxy9468615 <63351664+xxy9468615@users.noreply.github.com> * fix(cli): honor TAILSCALE_AUTHKEY for non-interactive tailscale login (#4343) * fix(cli): honor TAILSCALE_AUTHKEY for non-interactive tailscale login (port from 9router#1263) startTailscaleLogin built `tailscale up` without ever reading process.env.TAILSCALE_AUTHKEY, so a pre-authenticated / headless daemon waited for an interactive auth URL and timed out (~15s). When TAILSCALE_AUTHKEY is set it is now passed via `--auth-key=` (an argv element to spawn(binary, args) — no shell interpolation, Hard Rule #13); when unset, behavior is unchanged. The arg builder is extracted into a pure exported `tailscaleUpArgs()` for testing. Reported-by: ipeterpetrus (decolua/9router#1263) Co-authored-by: ipeterpetrus <93033698+ipeterpetrus@users.noreply.github.com> * chore(quality): rebaseline tailscaleTunnel.ts file-size to 1202 (#1263 +13) --------- Co-authored-by: ipeterpetrus <93033698+ipeterpetrus@users.noreply.github.com> * fix(dashboard): OAuth modal surfaces the real error on a non-JSON response (#4351) * fix(dashboard): OAuth modal surfaces real error on non-JSON responses (port from 9router#1318) The OAuth connect/reauth modal called `await res.json()` unconditionally, so a non-JSON error response (e.g. a plain-text 500 page from a build/OAuth endpoint) threw `Unexpected token 'I'...` and hid the real failure. New shared helpers parseResponseBody / getErrorMessage (src/shared/utils/api.ts) read the body safely (JSON when JSON, raw text otherwise) and produce a clean message either way; every modal fetch site now uses them. Reported-by: DNNYF (decolua/9router#1318) Co-authored-by: DNNYF <74033321+DNNYF@users.noreply.github.com> * fix(dashboard): type OAuth modal response body as Record<string, unknown> (t11 any-budget) Switch the parseResponseBody casts from Record<string, any> to Record<string, unknown> so OAuthModal.tsx stays within its t11 explicit-any budget. getErrorMessage already takes unknown; the success paths typecheck clean under strict:false. No runtime change. --------- Co-authored-by: DNNYF <74033321+DNNYF@users.noreply.github.com> * fix(translator): accept AI SDK-style { type: image, image: data-URL } content parts (#4345) * fix(translator): accept AI SDK-style { type: image, image: "data:..." } parts (port from 9router#1330) Several OpenAI-input translators only recognized images shaped as `image_url.url` (or an object with `.source`/`.url`), so an AI SDK-style content part where `image` is a bare data-URL STRING was silently dropped before reaching a vision provider (OpenCode is one affected client; the gap is generic). The OpenAI->Claude, OpenAI->Kiro and OpenAI->Gemini/Antigravity translators now parse a string `image` data URL into each provider's native image shape (Claude base64 source, Kiro images[].source.bytes, Gemini inlineData). Reported-by: mugnimaestra (decolua/9router#1330) Co-authored-by: mugnimaestra <13349159+mugnimaestra@users.noreply.github.com> * chore(quality): freeze openai-to-kiro.ts file-size at 807 (#1330 +9, over 800 cap) --------- Co-authored-by: mugnimaestra <13349159+mugnimaestra@users.noreply.github.com> * fix(dashboard): show a disabled connection's last error in the row (#4352) * fix(dashboard): show a disabled connection's last error in the row (port from 9router#1447) The provider card's error badge counts a disabled connection (isActive === false) that has an error — its effective status is still error/expired/unavailable — but the connection row hid the lastError text for disabled rows, so the operator saw the count without the cause. The row's error-visibility decision is extracted into shouldShowConnectionLastError() and now shows the error whenever there is one, regardless of the active toggle. Reported-by: ntdung6868 (decolua/9router#1447) Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> * chore(quality): rebaseline ConnectionRow.tsx file-size to 942 (#1447 +1 import) --------- Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> * fix(providers): bound the OAuth connection-test probe with a timeout (#4347) * fix(providers): bound OAuth connection-test probe with a timeout (port from 9router#1449) The OAuth path of "Test Connection One-by-One" called bare fetch() with no AbortController/signal, so a provider probe that accepted the socket but never responded wedged the test queue forever. Both the initial probe and the post-refresh retry are now bounded with AbortSignal.timeout(30s) — matching the API-key path's existing budget — and a timed-out probe resolves as a failure with a clear "Test timed out after 30s" message in the route's normal error shape. Reported-by: ntdung6868 (decolua/9router#1449) Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> * chore(quality): rebaseline providers test route file-size to 887 (#1449 + sibling #1444 growth) --------- Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> * fix(providers): label a deactivated account distinctly from a revoked token (#4353) A Codex connection whose OAuth refresh is fully healthy but whose ChatGPT account has been deactivated by the provider gets a 401 from the upstream API. The connection test labeled that the same as a bad credential ("Token invalid or revoked" -> upstream_auth_error), so an operator could not tell a deactivated account from a revoked token. The test now reads the 401/403 body and, when it indicates account deactivation, classifies it as account_deactivated (which the dashboard already renders as "Account Deactivated"); a plain auth 401 is unchanged. Reported-by: ntdung6868 (decolua/9router#1444) Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> * fix(db): cascade-delete orphaned model aliases when a provider is removed (#4348) * fix(db): cascade-delete orphaned model aliases when a provider is removed (port from 9router#1409) Deleting a custom provider removed its connections and node but left the imported model-alias rows (key=<alias>, value="<providerId>/<model>") behind, so re-importing the same provider was blocked by stale "already exists" aliases. Add a deleteModelAliasesForProvider(providerId) DB helper that drops every alias whose stored value begins with "<providerId>/", and call it from the provider-node DELETE handler so a fresh import is unblocked. Reported-by: nguyenvanhuy0612 (decolua/9router#1409) Co-authored-by: nguyenvanhuy0612 <57367674+nguyenvanhuy0612@users.noreply.github.com> * chore(quality): rebaseline models.ts file-size to 1221 (#1409 + sibling #1294 growth) --------- Co-authored-by: nguyenvanhuy0612 <57367674+nguyenvanhuy0612@users.noreply.github.com> * fix(api): persist max_input_tokens/max_output_tokens when adding a custom model (#4349) The POST /api/provider-models handler read the rest of the body but never the two token-limit fields, and addCustomModel() had no parameter for them, so the form values were dropped on write while the DB layer and /v1/models catalog already round-trip inputTokenLimit/outputTokenLimit. Accept the two optional limits in the schema, forward them through the handler, and persist them in addCustomModel(). TDD: failing-then-passing unit test. Reported-by: codename-zen (decolua/9router#1294) Co-authored-by: codename-zen <263238141+codename-zen@users.noreply.github.com> * docs: feature-documentation catch-up (v3.8.20 → v3.8.30) (#4391) One-time reconciliation of the docs with every user-facing feature shipped since v3.8.20 (we had never done a dedicated pass, so debt had accumulated): - README: new '✨ What's New' section (curated v3.8.20→v3.8.30 highlights). - New guides: CLI-INTEGRATIONS (all setup-*/launch commands), MITM-TPROXY-DECRYPT (transparent-decrypt epic), CONTEXT_EDITING (delegated Anthropic clear_tool_uses). - Refreshed: AUTO-COMBO (auto/<category>:<tier> + Arena-ELO), API_REFERENCE (x-omniroute-no-memory), MEMORY (int8 quantization + off-by-default), RESILIENCE (model-lockout success-decay), RTK, AGENTBRIDGE, TRAFFIC_INSPECTOR, GUARDRAILS, CLOUD_AGENT, ENVIRONMENT, SETUP_GUIDE, CLI-TOOLS, MCP-SERVER. - Regenerated PROVIDER_REFERENCE (231 providers); synced the count in README/CLAUDE/AGENTS. - Allowlisted external-tool env vars (OPENAI_API_BASE, PROMPTFOO_PROVIDER_KEY) and the STREAM_RECOVERY config-object name in the docs-accuracy gates. All claims source-verified; check:docs-all (sync/counts/env/links/fabricated) passes. Going forward this runs every release via generate-release step 6b. * fix(executors): don't inject thinking when tool_choice forces a tool (native Claude) (#4389) Forced tool_choice now strips the adaptive thinking injection to avoid Anthropic 400. Thanks @NomenAK. * fix(translator): Gemini accepts HTTP/HTTPS image URLs (port from 9router#344) (#4373) OpenAI-style `image_url` parts with an `http://` or `https://` URL reached `convertOpenAIContentToParts` and were dropped with only a `console.warn`, because Gemini's `inlineData` requires base64 (the helper is synchronous and cannot fetch+encode upstream assets). Gemini's `Part` schema, however, natively accepts `fileData: { fileUri }` for remote URIs — the model fetches the asset itself. The helper now emits a `fileData` part (`mimeType: "image/*"`, inferred upstream on fetch) for HTTP/HTTPS URLs instead of silently dropping them. Vision requests that pass a URL — not a data: URI — now reach Gemini intact. No behavioral change for: - `data:` URIs → still emitted as `inlineData` with the parsed media type. - Unsupported schemes (e.g. `ftp:`) → still skipped (Gemini would reject them). The openai-to-claude side already passed HTTP/HTTPS URLs through as `source: { type: "url", url }` (lines 573–578) — the upstream PR's Claude-side change was already covered. Regression test: tests/unit/gemini-helper-http-image-url-port344.test.ts (4 cases: https URL, http URL, data: URI no-regression, unsupported-scheme guard). Inspired-by: decolua/9router#344 Co-authored-by: Ibrahim Ryan <ryan@nuevanext.com> * fix(executors): strip stream_options for qwen non-streaming / thinking Claude Code requests (port from 9router#663) (#4374) Claude-Code-compatible providers force the executor-level `stream` flag on via `upstreamStream = stream || isClaudeCodeCompatible` (open-sse/handlers/chatCore.ts), but the outgoing body keeps the caller's original `stream: false`. The shared `stream && targetFormat === "openai"` branch in DefaultExecutor.transformRequest then injected `stream_options: { include_usage: true }` onto a body that still said `stream: false`, and qwen upstream rejected the request with `400 "'stream_options' only set this when you set stream: true"`. The same rejection surfaced when the body carried `thinking` / `enable_thinking`. The qwen branch now skips the injection (and strips any client-sent `stream_options`) when the body explicitly says `stream: false` or requests thinking, leaving regular qwen streaming requests with the include_usage injection intact. Other providers are unaffected. Adds a TDD regression with 4 cases covering both opt-out paths and the normal-streaming positive control. Inspired-by: decolua/9router#663 Co-authored-by: anuragg-saxenaa <anuragg.saxenaa@gmail.com> * fix(security): scope OAuth callback postMessage to a trusted-origin allowlist (port from 9router#998) (#4372) The OAuth callback at `/callback` previously fell back to `window.opener.postMessage({ code, state, ... }, "*")` whenever the opener was cross-origin. The fallback was intended to support remote-OmniRoute + local-loopback callbacks (where opener and callback live on different origins), but the same code path also delivers the OAuth code/state to any hostile opener that pops the well-known callback URL — letting that attacker complete the OAuth flow as the user. Replace the wildcard fallback with iteration over a fixed allowlist: `window.location.origin` (same-origin parent — the popup-mode dashboard) plus Codex's fixed loopback helper (`http://localhost:1455` and the IPv4 literal `http://127.0.0.1:1455`). The browser drops `postMessage` to any opener whose actual origin is not in `targetOrigin`, so the message reaches only known parents and is silently dropped for any other. The same-origin fallback path is unchanged — methods 2 (`BroadcastChannel`) and 3 (`localStorage` storage event) still cover same-origin openers that COOP severed. The `openerSameOrigin` probe stays in place to drive the auto-close vs manual-copy UI decision (no behavior change for the success path). Adds a regression test (`tests/unit/ui/oauth-callback-postmessage-scope.test.tsx`) that mounts the page with a stubbed cross-origin opener and asserts no `postMessage` call ever uses `"*"` and every call lands on an allowlisted origin. The test failed against the pre-fix code (red), passes after the fix (green) — TDD per CLAUDE.md hard rule #18. Partial port of upstream decolua/9router#998: the upstream PR also re-enabled TLS verification on a DNS-bypass fetch in `open-sse/utils/proxyFetch.js`; that part is N/A here because OmniRoute's `proxyFetch.ts` never disabled TLS verification (no `rejectUnauthorized: false` anywhere in the file). Inspired-by: decolua/9router#998 Co-authored-by: aeonframework <aeon@aeonframework.dev> * fix(sse): default combo per-target timeout to 120s for fast failover (#4365) Combo per-target timeout inherited the full FETCH_TIMEOUT_MS (600s) when a combo did not set its own targetTimeoutMs, so a single hung/slow target stalled the whole combo for up to 10 minutes before falling through to the next model. Introduce DEFAULT_COMBO_TARGET_TIMEOUT_MS (120s) as the unset-default in resolveComboTargetTimeoutMs (new 3rd arg) and wire it in phaseComboSetup. The upstream ceiling (600s) and per-combo opt-out (targetTimeoutMs, up to the ceiling) are preserved; single non-combo requests are unchanged. For streaming requests this only bounds time-to-first-headers, so token generation is not cut short. TDD: failing-then-passing unit test in tests/unit/combo-config.test.ts. * refactor(combo): de-dup exhausted-target skip predicate across both dispatchers (#4362) Primeiro incremento da de-dup dos 2 dispatchers de combo (handleComboChat + handleRoundRobinCombo). O bloco de pre-check #1731/#1731v2 (skip de target já exhausted no provider/connection) era BYTE-IDÊNTICO nos dois (mesmas condições, mesmas mensagens), diferindo só na tag de log e no control-flow. - comboPredicates.ts: getExhaustedTargetSkipReason(target, exhaustedProviders, exhaustedConnections) — predicate PURO que retorna a mensagem de skip (ou null); cada dispatcher mantém seu próprio log-tag + control-flow (return null / continue) + fallbackCount. No mutate do stryker (cobertura de mutação). - combo.ts: −20 linhas (os 2 blocos viram 1 chamada cada). - 7 testes de caracterização travam condições + strings exatas. Comportamento preservado: 376/376 testes combo (357 caracterização + 7 novos), integração sse-correctness 5/5, typecheck 0, complexity neutro (1895), file-size encolhe. Próximo incremento: de-dup do error-handling/exhausted-tracking (handleTargetError). * refactor(combo): de-dup upstream-error exhaustion classification across both dispatchers (#4366) Segundo incremento da de-dup dos 2 dispatchers (handleTargetError). Após cada erro de target, ambos rodavam um bloco quase-idêntico que marca o provider exhausted (#1731), a conexão connection-errored (#1731v2) ou o provider transiently rate-limited. - combo/targetExhaustion.ts: applyComboTargetExhaustion(target, opts) — atualiza os 3 Sets de exhaustion e retorna providerExhausted. As MUTAÇÕES de Set (que dirigem o skip de targets, lidas por getExhaustedTargetSkipReason) são BYTE-IDÊNTICAS nos dois; as diferenças reais viram parâmetros: tag, allAccountsRateLimited (termo extra do RR, false no handleComboChat), exhaustedLogLevel (info no handleComboChat, debug no RR). Connection-level extraído p/ markConnectionLevelExhaustion (privado, <15 complexity). - combo.ts: −73 linhas; 4 imports órfãos removidos. - 7 testes de caracterização travam as mutações + o return. ÚNICA mudança de comportamento: o WORDING das mensagens de log do RR ganha o sufixo 'on remaining targets' (cosmético; mesmo #code, mesmas mutações, mesmos níveis de log). 376/376 combo (caracterização preservada), integração sse 5/5, typecheck 0, complexity neutro (1895), file-size encolhe. * refactor(chatCore): extract checkHeapPressureGuard leaf (god-file decomposition start) (#4371) Primeiro incremento da decomposição do chatCore.ts (5127 LOC, hot-path mais quente). O guard de memória do topo do handleChatCore (rejeita 503 quando o heap V8 passa o threshold de shed) vira um leaf testável, co-locado com o threshold em heapPressure.ts. - heapPressure.ts: checkHeapPressureGuard(heapUsedMb, thresholdMb) — retorna o result 503 pronto ou null. Byte-idêntico ao guard inline (mesmo check, mesma 503, mesmo warn). A figura de heap fica em telemetria INTERNA, nunca no response do cliente (Hard Rule #12). - chatCore.ts: o bloco inline (~22 ln) vira 3 linhas; import órfão de HEAP_PRESSURE_THRESHOLD_MB trocado por checkHeapPressureGuard. - 3 testes novos (incl. assert Rule #12: o MB medido não vaza no payload). complexity-baseline 1895->1896: drift de base pós-#4338 (medido com minhas mudanças stashed = 1896); esta mudança é complexity-NEUTRA (helper complexity 2, handleChatCore só perde código). 190/190 chatcore tests, typecheck 0, file-size encolhe. * Localize CLI and stabilize fetch, memory, and coverage handling (#4383) en-only i18n, fetch-start-timeout hardening, EngineConfigPage icon fix, CI build-artifact-reuse overhaul. Memory production hunk dropped as a no-op (tests kept). Thanks @JxnLexn. * test(combo): reset circuit breakers between stream-readiness cases (restore green) (#4396) The combo-dispatch cases in combo-stream-readiness-fallback.test.ts deliberately fail `glm` (zombie streams / repeated 504s), which legitimately trips the per-provider circuit breaker. That OPEN state is a module-level singleton, so it leaked into the next test and combo.ts then SKIPPED `glm/*` targets entirely ("Skipping … circuit breaker OPEN"). That made "combo does not retry stream readiness timeouts on the same model" never attempt glm/zombie — expected ['glm/zombie','openai/gpt-5.4-mini'] but got ['openai/gpt-5.4-mini']. This was a pre-existing red on release/v3.8.31 (present at the cycle-open tip), order-dependent: the test passes in isolation, fails after the preceding cases. Add a test.beforeEach(resetAllCircuitBreakers) so each scenario starts from a clean breaker slate. Test-isolation only — the breaker behavior is correct and no production code or assertion changes. Full combo suite: 390/390 green. * fix(cli): decline confirm() cleanly on non-interactive stdin + document contexts workflow The `contexts remove` command already has `--yes` to skip confirmation, but when run without it under a non-interactive stdin (pipe, CI, EOF) the [y/N] prompt could never be answered — the readline question stayed pending and Node warned about an "unsettled top-level await" at exit. confirm() now detects `!process.stdin.isTTY` and declines cleanly (returns false), pointing at `--yes` for non-interactive use. Exported confirm() for a regression test. Docs: REMOTE-MODE.md gains a full "Managing contexts" section (list/current/use to switch between remote and local, add/show/rename, remove with --yes, export/import), fixes a `context current` -> `contexts current` typo, and the README remote-mode snippet now shows switching back to local. Verified against the live CLI: command signatures, --yes, and the non-TTY decline path all behave as documented. Tests: cli-contexts.test.ts asserts confirm() declines on non-TTY stdin (RED before, GREEN after). All docs gates (fabricated/links/symbols) pass. * ci(t11): bump any-budget for executors/base.ts (2 false-positive "any" strings) Unblocks the Fast Quality Gates on release/v3.8.31: `check:any-budget:t11` was red on `open-sse/executors/base.ts` for ALL PRs (pre-existing base drift, unrelated to this branch). The checker counts `\bany\b` after stripping comments but NOT strings, and the native-Claude tool_choice logic uses the API value `"any"` in two string literals (`tb.tool_choice === "any"`, `.type === "any"`). There are zero actual TypeScript `any` types in the file — budget set to the matched count, mirroring the existing cursor.ts false-positive entry right below it. * perf: combos UI split + next config + 1-click redis + bifrost sidecar (#3932) (#4381) Combos UI split + next.config perf + 1-click local Redis launcher + bifrost relay. Review fixes (co-author): --rm/--restart conflict, error sanitization, UI/route names, dead-guard re-doc, bifrost Zod, IPv6 + CLI test fixes. Thanks @KooshaPari. * fix(plugin): prefix OC static-catalog combo+raw keys with providerId (#4384) OC parses model ids on '/'; combo keys now carry 'omniroute/' (was 'combo/'). Live-validated against the VPS via OpenCode. Thanks @herjarsa. * docs(env): document TAILSCALE_AUTHKEY (env/docs contract drift on .31) Second pre-existing base-drift fix needed to get Fast Quality Gates green: the `repository contract is in sync` test (check:env-doc-sync) was red on ALL .31 PRs. PR #4343 added a code reference to `process.env.TAILSCALE_AUTHKEY` (src/lib/tailscaleTunnel.ts) for non-interactive `tailscale up`, but never added the var to .env.example / docs/reference/ENVIRONMENT.md — the contract requires code vars to appear in both. Add the (commented) entry to .env.example next to TAILSCALE_BIN and a row to ENVIRONMENT.md. Verified: `node scripts/check/check-env-doc-sync.mjs` → in sync. Unrelated to this branch's confirm()/docs change; surfaced because touching a quality script triggers the TIA fail-safe full unit suite. * chore(release): v3.8.31 — 2026-06-20 Finalize the v3.8.31 release: reconcile the CHANGELOG (full commit-to-bullet coverage, 26 bullets across Features/Fixed/Security/Maintenance), refresh the README What's New section, back-fill the .30/.31 sections into the 41 i18n CHANGELOG mirrors, and add TAILSCALE_AUTHKEY to the env contract (.env.example + ENVIRONMENT.md). * chore(release): align mitm-hosts test comment with main to clear merge conflict The same CodeQL false-positive fix landed twice — #4386 on release/v3.8.31 and #4387 directly on main — with only the explanatory comment differing (the assertion is byte-identical). Adopt main's comment wording on the release branch so the release PR merges without a comment-only conflict. * test(translator): align stale openai->gemini remote-URL tests with #4373 Third pre-existing base-drift fix to get Fast Quality Gates green on .31. PR #4373 ('Gemini accepts HTTP/HTTPS image URLs', port of 9router#344) intentionally changed convertOpenAIContentToParts so remote http(s) image URLs pass through as a native `fileData: { fileUri }` part instead of the old #2807 drop+warn — and added its own test (gemini-helper-http-image-url-port344.test.ts) for the new behavior. But it left three tests in translator-openai-to-gemini.test.ts asserting the OLD drop+warn contract, so they fail deterministically (verified: the file fails in isolation on clean .31). These only surface under the TIA fail-safe FULL suite, which a quality- script touch triggers. Align the three stale tests to the real, intended behavior (captured by running the function): remote URLs -> fileData.fileUri (mimeType image/*), still never inlineData (the sync path cannot fetch+encode). This is test-vs-code alignment to a deliberate, separately-tested change — not a weakened assertion. 40/40 in the file; 94/94 across the translator + env-doc combo that previously failed. * chore(quality): reconcile complexity baseline 1896->1900 (/review-prs v3.8.31 batch) (#4410) * fix(release): reconcile full-CI drift for v3.8.31 (gemini tests #4373, any-budget #4389, masking allowlist #4384) The release PR's full CI surfaced cumulative cycle drift the per-PR fast gates skip: - tests/unit/translator-openai-to-gemini.test.ts: realign 3 cases to #4373's HTTP/HTTPS-URL fileData pass-through (they asserted the old warn-and-drop). - scripts/check/check-t11-any-budget.mjs: base.ts budget 0→2 — #4389 compares tool_choice against the string literal "any" (not a TS any type). - config/quality/test-masking-allowlist.json: allowlist #4384's opencode combos net-assert reduction (obsolete combo/ namespace removed). No production behavior change. * chore(release): re-trigger full CI for v3.8.31 finalization Force a fresh pull_request CI run on the head carrying the cycle-drift fixes (gemini #4373 tests, any-budget #4389, masking allowlist #4384) — the prior synchronize event did not spawn a ci.yml run. * chore: re-trigger CI (no Actions runs registered for prior push) --------- Co-authored-by: Xiangzhe <32761048+xz-dev@users.noreply.github.com> Co-authored-by: hydraromania <252583922+hydraromania@users.noreply.github.com> Co-authored-by: Bian-Sh <24520547+Bian-Sh@users.noreply.github.com> Co-authored-by: thaitryhand <248103256+thaitryhand@users.noreply.github.com> Co-authored-by: xxy9468615 <63351664+xxy9468615@users.noreply.github.com> Co-authored-by: ipeterpetrus <93033698+ipeterpetrus@users.noreply.github.com> Co-authored-by: DNNYF <74033321+DNNYF@users.noreply.github.com> Co-authored-by: mugnimaestra <13349159+mugnimaestra@users.noreply.github.com> Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> Co-authored-by: nguyenvanhuy0612 <57367674+nguyenvanhuy0612@users.noreply.github.com> Co-authored-by: codename-zen <263238141+codename-zen@users.noreply.github.com> Co-authored-by: Anton <39598727+NomenAK@users.noreply.github.com> Co-authored-by: Ibrahim Ryan <ryan@nuevanext.com> Co-authored-by: anuragg-saxenaa <anuragg.saxenaa@gmail.com> Co-authored-by: aeonframework <aeon@aeonframework.dev> Co-authored-by: Jan Leon <Jan.gaschler@gmail.com> Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com>
…checkExpiry Codex OAuth connection test previously only validated `checkExpiry: true` — i.e. it inspected the local token's `expiresAt` and returned valid=true if the timestamp was in the future. A revoked-but-not-yet-expired token would still report as valid. The new approach probes ChatGPT's /backend-api/codex/responses with a minimal invalid body: 400 means auth was accepted (only the body is the problem), 401/403 means the token is bad. Also adds generic `body` and `acceptStatuses` support to the OAuth test framework so other providers can use the same pattern. Codex's status as a rotating provider (shares an Auth0 family with openai) is preserved — the test does NOT proactively refresh the token, matching the openai/codex#9648 anti-cascade guard already in place. TDD: 3 new tests in tests/unit/oauth-connection-test-codex-endpoint.test.ts cover the 400-accept, 401-reject, 403-reject paths. Existing #1449 timeout test remains green. Co-authored-by: Ibrahim Ryan <ryan@nuevanext.com> Inspired-by: decolua/9router#347
Integrated into release/v3.7.0
…iegosouzapw#4347) * fix(providers): bound OAuth connection-test probe with a timeout (port from 9router#1449) The OAuth path of "Test Connection One-by-One" called bare fetch() with no AbortController/signal, so a provider probe that accepted the socket but never responded wedged the test queue forever. Both the initial probe and the post-refresh retry are now bounded with AbortSignal.timeout(30s) — matching the API-key path's existing budget — and a timed-out probe resolves as a failure with a clear "Test timed out after 30s" message in the route's normal error shape. Reported-by: ntdung6868 (decolua/9router#1449) Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> * chore(quality): rebaseline providers test route file-size to 887 (diegosouzapw#1449 + sibling diegosouzapw#1444 growth) --------- Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com>
…iegosouzapw#4397) * chore(release): open v3.8.31 development cycle * fix(mitm): exact host membership in MITM hosts test (CodeQL false positive) (diegosouzapw#4386) getMitmToolHosts returns string[], so .includes(host) is Array.prototype.includes (exact membership). CodeQL's js/incomplete-url-substring-sanitization heuristic misreads it as a String.includes() URL-substring sanitization check and raises a HIGH alert. Switch to .some(h => h === host) — identical semantics, explicit intent, no flagged pattern. Surfaced post-v3.8.30 (diegosouzapw#4325) once the test landed on main. Test-only change (no runtime behavior); the suite still passes and the CodeQL re-scan on merge clears the alert. * fix(codex): request reasoning summaries (diegosouzapw#4359) Adds reasoning.summary=auto + reasoning.encrypted_content include for Codex. Thanks @xz-dev. * fix(embeddings): inject NVIDIA NIM input_type for asymmetric embed models (diegosouzapw#4341) NVIDIA NIM asymmetric embedding models (e.g. nvidia/nv-embedqa-e5-v5) reject requests without an `input_type` ("query" | "passage") with 400 "'input_type' parameter is required". The embedding registry now carries a model-level default param for the asymmetric NVIDIA model, and the embeddings handler injects a model's default params into the upstream body only when the client omitted them, leaving a client-supplied value untouched. Reported-by: hydraromania (decolua/9router#1378) Co-authored-by: hydraromania <252583922+hydraromania@users.noreply.github.com> * fix(api): migrate deprecated Codex [features].codex_hooks to [features].hooks (diegosouzapw#4342) Codex renamed the `codex_hooks` feature flag to `hooks`; recent Codex CLI versions ignore the old key and warn. When OmniRoute rewrites an existing config.toml (configure/reset Codex provider) it now renames [features].codex_hooks -> [features].hooks, preserving the value and never clobbering an already-present `hooks`, then drops the deprecated key. The migration is a no-op when the flag is absent and runs on both the POST and DELETE config paths. Reported-by: Bian-Sh (decolua/9router#1327) Co-authored-by: Bian-Sh <24520547+Bian-Sh@users.noreply.github.com> * fix(translator): drop the null flush on the same-format response path (diegosouzapw#4344) The streaming response translator's same-format fast path returned `[chunk]` unconditionally, so the end-of-stream null/flush signal (chunk === null) propagated as a literal `[null]`. Downstream this surfaced as an empty `data: null` SSE event between chunks and crashed strict clients (e.g. Factory Droid BYOK on /v1/responses). The fast path now returns `[]` for the null flush while still passing real chunks through unchanged. Reported-by: thaitryhand (decolua/9router#1052) Co-authored-by: thaitryhand <248103256+thaitryhand@users.noreply.github.com> * fix(translator): strip assistant echo fields on the OpenAI target path (Mistral 422) (diegosouzapw#4350) Strict OpenAI-compatible upstreams (e.g. mistral/codestral-latest) reject client-only assistant echo fields sent back as input with 422 extra_forbidden (the report hit messages[].assistant.reasoning_content via Codex /responses). Only reasoning_content was stripped on the OpenAI target path; the sibling fields reasoning / refusal / annotations / cache_control leaked through. They are now all dropped on the non-reasoner OpenAI target path. `audio` is intentionally preserved (OpenAI audio models reference a prior assistant audio response by id; Mistral never emits audio). Reported-by: xxy9468615 (decolua/9router#1649) Co-authored-by: xxy9468615 <63351664+xxy9468615@users.noreply.github.com> * fix(cli): honor TAILSCALE_AUTHKEY for non-interactive tailscale login (diegosouzapw#4343) * fix(cli): honor TAILSCALE_AUTHKEY for non-interactive tailscale login (port from 9router#1263) startTailscaleLogin built `tailscale up` without ever reading process.env.TAILSCALE_AUTHKEY, so a pre-authenticated / headless daemon waited for an interactive auth URL and timed out (~15s). When TAILSCALE_AUTHKEY is set it is now passed via `--auth-key=` (an argv element to spawn(binary, args) — no shell interpolation, Hard Rule diegosouzapw#13); when unset, behavior is unchanged. The arg builder is extracted into a pure exported `tailscaleUpArgs()` for testing. Reported-by: ipeterpetrus (decolua/9router#1263) Co-authored-by: ipeterpetrus <93033698+ipeterpetrus@users.noreply.github.com> * chore(quality): rebaseline tailscaleTunnel.ts file-size to 1202 (diegosouzapw#1263 +13) --------- Co-authored-by: ipeterpetrus <93033698+ipeterpetrus@users.noreply.github.com> * fix(dashboard): OAuth modal surfaces the real error on a non-JSON response (diegosouzapw#4351) * fix(dashboard): OAuth modal surfaces real error on non-JSON responses (port from 9router#1318) The OAuth connect/reauth modal called `await res.json()` unconditionally, so a non-JSON error response (e.g. a plain-text 500 page from a build/OAuth endpoint) threw `Unexpected token 'I'...` and hid the real failure. New shared helpers parseResponseBody / getErrorMessage (src/shared/utils/api.ts) read the body safely (JSON when JSON, raw text otherwise) and produce a clean message either way; every modal fetch site now uses them. Reported-by: DNNYF (decolua/9router#1318) Co-authored-by: DNNYF <74033321+DNNYF@users.noreply.github.com> * fix(dashboard): type OAuth modal response body as Record<string, unknown> (t11 any-budget) Switch the parseResponseBody casts from Record<string, any> to Record<string, unknown> so OAuthModal.tsx stays within its t11 explicit-any budget. getErrorMessage already takes unknown; the success paths typecheck clean under strict:false. No runtime change. --------- Co-authored-by: DNNYF <74033321+DNNYF@users.noreply.github.com> * fix(translator): accept AI SDK-style { type: image, image: data-URL } content parts (diegosouzapw#4345) * fix(translator): accept AI SDK-style { type: image, image: "data:..." } parts (port from 9router#1330) Several OpenAI-input translators only recognized images shaped as `image_url.url` (or an object with `.source`/`.url`), so an AI SDK-style content part where `image` is a bare data-URL STRING was silently dropped before reaching a vision provider (OpenCode is one affected client; the gap is generic). The OpenAI->Claude, OpenAI->Kiro and OpenAI->Gemini/Antigravity translators now parse a string `image` data URL into each provider's native image shape (Claude base64 source, Kiro images[].source.bytes, Gemini inlineData). Reported-by: mugnimaestra (decolua/9router#1330) Co-authored-by: mugnimaestra <13349159+mugnimaestra@users.noreply.github.com> * chore(quality): freeze openai-to-kiro.ts file-size at 807 (diegosouzapw#1330 +9, over 800 cap) --------- Co-authored-by: mugnimaestra <13349159+mugnimaestra@users.noreply.github.com> * fix(dashboard): show a disabled connection's last error in the row (diegosouzapw#4352) * fix(dashboard): show a disabled connection's last error in the row (port from 9router#1447) The provider card's error badge counts a disabled connection (isActive === false) that has an error — its effective status is still error/expired/unavailable — but the connection row hid the lastError text for disabled rows, so the operator saw the count without the cause. The row's error-visibility decision is extracted into shouldShowConnectionLastError() and now shows the error whenever there is one, regardless of the active toggle. Reported-by: ntdung6868 (decolua/9router#1447) Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> * chore(quality): rebaseline ConnectionRow.tsx file-size to 942 (diegosouzapw#1447 +1 import) --------- Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> * fix(providers): bound the OAuth connection-test probe with a timeout (diegosouzapw#4347) * fix(providers): bound OAuth connection-test probe with a timeout (port from 9router#1449) The OAuth path of "Test Connection One-by-One" called bare fetch() with no AbortController/signal, so a provider probe that accepted the socket but never responded wedged the test queue forever. Both the initial probe and the post-refresh retry are now bounded with AbortSignal.timeout(30s) — matching the API-key path's existing budget — and a timed-out probe resolves as a failure with a clear "Test timed out after 30s" message in the route's normal error shape. Reported-by: ntdung6868 (decolua/9router#1449) Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> * chore(quality): rebaseline providers test route file-size to 887 (diegosouzapw#1449 + sibling diegosouzapw#1444 growth) --------- Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> * fix(providers): label a deactivated account distinctly from a revoked token (diegosouzapw#4353) A Codex connection whose OAuth refresh is fully healthy but whose ChatGPT account has been deactivated by the provider gets a 401 from the upstream API. The connection test labeled that the same as a bad credential ("Token invalid or revoked" -> upstream_auth_error), so an operator could not tell a deactivated account from a revoked token. The test now reads the 401/403 body and, when it indicates account deactivation, classifies it as account_deactivated (which the dashboard already renders as "Account Deactivated"); a plain auth 401 is unchanged. Reported-by: ntdung6868 (decolua/9router#1444) Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> * fix(db): cascade-delete orphaned model aliases when a provider is removed (diegosouzapw#4348) * fix(db): cascade-delete orphaned model aliases when a provider is removed (port from 9router#1409) Deleting a custom provider removed its connections and node but left the imported model-alias rows (key=<alias>, value="<providerId>/<model>") behind, so re-importing the same provider was blocked by stale "already exists" aliases. Add a deleteModelAliasesForProvider(providerId) DB helper that drops every alias whose stored value begins with "<providerId>/", and call it from the provider-node DELETE handler so a fresh import is unblocked. Reported-by: nguyenvanhuy0612 (decolua/9router#1409) Co-authored-by: nguyenvanhuy0612 <57367674+nguyenvanhuy0612@users.noreply.github.com> * chore(quality): rebaseline models.ts file-size to 1221 (diegosouzapw#1409 + sibling diegosouzapw#1294 growth) --------- Co-authored-by: nguyenvanhuy0612 <57367674+nguyenvanhuy0612@users.noreply.github.com> * fix(api): persist max_input_tokens/max_output_tokens when adding a custom model (diegosouzapw#4349) The POST /api/provider-models handler read the rest of the body but never the two token-limit fields, and addCustomModel() had no parameter for them, so the form values were dropped on write while the DB layer and /v1/models catalog already round-trip inputTokenLimit/outputTokenLimit. Accept the two optional limits in the schema, forward them through the handler, and persist them in addCustomModel(). TDD: failing-then-passing unit test. Reported-by: codename-zen (decolua/9router#1294) Co-authored-by: codename-zen <263238141+codename-zen@users.noreply.github.com> * docs: feature-documentation catch-up (v3.8.20 → v3.8.30) (diegosouzapw#4391) One-time reconciliation of the docs with every user-facing feature shipped since v3.8.20 (we had never done a dedicated pass, so debt had accumulated): - README: new '✨ What's New' section (curated v3.8.20→v3.8.30 highlights). - New guides: CLI-INTEGRATIONS (all setup-*/launch commands), MITM-TPROXY-DECRYPT (transparent-decrypt epic), CONTEXT_EDITING (delegated Anthropic clear_tool_uses). - Refreshed: AUTO-COMBO (auto/<category>:<tier> + Arena-ELO), API_REFERENCE (x-omniroute-no-memory), MEMORY (int8 quantization + off-by-default), RESILIENCE (model-lockout success-decay), RTK, AGENTBRIDGE, TRAFFIC_INSPECTOR, GUARDRAILS, CLOUD_AGENT, ENVIRONMENT, SETUP_GUIDE, CLI-TOOLS, MCP-SERVER. - Regenerated PROVIDER_REFERENCE (231 providers); synced the count in README/CLAUDE/AGENTS. - Allowlisted external-tool env vars (OPENAI_API_BASE, PROMPTFOO_PROVIDER_KEY) and the STREAM_RECOVERY config-object name in the docs-accuracy gates. All claims source-verified; check:docs-all (sync/counts/env/links/fabricated) passes. Going forward this runs every release via generate-release step 6b. * fix(executors): don't inject thinking when tool_choice forces a tool (native Claude) (diegosouzapw#4389) Forced tool_choice now strips the adaptive thinking injection to avoid Anthropic 400. Thanks @NomenAK. * fix(translator): Gemini accepts HTTP/HTTPS image URLs (port from 9router#344) (diegosouzapw#4373) OpenAI-style `image_url` parts with an `http://` or `https://` URL reached `convertOpenAIContentToParts` and were dropped with only a `console.warn`, because Gemini's `inlineData` requires base64 (the helper is synchronous and cannot fetch+encode upstream assets). Gemini's `Part` schema, however, natively accepts `fileData: { fileUri }` for remote URIs — the model fetches the asset itself. The helper now emits a `fileData` part (`mimeType: "image/*"`, inferred upstream on fetch) for HTTP/HTTPS URLs instead of silently dropping them. Vision requests that pass a URL — not a data: URI — now reach Gemini intact. No behavioral change for: - `data:` URIs → still emitted as `inlineData` with the parsed media type. - Unsupported schemes (e.g. `ftp:`) → still skipped (Gemini would reject them). The openai-to-claude side already passed HTTP/HTTPS URLs through as `source: { type: "url", url }` (lines 573–578) — the upstream PR's Claude-side change was already covered. Regression test: tests/unit/gemini-helper-http-image-url-port344.test.ts (4 cases: https URL, http URL, data: URI no-regression, unsupported-scheme guard). Inspired-by: decolua/9router#344 Co-authored-by: Ibrahim Ryan <ryan@nuevanext.com> * fix(executors): strip stream_options for qwen non-streaming / thinking Claude Code requests (port from 9router#663) (diegosouzapw#4374) Claude-Code-compatible providers force the executor-level `stream` flag on via `upstreamStream = stream || isClaudeCodeCompatible` (open-sse/handlers/chatCore.ts), but the outgoing body keeps the caller's original `stream: false`. The shared `stream && targetFormat === "openai"` branch in DefaultExecutor.transformRequest then injected `stream_options: { include_usage: true }` onto a body that still said `stream: false`, and qwen upstream rejected the request with `400 "'stream_options' only set this when you set stream: true"`. The same rejection surfaced when the body carried `thinking` / `enable_thinking`. The qwen branch now skips the injection (and strips any client-sent `stream_options`) when the body explicitly says `stream: false` or requests thinking, leaving regular qwen streaming requests with the include_usage injection intact. Other providers are unaffected. Adds a TDD regression with 4 cases covering both opt-out paths and the normal-streaming positive control. Inspired-by: decolua/9router#663 Co-authored-by: anuragg-saxenaa <anuragg.saxenaa@gmail.com> * fix(security): scope OAuth callback postMessage to a trusted-origin allowlist (port from 9router#998) (diegosouzapw#4372) The OAuth callback at `/callback` previously fell back to `window.opener.postMessage({ code, state, ... }, "*")` whenever the opener was cross-origin. The fallback was intended to support remote-OmniRoute + local-loopback callbacks (where opener and callback live on different origins), but the same code path also delivers the OAuth code/state to any hostile opener that pops the well-known callback URL — letting that attacker complete the OAuth flow as the user. Replace the wildcard fallback with iteration over a fixed allowlist: `window.location.origin` (same-origin parent — the popup-mode dashboard) plus Codex's fixed loopback helper (`http://localhost:1455` and the IPv4 literal `http://127.0.0.1:1455`). The browser drops `postMessage` to any opener whose actual origin is not in `targetOrigin`, so the message reaches only known parents and is silently dropped for any other. The same-origin fallback path is unchanged — methods 2 (`BroadcastChannel`) and 3 (`localStorage` storage event) still cover same-origin openers that COOP severed. The `openerSameOrigin` probe stays in place to drive the auto-close vs manual-copy UI decision (no behavior change for the success path). Adds a regression test (`tests/unit/ui/oauth-callback-postmessage-scope.test.tsx`) that mounts the page with a stubbed cross-origin opener and asserts no `postMessage` call ever uses `"*"` and every call lands on an allowlisted origin. The test failed against the pre-fix code (red), passes after the fix (green) — TDD per CLAUDE.md hard rule diegosouzapw#18. Partial port of upstream decolua/9router#998: the upstream PR also re-enabled TLS verification on a DNS-bypass fetch in `open-sse/utils/proxyFetch.js`; that part is N/A here because OmniRoute's `proxyFetch.ts` never disabled TLS verification (no `rejectUnauthorized: false` anywhere in the file). Inspired-by: decolua/9router#998 Co-authored-by: aeonframework <aeon@aeonframework.dev> * fix(sse): default combo per-target timeout to 120s for fast failover (diegosouzapw#4365) Combo per-target timeout inherited the full FETCH_TIMEOUT_MS (600s) when a combo did not set its own targetTimeoutMs, so a single hung/slow target stalled the whole combo for up to 10 minutes before falling through to the next model. Introduce DEFAULT_COMBO_TARGET_TIMEOUT_MS (120s) as the unset-default in resolveComboTargetTimeoutMs (new 3rd arg) and wire it in phaseComboSetup. The upstream ceiling (600s) and per-combo opt-out (targetTimeoutMs, up to the ceiling) are preserved; single non-combo requests are unchanged. For streaming requests this only bounds time-to-first-headers, so token generation is not cut short. TDD: failing-then-passing unit test in tests/unit/combo-config.test.ts. * refactor(combo): de-dup exhausted-target skip predicate across both dispatchers (diegosouzapw#4362) Primeiro incremento da de-dup dos 2 dispatchers de combo (handleComboChat + handleRoundRobinCombo). O bloco de pre-check diegosouzapw#1731/#1731v2 (skip de target já exhausted no provider/connection) era BYTE-IDÊNTICO nos dois (mesmas condições, mesmas mensagens), diferindo só na tag de log e no control-flow. - comboPredicates.ts: getExhaustedTargetSkipReason(target, exhaustedProviders, exhaustedConnections) — predicate PURO que retorna a mensagem de skip (ou null); cada dispatcher mantém seu próprio log-tag + control-flow (return null / continue) + fallbackCount. No mutate do stryker (cobertura de mutação). - combo.ts: −20 linhas (os 2 blocos viram 1 chamada cada). - 7 testes de caracterização travam condições + strings exatas. Comportamento preservado: 376/376 testes combo (357 caracterização + 7 novos), integração sse-correctness 5/5, typecheck 0, complexity neutro (1895), file-size encolhe. Próximo incremento: de-dup do error-handling/exhausted-tracking (handleTargetError). * refactor(combo): de-dup upstream-error exhaustion classification across both dispatchers (diegosouzapw#4366) Segundo incremento da de-dup dos 2 dispatchers (handleTargetError). Após cada erro de target, ambos rodavam um bloco quase-idêntico que marca o provider exhausted (diegosouzapw#1731), a conexão connection-errored (#1731v2) ou o provider transiently rate-limited. - combo/targetExhaustion.ts: applyComboTargetExhaustion(target, opts) — atualiza os 3 Sets de exhaustion e retorna providerExhausted. As MUTAÇÕES de Set (que dirigem o skip de targets, lidas por getExhaustedTargetSkipReason) são BYTE-IDÊNTICAS nos dois; as diferenças reais viram parâmetros: tag, allAccountsRateLimited (termo extra do RR, false no handleComboChat), exhaustedLogLevel (info no handleComboChat, debug no RR). Connection-level extraído p/ markConnectionLevelExhaustion (privado, <15 complexity). - combo.ts: −73 linhas; 4 imports órfãos removidos. - 7 testes de caracterização travam as mutações + o return. ÚNICA mudança de comportamento: o WORDING das mensagens de log do RR ganha o sufixo 'on remaining targets' (cosmético; mesmo #code, mesmas mutações, mesmos níveis de log). 376/376 combo (caracterização preservada), integração sse 5/5, typecheck 0, complexity neutro (1895), file-size encolhe. * refactor(chatCore): extract checkHeapPressureGuard leaf (god-file decomposition start) (diegosouzapw#4371) Primeiro incremento da decomposição do chatCore.ts (5127 LOC, hot-path mais quente). O guard de memória do topo do handleChatCore (rejeita 503 quando o heap V8 passa o threshold de shed) vira um leaf testável, co-locado com o threshold em heapPressure.ts. - heapPressure.ts: checkHeapPressureGuard(heapUsedMb, thresholdMb) — retorna o result 503 pronto ou null. Byte-idêntico ao guard inline (mesmo check, mesma 503, mesmo warn). A figura de heap fica em telemetria INTERNA, nunca no response do cliente (Hard Rule diegosouzapw#12). - chatCore.ts: o bloco inline (~22 ln) vira 3 linhas; import órfão de HEAP_PRESSURE_THRESHOLD_MB trocado por checkHeapPressureGuard. - 3 testes novos (incl. assert Rule diegosouzapw#12: o MB medido não vaza no payload). complexity-baseline 1895->1896: drift de base pós-diegosouzapw#4338 (medido com minhas mudanças stashed = 1896); esta mudança é complexity-NEUTRA (helper complexity 2, handleChatCore só perde código). 190/190 chatcore tests, typecheck 0, file-size encolhe. * Localize CLI and stabilize fetch, memory, and coverage handling (diegosouzapw#4383) en-only i18n, fetch-start-timeout hardening, EngineConfigPage icon fix, CI build-artifact-reuse overhaul. Memory production hunk dropped as a no-op (tests kept). Thanks @JxnLexn. * test(combo): reset circuit breakers between stream-readiness cases (restore green) (diegosouzapw#4396) The combo-dispatch cases in combo-stream-readiness-fallback.test.ts deliberately fail `glm` (zombie streams / repeated 504s), which legitimately trips the per-provider circuit breaker. That OPEN state is a module-level singleton, so it leaked into the next test and combo.ts then SKIPPED `glm/*` targets entirely ("Skipping … circuit breaker OPEN"). That made "combo does not retry stream readiness timeouts on the same model" never attempt glm/zombie — expected ['glm/zombie','openai/gpt-5.4-mini'] but got ['openai/gpt-5.4-mini']. This was a pre-existing red on release/v3.8.31 (present at the cycle-open tip), order-dependent: the test passes in isolation, fails after the preceding cases. Add a test.beforeEach(resetAllCircuitBreakers) so each scenario starts from a clean breaker slate. Test-isolation only — the breaker behavior is correct and no production code or assertion changes. Full combo suite: 390/390 green. * fix(cli): decline confirm() cleanly on non-interactive stdin + document contexts workflow The `contexts remove` command already has `--yes` to skip confirmation, but when run without it under a non-interactive stdin (pipe, CI, EOF) the [y/N] prompt could never be answered — the readline question stayed pending and Node warned about an "unsettled top-level await" at exit. confirm() now detects `!process.stdin.isTTY` and declines cleanly (returns false), pointing at `--yes` for non-interactive use. Exported confirm() for a regression test. Docs: REMOTE-MODE.md gains a full "Managing contexts" section (list/current/use to switch between remote and local, add/show/rename, remove with --yes, export/import), fixes a `context current` -> `contexts current` typo, and the README remote-mode snippet now shows switching back to local. Verified against the live CLI: command signatures, --yes, and the non-TTY decline path all behave as documented. Tests: cli-contexts.test.ts asserts confirm() declines on non-TTY stdin (RED before, GREEN after). All docs gates (fabricated/links/symbols) pass. * ci(t11): bump any-budget for executors/base.ts (2 false-positive "any" strings) Unblocks the Fast Quality Gates on release/v3.8.31: `check:any-budget:t11` was red on `open-sse/executors/base.ts` for ALL PRs (pre-existing base drift, unrelated to this branch). The checker counts `\bany\b` after stripping comments but NOT strings, and the native-Claude tool_choice logic uses the API value `"any"` in two string literals (`tb.tool_choice === "any"`, `.type === "any"`). There are zero actual TypeScript `any` types in the file — budget set to the matched count, mirroring the existing cursor.ts false-positive entry right below it. * perf: combos UI split + next config + 1-click redis + bifrost sidecar (diegosouzapw#3932) (diegosouzapw#4381) Combos UI split + next.config perf + 1-click local Redis launcher + bifrost relay. Review fixes (co-author): --rm/--restart conflict, error sanitization, UI/route names, dead-guard re-doc, bifrost Zod, IPv6 + CLI test fixes. Thanks @KooshaPari. * fix(plugin): prefix OC static-catalog combo+raw keys with providerId (diegosouzapw#4384) OC parses model ids on '/'; combo keys now carry 'omniroute/' (was 'combo/'). Live-validated against the VPS via OpenCode. Thanks @herjarsa. * docs(env): document TAILSCALE_AUTHKEY (env/docs contract drift on .31) Second pre-existing base-drift fix needed to get Fast Quality Gates green: the `repository contract is in sync` test (check:env-doc-sync) was red on ALL .31 PRs. PR diegosouzapw#4343 added a code reference to `process.env.TAILSCALE_AUTHKEY` (src/lib/tailscaleTunnel.ts) for non-interactive `tailscale up`, but never added the var to .env.example / docs/reference/ENVIRONMENT.md — the contract requires code vars to appear in both. Add the (commented) entry to .env.example next to TAILSCALE_BIN and a row to ENVIRONMENT.md. Verified: `node scripts/check/check-env-doc-sync.mjs` → in sync. Unrelated to this branch's confirm()/docs change; surfaced because touching a quality script triggers the TIA fail-safe full unit suite. * chore(release): v3.8.31 — 2026-06-20 Finalize the v3.8.31 release: reconcile the CHANGELOG (full commit-to-bullet coverage, 26 bullets across Features/Fixed/Security/Maintenance), refresh the README What's New section, back-fill the .30/.31 sections into the 41 i18n CHANGELOG mirrors, and add TAILSCALE_AUTHKEY to the env contract (.env.example + ENVIRONMENT.md). * chore(release): align mitm-hosts test comment with main to clear merge conflict The same CodeQL false-positive fix landed twice — diegosouzapw#4386 on release/v3.8.31 and diegosouzapw#4387 directly on main — with only the explanatory comment differing (the assertion is byte-identical). Adopt main's comment wording on the release branch so the release PR merges without a comment-only conflict. * test(translator): align stale openai->gemini remote-URL tests with diegosouzapw#4373 Third pre-existing base-drift fix to get Fast Quality Gates green on .31. PR diegosouzapw#4373 ('Gemini accepts HTTP/HTTPS image URLs', port of 9router#344) intentionally changed convertOpenAIContentToParts so remote http(s) image URLs pass through as a native `fileData: { fileUri }` part instead of the old diegosouzapw#2807 drop+warn — and added its own test (gemini-helper-http-image-url-port344.test.ts) for the new behavior. But it left three tests in translator-openai-to-gemini.test.ts asserting the OLD drop+warn contract, so they fail deterministically (verified: the file fails in isolation on clean .31). These only surface under the TIA fail-safe FULL suite, which a quality- script touch triggers. Align the three stale tests to the real, intended behavior (captured by running the function): remote URLs -> fileData.fileUri (mimeType image/*), still never inlineData (the sync path cannot fetch+encode). This is test-vs-code alignment to a deliberate, separately-tested change — not a weakened assertion. 40/40 in the file; 94/94 across the translator + env-doc combo that previously failed. * chore(quality): reconcile complexity baseline 1896->1900 (/review-prs v3.8.31 batch) (diegosouzapw#4410) * fix(release): reconcile full-CI drift for v3.8.31 (gemini tests diegosouzapw#4373, any-budget diegosouzapw#4389, masking allowlist diegosouzapw#4384) The release PR's full CI surfaced cumulative cycle drift the per-PR fast gates skip: - tests/unit/translator-openai-to-gemini.test.ts: realign 3 cases to diegosouzapw#4373's HTTP/HTTPS-URL fileData pass-through (they asserted the old warn-and-drop). - scripts/check/check-t11-any-budget.mjs: base.ts budget 0→2 — diegosouzapw#4389 compares tool_choice against the string literal "any" (not a TS any type). - config/quality/test-masking-allowlist.json: allowlist diegosouzapw#4384's opencode combos net-assert reduction (obsolete combo/ namespace removed). No production behavior change. * chore(release): re-trigger full CI for v3.8.31 finalization Force a fresh pull_request CI run on the head carrying the cycle-drift fixes (gemini diegosouzapw#4373 tests, any-budget diegosouzapw#4389, masking allowlist diegosouzapw#4384) — the prior synchronize event did not spawn a ci.yml run. * chore: re-trigger CI (no Actions runs registered for prior push) --------- Co-authored-by: Xiangzhe <32761048+xz-dev@users.noreply.github.com> Co-authored-by: hydraromania <252583922+hydraromania@users.noreply.github.com> Co-authored-by: Bian-Sh <24520547+Bian-Sh@users.noreply.github.com> Co-authored-by: thaitryhand <248103256+thaitryhand@users.noreply.github.com> Co-authored-by: xxy9468615 <63351664+xxy9468615@users.noreply.github.com> Co-authored-by: ipeterpetrus <93033698+ipeterpetrus@users.noreply.github.com> Co-authored-by: DNNYF <74033321+DNNYF@users.noreply.github.com> Co-authored-by: mugnimaestra <13349159+mugnimaestra@users.noreply.github.com> Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> Co-authored-by: nguyenvanhuy0612 <57367674+nguyenvanhuy0612@users.noreply.github.com> Co-authored-by: codename-zen <263238141+codename-zen@users.noreply.github.com> Co-authored-by: Anton <39598727+NomenAK@users.noreply.github.com> Co-authored-by: Ibrahim Ryan <ryan@nuevanext.com> Co-authored-by: anuragg-saxenaa <anuragg.saxenaa@gmail.com> Co-authored-by: aeonframework <aeon@aeonframework.dev> Co-authored-by: Jan Leon <Jan.gaschler@gmail.com> Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com>
Integrated into release/v3.7.0
Summary
This PR refactors OmniRoute's entire circuit-breaker and cooldown stack for
release/v3.7.0.Before this work, resilience behavior was spread across too many overlapping layers: connection cooldowns, provider breakers, model-availability state, combo-specific health state, and dashboard/API-specific views of runtime health. The result was hard to reason about and hard to trust: the same failure could be interpreted differently depending on where it was handled, and different surfaces could drift away from the actual runtime behavior.
The goal of this refactor is to give OmniRoute one clear resilience model with clear responsibilities at each layer, so the runtime, dashboard, MCP, API, and docs all describe the same system.
What the new system is for
The rebuilt resilience system is split into four layers. Each layer exists to answer a different operational question.
1.
requestQueueWhat this layer is for
This layer protects a single connection/account from being hit by overlapping local requests when that connection should process work sequentially.
What it means operationally
2.
connectionCooldownWhat this layer is for
This layer decides when one specific connection/account should temporarily step out of rotation after a retryable failure.
What it means operationally
429) or temporarily unstable, OmniRoute cools down that connection instead of repeatedly hammering itbaseCooldownMs, upstream retry hints, and bounded backoff to decide how long that connection should rest429rate limits stay here and do not trip the provider breaker3.
providerBreakerWhat this layer is for
This layer decides when a provider as a whole is unhealthy enough that OmniRoute should stop routing traffic into it for a while.
What it means operationally
408,500,502,503,504)429rate limits do not contribute to the provider-wide breaker4.
waitForCooldownWhat this layer is for
This layer decides what OmniRoute should do when every eligible connection is temporarily unavailable only because of cooldowns.
What it means operationally
What combo routing is responsible for
Combo routing is now treated as a routing layer, not as a second resilience system.
That distinction is important.
A combo should answer questions like:
A combo should not maintain its own parallel understanding of provider health, cooldown state, or breaker state.
What this means operationally
In other words: combo decides where to try next; the resilience system decides whether that target is currently eligible.
Runtime behavior after the refactor
The runtime now follows one consistent flow:
That gives each decision a single home instead of spreading the same responsibility across multiple overlapping subsystems.
Account and fallback handling
This refactor also makes the fallback path easier to reason about.
429rate limits stay in cooldown/backoff and wait-for-cooldown behavior instead of being counted as provider-breaker failuresDashboard, API, and MCP behavior
The non-runtime surfaces now describe the same resilience model instead of inventing their own parallel layers.
Dashboard
429handling as part of Connection CooldownAPI
/api/resilienceexposes the same settings shape used by the runtime and dashboardconnectionCooldownis configured with:baseCooldownMsuseUpstreamRetryHintsmaxBackoffStepsproviderBreakeronly counts provider-wide final transient failures; connection-scoped429handling stays outside this layerMCP
Documentation
The docs now describe the same 3.7.0 resilience model used by the implementation:
README.mddocs/API_REFERENCE.mddocs/ARCHITECTURE.mddocs/USER_GUIDE.mddocs/openapi.yamlValidation
Type and test coverage
npm run typecheck:corenode --import tsx/esm --test tests/integration/integration-wiring.test.tsnode --import tsx/esm --test tests/unit/autocombo-unification.test.tsnode --import tsx/esm --test tests/unit/account-fallback-service.test.tsnode --import tsx/esm --test tests/integration/chat-pipeline.test.tsgit pushpre-push hook:npm run test:unit(3240passing tests)Playwright coverage
Playwright coverage was run in dev-server mode and verifies the behavior of the new model across UI surfaces:
Commands used:
OMNIROUTE_PLAYWRIGHT_SERVER_MODE=dev npx playwright test tests/e2e/resilience-plan-alignment.spec.tsOMNIROUTE_PLAYWRIGHT_SERVER_MODE=dev npx playwright test tests/e2e/combo-unification.spec.tsNote
Playwright was executed in dev-server mode because the current branch still has unrelated Next build/prerender failures in start/build mode (
/_global-error,/callback). The behavior covered above was validated successfully in dev mode.