From 50e66211011327dd0debcabae0205ce16d770b60 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:22:45 -0400 Subject: [PATCH] Fix resilience settings page response mapping (#5139) Integrated into release/v3.8.38. Thanks @rdself for the fix and the regression test. --- CHANGELOG.md | 1 + .../settings/components/ResilienceTab.tsx | 28 ++++----- src/app/api/resilience/route.ts | 1 + .../resilience-tab-response-fields.test.ts | 62 +++++++++++++++++++ 4 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 tests/unit/resilience-tab-response-fields.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0067fe9b74..100f052183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) - **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) - **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) +- **fix(dashboard): preserve every rendered field when loading/saving Resilience settings** — `ResilienceTab` renders `comboCooldownWait` and `quotaShareConcurrencyLimit`, but both the initial-load and save paths rewrote component state without those fields, so after a successful `/api/resilience` response the cards received `undefined` and the page fell back to the generic "failed to load" state. A shared `toResilienceResponse()` mapper now keeps all rendered fields, and `PATCH /api/resilience` returns `quotaShareConcurrencyLimit` to match GET and the UI contract. ([#5139](https://github.com/diegosouzapw/OmniRoute/pull/5139) — thanks @rdself) - **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) - **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) - **fix(streaming): harden long OpenAI-compatible SSE streams** — a late pipeline-wind-down error can no longer overwrite an already-recorded successful stream (`streamCompletionRecorded` guard), client disconnects finalize as `499 client_disconnected` instead of poisoning provider/account failure state, JSON bodies that are actually SSE (wrong `application/json` content-type) are sniffed and re-streamed, and reasoning fields (`reasoning`/`reasoning_content` + OpenRouter/Gemini encrypted `reasoning_details`) are preserved through the JSON-as-SSE fallback. ([#5124](https://github.com/diegosouzapw/OmniRoute/pull/5124) — thanks @rdself) diff --git a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx index 206b380f2f..9cea27492d 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx @@ -69,6 +69,18 @@ type ResilienceResponse = { providerCooldown: ProviderCooldownSettings; }; +function toResilienceResponse(json: ResilienceResponse): ResilienceResponse { + return { + requestQueue: json.requestQueue, + connectionCooldown: json.connectionCooldown, + providerBreaker: json.providerBreaker, + waitForCooldown: json.waitForCooldown, + comboCooldownWait: json.comboCooldownWait, + quotaShareConcurrencyLimit: json.quotaShareConcurrencyLimit, + providerCooldown: json.providerCooldown, + }; +} + function formatMs(value: number | null | undefined) { if (typeof value !== "number") return "—"; return `${value}ms`; @@ -1058,13 +1070,7 @@ export default function ResilienceTab() { } const json = await response.json(); if (!mounted) return; - setData({ - requestQueue: json.requestQueue, - connectionCooldown: json.connectionCooldown, - providerBreaker: json.providerBreaker, - waitForCooldown: json.waitForCooldown, - providerCooldown: json.providerCooldown, - }); + setData(toResilienceResponse(json)); } catch (error) { notify.error( error instanceof Error @@ -1094,13 +1100,7 @@ export default function ResilienceTab() { if (!response.ok) { throw new Error(json?.error?.message || json?.error || `HTTP ${response.status}`); } - setData({ - requestQueue: json.requestQueue, - connectionCooldown: json.connectionCooldown, - providerBreaker: json.providerBreaker, - waitForCooldown: json.waitForCooldown, - providerCooldown: json.providerCooldown, - }); + setData(toResilienceResponse(json)); notify.success(tx("savedSuccessfully", "Resilience settings updated.")); } catch (error) { notify.error( diff --git a/src/app/api/resilience/route.ts b/src/app/api/resilience/route.ts index 4f5c862820..8820cd5173 100644 --- a/src/app/api/resilience/route.ts +++ b/src/app/api/resilience/route.ts @@ -243,6 +243,7 @@ export async function PATCH(request) { maxRetryWaitSec: nextResilience.waitForCooldown.maxRetryWaitSec, }, comboCooldownWait: nextResilience.comboCooldownWait, + quotaShareConcurrencyLimit: nextResilience.quotaShareConcurrencyLimit, providerCooldown: nextResilience.providerCooldown, legacy: buildLegacyResilienceCompat(nextResilience), }); diff --git a/tests/unit/resilience-tab-response-fields.test.ts b/tests/unit/resilience-tab-response-fields.test.ts new file mode 100644 index 0000000000..f1cd92d618 --- /dev/null +++ b/tests/unit/resilience-tab-response-fields.test.ts @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const RESILIENCE_TAB_PATH = path.resolve( + process.cwd(), + "src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx" +); +const RESILIENCE_ROUTE_PATH = path.resolve(process.cwd(), "src/app/api/resilience/route.ts"); + +const REQUIRED_RESPONSE_FIELDS = [ + "requestQueue", + "connectionCooldown", + "providerBreaker", + "waitForCooldown", + "comboCooldownWait", + "quotaShareConcurrencyLimit", + "providerCooldown", +]; + +test("ResilienceTab maps every rendered /api/resilience field into component state", () => { + const source = fs.readFileSync(RESILIENCE_TAB_PATH, "utf8"); + const mapper = source.match( + /function\s+toResilienceResponse\s*\([^)]*\)\s*:\s*ResilienceResponse\s*{(?[\s\S]*?)\n}/ + )?.groups?.body; + + assert.ok(mapper, "ResilienceTab should use a shared toResilienceResponse mapper"); + + for (const field of REQUIRED_RESPONSE_FIELDS) { + assert.match( + mapper, + new RegExp(`${field}:\\s*json\\.${field}\\b`), + `toResilienceResponse should preserve ${field} from /api/resilience` + ); + } + + for (const field of ["comboCooldownWait", "quotaShareConcurrencyLimit"]) { + assert.match( + source, + new RegExp(`value=\\{data\\.${field}\\}`), + `ResilienceTab should render ${field}; missing state mapping would crash the page` + ); + } +}); + +test("/api/resilience returns rendered card fields after GET and PATCH", () => { + const source = fs.readFileSync(RESILIENCE_ROUTE_PATH, "utf8"); + + for (const field of ["comboCooldownWait", "quotaShareConcurrencyLimit", "providerCooldown"]) { + assert.match( + source, + new RegExp(`${field}:\\s*resilience\\.${field}\\b`), + `GET /api/resilience should return ${field}` + ); + assert.match( + source, + new RegExp(`${field}:\\s*nextResilience\\.${field}\\b`), + `PATCH /api/resilience should return ${field}` + ); + } +});