mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
This commit is contained in:
committed by
GitHub
parent
b8cc4feef1
commit
c4fc33480c
@@ -26,6 +26,7 @@ _In development — bullets added per PR; finalized at release._
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **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(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)
|
||||
|
||||
@@ -222,6 +222,21 @@ async function proxyUpgrade(req: IncomingMessage, socket: net.Socket, head: Buff
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the bind host for the embed WS proxy.
|
||||
*
|
||||
* `EMBED_WS_PROXY_HOST` takes precedence, but we fall back to `LIVE_WS_HOST`
|
||||
* so a single env var exposes BOTH WebSocket sockets (the Live dashboard server
|
||||
* on :20129 and this embed proxy on :20131) in Docker / behind a reverse proxy
|
||||
* or tunnel. Without this fallback the embed proxy stayed bound to 127.0.0.1
|
||||
* even when the operator set `LIVE_WS_HOST=0.0.0.0`, so the Live view was
|
||||
* permanently "disconnected" in headless deployments (#5110). Defaults to
|
||||
* loopback for safety when neither is set.
|
||||
*/
|
||||
export function resolveEmbedWsHost(): string {
|
||||
return process.env.EMBED_WS_PROXY_HOST ?? process.env.LIVE_WS_HOST ?? DEFAULT_HOST;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the embed WebSocket proxy server.
|
||||
* Idempotent — safe to call multiple times.
|
||||
@@ -229,7 +244,7 @@ async function proxyUpgrade(req: IncomingMessage, socket: net.Socket, head: Buff
|
||||
export function initEmbedWsProxy(): void {
|
||||
if (globalThis.__omnirouteEmbedWsStarted) return;
|
||||
|
||||
const host = process.env.EMBED_WS_PROXY_HOST ?? DEFAULT_HOST;
|
||||
const host = resolveEmbedWsHost();
|
||||
const port = parseInt(process.env.EMBED_WS_PROXY_PORT ?? String(DEFAULT_PORT), 10);
|
||||
|
||||
const server = http.createServer((_req, res) => {
|
||||
|
||||
@@ -223,6 +223,21 @@ export async function handleChat(
|
||||
|
||||
const rawClientBody = cloneLogPayload(body);
|
||||
|
||||
// Early guard: an explicitly empty `messages` array is invalid for every
|
||||
// upstream (Anthropic/OpenAI both reject "at least one message is required").
|
||||
// Forwarding it produced a confusing raw upstream 400/502; reject it here with
|
||||
// a clear OmniRoute-level error before any routing or upstream call (#5110).
|
||||
// Responses-API requests use `input` (not `messages`) so they are unaffected,
|
||||
// and an absent `messages` field is left to downstream validation.
|
||||
if (Array.isArray((body as { messages?: unknown }).messages) &&
|
||||
(body as { messages: unknown[] }).messages.length === 0) {
|
||||
log.warn("CHAT", "Rejecting request with empty messages array");
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
"messages: at least one message is required"
|
||||
);
|
||||
}
|
||||
|
||||
// Build clientRawRequest for logging (if not provided)
|
||||
if (!clientRawRequest) {
|
||||
clientRawRequest = buildClientRawRequest(request, rawClientBody);
|
||||
|
||||
118
tests/unit/embed-ws-host-empty-messages-5110.test.ts
Normal file
118
tests/unit/embed-ws-host-empty-messages-5110.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts";
|
||||
|
||||
// Regression tests for #5110 — two independent Docker/headless deployment bugs:
|
||||
//
|
||||
// Issue 1) The Live/embed WebSocket proxy binds 127.0.0.1 only and ignores
|
||||
// LIVE_WS_HOST. Behind a reverse proxy/tunnel it can never be reached,
|
||||
// so the dashboard shows "Live disabled — WebSocket disconnected"
|
||||
// even with LIVE_WS_HOST=0.0.0.0 set. The embed proxy read only
|
||||
// EMBED_WS_PROXY_HOST; it now falls back to LIVE_WS_HOST.
|
||||
//
|
||||
// Issue 4) A request with `messages: []` (empty array) was forwarded upstream,
|
||||
// which Anthropic rejects with a raw "[400]: messages: at least one
|
||||
// message is required". OmniRoute now rejects it early with a clear
|
||||
// OmniRoute-level 400 before any upstream call.
|
||||
|
||||
// ── Issue 1: embed WS bind host honours LIVE_WS_HOST ──────────────────────────
|
||||
test("#5110-1: resolveEmbedWsHost prefers EMBED_WS_PROXY_HOST, then LIVE_WS_HOST, then loopback", async () => {
|
||||
const { resolveEmbedWsHost } = await import("../../src/lib/services/embedWsProxy.ts");
|
||||
const prevEmbed = process.env.EMBED_WS_PROXY_HOST;
|
||||
const prevLive = process.env.LIVE_WS_HOST;
|
||||
try {
|
||||
delete process.env.EMBED_WS_PROXY_HOST;
|
||||
delete process.env.LIVE_WS_HOST;
|
||||
assert.equal(resolveEmbedWsHost(), "127.0.0.1", "default stays loopback for safety");
|
||||
|
||||
process.env.LIVE_WS_HOST = "0.0.0.0";
|
||||
assert.equal(
|
||||
resolveEmbedWsHost(),
|
||||
"0.0.0.0",
|
||||
"LIVE_WS_HOST should control the embed WS bind when EMBED_WS_PROXY_HOST is unset"
|
||||
);
|
||||
|
||||
process.env.EMBED_WS_PROXY_HOST = "10.0.0.5";
|
||||
assert.equal(
|
||||
resolveEmbedWsHost(),
|
||||
"10.0.0.5",
|
||||
"EMBED_WS_PROXY_HOST still wins when both are set"
|
||||
);
|
||||
} finally {
|
||||
if (prevEmbed === undefined) delete process.env.EMBED_WS_PROXY_HOST;
|
||||
else process.env.EMBED_WS_PROXY_HOST = prevEmbed;
|
||||
if (prevLive === undefined) delete process.env.LIVE_WS_HOST;
|
||||
else process.env.LIVE_WS_HOST = prevLive;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Issue 4: empty messages array rejected early with a clear 400 ─────────────
|
||||
const harness = await createChatPipelineHarness("chat-empty-messages-5110");
|
||||
const { handleChat, buildRequest, resetStorage, seedConnection } = harness;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
await harness.cleanup();
|
||||
});
|
||||
|
||||
test("#5110-4: an empty messages array is rejected with a clear 400 before hitting upstream", async () => {
|
||||
await seedConnection("anthropic", { apiKey: "sk-ant" });
|
||||
|
||||
let upstreamCalled = false;
|
||||
globalThis.fetch = async () => {
|
||||
upstreamCalled = true;
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
url: "http://localhost/v1/messages",
|
||||
body: {
|
||||
model: "anthropic/claude-haiku-4-5",
|
||||
max_tokens: 100,
|
||||
system: "You are helpful.",
|
||||
messages: [],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400, "empty messages must be a 400, not a forwarded upstream error");
|
||||
const body = (await response.json()) as { error?: { message?: string } };
|
||||
assert.match(
|
||||
body.error?.message ?? "",
|
||||
/at least one message is required/i,
|
||||
"error should clearly state messages must be non-empty"
|
||||
);
|
||||
assert.equal(upstreamCalled, false, "must not forward an empty-messages request upstream");
|
||||
});
|
||||
|
||||
test("#5110-4: a non-empty messages array still routes normally (guard is not over-broad)", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai" });
|
||||
|
||||
let upstreamCalled = false;
|
||||
globalThis.fetch = async () => {
|
||||
upstreamCalled = true;
|
||||
return Response.json({
|
||||
id: "x",
|
||||
object: "chat.completion",
|
||||
choices: [{ index: 0, message: { role: "assistant", content: "hi" }, finish_reason: "stop" }],
|
||||
});
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
model: "openai/gpt-4.1",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.notEqual(response.status, 400, "a valid request must not be caught by the empty guard");
|
||||
assert.equal(upstreamCalled, true, "a valid request must still reach upstream");
|
||||
});
|
||||
Reference in New Issue
Block a user