fix(ai): handle explicit thinking off across providers closes #2490

This commit is contained in:
Mario Zechner
2026-03-22 20:27:44 +01:00
parent 6129971c04
commit d1613e3f53
8 changed files with 246 additions and 28 deletions

View File

@@ -11,6 +11,7 @@
- Fixed OpenAI Responses replay for foreign tool-call item IDs by hashing foreign `function_call.id` values into bounded `fc_<hash>` IDs instead of preserving backend-specific normalized shapes that OpenAI Codex rejects.
- Fixed Anthropic thinking disable handling to send `thinking: { type: "disabled" }` for reasoning-capable models when thinking is explicitly off, and added payload and env-gated end-to-end coverage for the Anthropic provider ([#2022](https://github.com/badlogic/pi-mono/issues/2022))
- Fixed explicit thinking disable handling across Google, Google Vertex, Gemini CLI, OpenAI Responses, Azure OpenAI Responses, and OpenRouter-backed OpenAI-compatible completions. Gemini 3 models now fall back to the lowest supported thinking level when full disable is not supported, and OpenAI/OpenRouter reasoning models now send explicit `none` effort instead of relying on provider defaults ([#2490](https://github.com/badlogic/pi-mono/issues/2490))
## [0.61.1] - 2026-03-20

View File

@@ -240,18 +240,7 @@ function buildParams(
};
params.include = ["reasoning.encrypted_content"];
} else {
if (model.name.toLowerCase().startsWith("gpt-5")) {
// Jesus Christ, see https://community.openai.com/t/need-reasoning-false-option-for-gpt-5/1351588/7
messages.push({
role: "developer",
content: [
{
type: "input_text",
text: "# Juice: 0 !important",
},
],
});
}
params.reasoning = { effort: "none" };
}
}

View File

@@ -889,6 +889,8 @@ export function buildRequest(
} else if (options.thinking.budgetTokens !== undefined) {
generationConfig.thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
}
} else if (model.reasoning && options.thinking && !options.thinking.enabled) {
generationConfig.thinkingConfig = getDisabledThinkingConfig(model.id);
}
const request: CloudCodeAssistRequest["request"] = {
@@ -946,6 +948,21 @@ export function buildRequest(
type ClampedThinkingLevel = Exclude<ThinkingLevel, "xhigh">;
function getDisabledThinkingConfig(modelId: string): ThinkingConfig {
// Google docs: Gemini 3.1 Pro cannot disable thinking, and Gemini 3 Flash / Flash-Lite
// do not support full thinking-off either. For Gemini 3 models, use the lowest supported
// thinkingLevel without includeThoughts so hidden thinking remains invisible to pi.
if (isGemini3ProModel(modelId)) {
return { thinkingLevel: "LOW" as any };
}
if (isGemini3FlashModel(modelId)) {
return { thinkingLevel: "MINIMAL" as any };
}
// Gemini 2.x supports disabling via thinkingBudget = 0.
return { thinkingBudget: 0 };
}
function getGeminiCliThinkingLevel(effort: ClampedThinkingLevel, modelId: string): GoogleThinkingLevel {
if (isGemini3ProModel(modelId)) {
switch (effort) {

View File

@@ -436,6 +436,8 @@ function buildParams(
thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
}
config.thinkingConfig = thinkingConfig;
} else if (model.reasoning && options.thinking && !options.thinking.enabled) {
config.thinkingConfig = getDisabledThinkingConfig(model);
}
if (options.signal) {
@@ -464,6 +466,22 @@ function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
return /gemini-3(?:\.\d+)?-flash/.test(model.id.toLowerCase());
}
function getDisabledThinkingConfig(model: Model<"google-vertex">): ThinkingConfig {
// Google docs: Gemini 3.1 Pro cannot disable thinking, and Gemini 3 Flash / Flash-Lite
// do not support full thinking-off either. For Gemini 3 models, use the lowest supported
// thinkingLevel without includeThoughts so hidden thinking remains invisible to pi.
const geminiModel = model as unknown as Model<"google-generative-ai">;
if (isGemini3ProModel(geminiModel)) {
return { thinkingLevel: ThinkingLevel.LOW };
}
if (isGemini3FlashModel(geminiModel)) {
return { thinkingLevel: ThinkingLevel.MINIMAL };
}
// Gemini 2.x supports disabling via thinkingBudget = 0.
return { thinkingBudget: 0 };
}
function getGemini3ThinkingLevel(
effort: ClampedThinkingLevel,
model: Model<"google-generative-ai">,

View File

@@ -371,6 +371,8 @@ function buildParams(
thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
}
config.thinkingConfig = thinkingConfig;
} else if (model.reasoning && options.thinking && !options.thinking.enabled) {
config.thinkingConfig = getDisabledThinkingConfig(model);
}
if (options.signal) {
@@ -399,6 +401,21 @@ function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
return /gemini-3(?:\.\d+)?-flash/.test(model.id.toLowerCase());
}
function getDisabledThinkingConfig(model: Model<"google-generative-ai">): ThinkingConfig {
// Google docs: Gemini 3.1 Pro cannot disable thinking, and Gemini 3 Flash / Flash-Lite
// do not support full thinking-off either. For Gemini 3 models, use the lowest supported
// thinkingLevel without includeThoughts so hidden thinking remains invisible to pi.
if (isGemini3ProModel(model)) {
return { thinkingLevel: "LOW" as any };
}
if (isGemini3FlashModel(model)) {
return { thinkingLevel: "MINIMAL" as any };
}
// Gemini 2.x supports disabling via thinkingBudget = 0.
return { thinkingBudget: 0 };
}
function getGemini3ThinkingLevel(
effort: ClampedThinkingLevel,
model: Model<"google-generative-ai">,

View File

@@ -410,12 +410,16 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio
(params as any).enable_thinking = !!options?.reasoningEffort;
} else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) {
(params as any).chat_template_kwargs = { enable_thinking: !!options?.reasoningEffort };
} else if (compat.thinkingFormat === "openrouter" && options?.reasoningEffort && model.reasoning) {
} else if (compat.thinkingFormat === "openrouter" && model.reasoning) {
// OpenRouter normalizes reasoning across providers via a nested reasoning object.
const openRouterParams = params as typeof params & { reasoning?: { effort?: string } };
openRouterParams.reasoning = {
effort: mapReasoningEffort(options.reasoningEffort, compat.reasoningEffortMap),
};
if (options?.reasoningEffort) {
openRouterParams.reasoning = {
effort: mapReasoningEffort(options.reasoningEffort, compat.reasoningEffortMap),
};
} else {
openRouterParams.reasoning = { effort: "none" };
}
} else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) {
// OpenAI-style reasoning_effort
(params as any).reasoning_effort = mapReasoningEffort(options.reasoningEffort, compat.reasoningEffortMap);

View File

@@ -221,18 +221,7 @@ function buildParams(model: Model<"openai-responses">, context: Context, options
};
params.include = ["reasoning.encrypted_content"];
} else {
if (model.name.startsWith("gpt-5")) {
// Jesus Christ, see https://community.openai.com/t/need-reasoning-false-option-for-gpt-5/1351588/7
messages.push({
role: "developer",
content: [
{
type: "input_text",
text: "# Juice: 0 !important",
},
],
});
}
params.reasoning = { effort: "none" };
}
}

View File

@@ -0,0 +1,183 @@
import { describe, expect, it } from "vitest";
import { getModel } from "../src/models.js";
import { streamSimple } from "../src/stream.js";
import type { Api, Context, Model, SimpleStreamOptions } from "../src/types.js";
import { resolveApiKey } from "./oauth.js";
type SimpleOptionsWithExtras = SimpleStreamOptions & Record<string, unknown>;
interface RunResult {
thinkingEventCount: number;
thinkingCharCount: number;
text: string;
outputTokens: number;
contentTypes: string[];
}
interface DisableExpectations {
requestOptions?: SimpleOptionsWithExtras;
minPongs?: number;
maxOutputTokens?: number;
}
const oauthTokens = await Promise.all([resolveApiKey("google-gemini-cli"), resolveApiKey("google-antigravity")]);
const [geminiCliToken, antigravityToken] = oauthTokens;
function makeContext(): Context {
return {
systemPrompt: "You are a precise assistant. Follow the requested output format exactly.",
messages: [
{
role: "user",
content:
"Before replying, carefully solve 36863 * 5279 internally. Then reply with the word pong repeated exactly 40 times, separated by single spaces. Do not add any other text.",
timestamp: Date.now(),
},
],
};
}
function countPongs(text: string): number {
return text.match(/\bpong\b/gi)?.length ?? 0;
}
async function runWithoutReasoning<TApi extends Api>(
model: Model<TApi>,
options: SimpleOptionsWithExtras = {},
): Promise<RunResult> {
const s = streamSimple(model, makeContext(), {
maxTokens: 160,
temperature: 0,
...options,
});
let thinkingEventCount = 0;
let thinkingCharCount = 0;
for await (const event of s) {
if (event.type === "thinking_start" || event.type === "thinking_end") {
thinkingEventCount += 1;
}
if (event.type === "thinking_delta") {
thinkingEventCount += 1;
thinkingCharCount += event.delta.length;
}
}
const response = await s.result();
expect(response.stopReason, response.errorMessage).toBe("stop");
const text = response.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("")
.trim();
return {
thinkingEventCount,
thinkingCharCount,
text,
outputTokens: response.usage.output,
contentTypes: response.content.map((block) => block.type),
};
}
async function expectThinkingDisabledE2E<TApi extends Api>(model: Model<TApi>, expectations: DisableExpectations = {}) {
const result = await runWithoutReasoning(model, expectations.requestOptions);
expect(result.thinkingEventCount).toBe(0);
expect(result.thinkingCharCount).toBe(0);
expect(result.contentTypes).not.toContain("thinking");
expect(countPongs(result.text)).toBeGreaterThanOrEqual(expectations.minPongs ?? 35);
if (expectations.maxOutputTokens !== undefined) {
expect(result.outputTokens).toBeLessThan(expectations.maxOutputTokens);
}
}
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic thinking disable E2E", () => {
it("disables thinking for budget-based reasoning models", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("anthropic", "claude-sonnet-4-5"), {
requestOptions: { maxTokens: 320, temperature: 0 },
});
});
it("disables thinking for adaptive reasoning models", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("anthropic", "claude-sonnet-4-6"), {
requestOptions: { maxTokens: 320, temperature: 0 },
});
});
});
describe.skipIf(!process.env.GEMINI_API_KEY)("Google thinking disable E2E", () => {
it("disables thinking for Gemini 2.5", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("google", "gemini-2.5-flash"));
});
it("disables thinking for Gemini 3.x", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("google", "gemini-3-flash-preview"));
});
it("does not error when thinking is off for Gemini 3.1 Pro", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("google", "gemini-3.1-pro-preview"), {
requestOptions: { maxTokens: 512 },
minPongs: 20,
});
});
});
describe("Google Vertex thinking disable E2E", () => {
const vertexProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT;
const vertexLocation = process.env.GOOGLE_CLOUD_LOCATION;
const vertexApiKey = process.env.GOOGLE_CLOUD_API_KEY;
const vertexOptions = vertexApiKey
? ({ apiKey: vertexApiKey } satisfies SimpleOptionsWithExtras)
: vertexProject && vertexLocation
? ({ project: vertexProject, location: vertexLocation } satisfies SimpleOptionsWithExtras)
: undefined;
it.skipIf(!vertexOptions)("disables thinking for Gemini 2.5", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("google-vertex", "gemini-2.5-flash"), {
requestOptions: vertexOptions,
});
});
it.skipIf(!vertexOptions)("disables thinking for Gemini 3.x", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("google-vertex", "gemini-3-flash-preview"), {
requestOptions: vertexOptions,
});
});
});
describe("Google Gemini CLI thinking disable E2E", () => {
it.skipIf(!geminiCliToken)("disables thinking for Gemini 2.5", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("google-gemini-cli", "gemini-2.5-flash"), {
requestOptions: { apiKey: geminiCliToken! },
maxOutputTokens: 100,
});
});
});
describe("Google Antigravity thinking disable E2E", () => {
it.skipIf(!antigravityToken)("disables thinking for Gemini 3.x", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("google-antigravity", "gemini-3-flash"), {
requestOptions: { apiKey: antigravityToken! },
maxOutputTokens: 100,
});
});
});
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI thinking disable E2E", () => {
it("disables thinking for Responses reasoning models", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("openai", "gpt-5.4-mini"), {
requestOptions: { temperature: undefined },
});
});
});
describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter thinking disable E2E", () => {
it("disables thinking for Qwen 3.5 reasoning models", { retry: 2, timeout: 30000 }, async () => {
await expectThinkingDisabledE2E(getModel("openrouter", "qwen/qwen3.5-plus-02-15"), {
maxOutputTokens: 100,
});
});
});