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/translator/index.ts b/open-sse/translator/index.ts index cc31aca671..86e39f2554 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -31,6 +31,7 @@ import { recordReplay, requiresReasoningReplay, } from "../services/reasoningCache.ts"; +import { normalizeResponsesReasoningEffort } from "./request/openai-responses/helpers.ts"; bootstrapTranslatorRegistry(); export { register } from "./registry.ts"; @@ -61,10 +62,37 @@ function normalizeResponsesInputItem(item) { return item; } +// Promote a stray top-level Chat-Completions-shaped `reasoning_effort` into the +// Responses-shaped `reasoning:{effort}` object, in place, removing the top-level key. +// No-op when `reasoning` is already present (an explicit Responses-shaped value always +// wins) or when `reasoning_effort` is absent. +// +// This exists for the SAME-FORMAT lane (source === target === OPENAI_RESPONSES), where +// translateRequest's hub-and-spoke translation block is skipped entirely (#7631): a +// caller that lands a top-level `reasoning_effort` there — e.g. applyNoThinkingAlias +// on the OpenAI path, which runs upstream of model-format resolution and cannot know +// yet whether the target lane is Responses-native — would otherwise reach the upstream +// with BOTH an unrecognized top-level field AND no `reasoning.effort`, so suppression +// silently does not take effect. The cross-format path (openai -> openai-responses) +// already performs the equivalent promotion in toResponses.ts; this covers the lane +// that promotion never runs on. +function promoteStrayReasoningEffort(body) { + if (!body || typeof body !== "object") return body; + if (body.reasoning !== undefined) return body; + if (body.reasoning_effort === undefined) return body; + + const effort = normalizeResponsesReasoningEffort(body.reasoning_effort); + if (effort) { + body.reasoning = { effort }; + } + delete body.reasoning_effort; + return body; +} + function normalizeOpenAIResponsesRequest(body) { if (!body || typeof body !== "object") return body; - const normalized = { ...body }; + const normalized = promoteStrayReasoningEffort({ ...body }); if (typeof normalized.input === "string") { normalized.input = [ 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..3ed6a592bc --- /dev/null +++ b/tests/unit/default-reasoning-effort-6879.test.ts @@ -0,0 +1,206 @@ +/** + * #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); +}); + +// --------------------------------------------------------------------------- +// #7631: the same-format /v1/responses lane (source === target === OPENAI_RESPONSES) +// skips translateRequest's hub-and-spoke translation block entirely, so a stray +// top-level `reasoning_effort` (set upstream by applyNoThinkingAlias on the OpenAI +// path, before model-format resolution knows the target lane is Responses-native) +// must still be promoted into the Responses-shaped `reasoning.effort`, or thinking +// suppression silently does not take effect on that lane. +// --------------------------------------------------------------------------- + +test("7631: translateRequest promotes a stray top-level reasoning_effort into reasoning.effort on the same-format OPENAI_RESPONSES lane", async () => { + const { translateRequest } = await import("../../open-sse/translator/index.ts"); + const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + + const body: Record = { + model: "gpt-5.1-codex", + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], + reasoning_effort: "none", + }; + + const result = translateRequest( + FORMATS.OPENAI_RESPONSES, + FORMATS.OPENAI_RESPONSES, + "gpt-5.1-codex", + body + ); + + assert.equal("reasoning_effort" in result, false); + assert.deepEqual(result.reasoning, { effort: "none" }); +}); + +test("7631: same-format OPENAI_RESPONSES lane leaves an explicit reasoning object untouched (no double-promotion)", async () => { + const { translateRequest } = await import("../../open-sse/translator/index.ts"); + const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + + const body: Record = { + model: "gpt-5.1-codex", + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], + reasoning: { effort: "high", summary: "auto" }, + }; + + const result = translateRequest( + FORMATS.OPENAI_RESPONSES, + FORMATS.OPENAI_RESPONSES, + "gpt-5.1-codex", + body + ); + + assert.deepEqual(result.reasoning, { effort: "high", summary: "auto" }); + assert.equal("reasoning_effort" in result, false); +}); + +test("7631: cross-format openai -> openai-responses promotion is unchanged (no regression)", async () => { + const { translateRequest } = await import("../../open-sse/translator/index.ts"); + const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + + const body: Record = { + model: "gpt-5.1-codex", + messages: [{ role: "user", content: "hi" }], + reasoning_effort: "none", + }; + + const result = translateRequest(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, "gpt-5.1-codex", body); + + assert.equal("reasoning_effort" in result, false); + assert.deepEqual(result.reasoning, { effort: "none" }); +}); diff --git a/tests/unit/no-thinking-alias.test.ts b/tests/unit/no-thinking-alias.test.ts index d70d517f25..5818dc6465 100644 --- a/tests/unit/no-thinking-alias.test.ts +++ b/tests/unit/no-thinking-alias.test.ts @@ -59,7 +59,7 @@ test("applyNoThinkingAlias rewrites the model and disables thinking (Claude form assert.ok(!("reasoning_effort" in body), "reasoning_effort must be stripped"); }); -test("applyNoThinkingAlias strips reasoning fields without a thinking block (OpenAI format)", () => { +test("applyNoThinkingAlias expresses reasoning_effort:none without a thinking block (OpenAI format)", () => { const body: Record = { model: "no-think/openai/gpt-5.4", reasoning_effort: "high", @@ -70,8 +70,11 @@ test("applyNoThinkingAlias strips reasoning fields without a thinking block (Ope assert.equal(res.applied, true); assert.equal(body.model, "openai/gpt-5.4"); assert.ok(!("thinking" in body), "no Claude thinking block on an OpenAI body"); - assert.ok(!("reasoning_effort" in body), "reasoning_effort must be stripped"); - assert.ok(!("reasoning" in body), "reasoning must be stripped"); + // #6879: a thinks-by-default OpenAI-shape model must carry reasoning_effort:"none" + // explicitly (not merely have the field deleted), so suppression actually takes + // effect downstream; the Responses-shaped `reasoning` object is still dropped. + assert.equal(body.reasoning_effort, "none", "reasoning_effort must express none, not be stripped"); + assert.ok(!("reasoning" in body), "reasoning object must be dropped"); }); test("applyNoThinkingAlias is a no-op for plain models", () => {