Files
OmniRoute/src/shared/reasoning/effortStandardization.ts
Diego Rodrigues de Sa e Souza 62b1bc9d05 feat(api): standardize effort + thinking request params (#6241) (#6398)
standardize effort + thinking request params (#6241) (net +1/-0, tests OK). Integrated into release/v3.8.46.
2026-07-06 19:30:40 -03:00

125 lines
5.5 KiB
TypeScript

import { z } from "zod";
/**
* Standardization layer for the canonical `effort` + `thinking` request params (#6241).
*
* OmniRoute already has a mature, per-provider reasoning-mapping pipeline: the translators
* consume `reasoning_effort` / `reasoning.effort` / `thinking` and fan them out to the
* Anthropic thinking blocks, Gemini `thinkingConfig`, xAI `reasoning.effort`, and the
* Responses API. This module is a THIN normalization layer on top of that plumbing — it
* does NOT re-implement any provider mapping. It only exposes a single, documented,
* provider-agnostic pair of request fields and folds them onto the fields the existing
* mappers already read.
*
* Canonical effort vocabulary — the SAME five values used everywhere else in the codebase
* (`providerSpecificData.ts` CODEX_REASONING_EFFORT_VALUES, `vscode/reasoningMetadata.ts`
* KNOWN_REASONING_EFFORTS, `modelSpecs.ts`). We deliberately REUSE this set instead of
* inventing a parallel Low/Medium/High/Extra/Max enum that would diverge from the rest of
* the codebase.
*/
export const CANONICAL_EFFORT_VALUES = ["none", "low", "medium", "high", "xhigh"] as const;
export type CanonicalEffort = (typeof CANONICAL_EFFORT_VALUES)[number];
/**
* UI-facing tier synonyms mapped onto the canonical set. The issue (#6241) requested a
* 5-tier UI vocabulary (Low / Medium / High / Extra / Max); that request collapses onto
* the existing 5-value canonical set. "extra" and "max" are both synonyms for the top
* reasoning tier and map to canonical `xhigh`. The per-provider mappers already down-shift
* `xhigh` to `high` for models that do not support it (see
* `open-sse/translator/request/openai-to-claude.ts`), so a caller can always request the
* highest tier without knowing which models support `xhigh`.
*/
const EFFORT_TIER_ALIASES: Record<string, CanonicalEffort> = {
extra: "xhigh",
max: "xhigh",
};
/**
* Normalize an arbitrary effort value onto the canonical vocabulary. Accepts the canonical
* values plus the UI tier synonyms (`extra`/`max` → `xhigh`), case-insensitively. Returns
* `undefined` for anything unrecognized so callers can leave the request untouched.
*/
export function normalizeEffort(value: unknown): CanonicalEffort | undefined {
if (typeof value !== "string") return undefined;
const lowered = value.trim().toLowerCase();
if (!lowered) return undefined;
if (lowered in EFFORT_TIER_ALIASES) return EFFORT_TIER_ALIASES[lowered];
return (CANONICAL_EFFORT_VALUES as readonly string[]).includes(lowered)
? (lowered as CanonicalEffort)
: undefined;
}
/**
* Zod schema for the canonical `effort` request field. Accepts the canonical values plus
* the UI tier synonyms (case-insensitively) and normalizes them onto the canonical set.
* Unrecognized strings are rejected with a clear enum error.
*/
export const effortRequestSchema = z.preprocess(
(value) => normalizeEffort(value) ?? value,
z.enum(CANONICAL_EFFORT_VALUES)
);
/**
* Zod schema for the canonical `thinking` request field: a simple boolean toggle. Kept as
* a union with an object so the existing Anthropic-style `thinking: { type, budget_tokens }`
* object shape that clients already send keeps validating (backward compatible) — the
* normalizer only acts on the boolean form.
*/
export const thinkingRequestSchema = z.union([z.boolean(), z.record(z.string(), z.unknown())]);
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Fold the canonical `effort` / `thinking` request params onto the per-provider reasoning
* fields the existing translators already consume (`reasoning_effort`, `reasoning.effort`,
* `thinking`). Pure function — returns the same reference untouched when there is nothing
* to normalize, otherwise a shallow copy with the derived fields populated.
*
* Backward compatibility rules (an explicit client signal ALWAYS wins):
* - `reasoning_effort` / `reasoning.effort` explicitly set by the client are never
* overwritten by the canonical `effort`.
* - An explicit object-shaped `thinking` (the Anthropic `{ type, budget_tokens }` config)
* is never overwritten by the canonical boolean `thinking`.
*/
export function normalizeReasoningRequest<T>(body: T): T {
if (!isPlainObject(body)) return body;
const canonicalEffort = normalizeEffort(body.effort);
const canonicalThinking = body.thinking;
const hasCanonicalThinkingBool = typeof canonicalThinking === "boolean";
if (canonicalEffort === undefined && !hasCanonicalThinkingBool) return body;
const reasoning = body.reasoning;
const clientSetReasoningEffort = body.reasoning_effort !== undefined;
const clientSetReasoningObjEffort =
isPlainObject(reasoning) && reasoning.effort !== undefined;
const next: Record<string, unknown> = { ...body };
// Canonical effort → the fields the mappers read. Skip entirely if the client already
// expressed a reasoning effort (either shape) so client intent is preserved.
if (
canonicalEffort !== undefined &&
!clientSetReasoningEffort &&
!clientSetReasoningObjEffort
) {
next.reasoning_effort = canonicalEffort;
next.reasoning = {
...(isPlainObject(reasoning) ? reasoning : {}),
effort: canonicalEffort,
};
}
// Canonical boolean `thinking` → keep the truthy toggle the mappers read. Only when the
// client did NOT provide an explicit object-shaped thinking config (that always wins).
if (hasCanonicalThinkingBool) {
next.thinking = canonicalThinking;
}
return next as T;
}