diff --git a/changelog.d/fixes/10230-deepseek-native-max-effort.md b/changelog.d/fixes/10230-deepseek-native-max-effort.md new file mode 100644 index 0000000000..3a681d3190 --- /dev/null +++ b/changelog.d/fixes/10230-deepseek-native-max-effort.md @@ -0,0 +1 @@ +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White diff --git a/src/app/api/v1/models/catalogHelpers.ts b/src/app/api/v1/models/catalogHelpers.ts index 151d5ce733..bb1c600a16 100644 --- a/src/app/api/v1/models/catalogHelpers.ts +++ b/src/app/api/v1/models/catalogHelpers.ts @@ -6,6 +6,7 @@ import { CANONICAL_EFFORT_VALUES, extendCodexGpt56EffortValues, + extendDeepSeekEffortValues, } from "@/shared/reasoning/effortStandardization"; export interface CustomModelEntry { @@ -93,8 +94,7 @@ export function getThinkingCapabilityFields( ): Record { const supportsThinking = resolvedThinking; if (typeof supportsThinking !== "boolean") return {}; - const hasDeclaredTiers = - supportedThinkingEfforts && supportedThinkingEfforts.length > 0; + const hasDeclaredTiers = supportedThinkingEfforts && supportedThinkingEfforts.length > 0; return { thinking: supportsThinking, supportsThinking, @@ -102,7 +102,11 @@ export function getThinkingCapabilityFields( ? { effort_tiers: hasDeclaredTiers ? [...supportedThinkingEfforts!] - : extendCodexGpt56EffortValues(providerId, modelId, CANONICAL_EFFORT_VALUES), + : extendDeepSeekEffortValues( + providerId, + modelId, + extendCodexGpt56EffortValues(providerId, modelId, CANONICAL_EFFORT_VALUES) + ), } : {}), }; diff --git a/src/shared/reasoning/effortStandardization.ts b/src/shared/reasoning/effortStandardization.ts index 21a336556c..7f0edcd41c 100644 --- a/src/shared/reasoning/effortStandardization.ts +++ b/src/shared/reasoning/effortStandardization.ts @@ -63,6 +63,57 @@ const EFFORT_TIER_ALIASES: Record = { max: "xhigh", }; +/** + * DeepSeek V4 exposes a native `max` reasoning tier ABOVE its `high` tier. + * + * Per https://api-docs.deepseek.com/api/create-chat-completion the accepted + * `reasoning_effort` values are `low`, `high` and `max`, the default is `high`, + * and **`medium` / `xhigh` are both mapped to `high` upstream**. Canonical + * `max` collapses to `xhigh` (see EFFORT_TIER_ALIASES), so without this the + * top tier is unreachable: `{"effort":"max"}` → `xhigh` → upstream `high`. + * + * Mirrors extendCodexGpt56EffortValues: expose the provider-native tier for + * these models only, without widening the global request vocabulary. + */ +export function extendDeepSeekEffortValues( + provider: string | null | undefined, + model: string | null | undefined, + baseValues: readonly string[] +): string[] { + const values = [...baseValues]; + if (!isDeepSeekNativeMaxModel(provider, model)) return values; + return values.includes("max") ? values : [...values, "max"]; +} + +/** + * Whether `/` is a DeepSeek V4 model served by the native + * DeepSeek provider (registry id `deepseek`, alias `ds`). + * + * Deliberately scoped to the native provider: routed namespaces such as + * `openrouter/deepseek/...` or `tllm/deepseek_v4` terminate at a different + * upstream whose accepted effort vocabulary we do not control. + */ +export function isDeepSeekNativeMaxModel( + provider: string | null | undefined, + model: string | null | undefined +): boolean { + const rawModel = model?.trim().toLowerCase(); + if (!rawModel) return false; + + // The provider is not always resolved yet at the point the canonical request + // params are folded in (see chat.ts), so accept either an explicit provider or + // a `/` id carrying the native DeepSeek prefix. + const prefixMatch = rawModel.match(/^(deepseek|ds)\//); + const normalizedProvider = provider?.trim().toLowerCase() || prefixMatch?.[1]; + if (normalizedProvider !== "deepseek" && normalizedProvider !== "ds") return false; + + const normalizedModel = rawModel.replace(/^(?:deepseek|ds)\//, ""); + if (!normalizedModel) return false; + return /^deepseek-v4-(?:pro|flash)(?:-(?:none|minimal|low|medium|high|xhigh|max))?$/.test( + normalizedModel + ); +} + /** * Normalize an arbitrary effort value onto the canonical vocabulary. Accepts the canonical * values plus the UI tier synonyms (`extra`/`max` → `xhigh`), case-insensitively. Returns @@ -100,6 +151,11 @@ function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** Read a request body's `model` field when it is a usable string. */ +function asModelId(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + /** * Fold the canonical `effort` / `thinking` request params onto the per-provider reasoning * fields the existing translators already consume (`reasoning_effort`, `reasoning.effort`, @@ -112,10 +168,17 @@ function isPlainObject(value: unknown): value is Record { * - 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 { +export function normalizeReasoningRequest(body: T, provider?: string | null): T { if (!isPlainObject(body)) return body; - const canonicalEffort = normalizeEffort(body.effort); + // DeepSeek V4 has a native `max` tier above `high`. Canonical `max` normally + // collapses to `xhigh`, which DeepSeek maps back down to `high` — so preserve + // the literal value for those models instead of round-tripping it away. + const rawEffort = typeof body.effort === "string" ? body.effort.trim().toLowerCase() : undefined; + const canonicalEffort = + rawEffort === "max" && isDeepSeekNativeMaxModel(provider, asModelId(body.model)) + ? ("max" as const) + : normalizeEffort(body.effort); const canonicalThinking = body.thinking; const hasCanonicalThinkingBool = typeof canonicalThinking === "boolean"; diff --git a/tests/unit/deepseek-native-max-effort.test.ts b/tests/unit/deepseek-native-max-effort.test.ts new file mode 100644 index 0000000000..2910cb70e2 --- /dev/null +++ b/tests/unit/deepseek-native-max-effort.test.ts @@ -0,0 +1,104 @@ +/** + * DeepSeek V4 exposes a native `max` reasoning tier that the canonical vocabulary erases. + * + * Per https://api-docs.deepseek.com/api/create-chat-completion the accepted + * `reasoning_effort` values are `low`, `high` and `max`, the default is `high`, and + * **`medium` / `xhigh` are both mapped to `high`** upstream. (The live API's 400 on an + * invalid value enumerates the full accepted set: + * `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`.) + * + * OmniRoute's canonical vocabulary is `none|low|medium|high|xhigh`, and `max` is an alias + * that collapses onto `xhigh` (EFFORT_TIER_ALIASES). Since DeepSeek then maps `xhigh` back + * down to `high`, a client sending `{"effort":"max"}` silently received **high** — the top + * tier was unreachable through the canonical field. + * + * The fix mirrors the existing `extendCodexGpt56EffortValues` precedent: expose the + * provider-native tier for these models only, without widening the global request + * vocabulary for every other provider. + * + * Guards: A = `max` survives for native DeepSeek models; B = every other provider still + * collapses `max`→`xhigh`; C = routed DeepSeek namespaces (openrouter/tllm) are NOT treated + * as native; D = an explicit client `reasoning_effort` still wins; E = the catalog offers + * `max` as an effort tier for native DeepSeek models. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + normalizeReasoningRequest, + normalizeEffort, + isDeepSeekNativeMaxModel, + extendDeepSeekEffortValues, + CANONICAL_EFFORT_VALUES, +} = await import("../../src/shared/reasoning/effortStandardization.ts"); + +test("A: canonical effort `max` survives for native DeepSeek V4 models", () => { + for (const model of [ + "ds/deepseek-v4-pro", + "ds/deepseek-v4-flash", + "deepseek/deepseek-v4-pro", + "deepseek/deepseek-v4-flash", + ]) { + const out = normalizeReasoningRequest({ model, effort: "max" }) as Record; + assert.equal( + out.reasoning_effort, + "max", + `${model} must reach DeepSeek's native max tier, not the down-mapped xhigh` + ); + assert.deepEqual((out.reasoning as Record).effort, "max"); + } +}); + +test("A2: the provider can also be supplied explicitly (model id without prefix)", () => { + const out = normalizeReasoningRequest( + { model: "deepseek-v4-pro", effort: "max" }, + "deepseek" + ) as Record; + assert.equal(out.reasoning_effort, "max"); +}); + +test("B: `max` still collapses to `xhigh` for every other provider", () => { + for (const model of ["openai/gpt-5", "anthropic/claude-opus-4-8", "z-ai/glm-5.2"]) { + const out = normalizeReasoningRequest({ model, effort: "max" }) as Record; + assert.equal(out.reasoning_effort, "xhigh", `${model} must keep the canonical collapse`); + } + // The global vocabulary itself is unchanged. + assert.deepEqual([...CANONICAL_EFFORT_VALUES], ["none", "low", "medium", "high", "xhigh"]); + assert.equal(normalizeEffort("max"), "xhigh"); +}); + +test("C: routed DeepSeek namespaces are not treated as the native provider", () => { + // These terminate at a different upstream whose effort vocabulary we do not control. + for (const model of [ + "openrouter/deepseek/deepseek-v4-flash-0731", + "tllm/deepseek_v4", + "oc/deepseek-v4-flash-free", + ]) { + assert.equal(isDeepSeekNativeMaxModel(null, model), false, `${model} is not native`); + const out = normalizeReasoningRequest({ model, effort: "max" }) as Record; + assert.equal(out.reasoning_effort, "xhigh"); + } +}); + +test("D: an explicit client reasoning_effort still wins over canonical effort", () => { + const out = normalizeReasoningRequest({ + model: "ds/deepseek-v4-flash", + effort: "max", + reasoning_effort: "low", + }) as Record; + assert.equal(out.reasoning_effort, "low", "explicit client intent must be preserved"); +}); + +test("E: catalog effort tiers advertise `max` for native DeepSeek models only", () => { + const base = [...CANONICAL_EFFORT_VALUES]; + + const deepseekTiers = extendDeepSeekEffortValues("deepseek", "deepseek-v4-pro", base); + assert.ok(deepseekTiers.includes("max"), "native DeepSeek must advertise the max tier"); + + const otherTiers = extendDeepSeekEffortValues("openai", "gpt-5", base); + assert.ok(!otherTiers.includes("max"), "other providers must be untouched"); + + // Idempotent: never duplicate an already-present tier. + const twice = extendDeepSeekEffortValues("ds", "deepseek-v4-flash", deepseekTiers); + assert.equal(twice.filter((t) => t === "max").length, 1); +});