mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
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>
This commit is contained in:
committed by
GitHub
parent
472284190a
commit
aaf7e32f46
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
50
tests/unit/translator-mistral-strip-echo-fields.test.ts
Normal file
50
tests/unit/translator-mistral-strip-echo-fields.test.ts
Normal file
@@ -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<string, unknown>[] };
|
||||
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user