mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
* fix(nvidia): map GLM-5.2 reasoning to thinking toggle Fixes #7215. * fix(nvidia): shrink default.ts under the file-size ratchet The GLM-5.2 reasoning-mapping call in requestBodyDefaults() pushed open-sse/executors/default.ts from 877 to 881 lines, tripping the frozen check:file-size ceiling (Fast Quality Gates). withDefaults is typed unknown, so the `as typeof withDefaults` cast added by the multi-line call was unnecessary — collapsing to a single-line call removes the cast and the line-wrap, landing the file at 876 lines. Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com> * refactor(nvidia): extract mapNvidiaGlm52ReasoningParams helpers to clear complexity ratchet mapNvidiaGlm52ReasoningParams landed at cyclomatic complexity 24 (limit 15), a brand-new violation that pushed the project-wide complexity ratchet from 2056 to 2057 (Fast Quality Gates: check:complexity-ratchets). It was previously masked by the file-size failure aborting the job before this step ran. Split the function into three single-purpose helpers — effort extraction, chat_template_kwargs construction, and the reasoning_effort/reasoning.effort strip — bringing the orchestrating function's complexity back under threshold with no behavior change (all 41 cases in tests/unit/base-executor-sanitize-effort.test.ts still pass unchanged). Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com> * fix(nvidia): restore default executor file-size gate --------- Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
316 lines
13 KiB
TypeScript
316 lines
13 KiB
TypeScript
// Provider-aware reasoning_effort sanitation (xhigh/max normalization + reject strip).
|
||
// Extracted verbatim from base.ts. Deps are config/services only (no host import → no cycle).
|
||
import { PROVIDER_CLAUDE } from "../../services/systemTransforms.ts";
|
||
import { isClaudeCodeCompatible } from "../../services/provider.ts";
|
||
import { supportsClaudeMaxEffort, supportsXHighEffort } from "../../config/providerModels.ts";
|
||
|
||
/**
|
||
* Sanitize reasoning_effort for providers that don't accept all values.
|
||
*
|
||
* The claude→openai translator may emit reasoning_effort=max/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.
|
||
* xhigh support is opt-out: pass through unchanged unless the registry marks
|
||
* a model as unsupported. Literal max support is provider-specific and
|
||
* intentionally separate: some upstreams accept max even when they do not
|
||
* accept xhigh. For OpenAI-shape providers, max normalizes to xhigh by default
|
||
* and falls back to high only for explicit xhigh opt-outs.
|
||
*/
|
||
export const MISTRAL_NO_REASONING_EFFORT_PATTERN = /devstral/i;
|
||
// GitHub Copilot Claude routing is granular (upstream port: decolua/9router#791):
|
||
// ✅ Pass through — Claude Opus 4.6, Claude Sonnet 4.6. Copilot routes both to
|
||
// Anthropic's chat/completions surface, which honors reasoning_effort and
|
||
// emits visible reasoning tokens (verified upstream: 3× token increase
|
||
// between low/medium/high).
|
||
// ❌ Strip — Claude Haiku 4.5 and Claude Opus 4.7 (rejected upstream by
|
||
// Copilot's Claude backend), older Claude variants, all `haiku`-named
|
||
// models, and the `oswe-*` family (Raptor) which still rejects
|
||
// reasoning_effort.
|
||
// Order matters: the opt-in check must run BEFORE the broad Claude/haiku/oswe strip.
|
||
export const GITHUB_REASONING_EFFORT_OPT_IN_PATTERN = /claude[-_.]?(?:opus|sonnet)[-_.]?4[-_.]6/i;
|
||
export const GITHUB_NO_REASONING_EFFORT_PATTERN = /(claude|haiku|oswe)/i;
|
||
const NVIDIA_GLM_52_PATTERN = /z-ai\/glm-5\.2\b/i;
|
||
|
||
type ReasoningSanitizeLog = {
|
||
info?: (tag: string, msg: string) => void;
|
||
};
|
||
|
||
function isNvidiaGlm52(provider: string, model: string | undefined): boolean {
|
||
return provider === "nvidia" && NVIDIA_GLM_52_PATTERN.test(model || "");
|
||
}
|
||
|
||
type NvidiaGlm52EffortInfo = {
|
||
reasoning: Record<string, unknown> | null;
|
||
effortStr: string;
|
||
};
|
||
|
||
/** Pulls a normalized (lowercased) effort string out of top-level or nested `reasoning.effort`. */
|
||
function extractNvidiaGlm52Effort(b: Record<string, unknown>): NvidiaGlm52EffortInfo | null {
|
||
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 null;
|
||
|
||
const effortStr = typeof effort === "string" ? effort.toLowerCase() : "";
|
||
if (!effortStr) return null;
|
||
|
||
return { reasoning, effortStr };
|
||
}
|
||
|
||
/** Builds `chat_template_kwargs.enable_thinking`, or null when the existing kwargs shape is unusable. */
|
||
function buildNvidiaGlm52TemplateKwargs(
|
||
rawTemplateKwargs: unknown,
|
||
effortStr: string
|
||
): Record<string, unknown> | null {
|
||
if (
|
||
rawTemplateKwargs !== undefined &&
|
||
(!rawTemplateKwargs ||
|
||
typeof rawTemplateKwargs !== "object" ||
|
||
Array.isArray(rawTemplateKwargs))
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
const templateKwargs = {
|
||
...((rawTemplateKwargs as Record<string, unknown> | undefined) ?? {}),
|
||
};
|
||
if (!Object.prototype.hasOwnProperty.call(templateKwargs, "enable_thinking")) {
|
||
templateKwargs.enable_thinking = effortStr !== "none";
|
||
}
|
||
return templateKwargs;
|
||
}
|
||
|
||
/** Returns a copy of `b` with `reasoning_effort`/`reasoning.effort` replaced by `templateKwargs`. */
|
||
function withNvidiaGlm52TemplateKwargs(
|
||
b: Record<string, unknown>,
|
||
templateKwargs: Record<string, unknown>,
|
||
reasoning: Record<string, unknown> | null
|
||
): Record<string, unknown> {
|
||
const next: Record<string, unknown> = { ...b, chat_template_kwargs: templateKwargs };
|
||
delete next.reasoning_effort;
|
||
if (reasoning) {
|
||
const nextReasoning = { ...reasoning };
|
||
delete nextReasoning.effort;
|
||
if (Object.keys(nextReasoning).length === 0) delete next.reasoning;
|
||
else next.reasoning = nextReasoning;
|
||
}
|
||
return next;
|
||
}
|
||
|
||
/**
|
||
* Map OmniRoute's reasoning-effort inputs onto the binary thinking switch exposed by
|
||
* NVIDIA's hosted GLM-5.2 chat template. This runs before DefaultExecutor's unsupported
|
||
* parameter stripping so a nested `reasoning.effort` is not discarded first, and is also
|
||
* reused by the final provider sanitizer for non-default execution paths.
|
||
*/
|
||
export function mapNvidiaGlm52ReasoningParams(
|
||
body: unknown,
|
||
provider: string,
|
||
model: string | undefined,
|
||
log?: ReasoningSanitizeLog | null
|
||
): unknown {
|
||
if (!isNvidiaGlm52(provider, model)) return body;
|
||
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
||
|
||
const b = body as Record<string, unknown>;
|
||
const info = extractNvidiaGlm52Effort(b);
|
||
if (!info) return body;
|
||
|
||
const templateKwargs = buildNvidiaGlm52TemplateKwargs(b.chat_template_kwargs, info.effortStr);
|
||
if (!templateKwargs) return body;
|
||
|
||
const next = withNvidiaGlm52TemplateKwargs(b, templateKwargs, info.reasoning);
|
||
log?.info?.(
|
||
"REASONING_SANITIZE",
|
||
`nvidia/${model || ""}: mapped reasoning effort to enable_thinking`
|
||
);
|
||
return next;
|
||
}
|
||
|
||
export function supportsMaxEffortForProvider(provider: string, model: string): boolean {
|
||
const isClaude =
|
||
(provider === PROVIDER_CLAUDE || isClaudeCodeCompatible(provider)) &&
|
||
supportsClaudeMaxEffort(model);
|
||
// opencode-go proxies DeepSeek with the native DeepSeek API contract, which
|
||
// accepts {high, max} literally. Without this opt-in, max would be
|
||
// normalized to xhigh (the OmniRoute-internal top tier) and rejected by the
|
||
// upstream. Scoped to opencode-go deliberately: OpenRouter's DeepSeek path
|
||
// (pi#4055) is the documented inverse and expects xhigh, not max.
|
||
// Ollama Cloud also accepts literal max (for example GLM 5.2 supports
|
||
// low|medium|high|max|none) and rejects xhigh.
|
||
const isOpencodeGoDeepSeek =
|
||
provider === "opencode-go" && model.toLowerCase().includes("deepseek");
|
||
const isOllamaCloud = provider === "ollama-cloud";
|
||
const isMoonshotK3 =
|
||
(provider === "moonshot" || provider === "kimi") && /^kimi-k3(?:$|-)/i.test(model);
|
||
return isClaude || isOpencodeGoDeepSeek || isOllamaCloud || isMoonshotK3;
|
||
}
|
||
|
||
// ── Effort carrier helpers (#7044) ──────────────────────────────────────────
|
||
// OmniRoute carries the requested effort on up to three shapes:
|
||
// 1. top-level `reasoning_effort` — OpenAI / OmniRoute-internal
|
||
// 2. `reasoning.effort` — OpenAI Responses shape
|
||
// 3. `output_config.effort` — Anthropic Messages native (Claude Code / Claude passthrough)
|
||
// Carrier (3) was previously invisible to this sanitizer, so a native Claude request
|
||
// carrying `output_config.effort: "xhigh"` reached providers that don't accept xhigh
|
||
// (e.g. claude-sonnet-4-6, supportsXHighEffort=false) unchanged → HTTP 400 (#7044).
|
||
interface EffortCarriers {
|
||
reasoning: Record<string, unknown> | null;
|
||
outputConfig: Record<string, unknown> | null;
|
||
hasTopLevelReasoningEffort: boolean;
|
||
hasReasoningEffort: boolean;
|
||
hasOutputConfigEffort: boolean;
|
||
effort: unknown;
|
||
}
|
||
|
||
function readEffortCarriers(b: Record<string, unknown>): EffortCarriers {
|
||
const reasoning =
|
||
b.reasoning && typeof b.reasoning === "object" && !Array.isArray(b.reasoning)
|
||
? (b.reasoning as Record<string, unknown>)
|
||
: null;
|
||
const outputConfig =
|
||
b.output_config && typeof b.output_config === "object" && !Array.isArray(b.output_config)
|
||
? (b.output_config as Record<string, unknown>)
|
||
: null;
|
||
const hasTopLevelReasoningEffort = Object.prototype.hasOwnProperty.call(b, "reasoning_effort");
|
||
const hasReasoningEffort = !!(
|
||
reasoning && Object.prototype.hasOwnProperty.call(reasoning, "effort")
|
||
);
|
||
const hasOutputConfigEffort = !!(
|
||
outputConfig && Object.prototype.hasOwnProperty.call(outputConfig, "effort")
|
||
);
|
||
const effort = b.reasoning_effort ?? reasoning?.effort ?? outputConfig?.effort;
|
||
return {
|
||
reasoning,
|
||
outputConfig,
|
||
hasTopLevelReasoningEffort,
|
||
hasReasoningEffort,
|
||
hasOutputConfigEffort,
|
||
effort,
|
||
};
|
||
}
|
||
|
||
/** Write a normalized effort value back to every carrier that was present. */
|
||
function writeEffortValue(
|
||
b: Record<string, unknown>,
|
||
value: string,
|
||
c: EffortCarriers
|
||
): Record<string, unknown> {
|
||
const next: Record<string, unknown> = { ...b };
|
||
if (c.hasTopLevelReasoningEffort) next.reasoning_effort = value;
|
||
if (c.hasReasoningEffort && c.reasoning) next.reasoning = { ...c.reasoning, effort: value };
|
||
if (c.hasOutputConfigEffort && c.outputConfig)
|
||
next.output_config = { ...c.outputConfig, effort: value };
|
||
return next;
|
||
}
|
||
|
||
/** Strip the effort field from every carrier that was present. */
|
||
function stripEffortValue(
|
||
b: Record<string, unknown>,
|
||
c: EffortCarriers
|
||
): Record<string, unknown> {
|
||
const next: Record<string, unknown> = { ...b };
|
||
if (c.hasTopLevelReasoningEffort) delete next.reasoning_effort;
|
||
if (c.hasReasoningEffort && c.reasoning) {
|
||
const r: Record<string, unknown> = { ...c.reasoning };
|
||
delete r.effort;
|
||
if (Object.keys(r).length === 0) delete next.reasoning;
|
||
else next.reasoning = r;
|
||
}
|
||
if (c.hasOutputConfigEffort && c.outputConfig) {
|
||
const oc: Record<string, unknown> = { ...c.outputConfig };
|
||
delete oc.effort;
|
||
if (Object.keys(oc).length === 0) delete next.output_config;
|
||
else next.output_config = oc;
|
||
}
|
||
return next;
|
||
}
|
||
|
||
export function sanitizeReasoningEffortForProvider(
|
||
body: unknown,
|
||
provider: string,
|
||
model: string | undefined,
|
||
log?: ReasoningSanitizeLog | null
|
||
): unknown {
|
||
if (isNvidiaGlm52(provider, model)) {
|
||
return mapNvidiaGlm52ReasoningParams(body, provider, model, log);
|
||
}
|
||
|
||
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
||
const b = body as Record<string, unknown>;
|
||
const c = readEffortCarriers(b);
|
||
if (c.effort === undefined) return body;
|
||
const effortStr = typeof c.effort === "string" ? c.effort.toLowerCase() : "";
|
||
const modelStr = model || "";
|
||
|
||
const githubOptIn =
|
||
provider === "github" && GITHUB_REASONING_EFFORT_OPT_IN_PATTERN.test(modelStr);
|
||
const rejecting =
|
||
(provider === "mistral" && MISTRAL_NO_REASONING_EFFORT_PATTERN.test(modelStr)) ||
|
||
(provider === "github" && !githubOptIn && GITHUB_NO_REASONING_EFFORT_PATTERN.test(modelStr));
|
||
if (rejecting) {
|
||
log?.info?.(
|
||
"REASONING_SANITIZE",
|
||
`${provider}/${modelStr}: removed unsupported reasoning_effort`
|
||
);
|
||
return stripEffortValue(b, c);
|
||
}
|
||
|
||
// Native DeepSeek (api.deepseek.com) — V4 thinking mode accepts reasoning_effort
|
||
// ONLY as {high, max} (its own top tier is literally "max"). OmniRoute's internal
|
||
// scale is low|medium|high|xhigh where xhigh is the top, so map onto DeepSeek's
|
||
// vocabulary: xhigh → max (top→top), low|medium → high (below the enum floor).
|
||
// high/max pass through unchanged. Without this, the claude→openai translator's
|
||
// xhigh (and max-normalized-to-xhigh below) reaches DeepSeek as an unknown value,
|
||
// silently dropping the client's requested effort. This is the INVERSE of the
|
||
// OpenRouter-DeepSeek path, whose normalized API expects xhigh, not max (pi#4055).
|
||
if (provider === "deepseek") {
|
||
const mapped =
|
||
effortStr === "xhigh" ? "max" : effortStr === "low" || effortStr === "medium" ? "high" : null;
|
||
if (mapped && mapped !== effortStr) {
|
||
log?.info?.(
|
||
"REASONING_SANITIZE",
|
||
`deepseek/${modelStr}: normalized reasoning_effort ${effortStr} → ${mapped}`
|
||
);
|
||
return writeEffortValue(b, mapped, c);
|
||
}
|
||
return body;
|
||
}
|
||
|
||
const supportsXHigh = supportsXHighEffort(provider, modelStr);
|
||
const shouldDowngradeXHigh = effortStr === "xhigh" && !supportsXHigh;
|
||
const supportsXHighForMax = supportsXHigh;
|
||
const supportsMax = supportsMaxEffortForProvider(provider, modelStr);
|
||
const shouldNormalizeMaxToXHigh = effortStr === "max" && !supportsMax && supportsXHighForMax;
|
||
const shouldDowngradeMax = effortStr === "max" && !supportsMax && !supportsXHighForMax;
|
||
|
||
if (shouldNormalizeMaxToXHigh) {
|
||
log?.info?.(
|
||
"REASONING_SANITIZE",
|
||
`${provider}/${modelStr}: normalized reasoning_effort max → xhigh`
|
||
);
|
||
return writeEffortValue(b, "xhigh", c);
|
||
}
|
||
|
||
if (shouldDowngradeXHigh || shouldDowngradeMax) {
|
||
log?.info?.(
|
||
"REASONING_SANITIZE",
|
||
`${provider}/${modelStr}: downgraded reasoning_effort ${effortStr} → high`
|
||
);
|
||
return writeEffortValue(b, "high", c);
|
||
}
|
||
|
||
return body;
|
||
}
|