From aaf7e32f46d9394104e69dcaf283465fc9fb1682 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:54:56 -0300 Subject: [PATCH] fix(translator): strip assistant echo fields on the OpenAI target path (Mistral 422) (#4350) Strict OpenAI-compatible upstreams (e.g. mistral/codestral-latest) reject client-only assistant echo fields sent back as input with 422 extra_forbidden (the report hit messages[].assistant.reasoning_content via Codex /responses). Only reasoning_content was stripped on the OpenAI target path; the sibling fields reasoning / refusal / annotations / cache_control leaked through. They are now all dropped on the non-reasoner OpenAI target path. `audio` is intentionally preserved (OpenAI audio models reference a prior assistant audio response by id; Mistral never emits audio). Reported-by: xxy9468615 (https://github.com/decolua/9router/issues/1649) Co-authored-by: xxy9468615 <63351664+xxy9468615@users.noreply.github.com> --- CHANGELOG.md | 1 + open-sse/translator/index.ts | 20 +++++++- ...anslator-mistral-strip-echo-fields.test.ts | 50 +++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/unit/translator-mistral-strip-echo-fields.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index add795cad8..0cdeb696c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(mitm): exact host membership in the MITM hosts test (CodeQL false positive)** — `tests/unit/mitm-tool-hosts.test.ts` checked host membership with `Array.includes(host)`, which CodeQL's `js/incomplete-url-substring-sanitization` heuristic misreads as a `String.includes()` URL-substring sanitization test (HIGH false positive). Switched to `.some((h) => h === host)` — identical semantics, no flagged pattern. ([#4386](https://github.com/diegosouzapw/OmniRoute/pull/4386)) - **fix(api): migrate the deprecated Codex `[features].codex_hooks` flag to `[features].hooks`** — Codex renamed the `codex_hooks` feature flag to `hooks`; recent Codex CLI versions ignore the old key and print a deprecation notice. When OmniRoute rewrites an existing `~/.codex/config.toml` (configuring/resetting the Codex provider) it now carries the user's intent forward by renaming `[features].codex_hooks` → `[features].hooks` (preserving its value, never clobbering an already-present `hooks`) and dropping the deprecated key. No-op when the flag is absent. (thanks @Bian-Sh) - **fix(translator): same-format response path no longer leaks a `data: null` SSE event** — the streaming response translator's same-format fast path returned `[chunk]` unconditionally, so the end-of-stream null/flush signal (`chunk === null`) propagated as a literal `[null]`. Downstream this surfaced as an empty `data: null` SSE event between chunks and crashed strict clients (e.g. Factory Droid BYOK on `/v1/responses`). The fast path now drops the null flush (returns `[]`) while still passing real chunks through unchanged. (thanks @thaitryhand) +- **fix(translator): strip client-only assistant echo fields on the OpenAI target path (Mistral 422)** — strict OpenAI-compatible upstreams (e.g. `mistral/codestral-latest`) reject client-only assistant "echo" fields sent back as input history with `422 extra_forbidden` (the report hit `messages[].assistant.reasoning_content` via Codex `/responses`). Only `reasoning_content` was being stripped on the OpenAI target path; the sibling echo fields `reasoning`, `refusal`, `annotations` and `cache_control` leaked through and tripped the 422. They are now all dropped on the non-reasoner OpenAI target path. `audio` is deliberately preserved (OpenAI audio models reference a prior assistant audio response by id on multi-turn; Mistral never emits audio, so nothing is lost there). (thanks @xxy9468615) --- diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 7eae873fce..8920568278 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -117,6 +117,20 @@ function isDeepSeekReplayTarget(provider: unknown, model: unknown): boolean { /** @param options.preserveDeveloperRole - undefined/true: keep developer for OpenAI format (default); false: map to system */ /** @param options.preserveCacheControl - When true, preserve client-side cache_control markers (for Claude Code, etc.) */ // Translate request: source -> openai -> target +// Client-only assistant "echo" fields that strict OpenAI-compatible upstreams (e.g. +// Mistral) reject with 422 extra_forbidden when sent back as input history. They carry +// no value upstream and are dropped on the OpenAI target path (#1649). `audio` is +// deliberately NOT included: OpenAI audio models reference a prior assistant audio +// response by id on multi-turn, so stripping it would break that (Mistral never emits +// audio, so it is never present there). +const OPENAI_INCOMPATIBLE_ECHO_FIELDS = [ + "reasoning_content", + "reasoning", + "refusal", + "annotations", + "cache_control", +]; + export function translateRequest( sourceFormat, targetFormat, @@ -415,8 +429,10 @@ export function translateRequest( Array.isArray(result.messages) ) { for (const msg of result.messages) { - if (msg.reasoning_content !== undefined) { - delete msg.reasoning_content; + for (const field of OPENAI_INCOMPATIBLE_ECHO_FIELDS) { + if (msg[field] !== undefined) { + delete msg[field]; + } } } } diff --git a/tests/unit/translator-mistral-strip-echo-fields.test.ts b/tests/unit/translator-mistral-strip-echo-fields.test.ts new file mode 100644 index 0000000000..6b5874020e --- /dev/null +++ b/tests/unit/translator-mistral-strip-echo-fields.test.ts @@ -0,0 +1,50 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Regression for port-from-9router#1649: strict OpenAI-compatible upstreams (e.g. +// mistral/codestral) reject client-only assistant "echo" fields on input with +// 422 extra_forbidden (the report hit `messages[].assistant.reasoning_content`). +// Only `reasoning_content` was stripped on the OpenAI target path; the sibling echo +// fields (reasoning / refusal / annotations / cache_control) leaked through. +const { translateRequest } = await import("../../open-sse/translator/index.ts"); + +test("#1649: assistant echo fields are stripped on the OpenAI target path", () => { + const body = { + messages: [ + { role: "user", content: "hi" }, + { + role: "assistant", + content: "answer", + reasoning_content: "secret reasoning", + reasoning: { effort: "low" }, + refusal: null, + annotations: [], + cache_control: { type: "ephemeral" }, + audio: { id: "audio_123" }, + }, + ], + }; + + const out = translateRequest( + "openai", + "openai", + "mistral/codestral-latest", + body, + false, + null, + "mistral" + ) as { messages: Record[] }; + + const asst = out.messages[1]; + assert.equal(asst.reasoning_content, undefined, "reasoning_content stripped"); + assert.equal(asst.reasoning, undefined, "reasoning stripped"); + assert.equal(asst.refusal, undefined, "refusal stripped"); + assert.equal(asst.annotations, undefined, "annotations stripped"); + assert.equal(asst.cache_control, undefined, "cache_control stripped"); + // `audio` is intentionally preserved: OpenAI audio models reference a prior + // assistant audio response by id on multi-turn, and stripping it universally on + // the OpenAI path would break that. Mistral never emits audio, so nothing is lost. + assert.deepEqual(asst.audio, { id: "audio_123" }, "audio preserved"); + // The visible content is untouched. + assert.equal(asst.content, "answer"); +});