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

Validated in merge-train 2026-07-18 @ 9084b408b: 12198/12201 pass; single red = earlyStreamKeepalive timer test, confirmed load-flake (6/6 green isolated on release tip AND the merged tree; no boarded PR touches keepalive)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-18 03:07:37 -03:00
committed by GitHub
parent 2cca081b3c
commit b28331307e
8 changed files with 321 additions and 7 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

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

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

View File

@@ -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<string, unknown> = {
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", () => {