fix(executors): strip nested reasoning_content for Mistral (port from 9router#1649) (#6417)

strip nested reasoning_content for Mistral (port #1649) (net +1/-0, test OK). Integrated into release/v3.8.46.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-06 19:22:52 -03:00
committed by GitHub
parent 4b1c1859f8
commit 58bb2cd8c7
3 changed files with 99 additions and 0 deletions

View File

@@ -21,6 +21,7 @@
### 🐛 Bug Fixes
- **fix(executors):** strip nested `reasoning_content` from messages for **Mistral**. Mistral's API returns `422 extra_forbidden` when an assistant message carries `reasoning_content` (replayed thinking from a prior turn, e.g. via the Codex `/responses` path); the generic top-level 400 field-downgrade retry never covered the nested per-message field. `DefaultExecutor` now strips it for provider `mistral` only, so DeepSeek (which requires replayed `reasoning_content`) is unaffected. Regression guard: `tests/unit/mistral-strip-reasoning-content-1649.test.ts`. (thanks @xxy9468615)
- **fix(executors):** strip the `client_metadata` passthrough field on the **OpenCode** path. OpenCode upstreams (e.g. `kimi-k2.6` via opencode-go) reject it with `400 "Extra inputs are not permitted, field: 'client_metadata'"`; the DefaultExecutor strip only covered cerebras/mistral and `OpencodeExecutor` extends `BaseExecutor` directly, so nothing removed it there. Regression guard: `tests/unit/opencode-strip-client-metadata-1442.test.ts`. (thanks @yanpaing007)
- **fix(executors):** inject the `reasoning_content` echo for the native **Moonshot Kimi** provider. Kimi (executor `default`) is a thinking-mode upstream that 400s with "reasoning_content must be passed back" when a prior assistant turn lacks it; the placeholder injection was only wired into the OpenCode meta-provider, so direct multi-turn Kimi conversations failed. Scoped to `kimi` (gateway-served models matching the thinking-model name pattern are unaffected). Regression guard: `tests/unit/kimi-native-reasoning-injected-1480.test.ts`. (thanks @2220258345)
- **fix(executors):** recover from a strict gateway's `context_management: Extra inputs are not permitted` 400. **Claude Code** always sends a top-level `context_management` field; strict anthropic-compatible gateways reject it. The dedicated context-editing 400-fallback only fired when OmniRoute's own `contextEditing` feature was enabled (default off), so a client-sent field passed through untouched and 400'd. `context_management` is now in the generic reactive field-strip list, so it's stripped-and-retried once regardless of the feature flag (with correct request re-signing for claude-compatible relays). Regression guard: `tests/unit/provider-field-strips.test.ts`. (thanks @ohahe52-dot)

View File

@@ -566,6 +566,41 @@ export class DefaultExecutor extends BaseExecutor {
withDefaults = withoutClientMetadata;
}
// 9router#1649: Mistral's API returns 422 (extra_forbidden) when an
// assistant message carries a `reasoning_content` field (replayed thinking
// from a prior turn, e.g. via the Codex /responses path). The field is
// nested per-message, so the generic top-level 400/field-downgrade retry
// doesn't cover it. Strip it from every message before sending — scoped to
// Mistral so DeepSeek (which *requires* replayed reasoning_content) is
// unaffected.
if (
this.provider === "mistral" &&
withDefaults &&
typeof withDefaults === "object" &&
!Array.isArray(withDefaults) &&
Array.isArray((withDefaults as Record<string, unknown>).messages)
) {
const record = withDefaults as Record<string, unknown>;
const messages = record.messages as unknown[];
let mutated = false;
const cleaned = messages.map((msg) => {
if (
msg &&
typeof msg === "object" &&
!Array.isArray(msg) &&
Object.prototype.hasOwnProperty.call(msg, "reasoning_content")
) {
mutated = true;
const { reasoning_content: _dropped, ...rest } = msg as Record<string, unknown>;
return rest;
}
return msg;
});
if (mutated) {
withDefaults = { ...record, messages: cleaned };
}
}
const targetFormat = getTargetFormat(this.provider, credentials?.providerSpecificData);
const requestFormat =
withDefaults && typeof withDefaults === "object" && !Array.isArray(withDefaults)

View File

@@ -0,0 +1,63 @@
/**
* Regression guard for upstream 9router#1649.
*
* Mistral's API returns 422 (extra_forbidden) when an assistant message carries
* a `reasoning_content` field (replayed thinking from a prior turn, e.g. via the
* Codex /responses path). The field is nested per-message, so the generic
* top-level 400/field-downgrade retry in base.ts never covered it. DefaultExecutor
* now strips `reasoning_content` from every message for provider "mistral" only —
* DeepSeek (which requires replayed reasoning_content) must be unaffected.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { DefaultExecutor } from "../../open-sse/executors/default.ts";
const STREAM = false;
const CREDENTIALS = { apiKey: "k" } as Record<string, unknown>;
function bodyWithReasoningContent() {
return {
model: "mistral-large",
messages: [
{ role: "user", content: "hi" },
{ role: "assistant", content: "answer", reasoning_content: "internal chain of thought" },
],
stream: STREAM,
};
}
test("DefaultExecutor.transformRequest strips nested reasoning_content for mistral", () => {
const out = new DefaultExecutor("mistral").transformRequest(
"mistral-large",
bodyWithReasoningContent(),
STREAM,
CREDENTIALS
) as Record<string, unknown>;
const messages = out.messages as Array<Record<string, unknown>>;
const assistant = messages.find((m) => m.role === "assistant") as Record<string, unknown>;
assert.equal(
Object.prototype.hasOwnProperty.call(assistant, "reasoning_content"),
false,
"mistral assistant message must not carry reasoning_content"
);
assert.equal(assistant.content, "answer", "the rest of the message must be preserved");
assert.equal(messages.length, 2, "no messages dropped");
});
test("DefaultExecutor.transformRequest preserves reasoning_content for non-mistral (deepseek)", () => {
const out = new DefaultExecutor("deepseek").transformRequest(
"deepseek-chat",
bodyWithReasoningContent(),
STREAM,
CREDENTIALS
) as Record<string, unknown>;
const messages = out.messages as Array<Record<string, unknown>>;
const assistant = messages.find((m) => m.role === "assistant") as Record<string, unknown>;
assert.equal(
assistant.reasoning_content,
"internal chain of thought",
"deepseek requires replayed reasoning_content — must be preserved"
);
});