fix(services): embed WS proxy honours LIVE_WS_HOST; reject empty messages early (#5110) (#5133)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-27 00:14:58 -03:00
committed by GitHub
parent b8cc4feef1
commit c4fc33480c
4 changed files with 150 additions and 1 deletions

View File

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

View File

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

View File

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

View 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");
});