diff --git a/CHANGELOG.md b/CHANGELOG.md index bac53b7b4c..6b0965c66e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ _In development — bullets added per PR; finalized at release._ - **fix(api): persist `max_input_tokens` / `max_output_tokens` when adding a custom model** — `POST /api/provider-models` silently dropped the per-model token limits set in the "add custom model" form: the handler destructured the rest of the body but never read `max_input_tokens` / `max_output_tokens`, and `addCustomModel()` had no parameter for them, so the values were thrown away on write. The DB layer (`inputTokenLimit` / `outputTokenLimit`) and the `/v1/models` catalog already round-trip these fields — only the write path was missing. The validation schema now accepts the two optional limits, the handler forwards them, and `addCustomModel()` persists them so a custom model's context/output window survives into the catalog. ([#4349](https://github.com/diegosouzapw/OmniRoute/pull/4349) — thanks @codename-zen) - **fix(plugin): the OpenCode static-catalog plugin prefixes combo/raw model keys with the provider id** — OpenCode's static-catalog reader misdetected the `omniroute` provider: combo keys emitted as `combo/MASTER` were parsed as provider `combo` ("No credentials for provider: omniroute"), while a bare-`MASTER` form was misread as a model with no resolvable provider, and mixed `omniroute/MASTER` + bare-raw keys were rejected by OpenCode's schema. The plugin now emits every combo and raw model key prefixed with the `omniroute` provider id, emits the provider id explicitly, and drops the legacy `combo/` prefix — so the static-catalog reader detects the provider and the auth loader returns the right credentials (the catalog-fetch timeout was also raised so a cold-start server doesn't publish an empty stub). ([#4384](https://github.com/diegosouzapw/OmniRoute/pull/4384) — thanks @herjarsa) +- **fix(translator): inject placeholder message when Responses API input[] is empty (prevents upstream 400)** — a client (e.g. Fabric-AI) calling `POST /v1/responses` with `input: []` used to be translated into `messages: []`, which every upstream Chat-Completions provider rejects with `400: at least one message is required` (surfaced to the client as a confusing 406). The translator now treats an empty `input[]` the same as an empty string — a placeholder user message is injected so the request is always valid. (thanks @anuragg-saxenaa) +- **fix(embeddings): NVIDIA NIM asymmetric embedding models inject the required `input_type`** — NVIDIA NIM asymmetric embedders (e.g. `nvidia/nv-embedqa-e5-v5`) reject requests without an `input_type` parameter with `400 "'input_type' parameter is required"`, but OmniRoute only forwarded `input_type` when the client supplied it — so callers (and OpenAI-style SDKs that don't emit the field) got a hard failure. The embedding registry now carries a model-level default (`input_type: "query"`) for the asymmetric NVIDIA model, and the embeddings handler injects a model's default params into the upstream body **only** when the client didn't already send them — a client-supplied `input_type` (e.g. `"passage"`) is respected unchanged, and symmetric models that carry no default are unaffected. (thanks @hydraromania) ### 🔒 Security - **fix(security): scope the OAuth callback `postMessage` to a trusted-origin allowlist** — the OAuth callback at `/callback` previously posted `{ code, state, … }` to `window.opener.postMessage(…, "*")` whenever the opener was cross-origin, so a hostile page that opened the well-known redirect URI in a popup could receive the OAuth code/state and complete the flow as the user. The wildcard fallback is replaced with iteration over a fixed allowlist (same-origin + Codex's `localhost:1455` / `127.0.0.1:1455` loopback helper); the browser silently drops `postMessage` to any opener whose origin isn't listed. ([#4372](https://github.com/diegosouzapw/OmniRoute/pull/4372) — ported from 9router#998, thanks @aeonframework / @diegosouzapw) diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 228ba673e3..bc2a36c8c7 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -163,7 +163,14 @@ export function openaiResponsesToOpenAIRequest( let currentAssistantMsg: JsonRecord | null = null; let pendingToolResults: JsonRecord[] = []; - const inputItems = toArray(root.input); + // Upstream providers reject messages:[] with "400: at least one message is required". + // When the client sends input:[] (empty), inject a placeholder user message — mirrors + // upstream 9router#419 (and the existing empty-string handling elsewhere in this file). + const rawInputItems = toArray(root.input); + const inputItems: unknown[] = + rawInputItems.length === 0 + ? [{ type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }] + : rawInputItems; for (const itemValue of inputItems) { const item = toRecord(itemValue); diff --git a/tests/unit/responses-handler.test.ts b/tests/unit/responses-handler.test.ts index 6d35f8b19d..6ea4cb9bbb 100644 --- a/tests/unit/responses-handler.test.ts +++ b/tests/unit/responses-handler.test.ts @@ -208,7 +208,11 @@ test("handleResponsesCore strips previous_response_id by default and handles emp assert.equal(result.success, true); assert.equal(call.body.previous_response_id, undefined); assert.equal(call.body.metadata, undefined); - assert.deepEqual(call.body.messages, []); + // Empty input[] now injects a placeholder user message to avoid upstream + // "400: at least one message is required" rejections (9router#419). + assert.equal(Array.isArray(call.body.messages), true); + assert.equal(call.body.messages.length, 1); + assert.equal(call.body.messages[0].role, "user"); assert.equal(call.body.stream, true); }); diff --git a/tests/unit/translator-openai-responses-empty-input-419.test.ts b/tests/unit/translator-openai-responses-empty-input-419.test.ts new file mode 100644 index 0000000000..6a1e3ea860 --- /dev/null +++ b/tests/unit/translator-openai-responses-empty-input-419.test.ts @@ -0,0 +1,41 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiResponsesToOpenAIRequest } = await import( + "../../open-sse/translator/request/openai-responses.ts" +); + +// Regression: upstream 9router#419 +// When a client (e.g. Fabric-AI) POSTs /v1/responses with input:[] (empty array), the +// translator used to produce messages:[] which every upstream provider rejects with +// "400: messages: at least one message is required". Treat an empty input[] the same +// as an empty string — inject a placeholder user message so the request is always valid. +test("Responses -> Chat: empty input[] injects placeholder user message (not messages:[])", () => { + const result = openaiResponsesToOpenAIRequest("gpt-4o", { input: [] }, null, null) as Record< + string, + unknown + >; + + assert.ok(Array.isArray(result.messages), "messages should be an array"); + const messages = result.messages as Array>; + assert.ok(messages.length > 0, "messages should not be empty (upstream rejects messages:[])"); + + const userMessages = messages.filter((m) => m.role === "user"); + assert.ok(userMessages.length > 0, "at least one user message should be present"); +}); + +test("Responses -> Chat: empty input[] still preserves instructions as system message", () => { + const result = openaiResponsesToOpenAIRequest( + "gpt-4o", + { instructions: "Be terse.", input: [] }, + null, + null + ) as Record; + + const messages = result.messages as Array>; + assert.ok(messages.length >= 2, "instructions + placeholder = at least 2 messages"); + assert.equal(messages[0].role, "system"); + assert.equal(messages[0].content, "Be terse."); + const userMessages = messages.filter((m) => m.role === "user"); + assert.ok(userMessages.length > 0, "at least one user message should be present"); +});