Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/app/api/resilience/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/resilience-tab-response-fields.test.ts
Original file line number Diff line number Diff line change
@@ -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}`
);
}
});
Loading