diff --git a/CHANGELOG.md b/CHANGELOG.md index 568f7807e6..bb885558cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 4b844f4c76..c643a9e65e 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -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).messages) + ) { + const record = withDefaults as Record; + 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; + 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) diff --git a/tests/unit/mistral-strip-reasoning-content-1649.test.ts b/tests/unit/mistral-strip-reasoning-content-1649.test.ts new file mode 100644 index 0000000000..1ccdc79984 --- /dev/null +++ b/tests/unit/mistral-strip-reasoning-content-1649.test.ts @@ -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; + +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; + const messages = out.messages as Array>; + const assistant = messages.find((m) => m.role === "assistant") as Record; + 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; + const messages = out.messages as Array>; + const assistant = messages.find((m) => m.role === "assistant") as Record; + assert.equal( + assistant.reasoning_content, + "internal chain of thought", + "deepseek requires replayed reasoning_content — must be preserved" + ); +});