fix(ai): handle explicit thinking off across providers closes #2490
This commit is contained in:
@@ -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 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 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
|
## [0.61.1] - 2026-03-20
|
||||||
|
|
||||||
|
|||||||
@@ -240,18 +240,7 @@ function buildParams(
|
|||||||
};
|
};
|
||||||
params.include = ["reasoning.encrypted_content"];
|
params.include = ["reasoning.encrypted_content"];
|
||||||
} else {
|
} else {
|
||||||
if (model.name.toLowerCase().startsWith("gpt-5")) {
|
params.reasoning = { effort: "none" };
|
||||||
// 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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -889,6 +889,8 @@ export function buildRequest(
|
|||||||
} else if (options.thinking.budgetTokens !== undefined) {
|
} else if (options.thinking.budgetTokens !== undefined) {
|
||||||
generationConfig.thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
|
generationConfig.thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
|
||||||
}
|
}
|
||||||
|
} else if (model.reasoning && options.thinking && !options.thinking.enabled) {
|
||||||
|
generationConfig.thinkingConfig = getDisabledThinkingConfig(model.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const request: CloudCodeAssistRequest["request"] = {
|
const request: CloudCodeAssistRequest["request"] = {
|
||||||
@@ -946,6 +948,21 @@ export function buildRequest(
|
|||||||
|
|
||||||
type ClampedThinkingLevel = Exclude<ThinkingLevel, "xhigh">;
|
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 {
|
function getGeminiCliThinkingLevel(effort: ClampedThinkingLevel, modelId: string): GoogleThinkingLevel {
|
||||||
if (isGemini3ProModel(modelId)) {
|
if (isGemini3ProModel(modelId)) {
|
||||||
switch (effort) {
|
switch (effort) {
|
||||||
|
|||||||
@@ -436,6 +436,8 @@ function buildParams(
|
|||||||
thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
|
thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
|
||||||
}
|
}
|
||||||
config.thinkingConfig = thinkingConfig;
|
config.thinkingConfig = thinkingConfig;
|
||||||
|
} else if (model.reasoning && options.thinking && !options.thinking.enabled) {
|
||||||
|
config.thinkingConfig = getDisabledThinkingConfig(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.signal) {
|
if (options.signal) {
|
||||||
@@ -464,6 +466,22 @@ function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
|
|||||||
return /gemini-3(?:\.\d+)?-flash/.test(model.id.toLowerCase());
|
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(
|
function getGemini3ThinkingLevel(
|
||||||
effort: ClampedThinkingLevel,
|
effort: ClampedThinkingLevel,
|
||||||
model: Model<"google-generative-ai">,
|
model: Model<"google-generative-ai">,
|
||||||
|
|||||||
@@ -371,6 +371,8 @@ function buildParams(
|
|||||||
thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
|
thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
|
||||||
}
|
}
|
||||||
config.thinkingConfig = thinkingConfig;
|
config.thinkingConfig = thinkingConfig;
|
||||||
|
} else if (model.reasoning && options.thinking && !options.thinking.enabled) {
|
||||||
|
config.thinkingConfig = getDisabledThinkingConfig(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.signal) {
|
if (options.signal) {
|
||||||
@@ -399,6 +401,21 @@ function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
|
|||||||
return /gemini-3(?:\.\d+)?-flash/.test(model.id.toLowerCase());
|
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(
|
function getGemini3ThinkingLevel(
|
||||||
effort: ClampedThinkingLevel,
|
effort: ClampedThinkingLevel,
|
||||||
model: Model<"google-generative-ai">,
|
model: Model<"google-generative-ai">,
|
||||||
|
|||||||
@@ -410,12 +410,16 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio
|
|||||||
(params as any).enable_thinking = !!options?.reasoningEffort;
|
(params as any).enable_thinking = !!options?.reasoningEffort;
|
||||||
} else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) {
|
} else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) {
|
||||||
(params as any).chat_template_kwargs = { enable_thinking: !!options?.reasoningEffort };
|
(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.
|
// OpenRouter normalizes reasoning across providers via a nested reasoning object.
|
||||||
const openRouterParams = params as typeof params & { reasoning?: { effort?: string } };
|
const openRouterParams = params as typeof params & { reasoning?: { effort?: string } };
|
||||||
openRouterParams.reasoning = {
|
if (options?.reasoningEffort) {
|
||||||
effort: mapReasoningEffort(options.reasoningEffort, compat.reasoningEffortMap),
|
openRouterParams.reasoning = {
|
||||||
};
|
effort: mapReasoningEffort(options.reasoningEffort, compat.reasoningEffortMap),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
openRouterParams.reasoning = { effort: "none" };
|
||||||
|
}
|
||||||
} else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) {
|
} else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) {
|
||||||
// OpenAI-style reasoning_effort
|
// OpenAI-style reasoning_effort
|
||||||
(params as any).reasoning_effort = mapReasoningEffort(options.reasoningEffort, compat.reasoningEffortMap);
|
(params as any).reasoning_effort = mapReasoningEffort(options.reasoningEffort, compat.reasoningEffortMap);
|
||||||
|
|||||||
@@ -221,18 +221,7 @@ function buildParams(model: Model<"openai-responses">, context: Context, options
|
|||||||
};
|
};
|
||||||
params.include = ["reasoning.encrypted_content"];
|
params.include = ["reasoning.encrypted_content"];
|
||||||
} else {
|
} else {
|
||||||
if (model.name.startsWith("gpt-5")) {
|
params.reasoning = { effort: "none" };
|
||||||
// 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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
183
packages/ai/test/google-thinking-disable.test.ts
Normal file
183
packages/ai/test/google-thinking-disable.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user