Compare commits

..

10 Commits

Author SHA1 Message Date
diegosouzapw
cbec0d5a05 docs(agents): protected-surface merge rule — operator approval for agent-instruction files
PR #11770 (2026-09-01) added a CLAUDE.md section instructing every AI agent to
clone and execute a third-party setup script; a merge campaign swept it into
the release branch with no human risk review (reverted in #12249). Review
focus now carries the rule: PRs touching CLAUDE.md / AGENTS.md / GEMINI.md /
llm.txt / skills SKILL.md files are HOLD until explicit per-PR operator
approval — CI validates code, not instruction-surface intent.
2026-09-01 03:32:35 -03:00
Diego Rodrigues de Sa e Souza
3c8b553811 chore(quality): register native-codex turn-pin tests in stryker tap.testFiles (#12263)
Gate check:mutation-test-coverage --strict red→verde local (registro dos 2 testes turn-pin no tap.testFiles, drift da mesma classe do #12170). O único check vermelho desta PR (Unit shard 4/4) é o base-red dos próprios testes turn-pin desalinhados pelo #12247 — corrigido pela #12259, mergeada na sequência. Reds circulares: cada PR só está vermelha no item que a outra corrige.
2026-09-01 03:29:48 -03:00
Diego Rodrigues de Sa e Souza
aa2aec5e59 docs: Chaos Mode setup guide + weighted strategy semantics (#12250)
Gap surfaced by OmniCopilot#16: the Chaos Mode dashboard page, its per-key
chaosModeEnabled permission and both dispatch endpoints had no setup doc at
all (only the auto/chaos table line existed), and AUTO-COMBO.md never stated
that weighted is a proportional draw where zero-weight steps are never drawn.
New docs/guides/CHAOS-MODE.md (registered in meta.json + docs/README.md) and
a 'weighted semantics' subsection under the strategy table, both written from
the code (chaosConfig.ts, chaosExecutor.ts, both routes, targetSorters.ts,
targetResolution.ts). check:docs-all exits 0.
2026-09-01 03:25:47 -03:00
Diego Rodrigues de Sa e Souza
6c93e74f26 fix(quality): base-red pair — stryker tap registration + turn-pin suites aligned to the window gate (#12255)
* chore(quality): register native-codex-turn-pin tests in stryker tap.testFiles

The mutation-test-coverage gate (--strict) fails on the release tip: the two
native-codex-turn-pin suites (#10379 merge wave) cover open-sse turn-pin code
and src/shared/utils/circuitBreaker.ts but were not listed in
stryker.conf.json tap.testFiles, so their mutant kills would not count. Adds
both files; the gate now passes clean (4728 test files scanned, no drift).

* style: prettier pass on stryker.conf.json

* test(sse): align turn-pin suites to the provider-cooldown window gate

The two native-codex-turn-pin suites landed via the #10379 merge wave after
PR #12247 forked, so #12247's green CI never saw them: they set up 'provider
in global cooldown' with a single recordProviderCooldown call, the pre-#12247
contract. Since the window gate, a provider only counts as cooling after
providerFailureThreshold failures inside the window — the setup now loops to
the profile threshold (same alignment the tracker's own legacy suite got in
Sibling sweep: all 7 suites touching recordProviderCooldown pass (60/60).
2026-09-01 03:18:59 -03:00
Diego Rodrigues de Sa e Souza
2e17161ea2 feat(sse): wire the PROVIDER_PROFILES window gate into the global provider cooldown (#12247)
providerFailureThreshold / providerFailureWindowMs / providerCooldownMs shipped
in PROVIDER_PROFILES with no runtime consumer (2026-08-31 docs audit, P0.1).
Provider-level entries in providerCooldownTracker now honor them: the whole
provider only counts as cooling after providerFailureThreshold failures inside
providerFailureWindowMs, then cools for providerCooldownMs. Connection-level
entries keep the pre-existing exponential backoff, and the layer stays opt-in
(PROVIDER_COOLDOWN_ENABLED, default off) — default behavior is unchanged.

TDD: tests/unit/provider-cooldown-window-gate.test.ts written first (4 red on
the old behavior), then the wiring; legacy tracker suite aligned to the new
contract (23/23 green). Docs: AGENTS.md breaker section + RESILIENCE_GUIDE
opt-in layer subsection; executors soft-drift refresh (104 -> 106).
2026-09-01 01:57:17 -03:00
Diego Rodrigues de Sa e Souza
5eaafe8e17 Revert "docs: recommend gstack for AI-assisted workflows (#11770)" (#12248)
Merging --admin with red discrimination (merge-gates §4). The only failing check is Fast Quality Gates → `mutation-test-coverage`, which cannot be caused by this PR: the diff touches exactly one file, `CLAUDE.md` (13 deleted lines, zero .ts). The same gate is red on #12166, #12167 and #12169 — three unrelated PRs — confirming inherited base drift rather than a PR-introduced defect.
2026-09-01 01:48:51 -03:00
Diego Rodrigues de Sa e Souza
4bcd8cee99 fix(combo): always clear the loop-safety timer, not just on the happy path (#11804) (#12245)
dispatchWithCooldownRetry arms a loop-safety timer (setTimeout, 10 minutes by
default) on every setTry iteration, so a combo that never produces a terminal
response still answers with a 504 instead of hanging. The only clearTimeout in
the whole file sat inside the `if (anySuccess)` branch — the comment said so
verbatim: "clear the safety timer on the happy path".

Every error exit therefore returned the response to the client while leaving a
600s timer pending, its closure retaining orderedTargets and the exhausted
provider/connection sets: all_targets_skipped, all_accounts_inactive, the
aggregated-status return, the final fallback, and the global-timeout branch.
The timer is also re-armed per setTry iteration with no clear in between.

Field evidence from the issue: two requests that failed quality validation
returned 502 to the client immediately, and "Combo loop safety timeout ...
force-terminating" was logged for both exactly 600 seconds later — the leaked
timers firing long after the requests were gone.

Fixed structurally rather than by sprinkling clearTimeout across the five
return sites: the handle is hoisted to function scope and released in a
finally, so a future `return` added to this function cannot silently
reintroduce the leak. The 504 backstop itself is unchanged.

Note the timer already called .unref(), so it never held the event loop open —
this is a memory-retention leak, not a hang.
2026-09-01 01:08:09 -03:00
Diego Rodrigues de Sa e Souza
7f9195cd29 chore(lint): batch 7 of #12146 — final src tail: 37 react-hooks violations across 33 files resolved (#12244)
Closes the src/ side of the campaign (no eslint-disable, no new suppressions;
the 37 matching react-hooks/* entries are removed from
config/quality/eslint-suppressions.json — only the 5 CI-divergent entries in
tests/unit/ui remain, frozen by design, see #12144):

- set-state-in-effect (30×): fetch-on-mount and sync-setter effects wrapped in
  the async-continuation pattern (await Promise.resolve() for pure-sync
  bodies), preserving semantics exactly.
- refs/purity (ResilienceConnectionsClient): render now reads stopReason state
  instead of stoppedRef; the receivedAt fallback Date.now() in JSX was dead
  (every setData stamps receivedAt) and became 0.
- exhaustive-deps (ApiTab, SessionInfoCard, useLiveDashboard): clearResults
  wrapped in useCallback; missing t dep added; channels array stabilized via
  channelsKey + useMemo so connect deps are statically checkable.
- global-error: locale/messages load moved into one async continuation (also
  renames the import binding to mod per @next/next/no-assign-module-variable).

Refs #12146
2026-09-01 01:07:46 -03:00
Diego Rodrigues de Sa e Souza
06e7a6d50c Revert "docs: recommend gstack for AI-assisted workflows (#11770)" (#12249)
This reverts commit 8acdd53025.
2026-09-01 01:06:37 -03:00
diegosouzapw
9058e39b61 docs(cli): document CLI_PRIME_AGENT_BIN and correct the CLI Agents count
Two drifts left behind when the prime-agent runtime entry landed:

- CLI_PRIME_AGENT_BIN (src/shared/services/cliRuntime.ts, defaultCommand
  "prime-agent") was in neither ENVIRONMENT.md nor .env.example. The env-doc-sync
  gate does not resolve envBinKey values, so it could not catch this.
- CLI-TOOLS.md's summary table still counted 9 CLI Agents while the catalog holds
  10 — the section-2 heading and the README breakdown were already correct, only
  that cell lagged.

A full sweep of every envBinKey in cliRuntime now shows all of them documented in
both files.
2026-09-01 00:58:43 -03:00
53 changed files with 795 additions and 337 deletions

View File

@@ -812,6 +812,7 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# CLI_CRUSH_BIN=crush
# CLI_OMP_BIN=omp
# CLI_LETTA_BIN=letta
# CLI_PRIME_AGENT_BIN=prime-agent
# Windsurf has no default binary — set this to enable binary detection for it.
# CLI_WINDSURF_BIN=windsurf
# CLI_AUGGIE_BIN=auggie

View File

@@ -131,10 +131,14 @@ breaker runs on `circuitBreakerThreshold` / `circuitBreakerReset`:
| API key | `7` | `12` | `30s` |
| Local | (derived) | `2` | `15s` |
`PROVIDER_PROFILES` also defines `providerFailureThreshold` (10/15/2) and `providerCooldownMs`
(5min/10min/1min); those fields are loaded into the profile but have **no runtime consumer
today** — do not tune or document them as the live breaker. Every default is overridable
through the `OMNIROUTE_PROVIDER_BREAKER_*` and `OMNIROUTE_CIRCUIT_BREAKER_*` env vars; the
`PROVIDER_PROFILES` also defines `providerFailureThreshold` (10/15/2),
`providerFailureWindowMs` (15/30/5 min) and `providerCooldownMs` (5/10/1 min): these power the
**window gate of the opt-in global Provider Cooldown** (`PROVIDER_COOLDOWN_ENABLED`, default
off) — a provider-level entry in `open-sse/services/providerCooldownTracker.ts` only counts as
cooling after `providerFailureThreshold` failures inside `providerFailureWindowMs`, and then
cools for `providerCooldownMs`. They are NOT the live breaker's thresholds — do not tune them
expecting breaker behavior. Every default is overridable through the
`OMNIROUTE_PROVIDER_BREAKER_*` and `OMNIROUTE_CIRCUIT_BREAKER_*` env vars; the
runtime-accurate reference table lives in `docs/architecture/RESILIENCE_GUIDE.md`.
Only provider-level failure statuses should trip the provider breaker:
@@ -490,6 +494,12 @@ Why this matters: fixing bug A while opening bug B is worse than not fixing at a
pipeline, and A2A skills.
- Do not close a contributor pull request after using its code; merge it through GitHub so
the contributor receives credit.
- **Never merge a PR that touches an agent-instruction surface without explicit operator
approval** — `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `llm.txt` (+ mirrors) and
`skills/**/SKILL.md` are executed as authority by every AI session; a merged instruction
compromises every future agent run. Check with `gh pr diff <N> --name-only` before any
merge. Incident record: PR #11770 (2026-09-01) told agents to execute a third-party
setup script and was swept in by a merge campaign; reverted in #12249.
---

View File

@@ -824,14 +824,6 @@
"src/app/(dashboard)/dashboard/a2a/page.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 6
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/acp-agents/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx": {
@@ -844,26 +836,11 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/batch/components/wizard/CostEstimateStep.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/batch/components/wizard/InputStep.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/batch/components/wizard/JsonlValidationStep.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/batch/files/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/cli-agents/CliAgentsPageClient.tsx": {
"no-restricted-syntax": {
"count": 4
@@ -899,21 +876,6 @@
"count": 6
}
},
"src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/conductor/FaroChat.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/conversations/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -934,32 +896,9 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/discovery/DiscoveryPageClient.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/free-provider-rankings/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/health/ProviderHealthAutopilotCard.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/health/ProviderHealthMatrixCard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/health/TelemetryCard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/health/page.tsx": {
@@ -967,17 +906,9 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/log-export/LogExportPageClient.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/mcp/page.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/onboarding/page.tsx": {
@@ -985,26 +916,11 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": {
"react-hooks/exhaustive-deps": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/plugins/[name]/config/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/plugins/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 2
@@ -1080,11 +996,6 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/relay/RelayProxyClient.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/resilience/connections/components/ConnectionDetail.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -1095,19 +1006,6 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/resilience/connections/components/ResilienceConnectionsClient.tsx": {
"react-hooks/purity": {
"count": 1
},
"react-hooks/refs": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/runtime/components/ModelCooldownsCard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/search-tools/components/SearchHistory.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -1183,11 +1081,6 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/SessionInfoCard.tsx": {
"react-hooks/exhaustive-deps": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -1203,21 +1096,6 @@
"count": 3
}
},
"src/app/(dashboard)/dashboard/tools/agent-bridge/components/ModelSelectorModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/tools/agent-bridge/components/SetupWizard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CustomHostsManager.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestStreamingList.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -1228,11 +1106,6 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/translator/components/MonitorTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/translator/components/ResultNarrated.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -1273,26 +1146,11 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/webhooks/WebhooksPageClient.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/webhooks/__tests__/webhook-wizard.test.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/webhooks/components/AddWebhookWizard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/webhooks/components/WebhookDeliveriesPanel.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/api/assess/route.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -1556,21 +1414,11 @@
"count": 1
}
},
"src/app/global-error.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/login/page.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 3
}
},
"src/app/status/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/domain/assessment/assessor.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -1599,9 +1447,6 @@
"src/hooks/useLiveDashboard.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"react-hooks/exhaustive-deps": {
"count": 2
}
},
"src/lib/a2a/skills/healthReport.ts": {
@@ -2203,11 +2048,6 @@
"count": 1
}
},
"src/shared/hooks/cli/useToolBatchStatuses.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/hooks/useTheme.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1

View File

@@ -30,6 +30,7 @@ Simple guides for using OmniRoute — no technical background needed.
- [USER_GUIDE.md](guides/USER_GUIDE.md) — daily usage of the dashboard and API.
- [THINKING_BUDGET.md](guides/THINKING_BUDGET.md) — thinking/reasoning budget modes (passthrough vs auto-strip).
- [FEATURES.md](guides/FEATURES.md) — dashboard feature gallery.
- [CHAOS-MODE.md](guides/CHAOS-MODE.md) — multi-model parallel/collaborative execution (setup, permissions, API).
- [TIERS.md](guides/TIERS.md) — OmniRoute tiers explained (user guide).
- [USAGE_QUOTA_GUIDE.md](guides/USAGE_QUOTA_GUIDE.md) — usage, quota & spend tracking.
- [COST_TRACKING.md](guides/COST_TRACKING.md) — cost and spend tracking.

View File

@@ -17,7 +17,7 @@ It provides a single OpenAI-compatible endpoint (`/v1/*`) and routes traffic acr
Core capabilities:
- OpenAI-compatible API surface for CLI/tools (352 providers, 104 executors)
- OpenAI-compatible API surface for CLI/tools (352 providers, 106 executors)
- Request/response translation across provider formats
- Model combo fallback (multi-model sequence)
- Structured combo steps (`provider + model + connection`) with runtime ordering by `compositeTiers`

View File

@@ -450,7 +450,7 @@ open-sse/
├── types.d.ts
├── config/ Provider registries, header profiles, identity, …
├── handlers/ Request handlers (chat, embeddings, audio, image, …)
├── executors/ 104 provider-specific HTTP executors
├── executors/ 106 provider-specific HTTP executors
├── translator/ Format conversion (OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro)
├── transformer/ Responses API ↔ Chat Completions stream transformer
├── services/ 80+ service modules (combos, fallback, quotas, identity, …)
@@ -480,7 +480,7 @@ open-sse/
### 4.2 `open-sse/executors/`
104 provider executors, each extending `BaseExecutor` (`base.ts`):
106 provider executors, each extending `BaseExecutor` (`base.ts`):
`antigravity`, `azure-openai`, `blackbox-web`, `cliproxyapi`,
`chatgpt-web-codex`, `cloudflare-ai`, `codex`, `commandCode`, `cursor`, `default`, `devin-cli`,

View File

@@ -50,6 +50,25 @@ OmniRoute has three distinct but related resilience mechanisms. Each has a diffe
---
### Opt-in global Provider Cooldown (window gate)
A fourth, **opt-in** layer (`PROVIDER_COOLDOWN_ENABLED`, default **off**) keeps a
cross-request memory of failing providers in
`open-sse/services/providerCooldownTracker.ts`, consulted by combo target
resolution so consecutive combo requests stop re-walking a provider that just
failed. Provider-level entries honor the `PROVIDER_PROFILES` window gate:
| Profile | trips after (`providerFailureThreshold`) | inside (`providerFailureWindowMs`) | cools for (`providerCooldownMs`) |
| ------- | ---------------------------------------: | ---------------------------------: | -------------------------------: |
| OAuth | `10` | `15min` | `5min` |
| API key | `15` | `30min` | `10min` |
Below the threshold the provider is **not** considered cooling; a success clears
the window. Connection-level entries (`provider:connectionId`) keep the
exponential `minRetryCooldownMs → maxRetryCooldownMs` backoff instead. Overrides:
`OMNIROUTE_PROVIDER_BREAKER_{OAUTH,API_KEY}_{FAILURE_THRESHOLD,FAILURE_WINDOW_MS,COOLDOWN_MS}`.
Regression guard: `tests/unit/provider-cooldown-window-gate.test.ts`.
## 2. Connection Cooldown
**Scope:** single provider connection/account/key.

109
docs/guides/CHAOS-MODE.md Normal file
View File

@@ -0,0 +1,109 @@
---
title: "Chaos Mode"
version: 3.8.51
lastUpdated: 2026-09-01
---
# Chaos Mode
> **Dashboard:** **Chaos Mode** (sidebar) → `/dashboard/chaos`
> **API:** `GET` / `PUT` `/api/chaos/config` · `POST /api/chaos/run` (dashboard session) · `POST /api/skills/collect/chaos` (API key)
> **Source:** `src/lib/chaos/chaosExecutor.ts`, `src/lib/chaos/chaosConfig.ts`
Chaos Mode sends **one task to several providers at once** — every participating provider
contributes one model instance, and you get all the answers side by side (or chained). It is a
multi-model execution surface, not a routing strategy: your normal `/v1/chat/completions`
traffic is never affected by it.
**Disambiguation — three different things ship with "chaos" in the name:**
| Thing | What it is | Where documented |
| ------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| **Chaos Mode** | The dashboard page + API described here: fan one task out to many providers (parallel or collaborative). | This guide |
| `auto/chaos` | An Auto-Combo model id with fault-injection scoring weights, for resilience testing. Nothing to configure. | [AUTO-COMBO.md](../routing/AUTO-COMBO.md) |
| Chaos combo config | A persisted combo with `config.chaos.enabled` fans out to a panel with an optional judge model (API-only). | `open-sse/services/autoCombo/chaosEngine.ts` |
## Setup
1. Open **Dashboard → Chaos Mode** (`/dashboard/chaos`).
2. Turn it **on** — Chaos Mode ships **disabled by default** (`enabled: false` in
`src/lib/chaos/chaosConfig.ts`). While disabled, `POST /api/chaos/run` answers
`400 — "Chaos Mode is not enabled. Enable it in Dashboard → Chaos Mode."`.
3. Pick the participants and defaults (persisted per instance via the settings store):
| Field | Meaning | Default / limits |
| ------------------- | ------------------------------------------------------------------- | --------------------------------------- |
| `enabled` | Master switch | `false` |
| `defaultMode` | `parallel` or `collaborative` (see below) | `parallel` |
| `providerOverrides` | Per-provider participation (`providerId`, optional `modelId`, `enabled`) | empty = every active provider, max 200 |
| `systemPrompt` | Override for the built-in Chaos system prompt | optional, max 10 000 chars |
| `timeoutMs` | Max time per model call | `120000` (5 000600 000) |
| `maxTokens` | `max_tokens` per model call | `4096` (256128 000) |
4. Run a **test from the page itself** — the results panel shows each provider's answer,
status and duration.
## Execution modes
- **`parallel`** — every model gets the same task simultaneously; you receive all answers
independently.
- **`collaborative`** — models run **in a chain**: each one sees the previous model's output and
is asked to refine, extend, critique or offer an alternative. The response's `summary` field
concatenates the successful outputs in chain order (parallel runs have no `summary`).
## API
### `POST /api/chaos/run` — dashboard session
Cookie-authenticated (the management session — see
[MANAGEMENT-AUTH.md](MANAGEMENT-AUTH.md)); used by the dashboard page.
```jsonc
// body
{
"task": "Compare approaches to X", // required
"providers": ["glm", "kimi"], // optional filter
"mode": "parallel", // optional — overrides defaultMode
"systemPrompt": "…", // optional override
"maxTokens": 4096 // optional override
}
```
### `POST /api/skills/collect/chaos` — API key
Bearer-token variant for external callers. The key must carry the **Chaos Mode permission**
(`chaosModeEnabled`), which is **off by default** — enable it per key in
**Dashboard → API Manager → edit key → permissions → Chaos Mode**. Same body as above.
```bash
curl -X POST http://localhost:20128/api/skills/collect/chaos \
-H "Authorization: Bearer $OMNIROUTE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"task":"Compare approaches to X","mode":"parallel"}'
```
Both endpoints return the same shape:
```jsonc
{
"task": "…",
"mode": "parallel",
"startedAt": "2026-09-01T00:00:00.000Z",
"totalProviders": 3,
"totalResults": 3,
"models": [
{ "providerId": "glm", "providerName": "GLM", "modelId": "glm-4.7",
"status": "success", "content": "…", "durationMs": 3210 }
],
"summary": "…" // collaborative mode only
}
```
## Troubleshooting
- **`400 Chaos Mode is not enabled`** — step 2 above: the global switch is off.
- **API key gets rejected on `/api/skills/collect/chaos`** — the key lacks the per-key
`chaosModeEnabled` permission (off by default; this is a setting, not an error).
- **A provider you expected is missing from the results** — check `providerOverrides` on the
Chaos Mode page (a disabled override excludes it) and whether the provider connection is
active.

View File

@@ -8,6 +8,7 @@
"DOCKER_GUIDE",
"ELECTRON_GUIDE",
"FEATURES",
"CHAOS-MODE",
"FREE_PROVIDER_RANKINGS",
"COST_TRACKING",
"I18N",

View File

@@ -13,7 +13,7 @@ OmniRoute integrates with three categories of CLI tools spread across three dedi
| Page | Route | Concept | Count |
| -------------- | ----------------------- | ------------------------------------------------------------------------- | ------------ |
| **CLI Code's** | `/dashboard/cli-code` | Coding tools you point at OmniRoute (Client → CLI → OmniRoute → Provider) | 26 |
| **CLI Agents** | `/dashboard/cli-agents` | Autonomous agents you point at OmniRoute (same flow, broader scope) | 9 |
| **CLI Agents** | `/dashboard/cli-agents` | Autonomous agents you point at OmniRoute (same flow, broader scope) | 10 |
| **ACP Agents** | `/dashboard/acp-agents` | CLIs that OmniRoute spawns as backend via stdio/ACP (reverse flow) | see registry |
Legacy routes redirect via 308: `/dashboard/cli-tools``/dashboard/cli-code`, `/dashboard/agents``/dashboard/acp-agents`.

View File

@@ -415,6 +415,7 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex,
| `CLI_CRUSH_BIN` | `crush` | `src/shared/services/cliRuntime.ts` | Custom path to the Crush CLI binary. |
| `CLI_OMP_BIN` | `omp` | `src/shared/services/cliRuntime.ts` | Custom path to the Oh My Pi (`omp`) agent binary. |
| `CLI_LETTA_BIN` | `letta` | `src/shared/services/cliRuntime.ts` | Custom path to the Letta CLI binary. |
| `CLI_PRIME_AGENT_BIN` | `prime-agent` | `src/shared/services/cliRuntime.ts` | Custom path to the Prime Agent (Prime Intellect) binary. |
| `CLI_WINDSURF_BIN` | _(none)_ | `src/shared/services/cliRuntime.ts` | Custom path to the Windsurf binary. Windsurf ships **no default command** — binary detection stays disabled until this is set. |
| `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Custom path to the Devin CLI binary (v3.8.0). Used by the Windsurf/Devin executor. |
| `DEVIN_DESKTOP_VERSION` | `3.6.27` | `open-sse/executors/devin-desktop.ts` | Devin Desktop `ide_version`. Overrides must use `x.y.z` format; invalid values fall back to the verified default. |

View File

@@ -289,6 +289,26 @@ OmniRoute's combo engine supports **19 routing strategies** (declared in `src/sh
⭐ = New in v3.8.0 · 🧬 = New in v3.8.36
### `weighted` semantics
`weighted` is a **proportional random draw per request**
(`open-sse/services/combo/targetSorters.ts``selectWeightedTarget`), not an equalizer:
- Each request draws **one** step with probability `weight / totalWeight`; the remaining steps
are ordered by descending weight as the fallback chain for that request.
- A step whose weight is `0` (or missing) is **never drawn** while any other step has a
weight > 0 — it can only serve as a fallback after the drawn step fails. Only when **all**
weights are 0 does selection become uniform.
- Steps whose targets are all unavailable — provider circuit breaker `OPEN`, connection
cooldown, model lockout — are removed from the draw before it happens
(`open-sse/services/combo/targetResolution.ts`), so a single healthy step can temporarily
win every request.
- `stickyWeightedLimit` (combo config, default `1` = off) pins the drawn step for that many
consecutive successes before re-drawing.
For strict rotation use `round-robin`; equal weights on `weighted` give statistical — not
strict — balance.
## Fusion Strategy
`fusion` is the one strategy that does **not** pick a single target. It fans the prompt

View File

@@ -1132,8 +1132,19 @@ async function handleComboChatInner({
let lastError: string | null = null;
let earliestRetryAfter: ComboRetryAfter | null = null;
let lastStatus: number | null = null;
// #11804: the loop-safety timer is armed per setTry iteration but must be
// cleared on EVERY exit path, not just the happy one. Hoisted to function
// scope so the `finally` at the end of this function always reaches it —
// otherwise each error path (all_targets_skipped / all_accounts_inactive /
// aggregated status / final fallback / global timeout) returned to the client
// leaving a 10-minute timer pending, whose closure retains orderedTargets and
// the exhausted provider/connection sets. Field evidence on the issue: the
// client got a 502 immediately, and "Combo loop safety timeout ...
// force-terminating" was logged exactly 600s later.
let activeLoopSafetyTimer: ReturnType<typeof setTimeout> | null = null;
for (let setTry = 0; setTry <= maxSetRetries; setTry++) {
try {
for (let setTry = 0; setTry <= maxSetRetries; setTry++) {
// #1731: Per-set-iteration set of providers whose quota is fully exhausted.
// Reset each retry so providers excluded in a previous attempt get another chance.
const exhaustedProviders = new Set<string>();
@@ -1219,6 +1230,7 @@ async function handleComboChatInner({
);
}, loopSafetyMs);
loopSafetyTimer.unref?.();
activeLoopSafetyTimer = loopSafetyTimer;
});
const runningTasks = new Set<Promise<void>>();
let anySuccess = false;
@@ -2892,11 +2904,20 @@ async function handleComboChatInner({
// Surface the recovery hint with a generic retry recommendation so the client at least
// gets a non-opaque message instead of "Combo routing completed without an upstream response".
recordComboFailure(effectiveSessionId, combo.name);
return errorResponseWithComboDiagnostics(
503,
"Combo routing completed without an upstream response",
buildNoUpstreamResponseDiagnostics(orderedTargets.length)
);
return errorResponseWithComboDiagnostics(
503,
"Combo routing completed without an upstream response",
buildNoUpstreamResponseDiagnostics(orderedTargets.length)
);
} finally {
// #11804: always release the loop-safety timer. Covering every exit path by
// construction here means a future `return` added to this function cannot
// silently reintroduce the leak.
if (activeLoopSafetyTimer) {
clearTimeout(activeLoopSafetyTimer);
activeLoopSafetyTimer = null;
}
}
};
// FASE 2.1: acquire the per-connection concurrency slot for the selected

View File

@@ -11,6 +11,8 @@ import {
DEFAULT_RESILIENCE_SETTINGS,
type ResilienceSettings,
} from "../../src/lib/resilience/settings";
import { PROVIDER_PROFILES } from "../config/constants.ts";
import { getProviderCategory } from "../config/providerRegistry.ts";
interface CooldownEntry {
/** Timestamp of last recorded failure (ms since epoch) */
@@ -19,6 +21,47 @@ interface CooldownEntry {
failureCount: number;
/** How long this entry must be retained for cleanup purposes */
retentionMs: number;
/**
* Provider-level entries only: timestamps of recent failures, pruned to the
* profile's `providerFailureWindowMs`. Powers the PROVIDER_PROFILES window
* gate (`providerFailureThreshold` failures inside the window trip a
* `providerCooldownMs` cooldown for the whole provider).
*/
failureTimestamps?: number[];
}
// ── PROVIDER_PROFILES window gate (whole-provider scope) ─────────────────────
// `providerFailureThreshold` / `providerFailureWindowMs` / `providerCooldownMs`
// shipped in PROVIDER_PROFILES with no runtime consumer (2026-08-31 docs
// audit, P0.1). Provider-level entries (no connectionId) now honor them: the
// provider only counts as cooling after `providerFailureThreshold` failures
// inside `providerFailureWindowMs`, and then cools for `providerCooldownMs`.
// Connection-level entries keep the pre-existing exponential backoff.
function providerWindowProfile(provider: string) {
const category = getProviderCategory(provider);
const profile = PROVIDER_PROFILES[category] ?? PROVIDER_PROFILES.apikey;
return {
failureThreshold: profile.providerFailureThreshold,
failureWindowMs: profile.providerFailureWindowMs,
cooldownMs: profile.providerCooldownMs,
};
}
function pruneWindow(timestamps: number[], windowMs: number, now: number): number[] {
const cutoff = now - windowMs;
const pruned = timestamps.filter((t) => t >= cutoff);
// Memory bound: the gate only ever needs `failureThreshold` recent samples;
// keep a small multiple so bursts cannot grow the array unbounded.
return pruned.length > 200 ? pruned.slice(-200) : pruned;
}
function providerWindowCooldownMs(provider: string, entry: CooldownEntry, now: number): number {
const { failureThreshold, failureWindowMs, cooldownMs } = providerWindowProfile(provider);
const inWindow = pruneWindow(entry.failureTimestamps ?? [], failureWindowMs, now);
if (inWindow.length < failureThreshold) return 0;
const elapsed = now - entry.lastFailureAt;
const remaining = cooldownMs - elapsed;
return remaining > 0 ? remaining : 0;
}
// Global cooldown state: keyed by "provider:connectionId" or "provider"
@@ -90,8 +133,21 @@ export function recordProviderCooldown(
existing.lastFailureAt = now;
existing.failureCount++;
existing.retentionMs = Math.max(existing.retentionMs, retentionMs);
if (!connectionId) {
const { failureWindowMs } = providerWindowProfile(provider);
existing.failureTimestamps = pruneWindow(
[...(existing.failureTimestamps ?? []), now],
failureWindowMs,
now
);
}
} else {
cooldownMap.set(key, { lastFailureAt: now, failureCount: 1, retentionMs });
cooldownMap.set(key, {
lastFailureAt: now,
failureCount: 1,
retentionMs,
...(connectionId ? {} : { failureTimestamps: [now] }),
});
}
startCleanupIfNeeded();
@@ -119,6 +175,11 @@ export function isProviderInCooldown(
if (entry.failureCount === 0) return false;
const now = Date.now();
if (!connectionId) {
return providerWindowCooldownMs(provider, entry, now) > 0;
}
const elapsed = now - entry.lastFailureAt;
const minCooldownMs =
@@ -151,6 +212,12 @@ export function getRemainingCooldownMs(
if (!entry) return 0;
const now = Date.now();
if (!connectionId) {
if (entry.failureCount === 0) return 0;
return providerWindowCooldownMs(provider, entry, now);
}
const elapsed = now - entry.lastFailureAt;
const minCooldownMs =
@@ -183,8 +250,9 @@ export function recordProviderSuccess(provider: string, connectionId: string | u
const key = cooldownKey(provider, connectionId);
const entry = cooldownMap.get(key);
if (entry) {
// Reset failure count but keep the entry
// Reset failure count and the provider-level failure window, keep the entry
entry.failureCount = 0;
entry.failureTimestamps = [];
}
}

View File

@@ -162,7 +162,9 @@ export default function A2APage() {
}, []);
useEffect(() => {
void refreshStatus();
void (async () => {
await refreshStatus();
})();
const interval = setInterval(() => void refreshStatus(), 30000);
return () => clearInterval(interval);
}, [refreshStatus]);

View File

@@ -81,7 +81,9 @@ export default function AgentsPage() {
}, []);
useEffect(() => {
fetchAgents();
void (async () => {
await fetchAgents();
})();
}, [fetchAgents]);
const handleRefresh = async () => {

View File

@@ -28,27 +28,32 @@ export default function CostEstimateStep({
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
try {
const est = estimateBatchCost({ jsonl, model, endpoint });
setEstimate(est);
} catch (err) {
console.error("[CostEstimateStep] cost estimation error:", err);
// Fallback: zero-cost estimate so user can still proceed
setEstimate({
model,
totalRequests: 0,
estimatedInputTokens: 0,
estimatedOutputTokens: 0,
syncCostUsd: 0,
batchCostUsd: 0,
savingsUsd: 0,
pricingSource: "fallback",
warnings: ["Cost estimation failed — shown as $0."],
});
} finally {
setLoading(false);
}
// Deferred to a microtask: the compiler bars synchronous setState in an
// effect body; the loading state still settles before the next paint batch.
void (async () => {
await Promise.resolve();
setLoading(true);
try {
const est = estimateBatchCost({ jsonl, model, endpoint });
setEstimate(est);
} catch (err) {
console.error("[CostEstimateStep] cost estimation error:", err);
// Fallback: zero-cost estimate so user can still proceed
setEstimate({
model,
totalRequests: 0,
estimatedInputTokens: 0,
estimatedOutputTokens: 0,
syncCostUsd: 0,
batchCostUsd: 0,
savingsUsd: 0,
pricingSource: "fallback",
warnings: ["Cost estimation failed — shown as $0."],
});
} finally {
setLoading(false);
}
})();
}, [jsonl, model, endpoint]);
if (loading) {
@@ -96,7 +101,9 @@ export default function CostEstimateStep({
{/* Stats */}
<div className="flex items-center justify-between px-4 py-3">
<span className="text-xs text-[var(--color-text-muted)]">{t("wizardCostRequests")}</span>
<span className="text-xs text-[var(--color-text-muted)]">
{t("wizardCostRequests")}
</span>
<span className="text-xs text-[var(--color-text-muted)]">
{estimate.totalRequests.toLocaleString()} ·{" "}
{estimate.estimatedInputTokens.toLocaleString()} {t("wizardCostInputTok")} ·{" "}
@@ -116,7 +123,9 @@ export default function CostEstimateStep({
)}
{/* Disclaimer */}
<p className="text-xs text-[var(--color-text-muted)] italic">{t("wizardCostEstimatedNotice")}</p>
<p className="text-xs text-[var(--color-text-muted)] italic">
{t("wizardCostEstimatedNotice")}
</p>
{/* Warnings */}
{estimate && estimate.warnings.length > 0 && (
@@ -151,7 +160,9 @@ export default function CostEstimateStep({
>
{creating ? (
<>
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
<span className="material-symbols-outlined text-sm animate-spin">
progress_activity
</span>
{t("wizardCreating")}
</>
) : (

View File

@@ -22,30 +22,35 @@ export default function JsonlValidationStep({
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
setResult(null);
try {
const r = validateJsonl(jsonl, { endpoint });
setResult(r);
onResult(r);
} catch (err) {
console.error("[JsonlValidationStep] validate error:", err);
// Provide a minimal failed result on exception
const errResult: ValidationResult = {
ok: false,
totalLines: 0,
sampledLines: 0,
uniqueCustomIds: 0,
duplicateCustomIds: [],
errors: [{ lineNumber: 0, reason: t("wizardValidationParseFailed") }],
preview: [],
byteSize: 0,
};
setResult(errResult);
onResult(errResult);
} finally {
setLoading(false);
}
// Deferred to a microtask: the compiler bars synchronous setState in an
// effect body; validation still lands before the next paint batch.
void (async () => {
await Promise.resolve();
setLoading(true);
setResult(null);
try {
const r = validateJsonl(jsonl, { endpoint });
setResult(r);
onResult(r);
} catch (err) {
console.error("[JsonlValidationStep] validate error:", err);
// Provide a minimal failed result on exception
const errResult: ValidationResult = {
ok: false,
totalLines: 0,
sampledLines: 0,
uniqueCustomIds: 0,
duplicateCustomIds: [],
errors: [{ lineNumber: 0, reason: t("wizardValidationParseFailed") }],
preview: [],
byteSize: 0,
};
setResult(errResult);
onResult(errResult);
} finally {
setLoading(false);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jsonl, endpoint]);

View File

@@ -41,7 +41,9 @@ export default function BatchFilesPage() {
}, []);
useEffect(() => {
void fetchAll();
void (async () => {
await fetchAll();
})();
}, [fetchAll]);
return (

View File

@@ -74,7 +74,9 @@ export default function ConductorPageClient() {
}, []);
useEffect(() => {
void load();
void (async () => {
await load();
})();
const timer = setInterval(() => void load(), REFRESH_MS);
return () => clearInterval(timer);
}, [load]);
@@ -95,7 +97,9 @@ export default function ConductorPageClient() {
setCanceling(true);
setErr("");
try {
const res = await fetch(`/api/conductor/tasks/${encodeURIComponent(cancelTarget)}/cancel`, { method: "POST" });
const res = await fetch(`/api/conductor/tasks/${encodeURIComponent(cancelTarget)}/cancel`, {
method: "POST",
});
if (!res.ok) setErr(`${t("cancelFailed")} (HTTP ${res.status})`);
else {
setDetail(null);
@@ -141,11 +145,20 @@ export default function ConductorPageClient() {
const r = row as unknown as FleetRunner;
if (column.key === "name") return <span className="font-medium">{r.name}</span>;
if (column.key === "clis") return r.clis.join(" / ");
if (r.draining) return <Badge variant="warning" dot>{t("draining")}</Badge>;
if (r.draining)
return (
<Badge variant="warning" dot>
{t("draining")}
</Badge>
);
return r.online ? (
<Badge variant="success" dot>{t("online")}</Badge>
<Badge variant="success" dot>
{t("online")}
</Badge>
) : (
<Badge variant="error" dot>{t("offline")}</Badge>
<Badge variant="error" dot>
{t("offline")}
</Badge>
);
}}
/>
@@ -166,7 +179,12 @@ export default function ConductorPageClient() {
onRowClick={(row) => void openDetail(String(row.id))}
renderCell={(row, column) => {
const task = row as unknown as FleetTask;
if (column.key === "status") return <Badge variant={statusVariant(task.status)} dot>{task.status}</Badge>;
if (column.key === "status")
return (
<Badge variant={statusVariant(task.status)} dot>
{task.status}
</Badge>
);
if (column.key === "id") return <code className="text-xs">{task.id}</code>;
if (column.key === "summary") return task.summary ?? task.error ?? "—";
return (task as unknown as Record<string, unknown>)[column.key]?.toString() ?? "—";
@@ -178,19 +196,28 @@ export default function ConductorPageClient() {
<FaroChat />
<Modal isOpen={detail !== null} onClose={() => setDetail(null)} title={t("detailTitle")} size="lg">
<Modal
isOpen={detail !== null}
onClose={() => setDetail(null)}
title={t("detailTitle")}
size="lg"
>
{detail && (
<div className="space-y-4 text-sm">
<div className="flex items-center gap-2">
<code className="text-xs">{detail.id}</code>
<Badge variant={statusVariant(detail.status)} dot>{detail.status}</Badge>
<Badge variant={statusVariant(detail.status)} dot>
{detail.status}
</Badge>
<Badge>{detail.mode}</Badge>
{detail.runner && <Badge variant="info">{detail.runner}</Badge>}
</div>
{detail.prompt && (
<div>
<div className="font-medium">{t("prompt")}</div>
<pre className="whitespace-pre-wrap text-xs bg-black/5 dark:bg-white/5 rounded p-2">{detail.prompt}</pre>
<pre className="whitespace-pre-wrap text-xs bg-black/5 dark:bg-white/5 rounded p-2">
{detail.prompt}
</pre>
</div>
)}
{detail.summary && <p>{detail.summary}</p>}
@@ -199,7 +226,9 @@ export default function ConductorPageClient() {
<div>
<div className="font-medium">{t("branch")}</div>
<code className="text-xs">{detail.branch}</code>
<p className="text-xs text-text-muted">{t("fetchHint", { branch: detail.branch })}</p>
<p className="text-xs text-text-muted">
{t("fetchHint", { branch: detail.branch })}
</p>
</div>
)}
{detail.mode.startsWith("council") && detail.council?.candidate_task_ids && (

View File

@@ -60,8 +60,11 @@ export default function FaroChat() {
const logRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setSttModel(safeGet(STT_KEY, "openai/whisper-1"));
setTtsModel(safeGet(TTS_KEY, "openai/tts-1"));
void (async () => {
await Promise.resolve();
setSttModel(safeGet(STT_KEY, "openai/whisper-1"));
setTtsModel(safeGet(TTS_KEY, "openai/tts-1"));
})();
}, []);
useEffect(() => {
logRef.current?.scrollTo({ top: logRef.current.scrollHeight });
@@ -202,7 +205,9 @@ export default function FaroChat() {
{err && <Badge variant="error">{err}</Badge>}
{pending && (
<div className="flex items-center gap-2">
<Badge variant="warning" dot>{t("faroPending")}</Badge>
<Badge variant="warning" dot>
{t("faroPending")}
</Badge>
<button type="button" className="text-sm underline" onClick={() => void send("sim")}>
{t("yes")}
</button>
@@ -223,7 +228,12 @@ export default function FaroChat() {
}}
disabled={busy}
/>
<button type="button" className="text-sm underline" onClick={() => void send(input)} disabled={busy}>
<button
type="button"
className="text-sm underline"
onClick={() => void send(input)}
disabled={busy}
>
{t("faroSend")}
</button>
<button

View File

@@ -310,7 +310,10 @@ function ConversationsPageContent() {
if (!activeConversationId) return;
const fresh = conversations.find((c) => c.id === activeConversationId);
if (!fresh) return;
setActiveConversation((prev) => (prev && prev.id === fresh.id ? fresh : prev));
void (async () => {
await Promise.resolve();
setActiveConversation((prev) => (prev && prev.id === fresh.id ? fresh : prev));
})();
}, [conversations, activeConversationId]);
useEffect(() => {
@@ -595,7 +598,10 @@ function ConversationsPageContent() {
// RequestLoggerDetail's CONVERSATION_ACTIVE_POLL_INTERVAL_MS.
useEffect(() => {
if (!activeCallLogId) {
setLivePartialText("");
void (async () => {
await Promise.resolve();
setLivePartialText("");
})();
return;
}
let cancelled = false;

View File

@@ -80,7 +80,9 @@ function useDiscoveryResults(t: Translate) {
}, [t]);
useEffect(() => {
void load();
void (async () => {
await load();
})();
}, [load]);
return { results, loading, feedback, setFeedback, load };

View File

@@ -88,7 +88,9 @@ export default function FreeProviderRankingsPage() {
);
useEffect(() => {
fetchRankings(filter || undefined, { configuredOnly, availableOnly });
void (async () => {
await fetchRankings(filter || undefined, { configuredOnly, availableOnly });
})();
}, [filter, configuredOnly, availableOnly, fetchRankings]);
// Client-side Type filter + "group by type" sort (#6915) — purely derived

View File

@@ -250,7 +250,9 @@ export default function ProviderHealthAutopilotCard() {
}, [t]);
useEffect(() => {
void load();
void (async () => {
await load();
})();
const timer = setInterval(() => void load(), 15000);
return () => clearInterval(timer);
}, [load]);

View File

@@ -300,7 +300,9 @@ export default function ProviderHealthMatrixCard() {
}, [onlyIssues, providerFilter, range]);
useEffect(() => {
fetchMatrix();
void (async () => {
await fetchMatrix();
})();
const id = setInterval(fetchMatrix, 30000);
return () => clearInterval(id);
}, [fetchMatrix]);

View File

@@ -176,7 +176,9 @@ export default function TelemetryCard() {
}, [t]);
useEffect(() => {
void loadTelemetry();
void (async () => {
await loadTelemetry();
})();
const interval = setInterval(() => void loadTelemetry(), REFRESH_MS);
return () => clearInterval(interval);
}, [loadTelemetry]);

View File

@@ -70,7 +70,9 @@ export function LogExportPageClient() {
}, []);
useEffect(() => {
void load();
void (async () => {
await load();
})();
}, [load]);
const handleSubmit = async (payload: SubmitPayload) => {

View File

@@ -237,9 +237,10 @@ export default function McpPage() {
const [baseUrl, setBaseUrl] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
void (async () => {
await Promise.resolve();
setBaseUrl(`${window.location.protocol}//${window.location.host}`);
}
})();
}, []);
const patchSetting = useCallback(async (body: Record<string, unknown>) => {
@@ -277,7 +278,9 @@ export default function McpPage() {
}, []);
useEffect(() => {
void refreshStatus();
void (async () => {
await refreshStatus();
})();
const interval = setInterval(() => void refreshStatus(), 30000);
return () => clearInterval(interval);
}, [refreshStatus]);

View File

@@ -143,7 +143,9 @@ async function fileToBase64(file: File): Promise<string> {
function ImageResultsInline({ data }: { data: unknown }) {
const t = useTranslations("playground");
const typed = data as { data?: Array<{ url?: string; b64_json?: string; revised_prompt?: string }> };
const typed = data as {
data?: Array<{ url?: string; b64_json?: string; revised_prompt?: string }>;
};
const images = typed?.data || [];
if (images.length === 0) return null;
return (
@@ -340,14 +342,14 @@ export default function ApiTab(_props: ApiTabProps) {
clearResults();
};
const clearResults = () => {
const clearResults = useCallback(() => {
setResponseBody("");
setResponseStatus(null);
setResponseDuration(null);
setAudioUrl(null);
setImageData(null);
setTranscriptionText(null);
};
}, []);
const handleAudioFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
@@ -360,7 +362,10 @@ export default function ApiTab(_props: ApiTabProps) {
setUploadedImages((prev) => [...prev, ...base64s].slice(0, 4));
};
const buildChatBodyWithImages = (parsed: Record<string, unknown>, imageBase64s: string[]): Record<string, unknown> => {
const buildChatBodyWithImages = (
parsed: Record<string, unknown>,
imageBase64s: string[]
): Record<string, unknown> => {
if (!imageBase64s.length) return parsed;
const messages = [...((parsed.messages as Array<Record<string, unknown>>) || [])];
if (messages.length === 0) return parsed;
@@ -454,13 +459,15 @@ export default function ApiTab(_props: ApiTabProps) {
}
}
} else {
const data = await res.json() as Record<string, unknown>;
const data = (await res.json()) as Record<string, unknown>;
setResponseBody(JSON.stringify(data, null, 2));
if (isImageEndpoint && data?.data && Array.isArray(data.data) && res.ok) {
setImageData(data);
}
if (isTranscriptionEndpoint && typeof (data as { text?: string })?.text === "string") {
setTranscriptionText((data as { text?: string }).text || "(empty result — check provider credentials)");
setTranscriptionText(
(data as { text?: string }).text || "(empty result — check provider credentials)"
);
}
}
} catch (err: unknown) {
@@ -473,7 +480,17 @@ export default function ApiTab(_props: ApiTabProps) {
setResponseDuration(Date.now() - startTime);
}
setLoading(false);
}, [requestBody, isTranscriptionEndpoint, selectedEndpoint, uploadedFile, selectedConnection, supportsVision, uploadedImages, isImageEndpoint, clearResults]);
}, [
requestBody,
isTranscriptionEndpoint,
selectedEndpoint,
uploadedFile,
selectedConnection,
supportsVision,
uploadedImages,
isImageEndpoint,
clearResults,
]);
const handleCancel = () => {
if (abortRef.current) {
@@ -513,7 +530,9 @@ export default function ApiTab(_props: ApiTabProps) {
</label>
<Select
value={selectedEndpoint}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => handleEndpointChange(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
handleEndpointChange(e.target.value)
}
options={endpointOptions}
className="w-full"
/>
@@ -526,7 +545,9 @@ export default function ApiTab(_props: ApiTabProps) {
</label>
<Select
value={selectedProvider}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => handleProviderChange(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
handleProviderChange(e.target.value)
}
options={providers}
className="w-full"
/>
@@ -540,7 +561,9 @@ export default function ApiTab(_props: ApiTabProps) {
</label>
<Select
value={selectedModel}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => handleModelChange(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
handleModelChange(e.target.value)
}
options={filteredModels}
className="w-full"
/>
@@ -554,7 +577,9 @@ export default function ApiTab(_props: ApiTabProps) {
</label>
<Select
value={selectedConnection}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setSelectedConnection(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setSelectedConnection(e.target.value)
}
options={[
{
value: "",
@@ -713,7 +738,8 @@ export default function ApiTab(_props: ApiTabProps) {
<button
onClick={() => {
const template = { ...DEFAULT_BODIES[selectedEndpoint] };
if ("model" in template) (template as Record<string, unknown>).model = selectedModel;
if ("model" in template)
(template as Record<string, unknown>).model = selectedModel;
setRequestBody(JSON.stringify(template, null, 2));
}}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
@@ -762,9 +788,7 @@ export default function ApiTab(_props: ApiTabProps) {
<h3 className="text-sm font-semibold text-text-main">{t("response")}</h3>
{responseStatus !== null && (
<Badge
variant={
responseStatus >= 200 && responseStatus < 300 ? "success" : "error"
}
variant={responseStatus >= 200 && responseStatus < 300 ? "success" : "error"}
size="sm"
>
{responseStatus}

View File

@@ -20,11 +20,7 @@ interface PluginConfig {
configSchema: Record<string, ConfigField>;
}
export default function PluginConfigPage({
params,
}: {
params: Promise<{ name: string }>;
}) {
export default function PluginConfigPage({ params }: { params: Promise<{ name: string }> }) {
const { name } = use(params);
const { addNotification } = useNotificationStore();
const t = useTranslations("plugins");
@@ -49,7 +45,9 @@ export default function PluginConfigPage({
}, [name]);
useEffect(() => {
fetchConfig();
void (async () => {
await fetchConfig();
})();
}, [fetchConfig]);
const handleSave = async () => {
@@ -100,9 +98,7 @@ export default function PluginConfigPage({
<label className="text-sm font-medium">
{key}
{field.description && (
<span className="ml-2 text-xs text-gray-500">
{field.description}
</span>
<span className="ml-2 text-xs text-gray-500">{field.description}</span>
)}
</label>
{field.type === "boolean" ? (

View File

@@ -37,7 +37,9 @@ export default function PluginsPage() {
}, []);
useEffect(() => {
fetchPlugins();
void (async () => {
await fetchPlugins();
})();
}, [fetchPlugins]);
const handleScan = async () => {
@@ -60,11 +62,17 @@ export default function PluginsPage() {
try {
const res = await fetch(`/api/plugins/${name}/${endpoint}`, { method: "POST" });
if (res.ok) {
addNotification({ type: "success", message: enable ? t("activated", { name }) : t("deactivated", { name }) });
addNotification({
type: "success",
message: enable ? t("activated", { name }) : t("deactivated", { name }),
});
await fetchPlugins();
}
} catch {
addNotification({ type: "error", message: enable ? t("activateFailed", { name }) : t("deactivateFailed", { name }) });
addNotification({
type: "error",
message: enable ? t("activateFailed", { name }) : t("deactivateFailed", { name }),
});
}
};
@@ -97,10 +105,7 @@ export default function PluginsPage() {
</Button>
</div>
{plugins.length === 0 ? (
<EmptyState
title={t("noPlugins")}
description={t("noPluginsDescription")}
/>
<EmptyState title={t("noPlugins")} description={t("noPluginsDescription")} />
) : (
<div className="grid gap-4">
{plugins.map((plugin) => (
@@ -131,10 +136,7 @@ export default function PluginsPage() {
>
{plugin.enabled ? t("deactivate") : t("activate")}
</Button>
<Button
variant="danger"
onClick={() => handleUninstall(plugin.name)}
>
<Button variant="danger" onClick={() => handleUninstall(plugin.name)}>
{t("uninstall")}
</Button>
</div>

View File

@@ -44,7 +44,9 @@ export default function RelayProxyClient() {
}, []);
useEffect(() => {
void fetchTokens();
void (async () => {
await fetchTokens();
})();
}, [fetchTokens]);
const createToken = async () => {

View File

@@ -137,7 +137,7 @@ export default function ResilienceConnectionsClient() {
>
{stopReason === "local_only"
? t("pollErrorLocalOnly") // LOCAL_ONLY gate rejection - page only accessible from localhost/LAN
: stoppedRef.current
: stopReason !== "none"
? t("pollErrorStopped")
: t("pollErrorTransient")}
{stopReason !== "local_only" && (
@@ -193,7 +193,7 @@ export default function ResilienceConnectionsClient() {
<div style={{ fontSize: "11px", color: "var(--color-text-muted)" }}>{t("pollingNote")}</div>
<ConnectionsTable
connections={data.connections}
receivedAt={data.receivedAt ?? Date.now()}
receivedAt={data.receivedAt ?? 0}
degraded={data.meta.degraded}
/>
<BreakerTimeline breakers={data.breakers} onWindowChange={setWindowMs} />

View File

@@ -35,7 +35,9 @@ export default function ModelCooldownsCard() {
}, [notify, t]);
useEffect(() => {
void load();
void (async () => {
await load();
})();
const timer = setInterval(() => {
void load();
}, 5000);

View File

@@ -66,11 +66,13 @@ export default function SessionInfoCard() {
setLoading(false);
}
loadSession();
void (async () => {
await loadSession();
})();
return () => {
cancelled = true;
};
}, []);
}, [t]);
const handleLogout = async () => {
try {

View File

@@ -46,7 +46,9 @@ export function ModelSelectorModal({
useEffect(() => {
if (!open) return;
loadModels();
void (async () => {
await loadModels();
})();
}, [open, loadModels]);
useEffect(() => {

View File

@@ -13,7 +13,10 @@ interface SetupWizardProps {
currentMappings: { source: string; target: string }[]; // Current mappings for this agent
onClose: () => void;
onDnsToggle: (agentId: string, enabled: boolean) => Promise<void>;
onMappingsSave: (agentId: string, mappings: { source: string; target: string }[]) => Promise<void>;
onMappingsSave: (
agentId: string,
mappings: { source: string; target: string }[]
) => Promise<void>;
}
type Step = "verify" | "dns" | "mappings";
@@ -59,18 +62,21 @@ export function SetupWizard({
// Fetch detected models when we reach the mappings step
useEffect(() => {
if (step === "mappings") {
setLoadingModels(true);
fetch(`/api/tools/agent-bridge/agents/${target.id}/detected-models`)
.then((res) => res.json())
.then((data: DetectedModelsResponse) => {
setDetectedModels(data.detectedModels || []);
})
.catch(() => {
setDetectedModels([]);
})
.finally(() => {
setLoadingModels(false);
});
void (async () => {
await Promise.resolve();
setLoadingModels(true);
fetch(`/api/tools/agent-bridge/agents/${target.id}/detected-models`)
.then((res) => res.json())
.then((data: DetectedModelsResponse) => {
setDetectedModels(data.detectedModels || []);
})
.catch(() => {
setDetectedModels([]);
})
.finally(() => {
setLoadingModels(false);
});
})();
}
}, [step, target.id]);
@@ -267,13 +273,16 @@ export function SetupWizard({
{loadingModels ? (
<div className="flex items-center gap-2 text-sm text-text-muted">
<span className="material-symbols-outlined text-[16px] animate-spin">progress_activity</span>
<span className="material-symbols-outlined text-[16px] animate-spin">
progress_activity
</span>
Detecting models from intercepted traffic...
</div>
) : detectedModels.length > 0 ? (
<div className="flex flex-col gap-2">
<p className="text-sm text-text-muted">
Found {detectedModels.length} model{detectedModels.length !== 1 ? "s" : ""} in intercepted traffic. Select the ones you want to add:
Found {detectedModels.length} model{detectedModels.length !== 1 ? "s" : ""} in
intercepted traffic. Select the ones you want to add:
</p>
<div className="rounded-lg border border-border/40 bg-surface p-3 flex flex-col gap-2 max-h-[200px] overflow-y-auto">
{detectedModels.map((model) => (
@@ -293,14 +302,16 @@ export function SetupWizard({
</div>
{selectedModels.size > 0 && (
<p className="text-xs text-text-muted">
{selectedModels.size} model{selectedModels.size !== 1 ? "s" : ""} selected. You&apos;ll map them to OmniRoute models in the next screen.
{selectedModels.size} model{selectedModels.size !== 1 ? "s" : ""} selected.
You&apos;ll map them to OmniRoute models in the next screen.
</p>
)}
</div>
) : (
<div className="rounded-lg border border-border/40 bg-surface/30 p-3">
<p className="text-sm text-text-muted">
No models detected yet. Use {target.name} to make a request, then run this wizard again to auto-detect models from traffic.
No models detected yet. Use {target.name} to make a request, then run this
wizard again to auto-detect models from traffic.
</p>
<p className="text-xs text-text-muted mt-2">
Or close this wizard and add mappings manually in the agent card.

View File

@@ -42,7 +42,9 @@ export function CustomHostsManager({ onClose }: CustomHostsManagerProps) {
};
useEffect(() => {
void fetchHosts();
void (async () => {
await fetchHosts();
})();
}, []);
const addHost = async () => {

View File

@@ -115,7 +115,9 @@ export default function MonitorTab({ onGoToTranslate }: MonitorTabProps) {
}, []);
useEffect(() => {
void fetchHistory();
void (async () => {
await fetchHistory();
})();
if (autoRefresh) {
intervalRef.current = setInterval(() => {
void fetchHistory();

View File

@@ -46,7 +46,9 @@ export function WebhooksPageClient() {
}, [t]);
useEffect(() => {
void load();
void (async () => {
await load();
})();
}, [load]);
const stats = useMemo(

View File

@@ -92,10 +92,13 @@ export function AddWebhookWizard({
useEffect(() => {
if (!isOpen) return;
setStep(1);
setState(stateFromWebhook(editingWebhook));
setError(null);
setCreatedId(editingWebhook?.id ?? null);
void (async () => {
await Promise.resolve();
setStep(1);
setState(stateFromWebhook(editingWebhook));
setError(null);
setCreatedId(editingWebhook?.id ?? null);
})();
}, [editingWebhook, isOpen]);
const handleClose = () => {

View File

@@ -39,7 +39,9 @@ export function WebhookDeliveriesPanel({ webhookId, t }: WebhookDeliveriesPanelP
}, [webhookId, t]);
useEffect(() => {
void load();
void (async () => {
await load();
})();
}, [load]);
if (loading) {

View File

@@ -99,15 +99,18 @@ export default function GlobalError({ error, reset }: GlobalErrorProps) {
);
useEffect(() => {
const nextLocale = getCookieLocale();
setLocale(nextLocale);
if (nextLocale === DEFAULT_LOCALE) return;
void import(`../i18n/messages/${nextLocale}.json`)
.then((module) =>
setMessages(buildGlobalErrorMessages(module.default as Record<string, unknown>))
)
.catch(() => setMessages(buildGlobalErrorMessages(enMessages)));
void (async () => {
await Promise.resolve();
const nextLocale = getCookieLocale();
setLocale(nextLocale);
if (nextLocale === DEFAULT_LOCALE) return;
try {
const mod = await import(`../i18n/messages/${nextLocale}.json`);
setMessages(buildGlobalErrorMessages(mod.default as Record<string, unknown>));
} catch {
setMessages(buildGlobalErrorMessages(enMessages));
}
})();
}, []);
return (

View File

@@ -59,7 +59,9 @@ export default function StatusPage() {
}, [t]);
useEffect(() => {
void loadHealth();
void (async () => {
await loadHealth();
})();
}, [loadHealth]);
const providerStats = useMemo(() => {

View File

@@ -11,7 +11,7 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import type { DashboardChannel, DashboardEventName } from "@/lib/events/types";
import { deriveLiveWsPath, resolveLiveWsUrl, sanitizeLiveWsPort } from "@/shared/utils/wsPath";
@@ -168,6 +168,14 @@ export function useLiveDashboard({
onEventRef.current = onEvent;
}, [onEvent]);
// Key + memo pair: the channel ARRAY is usually a fresh literal each render,
// so `connect` deps use a stable identity derived from its contents.
const channelsKey = channels.join(",");
const stableChannels = useMemo(
() => channelsKey.split(",").filter(Boolean) as DashboardChannel[],
[channelsKey]
);
const connect = useCallback(() => {
if (!mountedRef.current) return;
if (wsRef.current?.readyState === WebSocket.OPEN) return;
@@ -196,7 +204,7 @@ export function useLiveDashboard({
});
// Subscribe to channels
ws.send(JSON.stringify({ type: "subscribe", channels }));
ws.send(JSON.stringify({ type: "subscribe", channels: stableChannels }));
// Heartbeat: send a periodic ping so the server (which only refreshes
// liveness from inbound messages) never terminates a healthy, idle
@@ -293,7 +301,7 @@ export function useLiveDashboard({
}, [
effectiveWsUrl,
apiKey,
channels.join(","),
stableChannels,
autoReconnect,
connection.reconnectAttempt,
stopPingHeartbeat,

View File

@@ -43,7 +43,9 @@ export function useToolBatchStatuses(): UseToolBatchStatusesResult {
}, [fetchStatuses]);
useEffect(() => {
void fetchStatuses();
void (async () => {
await fetchStatuses();
})();
function handleFocus() {
void fetchStatuses();

View File

@@ -283,6 +283,8 @@
"tests/unit/model-lockout-decay.test.ts",
"tests/unit/model-lockout-exact-cooldown-cap.test.ts",
"tests/unit/model-lockout-max-cooldown.test.ts",
"tests/unit/native-codex-turn-pin-10379.test.ts",
"tests/unit/native-codex-turn-pin-model-scoped-fallback.test.ts",
"tests/unit/no-memory-header.test.ts",
"tests/unit/noauth-autocombo-lockout-7623.test.ts",
"tests/unit/ollama-404-model-lockout-11071.test.ts",
@@ -406,7 +408,9 @@
"tests/unit/felo-web-runtime-block.test.ts",
"tests/unit/microsoft-designer-web-runtime-block.test.ts",
"tests/unit/qwen-web-runtime-block.test.ts",
"tests/unit/tunnel-routes-error-sanitization.test.ts"
"tests/unit/tunnel-routes-error-sanitization.test.ts",
"tests/unit/native-codex-turn-pin-10379.test.ts",
"tests/unit/native-codex-turn-pin-model-scoped-fallback.test.ts"
],
"nodeArgs": [
"--import",

View File

@@ -0,0 +1,74 @@
/**
* #11804 — the combo loop-safety timer must be cleared on EVERY exit path.
*
* `dispatchWithCooldownRetry` (open-sse/services/combo.ts) arms a
* `setTimeout(..., loopSafetyMs)` — 10 minutes by default — once per `setTry`
* iteration, so a combo that never produces a terminal response still answers
* the client with a 504 instead of hanging forever.
*
* Before this fix the only `clearTimeout` lived inside the `if (anySuccess)`
* branch (the code comment said so verbatim: "clear the safety timer on the
* happy path"). Every error exit — all_targets_skipped, all_accounts_inactive,
* the aggregated-status return, the final fallback, the global-timeout branch —
* returned the response to the client and left a 600s timer pending, its
* closure retaining `orderedTargets` and the exhausted provider/connection
* sets. Field evidence on the issue: a client received 502 immediately and the
* "Combo loop safety timeout ... force-terminating" line was logged exactly
* 600s later, long after the request was gone.
*
* This is a source-level guard rather than a runtime one: driving a real combo
* through each terminal branch needs the full provider/credential/DB stack, and
* the invariant we actually care about ("no exit path may skip the clear") is a
* structural property of the function. The guard fails if someone reintroduces
* a happy-path-only clear or drops the finally.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const comboSrc = readFileSync(resolve(here, "../../open-sse/services/combo.ts"), "utf8");
test("#11804: the loop-safety timer is released in a finally, not only on success", () => {
assert.match(
comboSrc,
/finally\s*\{[^}]*clearTimeout\(activeLoopSafetyTimer\)/s,
"dispatchWithCooldownRetry must clear the loop-safety timer in a finally block so every " +
"exit path (including future ones) releases it"
);
});
test("#11804: the timer handle is reachable from the function-scope cleanup", () => {
// The timer is created inside the `for (setTry...)` loop; the cleanup lives at
// function scope. If the handle is not published to that outer binding, the
// finally silently clears nothing.
assert.match(
comboSrc,
/activeLoopSafetyTimer = loopSafetyTimer/,
"the per-iteration timer must be published to the function-scope handle"
);
const declIdx = comboSrc.indexOf("let activeLoopSafetyTimer");
const loopIdx = comboSrc.indexOf("for (let setTry = 0");
assert.ok(declIdx > 0, "function-scope timer handle must be declared");
assert.ok(
declIdx < loopIdx,
"the handle must be declared OUTSIDE the setTry loop, otherwise each iteration " +
"gets a fresh binding and the previous iteration's timer leaks"
);
});
test("#11804: the safety timeout itself is preserved (fix must not disarm the 504)", () => {
// Guard against 'fixing' the leak by simply never arming the timer.
assert.match(
comboSrc,
/loopSafetyTimer = setTimeout\(/,
"the loop-safety timer must still be armed — the 504 backstop is the reason it exists"
);
assert.match(
comboSrc,
/Combo loop safety timeout/,
"the force-termination path must still exist"
);
});

View File

@@ -20,6 +20,7 @@ const { getCircuitBreaker, resetAllCircuitBreakers } =
await import("../../src/shared/utils/circuitBreaker.ts");
const { recordProviderCooldown, clearCooldownState } =
await import("../../open-sse/services/providerCooldownTracker.ts");
const { PROVIDER_PROFILES } = await import("../../open-sse/config/constants.ts");
const { resolveResilienceSettings } = await import("../../src/lib/resilience/settings.ts");
const BODY = {
@@ -222,9 +223,13 @@ test("isPinnedTargetModelScopedUnusable distinguishes model lockout from provide
false
);
// Reset breaker, test provider cooldown
// Reset breaker, test provider cooldown. Since #12247 the global provider
// cooldown honors the PROVIDER_PROFILES window gate: the provider only counts
// as cooling after providerFailureThreshold failures inside the window.
cb.reset();
recordProviderCooldown("antigravity", undefined, resilienceSettings);
for (let i = 0; i < PROVIDER_PROFILES.oauth.providerFailureThreshold; i++) {
recordProviderCooldown("antigravity", undefined, resilienceSettings);
}
assert.equal(
await isPinnedTargetModelScopedUnusable({
target,

View File

@@ -19,6 +19,7 @@ const {
} = await import("../../open-sse/services/combo/nativeCodexTurnPin.ts");
const { recordProviderCooldown, isProviderInCooldown, clearCooldownState } =
await import("../../open-sse/services/providerCooldownTracker.ts");
const { PROVIDER_PROFILES } = await import("../../open-sse/config/constants.ts");
const { getCircuitBreaker, resetAllCircuitBreakers } =
await import("../../src/shared/utils/circuitBreaker.ts");
const { resolveResilienceSettings } = await import("../../src/lib/resilience/settings.ts");
@@ -403,7 +404,11 @@ describe("Native Codex Turn Pin model-scoped fallback", () => {
allCombos: null,
});
recordProviderCooldown("antigravity", undefined, settings);
// #12247: the window gate needs providerFailureThreshold failures before
// the whole provider counts as cooling.
for (let i = 0; i < PROVIDER_PROFILES.oauth.providerFailureThreshold; i++) {
recordProviderCooldown("antigravity", undefined, settings);
}
assert.equal(isProviderInCooldown("antigravity", undefined, settings), true);
const attempted: string[] = [];

View File

@@ -0,0 +1,121 @@
import { test, beforeEach, mock } from "node:test";
import assert from "node:assert";
import {
recordProviderCooldown,
isProviderInCooldown,
getRemainingCooldownMs,
recordProviderSuccess,
clearCooldownState,
} from "../../open-sse/services/providerCooldownTracker.ts";
import { PROVIDER_PROFILES } from "../../open-sse/config/constants.ts";
import { DEFAULT_RESILIENCE_SETTINGS } from "../../src/lib/resilience/settings.ts";
// Provider-level entries (no connectionId) must honor the PROVIDER_PROFILES
// window gate: `providerFailureThreshold` failures inside
// `providerFailureWindowMs` put the whole provider in a `providerCooldownMs`
// cooldown — below the threshold the provider must NOT be considered cooling.
// These fields shipped in PROVIDER_PROFILES with no runtime consumer (2026-08-31
// docs audit, P0.1); this suite is the regression guard for wiring them in.
// Connection-level entries keep the pre-existing exponential-backoff behavior.
const settings = DEFAULT_RESILIENCE_SETTINGS;
// "openai" resolves to the apikey category in the provider registry.
const APIKEY = PROVIDER_PROFILES.apikey;
beforeEach(() => {
clearCooldownState();
});
test("provider-level: below providerFailureThreshold the provider is NOT in cooldown", () => {
for (let i = 0; i < APIKEY.providerFailureThreshold - 1; i++) {
recordProviderCooldown("openai", undefined, settings);
}
assert.equal(
isProviderInCooldown("openai", undefined, settings),
false,
`expected no provider-level cooldown below the ${APIKEY.providerFailureThreshold}-failure threshold`
);
assert.equal(getRemainingCooldownMs("openai", undefined, settings), 0);
});
test("provider-level: reaching providerFailureThreshold trips a providerCooldownMs cooldown", () => {
for (let i = 0; i < APIKEY.providerFailureThreshold; i++) {
recordProviderCooldown("openai", undefined, settings);
}
assert.equal(isProviderInCooldown("openai", undefined, settings), true);
const remaining = getRemainingCooldownMs("openai", undefined, settings);
assert.ok(
remaining > 0 && remaining <= APIKEY.providerCooldownMs,
`remaining ${remaining}ms should be within (0, providerCooldownMs=${APIKEY.providerCooldownMs}]`
);
assert.ok(
remaining > APIKEY.providerCooldownMs - 5_000,
`a freshly tripped cooldown should last ~providerCooldownMs (got ${remaining}ms)`
);
});
test("provider-level: failures outside providerFailureWindowMs do not count toward the threshold", () => {
mock.timers.enable({ apis: ["Date"], now: 1_000_000 });
try {
// threshold-1 failures, then jump past the window before the next one
for (let i = 0; i < APIKEY.providerFailureThreshold - 1; i++) {
recordProviderCooldown("openai", undefined, settings);
}
mock.timers.setTime(1_000_000 + APIKEY.providerFailureWindowMs + 60_000);
recordProviderCooldown("openai", undefined, settings);
assert.equal(
isProviderInCooldown("openai", undefined, settings),
false,
"stale failures beyond the window must not trip the provider cooldown"
);
} finally {
mock.timers.reset();
}
});
test("provider-level: the cooldown expires providerCooldownMs after the tripping failure", () => {
mock.timers.enable({ apis: ["Date"], now: 2_000_000 });
try {
for (let i = 0; i < APIKEY.providerFailureThreshold; i++) {
recordProviderCooldown("openai", undefined, settings);
}
assert.equal(isProviderInCooldown("openai", undefined, settings), true);
mock.timers.setTime(2_000_000 + APIKEY.providerCooldownMs + 1_000);
assert.equal(
isProviderInCooldown("openai", undefined, settings),
false,
"provider cooldown must expire after providerCooldownMs"
);
} finally {
mock.timers.reset();
}
});
test("provider-level: recordProviderSuccess clears the failure window", () => {
for (let i = 0; i < APIKEY.providerFailureThreshold; i++) {
recordProviderCooldown("openai", undefined, settings);
}
assert.equal(isProviderInCooldown("openai", undefined, settings), true);
recordProviderSuccess("openai", undefined);
assert.equal(isProviderInCooldown("openai", undefined, settings), false);
recordProviderCooldown("openai", undefined, settings);
assert.equal(
isProviderInCooldown("openai", undefined, settings),
false,
"one failure after a success must not re-trip the threshold gate"
);
});
test("connection-level entries keep the pre-existing backoff behavior (no window gate)", () => {
recordProviderCooldown("openai", "conn-1", settings);
assert.equal(
isProviderInCooldown("openai", "conn-1", settings),
true,
"a single connection-level failure still starts the legacy min-cooldown backoff"
);
const remaining = getRemainingCooldownMs("openai", "conn-1", settings);
assert.ok(
remaining > 0 && remaining <= settings.providerCooldown.minRetryCooldownMs,
`connection-level cooldown should follow minRetryCooldownMs (got ${remaining}ms)`
);
});

View File

@@ -9,6 +9,7 @@ import {
getCooldownEntryCount,
cleanupExpiredCooldownEntries,
} from "../../../open-sse/services/providerCooldownTracker.ts";
import { PROVIDER_PROFILES } from "../../../open-sse/config/constants.ts";
import {
resolveResilienceSettings,
DEFAULT_RESILIENCE_SETTINGS,
@@ -183,8 +184,16 @@ test("different connections have independent cooldowns", () => {
test("provider-only key works without connectionId", () => {
const settings = makeSettings();
// Provider-level entries honor the PROVIDER_PROFILES window gate: a single
// failure no longer cools the whole provider (2026-08-31 audit, P0.1 wiring).
recordProviderCooldown("openai", undefined, settings);
assert.equal(isProviderInCooldown("openai", undefined, settings), false);
// Reaching the profile threshold trips the whole-provider cooldown, still
// independent from any connection-level key.
for (let i = 1; i < PROVIDER_PROFILES.apikey.providerFailureThreshold; i++) {
recordProviderCooldown("openai", undefined, settings);
}
assert.ok(isProviderInCooldown("openai", undefined, settings));
assert.equal(isProviderInCooldown("openai", "conn-1", settings), false);
});