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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-06 19:30:40 -03:00
committed by GitHub
parent 8db5a665d6
commit 62b1bc9d05
14 changed files with 652 additions and 4 deletions

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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<K extends keyof PlaygroundParams>(key: K, value: PlaygroundParams[K]) {
setParams({ ...params, [key]: value });
}
return (
<div className="flex flex-col gap-3">
<span className="text-xs font-medium text-text-muted uppercase tracking-wider">
Reasoning
</span>
{/* Thinking toggle */}
<div className="flex items-center justify-between">
<label className="text-xs text-text-muted font-medium">Thinking</label>
<button
type="button"
role="switch"
aria-checked={params.thinking}
aria-label="Thinking"
onClick={() => update("thinking", !params.thinking)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 ${
params.thinking ? "bg-primary" : "bg-neutral-300 dark:bg-neutral-600"
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
params.thinking ? "translate-x-4" : "translate-x-0.5"
}`}
/>
</button>
</div>
{/* Effort selector */}
<div className="flex flex-col gap-1">
<label className="text-xs text-text-muted font-medium">Effort</label>
<select
value={params.effort}
onChange={(e) => update("effort", e.target.value)}
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
>
<option value="">Default</option>
{spec.effortOptions.map((opt) => (
<option key={opt} value={opt}>
{effortLabel(opt)}
</option>
))}
</select>
</div>
</div>
);
}

View File

@@ -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<K extends keyof ConfigState>(key: K, value: ConfigState[K]) {
setConfigState({ ...configState, [key]: value });
}
@@ -227,6 +253,13 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
</span>
<ParamSliders params={configState.params} setParams={(p) => update("params", p)} />
</div>
{/* Reasoning controls — only shown when the selected model supports thinking (#6241) */}
<ReasoningControls
spec={reasoningSpec}
params={configState.params}
setParams={(p) => update("params", p)}
/>
</div>
</aside>
);

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<string[]>([]);
// #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<string, ModelReasoningCapabilities>
>({});
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<string, ModelReasoningCapabilities> = {};
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 };
}

View File

@@ -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",

View File

@@ -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<T extends JsonRecord>(
: {}),
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 }

View File

@@ -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<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;
}

View File

@@ -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) => {

View File

@@ -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

View File

@@ -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<string, unknown>;
assert.equal(out.reasoning_effort, "high");
assert.equal((out.reasoning as Record<string, unknown>).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<string, unknown>;
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<string, unknown>;
assert.equal(extra.reasoning_effort, "xhigh");
const max = normalizeReasoningRequest({ effort: "Max" }) as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
assert.equal((out.reasoning as Record<string, unknown>).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<string, unknown>;
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<string, unknown>;
const caps = enriched.capabilities as Record<string, unknown>;
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);
});

View File

@@ -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,
});
});