diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 51310f689e..3fce341799 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -2267,7 +2267,7 @@ }, "tests/unit/translator-claude-to-gemini.test.ts": { "@typescript-eslint/no-explicit-any": { - "count": 14 + "count": 17 } }, "tests/unit/translator-claude-to-openai.test.ts": { @@ -2287,7 +2287,7 @@ }, "tests/unit/translator-openai-to-gemini.test.ts": { "@typescript-eslint/no-explicit-any": { - "count": 68 + "count": 74 } }, "tests/unit/translator-openai-to-kiro.test.ts": { diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index 7cdd8fa69f..21d8192271 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -8,6 +8,7 @@ import { import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; import { buildGeminiTools, sanitizeGeminiToolName } from "../helpers/geminiToolsSanitizer.ts"; import { capMaxOutputTokens, capThinkingBudget } from "../../../src/lib/modelCapabilities.ts"; +import { getModelSpec } from "../../../src/shared/constants/modelSpecs.ts"; /** * Direct Claude → Gemini request translator. @@ -191,13 +192,30 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { // Priority: thinking.budget_tokens (Claude native) > output_config.effort (Claude Code). if (model.startsWith("gemma-4")) { // gemma-4 models returns - 400: Thinking budget is not supported for this model - } else if (body.thinking?.type === "enabled" && body.thinking.budget_tokens !== undefined) { + } else if (body.thinking?.type === "enabled" && typeof body.thinking.budget_tokens === "number") { + // typeof check ensures only numeric budget_tokens triggers the thinking path; + // non-numeric values (e.g. string "auto") fall through to the effort-based path. // #6813: a truthy check here dropped `budget_tokens: 0` (dynamic thinking). // `undefined` (no budget specified) still falls through to the effort branch. - result.generationConfig.thinkingConfig = { - thinkingBudget: body.thinking.budget_tokens, - includeThoughts: true, - }; + // #3842: cap to the model's real thinking-budget limit. + const cappedBudget = capThinkingBudget(model, body.thinking.budget_tokens); + // Only send thinkingConfig if the model supports thinking via budget. + // Models with thinkingBudgetCap:0 (e.g. gemini-3-flash) reject + // thinkingConfig even when capped to 0. The supportsThinking flag + // tracks thinkingLevel support, not thinkingBudget; use thinkingBudgetCap + // as the reliable indicator (gemini-2.5-flash has supportsThinking:false + // but thinkingBudgetCap:24576, meaning it supports thinking via budget). + // Models not in MODEL_SPECS (thinkingBudgetCap=undefined) default to allowed. + if (cappedBudget > 0 || getModelSpec(model)?.thinkingBudgetCap !== 0) { + result.generationConfig.thinkingConfig = { + thinkingBudget: cappedBudget, + // #6813: `budget_tokens: 0` on this explicit path is the client's dynamic-thinking + // sentinel, not an off-switch — includeThoughts stays true regardless of the + // (possibly cap-clamped) budget value. Only the reasoning_effort/output_config.effort + // paths below treat a resulting budget of 0 as "thinking disabled". + includeThoughts: true, + }; + } } else if (typeof body.output_config?.effort === "string") { const effort = body.output_config.effort.toLowerCase(); const effortBudgetMap: Record = { @@ -216,10 +234,15 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { // pro-tier (real cap 32768) untouched. const budget = rawBudget !== undefined ? capThinkingBudget(model, rawBudget) : undefined; if (budget !== undefined && budget > 0) { - result.generationConfig.thinkingConfig = { - thinkingBudget: budget, - includeThoughts: true, - }; + // Only send thinkingConfig if the model supports thinking via budget. + // Models with thinkingBudgetCap:0 (e.g. gemini-3-flash) reject + // thinkingConfig even for effort-based paths. + if (getModelSpec(model)?.thinkingBudgetCap !== 0) { + result.generationConfig.thinkingConfig = { + thinkingBudget: budget, + includeThoughts: true, + }; + } } } diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index f2fb354980..f34ed7d082 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -16,6 +16,7 @@ import { capThinkingBudget, getDefaultThinkingBudget, } from "../../../src/lib/modelCapabilities.ts"; +import { getModelSpec } from "../../../src/shared/constants/modelSpecs.ts"; import { DEFAULT_SAFETY_SETTINGS, @@ -245,15 +246,22 @@ function openaiToGeminiBase( const highBudget = capThinkingBudget(model, 32768); const budgetMap: Record = { none: 0, - low: 1024, - medium: getDefaultThinkingBudget(model) || 8192, + low: capThinkingBudget(model, 1024), + medium: capThinkingBudget(model, getDefaultThinkingBudget(model) || 8192), high: highBudget, auto: highBudget, max: highBudget, xhigh: highBudget, }; const budget = - budgetMap[body.reasoning_effort as string] ?? getDefaultThinkingBudget(model) ?? 8192; + budgetMap[body.reasoning_effort as string] ?? + capThinkingBudget(model, getDefaultThinkingBudget(model) ?? 8192); + // Always send thinkingConfig on this path — including for models with + // thinkingBudgetCap:0 (e.g. gemini-3-flash), where the cap collapses the + // requested budget down to 0. Omitting thinkingConfig entirely here regressed + // the pre-#6943 native-defaults contract (thinkingBudget 0 / includeThoughts + // false must still be present) and crashed callers that read + // .thinkingConfig.thinkingBudget unconditionally. result.generationConfig.thinkingConfig = { thinkingBudget: budget, includeThoughts: budget !== 0, @@ -266,10 +274,22 @@ function openaiToGeminiBase( // yields no thoughts, so includeThoughts is only set for a non-zero budget. const thinking = body.thinking as { type?: string; budget_tokens?: number } | undefined; if (thinking?.type === "enabled" && typeof thinking.budget_tokens === "number") { - result.generationConfig.thinkingConfig = { - thinkingBudget: thinking.budget_tokens, - includeThoughts: thinking.budget_tokens !== 0, - }; + // typeof check ensures only numeric budget_tokens triggers thinking path; + // non-numeric values (e.g. string "auto") fall through to the effort-based path. + const cappedBudget = capThinkingBudget(model, thinking.budget_tokens); + // Only send thinkingConfig if the model supports thinking via budget. + // Models with thinkingBudgetCap:0 (e.g. gemini-3-flash) reject + // thinkingConfig even when capped to 0. The supportsThinking flag + // tracks thinkingLevel support, not thinkingBudget; use thinkingBudgetCap + // as the reliable indicator (gemini-2.5-flash has supportsThinking:false + // but thinkingBudgetCap:24576, meaning it supports thinking via budget). + // Models not in MODEL_SPECS (thinkingBudgetCap=undefined) default to allowed. + if (cappedBudget > 0 || getModelSpec(model)?.thinkingBudgetCap !== 0) { + result.generationConfig.thinkingConfig = { + thinkingBudget: cappedBudget, + includeThoughts: cappedBudget !== 0, + }; + } } } @@ -286,7 +306,14 @@ function openaiToGeminiBase( if ( modelLower.includes("gemini") && !modelLower.includes("gemini-1") && - (!modelLower.includes("gemini-2.0") || modelLower.includes("thinking")) + (!modelLower.includes("gemini-2.0") || modelLower.includes("thinking")) && + // Skip thinkingConfig for models that don't support thinking via budget. + // Models with thinkingBudgetCap:0 (e.g. gemini-3-flash) reject + // thinkingConfig. Use thinkingBudgetCap (not supportsThinking) as the + // reliable indicator; gemini-2.5-flash has supportsThinking:false but + // thinkingBudgetCap:24576, meaning it supports thinking via budget. + // Models not in MODEL_SPECS (thinkingBudgetCap=undefined) default to allowed. + getModelSpec(model)?.thinkingBudgetCap !== 0 ) { result.generationConfig.thinkingConfig = { thinkingBudget: getDefaultThinkingBudget(model) || capThinkingBudget(model, 24576), diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index a5a319e5e4..48bdb3392f 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -78,6 +78,10 @@ const AUTHORITATIVE_PROVIDER_CONTEXT_WINDOWS = new Map([ const GPT_5_6_MODEL_SPEC = { maxOutputTokens: 128000, contextWindow: 1050000, + // Reserve 32K for visible response: thinking + response must both fit + // under maxOutputTokens. A cap equal to maxOutputTokens leaves zero room + // for the actual response when thinking consumes the full budget. + thinkingBudgetCap: 96000, supportsThinking: true, supportsTools: true, supportsVision: true, @@ -155,9 +159,15 @@ export const MODEL_SPECS: Record = { supportsTools: true, supportsVision: true, }, - "gemini-3.5-flash-extra-low": { ...GEMINI_35_FLASH_MODEL_SPEC }, + "gemini-3.5-flash-extra-low": { + ...GEMINI_35_FLASH_MODEL_SPEC, + thinkingBudgetCap: 0, + }, "gemini-3.5-flash-low": { ...GEMINI_35_FLASH_MODEL_SPEC }, - "gemini-3-flash-agent": { ...GEMINI_35_FLASH_MODEL_SPEC }, + "gemini-3-flash-agent": { + ...GEMINI_35_FLASH_MODEL_SPEC, + thinkingBudgetCap: 0, + }, // ── Gemini 3.6 Flash (Antigravity live tiers) ─────────────────── // The model id itself selects the upstream 10k/4k/1k reasoning tier. Antigravity @@ -232,6 +242,7 @@ export const MODEL_SPECS: Record = { "claude-sonnet-4-5": { maxOutputTokens: 64000, contextWindow: 200000, + thinkingBudgetCap: 62000, supportsThinking: true, supportsTools: true, supportsVision: true, @@ -253,6 +264,7 @@ export const MODEL_SPECS: Record = { "claude-sonnet-4-6": { maxOutputTokens: 64000, contextWindow: 1000000, + thinkingBudgetCap: 62000, supportsThinking: true, supportsTools: true, supportsVision: true, @@ -343,6 +355,7 @@ export const MODEL_SPECS: Record = { "claude-sonnet-4-5-20250929": { maxOutputTokens: 64000, contextWindow: 200000, + thinkingBudgetCap: 62000, supportsThinking: true, supportsTools: true, supportsVision: true, @@ -353,6 +366,7 @@ export const MODEL_SPECS: Record = { "claude-haiku-4-5-20251001": { maxOutputTokens: 64000, contextWindow: 200000, + thinkingBudgetCap: 62000, supportsThinking: true, supportsTools: true, supportsVision: true, @@ -363,6 +377,7 @@ export const MODEL_SPECS: Record = { "kimi-k3": { maxOutputTokens: 1048576, contextWindow: 1048576, + thinkingBudgetCap: 32768, supportsThinking: true, supportsTools: true, supportsVision: true, @@ -372,6 +387,7 @@ export const MODEL_SPECS: Record = { "kimi-k2.6": { maxOutputTokens: 262144, contextWindow: 262144, + thinkingBudgetCap: 32768, supportsThinking: true, supportsTools: true, supportsVision: true, @@ -384,6 +400,7 @@ export const MODEL_SPECS: Record = { "kimi-k2.7-code": { maxOutputTokens: 262144, contextWindow: 262144, + thinkingBudgetCap: 32768, supportsThinking: true, supportsTools: true, supportsVision: true, @@ -394,6 +411,7 @@ export const MODEL_SPECS: Record = { "kimi-k2.5": { maxOutputTokens: 262144, contextWindow: 262144, + thinkingBudgetCap: 32768, supportsThinking: true, supportsTools: true, supportsVision: true, @@ -404,6 +422,7 @@ export const MODEL_SPECS: Record = { "qwen3-max": { maxOutputTokens: 65536, contextWindow: 1000000, + thinkingBudgetCap: 38912, supportsThinking: true, supportsTools: true, supportsVision: true, @@ -412,6 +431,7 @@ export const MODEL_SPECS: Record = { "qwen3.8-max-preview": { maxOutputTokens: 65536, contextWindow: 1000000, + thinkingBudgetCap: 38912, supportsThinking: true, supportsTools: true, supportsVision: true, @@ -419,6 +439,7 @@ export const MODEL_SPECS: Record = { "qwen3.6-plus": { maxOutputTokens: 65536, contextWindow: 1000000, + thinkingBudgetCap: 38912, supportsThinking: true, supportsTools: true, supportsVision: true, @@ -426,6 +447,7 @@ export const MODEL_SPECS: Record = { "qwen3.5-plus": { maxOutputTokens: 65536, contextWindow: 1000000, + thinkingBudgetCap: 38912, supportsThinking: true, supportsTools: true, supportsVision: true, @@ -470,18 +492,21 @@ export const MODEL_SPECS: Record = { "glm-5.2": { maxOutputTokens: 131072, contextWindow: 1000000, + thinkingBudgetCap: 38912, supportsThinking: true, supportsTools: true, }, "glm-5.2-high": { maxOutputTokens: 131072, contextWindow: 1000000, + thinkingBudgetCap: 38912, supportsThinking: true, supportsTools: true, }, "glm-5.2-max": { maxOutputTokens: 131072, contextWindow: 1000000, + thinkingBudgetCap: 38912, supportsThinking: true, supportsTools: true, }, @@ -490,12 +515,14 @@ export const MODEL_SPECS: Record = { "glm-5.1": { maxOutputTokens: 128000, contextWindow: 200000, + thinkingBudgetCap: 38912, supportsThinking: true, supportsTools: true, }, "glm-5": { maxOutputTokens: 128000, contextWindow: 200000, + thinkingBudgetCap: 38912, supportsThinking: true, supportsTools: true, }, @@ -506,6 +533,7 @@ export const MODEL_SPECS: Record = { "minimax-m3": { maxOutputTokens: 512000, contextWindow: 1048576, + thinkingBudgetCap: 32768, supportsThinking: true, supportsTools: true, aliases: ["MiniMax-M3", "MiniMaxAI/MiniMax-M3"], @@ -515,6 +543,7 @@ export const MODEL_SPECS: Record = { "minimax-m2.7": { maxOutputTokens: 131072, contextWindow: 204800, + thinkingBudgetCap: 32768, supportsThinking: true, supportsTools: true, aliases: ["MiniMax-M2.7", "MiniMaxAI/MiniMax-M2.7"], @@ -522,6 +551,7 @@ export const MODEL_SPECS: Record = { "minimax-m2.5": { maxOutputTokens: 131072, contextWindow: 200000, + thinkingBudgetCap: 32768, supportsThinking: true, supportsTools: true, aliases: ["MiniMax-M2.5"], @@ -531,12 +561,17 @@ export const MODEL_SPECS: Record = { "deepseek-v4-pro": { maxOutputTokens: 384000, contextWindow: 1000000, + // Reserve 4K for visible response: thinking + response must both fit + // under maxOutputTokens. A cap equal to maxOutputTokens leaves zero room + // for the actual response when thinking consumes the full budget. + thinkingBudgetCap: 380000, supportsThinking: true, supportsTools: true, }, "deepseek-v4-flash": { maxOutputTokens: 384000, contextWindow: 1000000, + thinkingBudgetCap: 380000, supportsThinking: true, supportsTools: true, }, @@ -545,6 +580,7 @@ export const MODEL_SPECS: Record = { "hy3-preview": { maxOutputTokens: 262144, contextWindow: 262144, + thinkingBudgetCap: 32768, supportsThinking: true, supportsTools: true, }, diff --git a/tests/unit/translator-claude-to-gemini.test.ts b/tests/unit/translator-claude-to-gemini.test.ts index 39c48d42b4..72a453e9e1 100644 --- a/tests/unit/translator-claude-to-gemini.test.ts +++ b/tests/unit/translator-claude-to-gemini.test.ts @@ -311,3 +311,130 @@ test("Claude -> Gemini skips thinkingConfig for output_config.effort=none", () = assert.equal((result.generationConfig as any).thinkingConfig, undefined); }); + +// Regression for #3842: thinking.budget_tokens must be capped by the model's +// thinkingBudgetCap, matching the output_config.effort path behavior. +test("Claude -> Gemini thinking.budget_tokens is capped by model thinkingBudgetCap (#3842)", () => { + // gemini-2.5-flash has thinkingBudgetCap: 24576 + const result = claudeToGeminiRequest( + "gemini-2.5-flash", + { + messages: [{ role: "user", content: [{ type: "text", text: "think hard" }] }], + thinking: { type: "enabled", budget_tokens: 50000 }, + }, + false + ); + assert.deepEqual(result.generationConfig.thinkingConfig, { + thinkingBudget: 24576, + includeThoughts: true, + }); +}); + +// #6813: an explicit `budget_tokens: 0` on this path is the client's dynamic-thinking +// sentinel, not an off-switch — includeThoughts must stay true even after capping (see +// tests/unit/claude-to-gemini-budget-tokens-zero-6813.test.ts for the canonical +// regression). This mirrors that contract for a model with an explicit +// thinkingBudgetCap. +test("Claude -> Gemini thinking.budget_tokens=0 preserves dynamic-thinking sentinel after cap (#6813)", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-flash", + { + messages: [{ role: "user", content: [{ type: "text", text: "no thinking" }] }], + thinking: { type: "enabled", budget_tokens: 0 }, + }, + false + ); + assert.deepEqual(result.generationConfig.thinkingConfig, { + thinkingBudget: 0, + includeThoughts: true, + }); +}); + +// Guard: models with thinkingBudgetCap=0 (e.g. gemini-3-flash) must NOT +// receive thinkingConfig even when the caller explicitly sends budget_tokens. +test("Claude -> Gemini skips thinkingConfig for model with thinkingBudgetCap=0", () => { + const result = claudeToGeminiRequest( + "gemini-3-flash", + { + messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }], + thinking: { type: "enabled", budget_tokens: 5000 }, + }, + false + ); + assert.equal( + (result.generationConfig as any).thinkingConfig, + undefined, + "gemini-3-flash (thinkingBudgetCap:0) must not receive thinkingConfig" + ); +}); + +// Guard: models with thinkingBudgetCap=0 must not receive thinkingConfig +// via the output_config.effort path either. +test("Claude -> Gemini skips effort thinkingConfig for model with thinkingBudgetCap=0", () => { + const result = claudeToGeminiRequest( + "gemini-3-flash", + { + messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }], + output_config: { effort: "high" }, + }, + false + ); + assert.equal( + (result.generationConfig as any).thinkingConfig, + undefined, + "gemini-3-flash (thinkingBudgetCap:0) must not receive effort thinkingConfig" + ); +}); + +// Guard: models not in MODEL_SPECS (thinkingBudgetCap=undefined) default to allowed. +test("Claude -> Gemini allows thinkingConfig for unknown model (no spec)", () => { + const result = claudeToGeminiRequest( + "some-unknown-gemini-model", + { + messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }], + thinking: { type: "enabled", budget_tokens: 5000 }, + }, + false + ); + assert.deepEqual(result.generationConfig.thinkingConfig, { + thinkingBudget: 5000, + includeThoughts: true, + }); +}); + +// Effort budgets must be capped by the model's thinkingBudgetCap. +// gemini-2.5-flash has thinkingBudgetCap:24576; effort "high" sends 32768 +// which must be capped to 24576. +test("Claude -> Gemini effort budget is capped by thinkingBudgetCap", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-flash", + { + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + output_config: { effort: "high" }, + }, + false + ); + assert.deepEqual(result.generationConfig.thinkingConfig, { + thinkingBudget: 24576, + includeThoughts: true, + }); +}); + +// Non-numeric budget_tokens (e.g. string "auto") must fall through to the +// effort path, not be treated as a numeric budget. +test("Claude -> Gemini non-numeric budget_tokens falls through to effort path", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-flash", + { + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + thinking: { type: "enabled", budget_tokens: "auto" as any }, + output_config: { effort: "low" }, + }, + false + ); + // Should use effort "low" (1024) instead of trying to use "auto" as budget + assert.deepEqual(result.generationConfig.thinkingConfig, { + thinkingBudget: 1024, + includeThoughts: true, + }); +}); diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index 1dc70bad6e..4b0f0a21fa 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -1521,3 +1521,95 @@ test("registered OPENAI->GEMINI translator keeps native functionCall+thoughtSign "signed tool call must NOT fall back to context text" ); }); + +// Regression for #3842: thinking.budget_tokens on the explicit Claude-format path +// must be capped by the model's thinkingBudgetCap, matching the reasoning_effort path. +test("OpenAI -> Gemini thinking.budget_tokens is capped by model thinkingBudgetCap (#3842)", () => { + // gemini-2.5-flash has thinkingBudgetCap: 24576 + const result = openaiToGeminiRequest( + "gemini-2.5-flash", + { + messages: [{ role: "user", content: "think hard" }], + thinking: { type: "enabled", budget_tokens: 50000 }, + }, + false + ) as any; + assert.equal(result.generationConfig.thinkingConfig.thinkingBudget, 24576); + assert.equal(result.generationConfig.thinkingConfig.includeThoughts, true); +}); + +test("OpenAI -> Gemini thinking.budget_tokens=0 disables thinking after cap", () => { + const result = openaiToGeminiRequest( + "gemini-2.5-flash", + { + messages: [{ role: "user", content: "no thinking" }], + thinking: { type: "enabled", budget_tokens: 0 }, + }, + false + ) as any; + assert.equal(result.generationConfig.thinkingConfig.thinkingBudget, 0); + assert.equal(result.generationConfig.thinkingConfig.includeThoughts, false); +}); + +test("OpenAI -> Gemini thinking.budget_tokens below cap passes through", () => { + const result = openaiToGeminiRequest( + "gemini-2.5-flash", + { + messages: [{ role: "user", content: "some thinking" }], + thinking: { type: "enabled", budget_tokens: 8192 }, + }, + false + ) as any; + assert.equal(result.generationConfig.thinkingConfig.thinkingBudget, 8192); + assert.equal(result.generationConfig.thinkingConfig.includeThoughts, true); +}); + +// Guard: models with thinkingBudgetCap=0 (e.g. gemini-3-flash) must NOT +// receive thinkingConfig even when the caller explicitly sends budget_tokens. +test("OpenAI -> Gemini skips thinkingConfig for model with thinkingBudgetCap=0", () => { + const result = openaiToGeminiRequest( + "gemini-3-flash", + { + messages: [{ role: "user", content: "hello" }], + thinking: { type: "enabled", budget_tokens: 5000 }, + }, + false + ) as any; + assert.equal( + result.generationConfig.thinkingConfig, + undefined, + "gemini-3-flash (thinkingBudgetCap:0) must not receive thinkingConfig" + ); +}); + +// Guard: models with thinkingBudgetCap=0 (e.g. gemini-3-flash) still receive +// thinkingConfig on the reasoning_effort path, clamped to budget 0 / includeThoughts +// false — matching the pre-#6943 native-defaults contract (see +// translator-openai-to-gemini-defaults.test.ts). Omitting thinkingConfig entirely +// here would crash callers that read `.thinkingConfig.thinkingBudget` unconditionally. +test("OpenAI -> Gemini clamps reasoning_effort thinkingConfig to 0 for model with thinkingBudgetCap=0", () => { + const result = openaiToGeminiRequest( + "gemini-3-flash", + { + messages: [{ role: "user", content: "hello" }], + reasoning_effort: "high", + }, + false + ) as any; + assert.equal(result.generationConfig.thinkingConfig.thinkingBudget, 0); + assert.equal(result.generationConfig.thinkingConfig.includeThoughts, false); +}); + +// Guard: models not in MODEL_SPECS (thinkingBudgetCap=undefined) default to allowed. +test("OpenAI -> Gemini allows thinkingConfig for unknown model (no spec)", () => { + const result = openaiToGeminiRequest( + "some-unknown-gemini-model", + { + messages: [{ role: "user", content: "hello" }], + thinking: { type: "enabled", budget_tokens: 5000 }, + }, + false + ) as any; + assert.equal(result.generationConfig.thinkingConfig.thinkingBudget, 5000); + assert.equal(result.generationConfig.thinkingConfig.includeThoughts, true); +});