Fix resilience settings page response mapping (#5139)

Integrated into release/v3.8.38. Thanks @rdself for the fix and the regression test.
This commit is contained in:
Randi
2026-06-27 01:22:45 -04:00
committed by GitHub
parent 3654ae50b3
commit 50e6621101
4 changed files with 78 additions and 14 deletions

View File

@@ -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)

View File

@@ -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(

View File

@@ -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),
});

View File

@@ -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*{(?<body>[\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}`
);
}
});