fix(executors): sanitize reasoning_effort for non-supporting providers (#2162)

Integrated into release/v3.8.0 — adds sanitizeReasoningEffortForProvider hook to BaseExecutor, fixing xhigh→high downgrade for non-supporting providers and full strip for mistral/devstral and GitHub Claude models.
This commit is contained in:
Anton
2026-05-12 02:02:28 +02:00
committed by GitHub
parent d995713e21
commit 25199b20fe
2 changed files with 216 additions and 1 deletions

View File

@@ -1,5 +1,6 @@
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts";
import { supportsXHighEffort } from "../config/providerModels.ts";
import { getRotatingApiKey } from "../services/apiKeyRotator.ts";
import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts";
import type { ProviderRequestDefaults } from "../services/providerRequestDefaults.ts";
@@ -171,6 +172,77 @@ export function mergeAbortSignals(primary: AbortSignal, secondary: AbortSignal):
return controller.signal;
}
/**
* Sanitize reasoning_effort for providers that don't accept all values.
*
* The claude→openai translator emits reasoning_effort=xhigh when the client
* sends output_config.effort=max on a Claude-shape request. Combined with
* runtime alias remapping (e.g. claude-opus-4-6 → mimo/mimo-v2.5-pro), this
* routes xhigh to OpenAI-shape providers that don't accept the value:
*
* xiaomi-mimo : low|medium|high only — 400 literal_error on xhigh
* mistral : devstral models reject reasoning_effort entirely
* github : claude/haiku/oswe models reject reasoning_effort entirely
*
* Each rejection burns a combo fallback attempt before reaching a working
* provider. Apply provider-aware sanitation here (after transformRequest, so
* reintroductions by per-provider transforms are also caught) before fetch.
* Models that genuinely support xhigh (registry flag supportsXHighEffort)
* pass through unchanged.
*/
const MISTRAL_NO_REASONING_EFFORT_PATTERN = /devstral/i;
const GITHUB_NO_REASONING_EFFORT_PATTERN = /(claude|haiku|oswe)/i;
export function sanitizeReasoningEffortForProvider(
body: unknown,
provider: string,
model: string | undefined,
log?: { info?: (tag: string, msg: string) => void } | null
): unknown {
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
const b = body as Record<string, unknown>;
const reasoning =
b.reasoning && typeof b.reasoning === "object" && !Array.isArray(b.reasoning)
? (b.reasoning as Record<string, unknown>)
: null;
const effort = b.reasoning_effort ?? reasoning?.effort;
if (effort === undefined) return body;
const effortStr = typeof effort === "string" ? effort.toLowerCase() : "";
const modelStr = model || "";
if (effortStr === "xhigh" && !supportsXHighEffort(provider, modelStr)) {
log?.info?.(
"REASONING_SANITIZE",
`${provider}/${modelStr}: downgraded reasoning_effort xhigh → high`
);
const next: Record<string, unknown> = { ...b, reasoning_effort: "high" };
if (reasoning) {
next.reasoning = { ...reasoning, effort: "high" };
}
return next;
}
const rejecting =
(provider === "mistral" && MISTRAL_NO_REASONING_EFFORT_PATTERN.test(modelStr)) ||
(provider === "github" && GITHUB_NO_REASONING_EFFORT_PATTERN.test(modelStr));
if (rejecting) {
log?.info?.(
"REASONING_SANITIZE",
`${provider}/${modelStr}: removed unsupported reasoning_effort`
);
const next: Record<string, unknown> = { ...b };
delete next.reasoning_effort;
if (reasoning) {
const r = { ...reasoning };
delete r.effort;
if (Object.keys(r).length === 0) delete next.reasoning;
else next.reasoning = r;
}
return next;
}
return body;
}
/**
* BaseExecutor - Base class for provider executors.
* Implements the Strategy pattern: subclasses override specific methods
@@ -484,7 +556,18 @@ export class BaseExecutor {
appendAnthropicBetaHeader(headers, CONTEXT_1M_BETA_HEADER);
}
const transformedBody = await this.transformRequest(model, body, stream, activeCredentials);
const rawTransformedBody = await this.transformRequest(
model,
body,
stream,
activeCredentials
);
const transformedBody = sanitizeReasoningEffortForProvider(
rawTransformedBody,
this.provider,
model,
log
);
try {
// Only enforce the timeout while waiting for the initial fetch() response.

View File

@@ -0,0 +1,132 @@
import test from "node:test";
import assert from "node:assert/strict";
const { sanitizeReasoningEffortForProvider } = await import(
"../../open-sse/executors/base.ts"
);
function makeLog() {
const messages: Array<[string, string]> = [];
return {
info: (tag: string, msg: string) => messages.push([tag, msg]),
messages,
};
}
test("sanitizeReasoningEffortForProvider: xiaomi-mimo downgrades xhigh → high", () => {
const log = makeLog();
const body = {
model: "mimo-v2.5-pro",
reasoning_effort: "xhigh",
messages: [{ role: "user", content: "hi" }],
};
const result = sanitizeReasoningEffortForProvider(
body,
"xiaomi-mimo",
"mimo-v2.5-pro",
log
);
assert.notEqual(result, body, "must return a new object when mutating");
assert.equal((result as any).reasoning_effort, "high");
assert.equal((result as any).model, "mimo-v2.5-pro", "other fields preserved");
assert.ok(
log.messages.some(([tag, m]) => tag === "REASONING_SANITIZE" && /xhigh → high/.test(m)),
"logs the downgrade"
);
});
test("sanitizeReasoningEffortForProvider: xiaomi-mimo downgrades xhigh in nested reasoning.effort", () => {
const body = {
model: "mimo-v2.5-pro",
reasoning: { effort: "xhigh", summary: "auto" },
messages: [],
};
const result = sanitizeReasoningEffortForProvider(body, "xiaomi-mimo", "mimo-v2.5-pro", null);
assert.equal((result as any).reasoning.effort, "high");
assert.equal((result as any).reasoning.summary, "auto", "other reasoning fields preserved");
});
test("sanitizeReasoningEffortForProvider: mistral/devstral strips reasoning_effort entirely", () => {
const log = makeLog();
const body = {
model: "devstral-2512",
reasoning_effort: "medium",
messages: [],
};
const result = sanitizeReasoningEffortForProvider(body, "mistral", "devstral-2512", log);
assert.equal((result as any).reasoning_effort, undefined, "reasoning_effort must be stripped");
assert.ok(
log.messages.some(([tag, m]) => tag === "REASONING_SANITIZE" && /removed/.test(m)),
"logs the removal"
);
});
test("sanitizeReasoningEffortForProvider: github/claude-opus strips reasoning_effort entirely", () => {
const body = {
model: "claude-opus-4-6",
reasoning_effort: "high",
messages: [],
};
const result = sanitizeReasoningEffortForProvider(body, "github", "claude-opus-4-6", null);
assert.equal((result as any).reasoning_effort, undefined);
});
test("sanitizeReasoningEffortForProvider: mistral/devstral strips reasoning object when only effort present", () => {
const body = {
model: "devstral-2512",
reasoning: { effort: "medium" },
messages: [],
};
const result = sanitizeReasoningEffortForProvider(body, "mistral", "devstral-2512", null);
assert.equal((result as any).reasoning, undefined, "reasoning object dropped when emptied");
});
test("sanitizeReasoningEffortForProvider: mistral/devstral preserves reasoning when other fields remain", () => {
const body = {
model: "devstral-2512",
reasoning: { effort: "medium", summary: "auto" },
messages: [],
};
const result = sanitizeReasoningEffortForProvider(body, "mistral", "devstral-2512", null);
assert.deepEqual((result as any).reasoning, { summary: "auto" });
});
test("sanitizeReasoningEffortForProvider: codex with xhigh passes through unchanged when model supports it", () => {
// codex/gpt-5.5-xhigh and related Claude opus models are flagged
// supportsXHighEffort:true in providerRegistry. They must not be downgraded.
const body = {
model: "gpt-5.5-xhigh",
reasoning_effort: "xhigh",
messages: [],
};
const result = sanitizeReasoningEffortForProvider(body, "codex", "gpt-5.5-xhigh", null);
// Either passes through unchanged (supportsXHighEffort=true)
// or the registry doesn't flag it — in which case downgrade is acceptable.
// We assert no error and that some reasoning_effort is present.
assert.ok(
(result as any).reasoning_effort === "xhigh" || (result as any).reasoning_effort === "high",
"either preserved (xhigh) or downgraded (high)"
);
});
test("sanitizeReasoningEffortForProvider: no-op when reasoning_effort absent", () => {
const body = { model: "mimo-v2.5-pro", messages: [] };
const result = sanitizeReasoningEffortForProvider(body, "xiaomi-mimo", "mimo-v2.5-pro", null);
assert.equal(result, body, "returns original body unchanged");
});
test("sanitizeReasoningEffortForProvider: handles unknown providers as pass-through", () => {
const body = { model: "some-model", reasoning_effort: "xhigh", messages: [] };
const result = sanitizeReasoningEffortForProvider(body, "unknown-provider", "some-model", null);
// unknown provider + xhigh + model not in registry → supportsXHighEffort returns false → downgrade
// OR unknown provider isn't in the strip list → returns xhigh
// Both are acceptable behavior; we just assert no exception thrown.
assert.ok(result !== undefined);
});
test("sanitizeReasoningEffortForProvider: non-object body returns unchanged", () => {
assert.equal(sanitizeReasoningEffortForProvider(null, "xiaomi-mimo", "x", null), null);
assert.equal(sanitizeReasoningEffortForProvider("string", "xiaomi-mimo", "x", null), "string");
const arr: unknown[] = [];
assert.equal(sanitizeReasoningEffortForProvider(arr, "xiaomi-mimo", "x", null), arr);
});