diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 13e5a265..5f05282c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -11,6 +11,7 @@ - Fixed OpenAI Responses replay for foreign tool-call item IDs by hashing foreign `function_call.id` values into bounded `fc_` 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 diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index 4f474e1a..f72cad1c 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -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" }; } } diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index a7ece809..926f4ab4 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -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; +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) { diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index 99bd57ba..b2faa865 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -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">, diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index 46ce2299..5a1242fd 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -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">, diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index dba0b641..fc95f717 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -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); diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index a2e56404..238403e0 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -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" }; } } diff --git a/packages/ai/test/google-thinking-disable.test.ts b/packages/ai/test/google-thinking-disable.test.ts new file mode 100644 index 00000000..6d38ad71 --- /dev/null +++ b/packages/ai/test/google-thinking-disable.test.ts @@ -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; + +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( + model: Model, + options: SimpleOptionsWithExtras = {}, +): Promise { + 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(model: Model, 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, + }); + }); +});