fix(translator): inject placeholder message when Responses API input[] is empty (#4393)

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

Mirror the existing empty-string handling: when input[] is empty, inject
a single placeholder user message so the request is always valid. Touches
`openaiResponsesToOpenAIRequest` only (the same translator that already
covers the empty-string case at the top of the function), preserves any
caller-supplied `instructions` as a system message, and leaves every
non-empty input path byte-identical.

Adds a TDD test reproducing the empty input[] case (RED before fix, GREEN
after) and updates one pre-existing test that enshrined the broken
behavior (`assert.deepEqual(call.body.messages, [])`) to assert the new
correct shape.


Inspired-by: https://github.com/decolua/9router/pull/419

Co-authored-by: anuragg-saxenaa <anuragg.saxenaa@gmail.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-20 17:07:51 -03:00
committed by GitHub
parent 13288e265a
commit d215079f66
4 changed files with 56 additions and 2 deletions

View File

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

View File

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

View File

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

View File

@@ -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<Record<string, unknown>>;
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<string, unknown>;
const messages = result.messages as Array<Record<string, unknown>>;
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");
});