mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
standardize effort + thinking request params (#6241) (net +1/-0, tests OK). Integrated into release/v3.8.46.
This commit is contained in:
committed by
GitHub
parent
8db5a665d6
commit
62b1bc9d05
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
124
src/shared/reasoning/effortStandardization.ts
Normal file
124
src/shared/reasoning/effortStandardization.ts
Normal 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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
185
tests/unit/effort-thinking-standardization-6241.test.ts
Normal file
185
tests/unit/effort-thinking-standardization-6241.test.ts
Normal 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);
|
||||
});
|
||||
85
tests/unit/playground-reasoning-controls-6241.test.ts
Normal file
85
tests/unit/playground-reasoning-controls-6241.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user