diff --git a/changelog.d/fixes/6943-gemini-thinking-none-effort.md b/changelog.d/fixes/6943-gemini-thinking-none-effort.md new file mode 100644 index 0000000000..0c6f67f2b9 --- /dev/null +++ b/changelog.d/fixes/6943-gemini-thinking-none-effort.md @@ -0,0 +1 @@ +- fix(providers): `openai->gemini` transform now maps `reasoning_effort: "none"` to `thinkingConfig.thinkingBudget: 0` (with `includeThoughts: false`), giving callers an explicit, documented off-switch for Gemini thinking; the no-knob-at-all default injection (#4170) is unchanged (#6813, thanks @rafaumeu) diff --git a/open-sse/translator/request/__tests__/openai-to-gemini.test.ts b/open-sse/translator/request/__tests__/openai-to-gemini.test.ts new file mode 100644 index 0000000000..2fad3bfa94 --- /dev/null +++ b/open-sse/translator/request/__tests__/openai-to-gemini.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { openaiToGeminiRequest } from "../openai-to-gemini"; + +describe("translator/request/openai-to-gemini.ts", () => { + describe("thinking budget handling (issue #6813)", () => { + it("should pass budget_tokens: 0 without dropping to default", () => { + // Zero budget yields no thoughts, so includeThoughts is false here — this is + // the already-merged #6821 fix for #6813 defect 1 (explicit numeric check, + // not truthy, so budget_tokens:0 isn't silently dropped to the default). + const body = { + model: "gemini/gemini-2.5-flash", + messages: [{ role: "user", content: "hi" }], + safetySettings: [], + thinking: { type: "enabled", budget_tokens: 0 }, + }; + const result = openaiToGeminiRequest("gemini/gemini-2.5-flash", body, false); + expect(result.generationConfig?.thinkingConfig?.thinkingBudget).toBe(0); + expect(result.generationConfig?.thinkingConfig?.includeThoughts).toBe(false); + }); + + it("should pass budget_tokens: 1", () => { + const body = { + model: "gemini/gemini-2.5-flash", + messages: [{ role: "user", content: "hi" }], + safetySettings: [], + thinking: { type: "enabled", budget_tokens: 1 }, + }; + const result = openaiToGeminiRequest("gemini/gemini-2.5-flash", body, false); + expect(result.generationConfig?.thinkingConfig?.thinkingBudget).toBe(1); + }); + + it("should still inject default thinkingConfig when no knobs present (#4170)", () => { + // Modern Gemini 2.5+ models think by default even with no thinkingConfig sent, + // so includeThoughts:true must stay on for the no-knob case or the model's + // reasoning leaks into visible content instead of reasoning_content (#4170). + // The supported off-switch for the "I don't want to pay for thinking" case + // (#6813 defect 2) is the explicit `reasoning_effort: "none"` knob below, + // not silent no-knob-at-all suppression. + const body = { + model: "gemini/gemini-2.5-flash", + messages: [{ role: "user", content: "hi" }], + safetySettings: [], + }; + const result = openaiToGeminiRequest("gemini/gemini-2.5-flash", body, false); + expect(result.generationConfig?.thinkingConfig?.includeThoughts).toBe(true); + expect(result.generationConfig?.thinkingConfig?.thinkingBudget).toBeGreaterThan(0); + }); + + it("should set thinkingBudget 0 (and includeThoughts false) when reasoning_effort: none", () => { + // A zero budget yields no thoughts at all, so includeThoughts is false here — + // consistent with the explicit budget_tokens:0 handling above (#6821/#6813). + const body = { + model: "gemini/gemini-2.5-flash", + messages: [{ role: "user", content: "hi" }], + safetySettings: [], + reasoning_effort: "none", + }; + const result = openaiToGeminiRequest("gemini/gemini-2.5-flash", body, false); + expect(result.generationConfig?.thinkingConfig?.thinkingBudget).toBe(0); + expect(result.generationConfig?.thinkingConfig?.includeThoughts).toBe(false); + }); + + it("should map reasoning_effort: low to thinkingBudget: 1024", () => { + const body = { + model: "gemini/gemini-2.5-flash", + messages: [{ role: "user", content: "hi" }], + safetySettings: [], + reasoning_effort: "low", + }; + const result = openaiToGeminiRequest("gemini/gemini-2.5-flash", body, false); + expect(result.generationConfig?.thinkingConfig?.thinkingBudget).toBe(1024); + }); + + it("should map reasoning_effort: medium to thinkingBudget: 10240", () => { + const body = { + model: "custom-model", + messages: [{ role: "user", content: "hi" }], + safetySettings: [], + reasoning_effort: "medium", + }; + const result = openaiToGeminiRequest("custom-model", body, false); + // medium falls back to getDefaultThinkingBudget which may return 8192 + expect(result.generationConfig?.thinkingConfig?.thinkingBudget).toBeGreaterThanOrEqual(1024); + }); + + it("should map reasoning_effort: high to thinkingBudget: 24576", () => { + const body = { + model: "gemini/gemini-2.5-flash", + messages: [{ role: "user", content: "hi" }], + safetySettings: [], + reasoning_effort: "high", + }; + const result = openaiToGeminiRequest("gemini/gemini-2.5-flash", body, false); + expect(result.generationConfig?.thinkingConfig?.thinkingBudget).toBe(24576); + }); + }); +}); diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 2d3fb8811f..a45fb3232d 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -195,14 +195,17 @@ function openaiToGeminiBase( if (model.startsWith("gemma-4")) { // gemma-4 models returns - 400: Thinking budget is not supported for this model } else { - // 1. OpenAI format: reasoning_effort (low/medium/high/auto/max/xhigh) + // 1. OpenAI format: reasoning_effort (none/low/medium/high/auto/max/xhigh) // "auto", "max", and "xhigh" are clamped to the high-tier budget because Gemini // does not accept these strings directly. "auto" signals "use max reasonable effort" // which maps to high. "max"/"xhigh" exceed Gemini's accepted range and are clamped. + // "none" maps to budget 0 — an explicit, documented off-switch (#6813 defect 2), + // distinct from the no-knob-at-all default-injection case below (#4170). // Port of decolua/9router#2043 by @nguyenxvotanminh3. if (body.reasoning_effort) { const highBudget = capThinkingBudget(model, 32768); const budgetMap: Record = { + none: 0, low: 1024, medium: getDefaultThinkingBudget(model) || 8192, high: highBudget, @@ -214,7 +217,7 @@ function openaiToGeminiBase( budgetMap[body.reasoning_effort as string] ?? getDefaultThinkingBudget(model) ?? 8192; result.generationConfig.thinkingConfig = { thinkingBudget: budget, - includeThoughts: true, + includeThoughts: budget !== 0, }; } // 2. Claude format: thinking (type: enabled, budget_tokens) @@ -236,7 +239,9 @@ function openaiToGeminiBase( // thinking.type), still set includeThoughts so the upstream marks thought // parts with thought:true. Without this, the model's reasoning leaks into // visible content instead of being routed to reasoning_content by the - // response translator. (#4170) + // response translator. (#4170) — this default-injection case is intentionally + // unconditional (no-knob-at-all still gets includeThoughts:true); the explicit + // "reasoning_effort: none" off-switch above (#6813) is the supported opt-out. if (!result.generationConfig.thinkingConfig) { const modelLower = model.toLowerCase(); if (