mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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.
|
||||
|
||||
132
tests/unit/base-executor-sanitize-effort.test.ts
Normal file
132
tests/unit/base-executor-sanitize-effort.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user