feat(sse): per-model default reasoning_effort + no-think none on OpenAI path (#6879)

Ask 1: add ModelSpec.defaultReasoningEffort ("none"|"low"|"medium"|"high"),
injected as reasoning_effort 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. Wired at the OpenAI Chat Completions dispatch chokepoint in
chatCore.ts, after model resolution, so the resolved upstream model's
default applies even when a combo/route substituted it.

Ask 2: applyNoThinkingAlias now sets reasoning_effort:"none" on the OpenAI
path instead of deleting the field, so a thinks-by-default model (measured:
gemini-flash-lite-latest via no-think/ still burned 202 reasoning tokens)
actually stops thinking instead of 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 (paramSupport.ts) — same end
state as the old delete-only behavior, correct on more lanes.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-17 14:50:36 -03:00
parent e6f81d827e
commit f27886e63f
6 changed files with 219 additions and 3 deletions

View File

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

View File

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

View File

@@ -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<string, unknown>): 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<T extends Record<string, unknown>>(
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 };
}

View File

@@ -10,9 +10,17 @@
*
* When such an id arrives on a request we strip the prefix back to the real
* `<provider>/<model>` 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 };
}

View File

@@ -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[]) => [

View File

@@ -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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = { 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<string, unknown> = {
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);
});