diff --git a/changelog.d/features/6879-default-reasoning-effort.md b/changelog.d/features/6879-default-reasoning-effort.md new file mode 100644 index 0000000000..ff62a749d6 --- /dev/null +++ b/changelog.d/features/6879-default-reasoning-effort.md @@ -0,0 +1 @@ +- feat(sse): per-model default `reasoning_effort` (`ModelSpec.defaultReasoningEffort`, injected only when the request carries no reasoning field) and make `no-think/` express `reasoning_effort:"none"` instead of deleting the field on the OpenAI path, so thinks-by-default models actually stop thinking (#6879) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 53c4218cf6..83a3eb7152 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -109,6 +109,7 @@ import { resolveModelAlias } from "../services/modelDeprecation.ts"; import { normalizeMimoThinking } from "../services/mimoThinking.ts"; import { normalizeClaudeAdaptiveThinking } from "../services/claudeAdaptiveThinking.ts"; import { normalizeClaudeHaikuConstraints } from "../services/claudeHaikuConstraints.ts"; +import { applyDefaultReasoningEffort } from "../services/defaultReasoningEffort.ts"; import { echoModelInObject } from "../services/responseModelEcho.ts"; import { stripGpt5SamplingWhenReasoning, @@ -2044,6 +2045,14 @@ export async function handleChatCore({ // model substitution. Mirrors upstream 9router 401d93bd5. See // services/claudeHaikuConstraints.ts. translatedBody = normalizeClaudeHaikuConstraints(translatedBody, finalModelToUpstream); + // #6879: per-model default reasoning_effort, injected only when the request + // carries no reasoning field of any shape — an explicit client/combo-leg value + // always wins. Scoped to the OpenAI Chat Completions dispatch shape (the shape + // `reasoning_effort` is native to); unset ModelSpec.defaultReasoningEffort is a + // no-op. See open-sse/services/defaultReasoningEffort.ts. + if (targetFormat === FORMATS.OPENAI) { + translatedBody = applyDefaultReasoningEffort(translatedBody, finalModelToUpstream); + } } // Xiaomi MiMo controls reasoning ONLY via `thinking:{type:"enabled"|"disabled"}` and diff --git a/open-sse/services/defaultReasoningEffort.ts b/open-sse/services/defaultReasoningEffort.ts new file mode 100644 index 0000000000..75ab833bbb --- /dev/null +++ b/open-sse/services/defaultReasoningEffort.ts @@ -0,0 +1,42 @@ +// Per-model default reasoning effort (#6879, "Ask 1"). Many models think by +// default with no client-visible way to turn it off (measured: +// gemini-flash-lite-latest burns ~277 reasoning tokens on a plain request with +// no reasoning params). ModelSpec.defaultReasoningEffort lets an operator +// configure a strip-by-default (or steer-by-default) value fleet-wide without +// patching every client. +// +// Semantics: applied ONLY when the request carries no reasoning field of any +// shape (`reasoning_effort`, `reasoning`, `thinking`) — an explicit client +// value, including one forwarded verbatim through a combo leg, always wins +// and this is a no-op. Models without a configured default are untouched +// (regression-safe). Wired at the OpenAI-format dispatch chokepoint in +// chatCore.ts, after model resolution, so the *upstream* model's default is +// used even when a combo/route substituted it. +import { getModelSpec } from "@/shared/constants/modelSpecs.ts"; + +/** True when `body` already expresses a reasoning-effort choice, in any known shape. */ +function hasExplicitReasoningField(body: Record): boolean { + return ( + body.reasoning_effort !== undefined || + body.reasoning !== undefined || + body.thinking !== undefined + ); +} + +/** + * Inject the resolved model's `defaultReasoningEffort` as `reasoning_effort` when the + * request has no reasoning field. Returns `body` unchanged (same reference) when there + * is nothing to inject, so callers can chain it without extra guards. + */ +export function applyDefaultReasoningEffort>( + body: T, + modelId: string +): T { + if (!body || typeof body !== "object") return body; + if (hasExplicitReasoningField(body)) return body; + + const defaultEffort = getModelSpec(modelId)?.defaultReasoningEffort; + if (!defaultEffort) return body; + + return { ...body, reasoning_effort: defaultEffort }; +} diff --git a/open-sse/utils/noThinkingAlias.ts b/open-sse/utils/noThinkingAlias.ts index c0dfcfbb6b..f83cefc69b 100644 --- a/open-sse/utils/noThinkingAlias.ts +++ b/open-sse/utils/noThinkingAlias.ts @@ -10,9 +10,17 @@ * * When such an id arrives on a request we strip the prefix back to the real * `/` and suppress reasoning (`thinking:{type:"disabled"}` for the - * Claude/Messages path; drop `reasoning`/`reasoning_effort` for the OpenAI path). + * Claude/Messages path; `reasoning_effort:"none"` for the OpenAI path — #6879: a + * thinks-by-default OpenAI-shape model left with no reasoning field at all keeps + * thinking with its provider default, so the alias must express "none" rather than + * merely deleting the field. The `reasoning` object is still dropped, since a + * Responses-shaped client's `reasoning:{...}` cannot itself express "none" and the + * translator promotes `reasoning_effort` into it downstream when absent). * The existing `normalizeThinkingForModel()` still runs downstream, so models that - * reject `disabled` are handled exactly as before. + * reject `disabled` are handled exactly as before, and the per-lane + * unsupported-param strip (open-sse/translator/paramSupport.ts) still removes + * `reasoning_effort` for lanes known to reject it, falling back to today's + * delete-only behavior for those. * * Catalog visibility is gated (see `shouldExposeNoThinkingAlias`): we only advertise * the variant for Claude-family models that actually support thinking AND honor @@ -61,8 +69,15 @@ export function applyNoThinkingAlias( body.model = realModel; if (opts.claudeFormat === true) { body.thinking = { type: "disabled" }; + delete body.reasoning_effort; + } else { + // #6879: express "none" instead of deleting, so a thinks-by-default model + // actually stops thinking instead of falling back to its provider default. + // Lanes that reject reasoning_effort are still cleaned up downstream by the + // per-lane unsupported-param strip (paramSupport.ts), which removes it just + // like it would have been removed here — same end state, correct on more lanes. + body.reasoning_effort = "none"; } - delete body.reasoning_effort; delete body.reasoning; return { applied: true, realModel }; } diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index e70783b4ef..6d3ab0c767 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -30,6 +30,16 @@ export interface ModelSpec { // Claude-family thinking-capable models that honor `disabled`. Set `true` to force the // variant on for any other model, or `false` to suppress it. See open-sse/utils/noThinkingAlias.ts. noThinkingAlias?: boolean; + // Per-model default reasoning effort (#6879). When the incoming request carries no + // `reasoning_effort` / `reasoning` / `thinking` field of any shape, the resolved + // upstream model's `defaultReasoningEffort` is injected as `reasoning_effort` on the + // OpenAI-format dispatch path before the request leaves the gateway. An explicit + // client value — including one forwarded verbatim through a combo leg — always wins; + // this is a no-op for it. Unset preserves current behavior (no injection). Lets an + // operator strip-by-default a thinks-by-default model (measured: gemini-flash-lite + // burns ~277 reasoning tokens on a plain request; `reasoning_effort:"none"` → 0) + // without patching every client. See open-sse/services/defaultReasoningEffort.ts. + defaultReasoningEffort?: "none" | "low" | "medium" | "high"; } const BEDROCK_CLAUDE_ALIASES = (...modelIds: string[]) => [ diff --git a/tests/unit/default-reasoning-effort-6879.test.ts b/tests/unit/default-reasoning-effort-6879.test.ts new file mode 100644 index 0000000000..1cff3f7e54 --- /dev/null +++ b/tests/unit/default-reasoning-effort-6879.test.ts @@ -0,0 +1,139 @@ +/** + * #6879 — per-model default `reasoning_effort` + `no-think/` expresses "none" on the + * OpenAI path (instead of deleting the field). + * + * Ask 1: `ModelSpec.defaultReasoningEffort` is injected as `reasoning_effort` only + * when the request carries no reasoning field of any shape (`reasoning_effort`, + * `reasoning`, `thinking`); an explicit client value always wins. + * + * Ask 2: `applyNoThinkingAlias` sets `reasoning_effort:"none"` on the OpenAI path + * (instead of deleting the field), so a thinks-by-default model actually stops + * thinking rather than falling back to its provider default. The Claude/Messages + * path is unchanged (`thinking:{type:"disabled"}`). Lanes known to reject + * `reasoning_effort` still end up with the field removed via the existing + * per-lane unsupported-param strip (open-sse/translator/paramSupport.ts) — same + * end state as today's delete-only behavior, just correct on more lanes. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { applyDefaultReasoningEffort } from "../../open-sse/services/defaultReasoningEffort.ts"; +import { applyNoThinkingAlias } from "../../open-sse/utils/noThinkingAlias.ts"; +import { stripUnsupportedParams } from "../../open-sse/translator/paramSupport.ts"; +import { MODEL_SPECS } from "../../src/shared/constants/modelSpecs.ts"; + +const FIXTURE_MODEL_ID = "__test_6879_default_reasoning_effort_model__"; + +test.before(() => { + MODEL_SPECS[FIXTURE_MODEL_ID] = { defaultReasoningEffort: "none" }; +}); + +test.after(() => { + delete MODEL_SPECS[FIXTURE_MODEL_ID]; +}); + +// --------------------------------------------------------------------------- +// Ask 1: applyDefaultReasoningEffort +// --------------------------------------------------------------------------- + +test("applyDefaultReasoningEffort: injects the model's default when no reasoning field is present", () => { + const body = { model: FIXTURE_MODEL_ID, messages: [] }; + const result = applyDefaultReasoningEffort(body, FIXTURE_MODEL_ID); + assert.equal(result.reasoning_effort, "none"); +}); + +test("applyDefaultReasoningEffort: an explicit reasoning_effort always wins over the model default", () => { + const body = { model: FIXTURE_MODEL_ID, messages: [], reasoning_effort: "high" }; + const result = applyDefaultReasoningEffort(body, FIXTURE_MODEL_ID); + assert.equal(result.reasoning_effort, "high"); +}); + +test("applyDefaultReasoningEffort: an explicit reasoning object always wins (Responses shape)", () => { + const body = { model: FIXTURE_MODEL_ID, messages: [], reasoning: { effort: "medium" } }; + const result = applyDefaultReasoningEffort(body, FIXTURE_MODEL_ID); + assert.deepEqual(result.reasoning, { effort: "medium" }); + assert.equal("reasoning_effort" in result, false); +}); + +test("applyDefaultReasoningEffort: an explicit thinking block always wins (Claude legacy shape)", () => { + const body = { model: FIXTURE_MODEL_ID, messages: [], thinking: { type: "enabled" } }; + const result = applyDefaultReasoningEffort(body, FIXTURE_MODEL_ID); + assert.deepEqual(result.thinking, { type: "enabled" }); + assert.equal("reasoning_effort" in result, false); +}); + +test("applyDefaultReasoningEffort: no injection when the model has no configured default (regression)", () => { + const body = { model: "gpt-5.4-sol", messages: [] }; + const result = applyDefaultReasoningEffort(body, "gpt-5.4-sol"); + assert.equal("reasoning_effort" in result, false); + assert.equal(result, body); // same reference — no allocation when nothing to inject +}); + +// --------------------------------------------------------------------------- +// Ask 2: applyNoThinkingAlias on the OpenAI path expresses "none" +// --------------------------------------------------------------------------- + +test("applyNoThinkingAlias: OpenAI path sets reasoning_effort:none instead of deleting the field", () => { + const body: Record = { + model: "no-think/gemini/gemini-flash-lite-latest", + messages: [], + }; + const result = applyNoThinkingAlias(body, { claudeFormat: false }); + assert.equal(result.applied, true); + assert.equal(result.realModel, "gemini/gemini-flash-lite-latest"); + assert.equal(body.model, "gemini/gemini-flash-lite-latest"); + assert.equal(body.reasoning_effort, "none"); + assert.equal("reasoning" in body, false); +}); + +test("applyNoThinkingAlias: OpenAI path overrides a client-supplied reasoning_effort with none (alias always wins)", () => { + const body: Record = { + model: "no-think/gemini/gemini-flash-lite-latest", + messages: [], + reasoning_effort: "high", + }; + const result = applyNoThinkingAlias(body, { claudeFormat: false }); + assert.equal(result.applied, true); + assert.equal(body.reasoning_effort, "none"); +}); + +test("applyNoThinkingAlias: Claude/Messages path is unchanged (thinking:disabled, no reasoning_effort field)", () => { + const body: Record = { + model: "no-think/claude/claude-opus-4-6", + messages: [], + reasoning_effort: "high", + }; + const result = applyNoThinkingAlias(body, { claudeFormat: true }); + assert.equal(result.applied, true); + assert.deepEqual(body.thinking, { type: "disabled" }); + assert.equal("reasoning_effort" in body, false); + assert.equal("reasoning" in body, false); +}); + +test("applyNoThinkingAlias: non-alias model is untouched (regression)", () => { + const body: Record = { model: "gemini/gemini-flash-lite-latest", messages: [] }; + const result = applyNoThinkingAlias(body, { claudeFormat: false }); + assert.equal(result.applied, false); + assert.equal("reasoning_effort" in body, false); +}); + +// --------------------------------------------------------------------------- +// Ask 2, fallback: a lane that rejects reasoning_effort still ends up with the +// field removed, via the pre-existing per-lane unsupported-param strip — same +// end state as today's delete-only behavior, correct on more lanes. +// --------------------------------------------------------------------------- + +test("a lane known to reject reasoning_effort still drops it downstream (delete-fallback preserved)", () => { + const body: Record = { + model: "no-think/github/claude-3-5-sonnet", + messages: [], + }; + const alias = applyNoThinkingAlias(body, { claudeFormat: false }); + assert.equal(alias.applied, true); + assert.equal(body.reasoning_effort, "none"); // set by the alias itself + + // github/claude (non-4.6) is a known-unsupported lane for both thinking and + // reasoning_effort (paramSupport.ts STRIP_RULES) — the field ends up removed + // before dispatch, exactly like the old delete-only behavior. + const stripped = stripUnsupportedParams("github", "claude-3-5-sonnet", body); + assert.equal("reasoning_effort" in stripped, false); +});