From 62b1bc9d05115fa2374d0323db4a799df2ae869f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:30:40 -0300 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + .../playground/components/ParamSliders.tsx | 6 + .../components/ReasoningControls.tsx | 75 +++++++ .../components/StudioConfigPane.tsx | 35 +++- .../components/reasoningControls.ts | 75 +++++++ .../playground/components/tabs/ChatTab.tsx | 11 ++ .../translator/hooks/useAvailableModels.tsx | 20 +- src/i18n/messages/en.json | 4 + src/lib/modelMetadataRegistry.ts | 13 +- src/shared/reasoning/effortStandardization.ts | 124 ++++++++++++ src/shared/validation/schemas/apiV1.ts | 13 ++ src/sse/handlers/chat.ts | 9 + ...fort-thinking-standardization-6241.test.ts | 185 ++++++++++++++++++ ...playground-reasoning-controls-6241.test.ts | 85 ++++++++ 14 files changed, 652 insertions(+), 4 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/playground/components/ReasoningControls.tsx create mode 100644 src/app/(dashboard)/dashboard/playground/components/reasoningControls.ts create mode 100644 src/shared/reasoning/effortStandardization.ts create mode 100644 tests/unit/effort-thinking-standardization-6241.test.ts create mode 100644 tests/unit/playground-reasoning-controls-6241.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8829e87019..43b3bebbf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### ✨ New Features +- **feat(api):** standardized, provider-agnostic **`effort` + `thinking` request params** ([#6241](https://github.com/diegosouzapw/OmniRoute/issues/6241)) — a thin standardization layer over the existing mature per-provider reasoning plumbing (no provider mapper touched). `providerChatCompletionSchema` gains a canonical `effort` (reusing the shared `none/low/medium/high/xhigh` vocabulary — the UI tiers `extra`/`max` collapse onto `xhigh`) and a boolean `thinking`. A pure `normalizeReasoningRequest` (wired once in `src/sse/handlers/chat.ts`, before any reasoning field is read) folds them onto the fields the translators already consume (`reasoning_effort` / `reasoning.effort` / `thinking`), so they fan out to Anthropic / Gemini / xAI / Responses — an explicit client `reasoning_effort` / object-shaped `thinking` always wins (backward-compatible). `/models` additively exposes `supportsThinking` + `effort_tiers` so the frontend can render the toggles (UI component is a follow-up). Regression guard: `tests/unit/effort-thinking-standardization-6241.test.ts` (12). (thanks @Iammilansoni, @shabeer) - **feat(combo):** new **`pipeline` (sequential) combo strategy** ([#6297](https://github.com/diegosouzapw/OmniRoute/issues/6297)) — the 18th routing strategy runs targets **in order**, threading each step's output into the next step's input, with an optional per-step `prompt` (system instruction); only the final step's response is returned. Distinct from `fusion` (parallel fan-out + judge). Implemented as a self-contained `open-sse/services/pipeline.ts` (sibling to `fusion.ts`), dispatched from `combo.ts`; the step list reuses `combo.models` order and reads an optional `prompt` off each target (backward-compatible — ignored by every other strategy). Intermediate steps run non-streaming with tools stripped (complete prose to thread forward); the final step keeps the client's `stream` flag + tools. A failing/empty/unparseable intermediate step fails the whole pipeline explicitly via a sanitized error (never silently swallowed). Kilo's dup flag vs #563 was a false positive (that's model→chain selection; this is a sequential chain). Regression guard: `tests/unit/combo-pipeline-strategy.test.ts` (5). (thanks @ofekbetzalel) - **feat(ci):** `check:test-masking` now flags **inline-reimplemented prod conditions** ([#6348](https://github.com/diegosouzapw/OmniRoute/issues/6348)) — a new **report-only** subcheck (v2, 6A.10 family) catches the wrong-shape contract test: a test that recomputes the condition under test inline instead of importing/exercising the real function (the #6216 class, where `=== 500` → `>= 500` stayed green because the test re-implemented the branch). For each added/modified test file it warns when the file textually duplicates a ≥3-token conditional from a production file touched in the same PR **and** does not import the symbol/module owning it, via a pure, fixture-tested `findReimplementedConditions()` with an allowlist mirroring `assertReductionAllowlist`. Report-only for now (does not fail the gate) — to be promoted to blocking after a triage cycle. Regression guard: `tests/unit/check-test-masking.test.ts` (45). - **feat(sse):** per-connection routing override (native vs CLIProxyAPI) ([#6339](https://github.com/diegosouzapw/OmniRoute/issues/6339)) — the previously-dead `isCliproxyapiDeepModeEnabled` helper is now wired into `resolveExecutorWithProxy`: a single connection can opt itself into the CLIProxyAPI passthrough executor via `providerSpecificData.cliproxyapiMode="claude-native"`, with precedence **connection override > provider `upstream_proxy_config` mode > default**. `resolveExecutorWithProxy` now receives the resolved connection's `providerSpecificData` (threaded from `chatCore.ts`), so one connection can deep-route while the provider's other connections stay native — no DB schema change (the toggle rides in `providerSpecificData`). Also resolves the same-provider mixing ask in #6340. Regression guard: `tests/unit/chatcore-executor-proxy.test.ts` (9). (thanks @RaviTharuma) diff --git a/src/app/(dashboard)/dashboard/playground/components/ParamSliders.tsx b/src/app/(dashboard)/dashboard/playground/components/ParamSliders.tsx index ebbd48883a..c13b60b269 100644 --- a/src/app/(dashboard)/dashboard/playground/components/ParamSliders.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/ParamSliders.tsx @@ -11,6 +11,10 @@ export interface PlaygroundParams { seed: number | null; stop: string; jsonMode: boolean; + // #6241: canonical reasoning params — surfaced by ReasoningControls only when the selected + // model supports thinking. `effort: ""` means "not set" (left off the request body). + effort: string; + thinking: boolean; } export const DEFAULT_PARAMS: PlaygroundParams = { @@ -22,6 +26,8 @@ export const DEFAULT_PARAMS: PlaygroundParams = { seed: null, stop: "", jsonMode: false, + effort: "", + thinking: false, }; interface ParamSlidersProps { diff --git a/src/app/(dashboard)/dashboard/playground/components/ReasoningControls.tsx b/src/app/(dashboard)/dashboard/playground/components/ReasoningControls.tsx new file mode 100644 index 0000000000..85777f71c1 --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/components/ReasoningControls.tsx @@ -0,0 +1,75 @@ +"use client"; + +// src/app/(dashboard)/dashboard/playground/components/ReasoningControls.tsx +// +// #6241: effort selector + thinking toggle for the Playground. Rendered only when the selected +// model supports thinking (spec.show); the effort options come from the model's `effort_tiers` +// (fallback to the canonical vocabulary), resolved by `resolveReasoningControls`. + +import type { PlaygroundParams } from "./ParamSliders"; +import type { ReasoningControlSpec } from "./reasoningControls"; + +interface ReasoningControlsProps { + spec: ReasoningControlSpec; + params: PlaygroundParams; + setParams: (params: PlaygroundParams) => void; +} + +/** Human-friendly label for a canonical/tier effort value (e.g. "xhigh" -> "Xhigh"). */ +function effortLabel(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +export default function ReasoningControls({ spec, params, setParams }: ReasoningControlsProps) { + if (!spec.show) return null; + + function update(key: K, value: PlaygroundParams[K]) { + setParams({ ...params, [key]: value }); + } + + return ( +
+ + Reasoning + + + {/* Thinking toggle */} +
+ + +
+ + {/* Effort selector */} +
+ + +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx index 21ca3082c2..0efa69f6e0 100644 --- a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx @@ -16,6 +16,11 @@ import { OPENAI_COMPATIBLE_PREFIX, } from "@/shared/constants/providers"; import { pickDefaultModel, resolveModelFilterKey } from "./modelSelection"; +import ReasoningControls from "./ReasoningControls"; +import { + resolveReasoningControls, + type ReasoningControlSpec, +} from "./reasoningControls"; export interface ConfigState { endpoint: PlaygroundEndpoint; @@ -24,6 +29,10 @@ export interface ConfigState { provider?: string; systemPrompt: string; params: PlaygroundParams; + // #6241: resolved reasoning-control spec for the selected model (which controls to show + the + // effort tiers). Kept here so the tabs (ChatTab) can gate `effort`/`thinking` on the request + // body to models that actually support thinking. + reasoning?: ReasoningControlSpec; } interface StudioConfigPaneProps { @@ -79,7 +88,12 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio selectedProviderOption?.modelPrefix, isCompatibleConnectionId ); - const { availableModels, loading: loadingModels } = useAvailableModels(modelFilterKey); + const { availableModels, modelCapabilities, loading: loadingModels } = + useAvailableModels(modelFilterKey); + + // #6241: resolve the reasoning controls for the currently selected model from the capability + // flags the /models catalog exposes (supportsThinking / effort_tiers). + const reasoningSpec = resolveReasoningControls(modelCapabilities[configState.model]); // #3731: selecting a provider resets the model to "", and nothing picked a default — // so the active model stayed empty and the chat failed with "Set a model". Auto-select @@ -90,6 +104,18 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio // eslint-disable-next-line react-hooks/exhaustive-deps }, [availableModels, configState.model]); + // #6241: keep the resolved reasoning spec on configState so the tabs (ChatTab) can gate the + // `effort`/`thinking` request fields on models that support thinking. Sync only when it changes. + useEffect(() => { + const current = configState.reasoning; + const changed = + !current || + current.show !== reasoningSpec.show || + current.effortOptions.join(",") !== reasoningSpec.effortOptions.join(","); + if (changed) setConfigState({ ...configState, reasoning: reasoningSpec }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reasoningSpec.show, reasoningSpec.effortOptions.join(",")]); + function update(key: K, value: ConfigState[K]) { setConfigState({ ...configState, [key]: value }); } @@ -227,6 +253,13 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio update("params", p)} /> + + {/* Reasoning controls — only shown when the selected model supports thinking (#6241) */} + update("params", p)} + /> ); diff --git a/src/app/(dashboard)/dashboard/playground/components/reasoningControls.ts b/src/app/(dashboard)/dashboard/playground/components/reasoningControls.ts new file mode 100644 index 0000000000..275ab206bf --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/components/reasoningControls.ts @@ -0,0 +1,75 @@ +// Playground reasoning-control helpers (#6241). +// +// The `/v1/models` catalog additively exposes per-model reasoning capability flags on each +// entry's `capabilities` object (see src/lib/modelMetadataRegistry.ts::enrichCatalogModelEntry): +// - `supportsThinking` (boolean) — whether the model exposes a thinking/reasoning mode. +// - `effort_tiers` (string[]) — the canonical effort vocabulary to offer, present only +// when the model supports thinking. +// These pure helpers turn that capability object into (a) a spec describing which controls the +// Playground should render + the effort options, and (b) the reasoning request fields to fold +// onto the chat request body — gated so `effort`/`thinking` are emitted ONLY when the selected +// model supports thinking and the chosen values are valid. Exported + unit-tested directly. + +import { CANONICAL_EFFORT_VALUES } from "@/shared/reasoning/effortStandardization"; + +/** Subset of a `/v1/models` entry's `capabilities` object the Playground reasoning UI reads. */ +export interface ModelReasoningCapabilities { + supportsThinking?: boolean | null; + effort_tiers?: unknown; +} + +/** What the Playground should render for the current model's reasoning support. */ +export interface ReasoningControlSpec { + /** Render the effort selector + thinking toggle only when true. */ + show: boolean; + /** Canonical effort tiers to offer in the selector (empty when `show` is false). */ + effortOptions: string[]; +} + +/** The subset of playground params the reasoning request-field builder consumes. */ +export interface ReasoningParams { + effort?: string; + thinking?: boolean; +} + +const HIDDEN_SPEC: ReasoningControlSpec = { show: false, effortOptions: [] }; + +/** + * Resolve which reasoning controls to render for a model's capability object. + * - Hidden (`show: false`) when the model does not support thinking (or caps is missing). + * - Otherwise `show: true` with the model's `effort_tiers`, falling back to the canonical + * effort vocabulary when the model omits or empties the tier list. + */ +export function resolveReasoningControls( + caps: ModelReasoningCapabilities | null | undefined +): ReasoningControlSpec { + if (!caps || caps.supportsThinking !== true) return HIDDEN_SPEC; + const tiers = Array.isArray(caps.effort_tiers) + ? caps.effort_tiers.filter((t): t is string => typeof t === "string" && t.length > 0) + : []; + return { + show: true, + effortOptions: tiers.length > 0 ? tiers : [...CANONICAL_EFFORT_VALUES], + }; +} + +/** + * Build the reasoning fields to fold onto the chat request body from the current params + the + * resolved control spec. Emits nothing when the model does not support thinking; includes + * `effort` only when it is set AND part of the offered tiers; includes `thinking` only when the + * toggle is on. This is the single "only when set/supported" gate for the request body. + */ +export function buildReasoningRequestFields( + params: ReasoningParams, + spec: ReasoningControlSpec +): { effort?: string; thinking?: boolean } { + if (!spec.show) return {}; + const out: { effort?: string; thinking?: boolean } = {}; + if (params.effort && spec.effortOptions.includes(params.effort)) { + out.effort = params.effort; + } + if (params.thinking) { + out.thinking = true; + } + return out; +} diff --git a/src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx b/src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx index 1dd6e6d551..8a0c3512f5 100644 --- a/src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx @@ -9,6 +9,7 @@ import { useStreamMetrics } from "../../hooks/useStreamMetrics"; import { getModelPricing } from "@/lib/playground/types"; import type { ConfigState } from "../StudioConfigPane"; import type { StreamMetrics } from "@/shared/schemas/playground"; +import { buildReasoningRequestFields } from "../reasoningControls"; interface Message { role: "system" | "user" | "assistant"; @@ -77,6 +78,16 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps) if (p.stop.trim()) body.stop = p.stop; if (p.jsonMode) body.response_format = { type: "json_object" }; + // #6241: fold the canonical effort / thinking params onto the body — gated to models that + // support thinking (via the resolved reasoning spec) and to valid effort tiers. + Object.assign( + body, + buildReasoningRequestFields( + { effort: p.effort, thinking: p.thinking }, + configState.reasoning ?? { show: false, effortOptions: [] } + ) + ); + return body; } diff --git a/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx b/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx index c3fdbe666e..339c716466 100644 --- a/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx +++ b/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { compareTr } from "@/shared/utils/turkishText"; +import type { ModelReasoningCapabilities } from "@/app/(dashboard)/dashboard/playground/components/reasoningControls"; /** * Prefix-based format→model matching, used to pick a smart default @@ -40,6 +41,12 @@ export function filterModelsByProvider(allModels: string[], provider?: string): export function useAvailableModels(provider?: string) { const [model, setModel] = useState(""); const [allModels, setAllModels] = useState([]); + // #6241: keep the per-model reasoning capability flags (supportsThinking / effort_tiers) the + // catalog exposes on each entry's `capabilities`, keyed by model id, so callers (Playground) + // can render the effort/thinking controls only when the selected model supports thinking. + const [modelCapabilities, setModelCapabilities] = useState< + Record + >({}); const [loading, setLoading] = useState(true); useEffect(() => { @@ -47,10 +54,19 @@ export function useAvailableModels(provider?: string) { try { const res = await fetch("/api/v1/models"); const data = await res.json(); - const models = (data.data || []).map((m) => m.id).sort((a, b) => compareTr(a, b)); + const entries = data.data || []; + const models = entries.map((m) => m.id).sort((a, b) => compareTr(a, b)); + const caps: Record = {}; + for (const entry of entries) { + if (entry && typeof entry.id === "string" && entry.capabilities) { + caps[entry.id] = entry.capabilities as ModelReasoningCapabilities; + } + } setAllModels(models); + setModelCapabilities(caps); } catch { setAllModels([]); + setModelCapabilities({}); } finally { setLoading(false); } @@ -80,5 +96,5 @@ export function useAvailableModels(provider?: string) { [availableModels] ); - return { model, setModel, availableModels, loading, pickModelForFormat }; + return { model, setModel, availableModels, modelCapabilities, loading, pickModelForFormat }; } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 32fa32bcb9..749ddd2697 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -8006,6 +8006,10 @@ "topP": "Top-p", "presencePenalty": "Presence penalty", "frequencyPenalty": "Frequency penalty", + "reasoningLabel": "Reasoning", + "thinking": "Thinking", + "effort": "Effort", + "effortDefault": "Default", "seedPlaceholder": "Random (leave empty)", "presetsLabel": "Presets", "loadPreset": "Load preset", diff --git a/src/lib/modelMetadataRegistry.ts b/src/lib/modelMetadataRegistry.ts index c635aaad31..37e0dfa6e3 100644 --- a/src/lib/modelMetadataRegistry.ts +++ b/src/lib/modelMetadataRegistry.ts @@ -12,6 +12,7 @@ import { import { AI_PROVIDERS } from "@/shared/constants/providers"; import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "@/shared/constants/models"; import { getSyncStatus, getSyncedCapability } from "@/lib/modelsDevSync"; +import { CANONICAL_EFFORT_VALUES } from "@/shared/reasoning/effortStandardization"; const MODEL_METADATA_SCHEMA_VERSION = "model-metadata-v1"; @@ -286,8 +287,18 @@ export function enrichCatalogModelEntry( : {}), tool_calling: metadata.capabilities.toolCalling, reasoning: metadata.capabilities.reasoning, + // #6241: surface thinking support + the canonical effort tiers so the frontend can + // render the effort/thinking toggles. `thinking` is kept for back-compat; `supportsThinking` + // is the explicit flag and `effort_tiers` lists the selectable reasoning levels + // (only when the model actually supports thinking). ...(typeof metadata.capabilities.supportsThinking === "boolean" - ? { thinking: metadata.capabilities.supportsThinking } + ? { + thinking: metadata.capabilities.supportsThinking, + supportsThinking: metadata.capabilities.supportsThinking, + ...(metadata.capabilities.supportsThinking + ? { effort_tiers: [...CANONICAL_EFFORT_VALUES] } + : {}), + } : {}), ...(typeof metadata.capabilities.attachment === "boolean" ? { attachment: metadata.capabilities.attachment } diff --git a/src/shared/reasoning/effortStandardization.ts b/src/shared/reasoning/effortStandardization.ts new file mode 100644 index 0000000000..b71b721ec9 --- /dev/null +++ b/src/shared/reasoning/effortStandardization.ts @@ -0,0 +1,124 @@ +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 = { + 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 { + 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(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 = { ...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; +} diff --git a/src/shared/validation/schemas/apiV1.ts b/src/shared/validation/schemas/apiV1.ts index e29e734ed6..d3d37009e5 100644 --- a/src/shared/validation/schemas/apiV1.ts +++ b/src/shared/validation/schemas/apiV1.ts @@ -13,6 +13,10 @@ import { isForbiddenCustomHeaderName, } from "@/shared/constants/upstreamHeaders"; import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts"; +import { + effortRequestSchema, + thinkingRequestSchema, +} from "@/shared/reasoning/effortStandardization"; import { modelIdSchema, nonEmptyStringSchema } from "./misc.ts"; @@ -126,6 +130,15 @@ export const providerChatCompletionSchema = z messages: z.array(chatMessageSchema).min(1).optional(), input: z.union([nonEmptyStringSchema, z.array(z.unknown()).min(1)]).optional(), prompt: nonEmptyStringSchema.optional(), + // Canonical, provider-agnostic reasoning controls (#6241). `effort` reuses the shared + // none/low/medium/high/xhigh vocabulary (UI tiers extra/max collapse onto xhigh); + // `thinking` is a simple boolean toggle. Both are optional and normalized onto the + // per-provider reasoning fields (reasoning_effort / reasoning.effort / thinking) by + // normalizeReasoningRequest before translation — an explicit client reasoning_effort / + // reasoning / object-shaped thinking always wins. See + // @/shared/reasoning/effortStandardization. + effort: effortRequestSchema.optional(), + thinking: thinkingRequestSchema.optional(), }) .catchall(z.unknown()) .superRefine((value, ctx) => { diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 3a72acfe39..b2539ad822 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -1,5 +1,6 @@ import { randomUUID } from "crypto"; import { resolveChatRequestBody } from "./requestBody"; +import { normalizeReasoningRequest } from "@/shared/reasoning/effortStandardization"; import { resolveRoutingModel } from "./resolveRoutingModel"; import { getProviderCredentialsWithQuotaPreflight, @@ -231,6 +232,14 @@ export async function handleChat( return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); } + // Feature #6241: fold the canonical `effort` / `thinking` request params onto the + // per-provider reasoning fields (reasoning_effort / reasoning.effort / thinking) that the + // existing translators already consume. Done here — right after the body is first + // resolved, before any reasoning field is read below — so it flows uniformly into every + // downstream mapper (Anthropic / Gemini / xAI / Responses). An explicit client + // reasoning_effort / reasoning / object-shaped thinking always wins (backward compatible). + body = normalizeReasoningRequest(body); + // Early guard: an explicitly empty `messages` array is invalid for every // upstream (Anthropic/OpenAI both reject "at least one message is required"). // Forwarding it produced a confusing raw upstream 400/502; reject it here with diff --git a/tests/unit/effort-thinking-standardization-6241.test.ts b/tests/unit/effort-thinking-standardization-6241.test.ts new file mode 100644 index 0000000000..bdc354537d --- /dev/null +++ b/tests/unit/effort-thinking-standardization-6241.test.ts @@ -0,0 +1,185 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// DB-backed pieces (/models enrichment) need an isolated DATA_DIR + a released handle +// (PII learning #3). Set it BEFORE importing any db-touching module. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-effort-6241-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { + CANONICAL_EFFORT_VALUES, + normalizeEffort, + effortRequestSchema, + normalizeReasoningRequest, +} = await import("../../src/shared/reasoning/effortStandardization.ts"); +const { providerChatCompletionSchema } = await import( + "../../src/shared/validation/schemas/apiV1.ts" +); +const core = await import("../../src/lib/db/core.ts"); +const modelsDevSync = await import("../../src/lib/modelsDevSync.ts"); +const registry = await import("../../src/lib/modelMetadataRegistry.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── Schema ───────────────────────────────────────────────────────────── + +test("providerChatCompletionSchema parses canonical effort + thinking", () => { + const parsed = providerChatCompletionSchema.parse({ + model: "openai/gpt-5", + messages: [{ role: "user", content: "hi" }], + effort: "high", + thinking: true, + }); + assert.equal(parsed.effort, "high"); + assert.equal(parsed.thinking, true); +}); + +test("schema still accepts the existing object-shaped thinking config (back-compat)", () => { + const parsed = providerChatCompletionSchema.parse({ + model: "anthropic/claude-sonnet-4-5", + messages: [{ role: "user", content: "hi" }], + thinking: { type: "enabled", budget_tokens: 2048 }, + }); + assert.deepEqual(parsed.thinking, { type: "enabled", budget_tokens: 2048 }); +}); + +test("schema normalizes UI tier synonyms (extra/max) onto xhigh, rejects garbage", () => { + assert.equal(effortRequestSchema.parse("extra"), "xhigh"); + assert.equal(effortRequestSchema.parse("MAX"), "xhigh"); + assert.equal(effortRequestSchema.parse("medium"), "medium"); + assert.throws(() => effortRequestSchema.parse("turbo")); +}); + +// ── normalizeEffort ──────────────────────────────────────────────────── + +test("normalizeEffort maps canonical + aliases, ignores unknown", () => { + assert.equal(normalizeEffort("high"), "high"); + assert.equal(normalizeEffort("HIGH"), "high"); + assert.equal(normalizeEffort("extra"), "xhigh"); + assert.equal(normalizeEffort("max"), "xhigh"); + assert.equal(normalizeEffort("none"), "none"); + assert.equal(normalizeEffort("turbo"), undefined); + assert.equal(normalizeEffort(3), undefined); + assert.deepEqual([...CANONICAL_EFFORT_VALUES], ["none", "low", "medium", "high", "xhigh"]); +}); + +// ── normalizeReasoningRequest ────────────────────────────────────────── + +test("canonical effort populates reasoning_effort + reasoning.effort when client did not", () => { + const out = normalizeReasoningRequest({ + model: "openai/gpt-5", + effort: "high", + }) as Record; + assert.equal(out.reasoning_effort, "high"); + assert.equal((out.reasoning as Record).effort, "high"); +}); + +test("canonical thinking boolean is preserved as the truthy toggle", () => { + const out = normalizeReasoningRequest({ + model: "openai/gpt-5", + effort: "medium", + thinking: true, + }) as Record; + assert.equal(out.reasoning_effort, "medium"); + assert.equal(out.thinking, true); +}); + +test("Extra / Max collapse to xhigh through the normalizer", () => { + const extra = normalizeReasoningRequest({ effort: "extra" }) as Record; + assert.equal(extra.reasoning_effort, "xhigh"); + const max = normalizeReasoningRequest({ effort: "Max" }) as Record; + assert.equal(max.reasoning_effort, "xhigh"); +}); + +test("explicit client reasoning_effort is NOT overwritten by canonical effort", () => { + const out = normalizeReasoningRequest({ + model: "openai/gpt-5", + reasoning_effort: "low", + effort: "high", + }) as Record; + assert.equal(out.reasoning_effort, "low"); +}); + +test("explicit client reasoning.effort is NOT overwritten by canonical effort", () => { + const out = normalizeReasoningRequest({ + reasoning: { effort: "low" }, + effort: "high", + }) as Record; + assert.equal((out.reasoning as Record).effort, "low"); + assert.equal(out.reasoning_effort, undefined); +}); + +test("explicit object-shaped thinking config is preserved (not clobbered by boolean)", () => { + const cfg = { type: "enabled", budget_tokens: 4096 }; + const out = normalizeReasoningRequest({ + effort: "high", + thinking: cfg, + }) as Record; + assert.deepEqual(out.thinking, cfg); + assert.equal(out.reasoning_effort, "high"); +}); + +test("returns the same reference untouched when no canonical fields are set", () => { + const body = { model: "openai/gpt-5", reasoning_effort: "low" }; + const out = normalizeReasoningRequest(body); + assert.equal(out, body); +}); + +// ── /models capability exposure ──────────────────────────────────────── + +test("enrichCatalogModelEntry exposes supportsThinking + effort_tiers for a thinking model", () => { + modelsDevSync.saveModelsDevCapabilities({ + openai: { + "gpt-5": { + tool_call: true, + reasoning: true, + attachment: false, + structured_output: true, + temperature: true, + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + knowledge_cutoff: null, + release_date: null, + last_updated: null, + status: "stable", + family: "gpt-5", + open_weights: false, + limit_context: 400000, + limit_input: 400000, + limit_output: 128000, + interleaved_field: null, + }, + }, + }); + + const enriched = registry.enrichCatalogModelEntry({ + id: "openai/gpt-5", + object: "model", + owned_by: "openai", + root: "gpt-5", + }) as Record; + + const caps = enriched.capabilities as Record; + assert.ok(caps, "capabilities object present"); + assert.equal(caps.supportsThinking, true); + assert.deepEqual(caps.effort_tiers, ["none", "low", "medium", "high", "xhigh"]); + // additive — existing flags preserved + assert.equal(caps.thinking, true); + assert.equal(caps.reasoning, true); +}); diff --git a/tests/unit/playground-reasoning-controls-6241.test.ts b/tests/unit/playground-reasoning-controls-6241.test.ts new file mode 100644 index 0000000000..318eb1e2b2 --- /dev/null +++ b/tests/unit/playground-reasoning-controls-6241.test.ts @@ -0,0 +1,85 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + resolveReasoningControls, + buildReasoningRequestFields, + type ReasoningControlSpec, +} from "../../src/app/(dashboard)/dashboard/playground/components/reasoningControls.ts"; +import { CANONICAL_EFFORT_VALUES } from "../../src/shared/reasoning/effortStandardization.ts"; + +// #6241: the Playground effort selector + thinking toggle read a model's `supportsThinking` / +// `effort_tiers` capability flags to decide which controls to render, and the request-body +// builder must fold `effort`/`thinking` onto the body ONLY when set AND supported. + +test("resolveReasoningControls: hidden when caps missing / not supported", () => { + assert.deepEqual(resolveReasoningControls(undefined), { show: false, effortOptions: [] }); + assert.deepEqual(resolveReasoningControls(null), { show: false, effortOptions: [] }); + assert.deepEqual(resolveReasoningControls({ supportsThinking: false }), { + show: false, + effortOptions: [], + }); + // `thinking` alone (back-compat flag) without an explicit supportsThinking must stay hidden. + assert.deepEqual(resolveReasoningControls({} as never), { show: false, effortOptions: [] }); +}); + +test("resolveReasoningControls: shows model's effort_tiers when supported", () => { + const spec = resolveReasoningControls({ + supportsThinking: true, + effort_tiers: ["low", "medium", "high"], + }); + assert.equal(spec.show, true); + assert.deepEqual(spec.effortOptions, ["low", "medium", "high"]); +}); + +test("resolveReasoningControls: falls back to canonical values when tiers absent/empty", () => { + const canonical = [...CANONICAL_EFFORT_VALUES]; + assert.deepEqual(resolveReasoningControls({ supportsThinking: true }).effortOptions, canonical); + assert.deepEqual( + resolveReasoningControls({ supportsThinking: true, effort_tiers: [] }).effortOptions, + canonical + ); + // Non-array / dirty tiers → fallback + only string members kept. + assert.deepEqual( + resolveReasoningControls({ supportsThinking: true, effort_tiers: "nope" as never }) + .effortOptions, + canonical + ); + assert.deepEqual( + resolveReasoningControls({ + supportsThinking: true, + effort_tiers: ["low", 3, "", "high"] as never, + }).effortOptions, + ["low", "high"] + ); +}); + +const SUPPORTED: ReasoningControlSpec = { + show: true, + effortOptions: ["low", "medium", "high", "xhigh"], +}; +const HIDDEN: ReasoningControlSpec = { show: false, effortOptions: [] }; + +test("buildReasoningRequestFields: emits nothing when model does not support thinking", () => { + assert.deepEqual(buildReasoningRequestFields({ effort: "high", thinking: true }, HIDDEN), {}); +}); + +test("buildReasoningRequestFields: includes effort only when set and in the offered tiers", () => { + assert.deepEqual(buildReasoningRequestFields({ effort: "high" }, SUPPORTED), { effort: "high" }); + // Unset effort ("" / undefined) is left off the body. + assert.deepEqual(buildReasoningRequestFields({ effort: "" }, SUPPORTED), {}); + assert.deepEqual(buildReasoningRequestFields({}, SUPPORTED), {}); + // An effort value not in the model's tiers is dropped. + assert.deepEqual(buildReasoningRequestFields({ effort: "bogus" }, SUPPORTED), {}); +}); + +test("buildReasoningRequestFields: includes thinking only when toggled on", () => { + assert.deepEqual(buildReasoningRequestFields({ thinking: true }, SUPPORTED), { thinking: true }); + assert.deepEqual(buildReasoningRequestFields({ thinking: false }, SUPPORTED), {}); +}); + +test("buildReasoningRequestFields: emits both when both set and supported", () => { + assert.deepEqual(buildReasoningRequestFields({ effort: "xhigh", thinking: true }, SUPPORTED), { + effort: "xhigh", + thinking: true, + }); +});