diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 48ba9a078..bb95125e3 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -81,7 +81,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `codexAccountMode?` | `"pool" \| "direct"` | Canonical `openai` only; defaults to Pool. Direct bypasses pool state. | | `refreshPolicy?` | `"proactive" \| "lazy-only" \| "disabled"` | Override this OAuth provider's Token Guardian policy. | | `reasoningEfforts?` | `string[]` | Provider-wide Codex reasoning labels to advertise and send. | -| `modelReasoningEfforts?` | `Record` | Per-model labels. An empty list hides effort control. | +| `modelReasoningEfforts?` | `Record` | Per-model labels. An empty list hides effort control. For `google`-adapter providers a configured ladder is also a capability assertion: the selected effort is sent on the wire as `generationConfig.thinkingConfig.thinkingLevel` (except Cloud Code Assist and image-capable models), so only configure a ladder for models that accept that field. | | `modelSupportsReasoningSummaries?` | `Record` | Set a model to `false` to stop advertising summaries and strip summary-delivery fields. | | `modelReasoningSummaryDelivery?` | `Record` | Per-model Responses delivery enum; rewrites an existing delivery field. | | `modelAdapters?` | `Record` | Per-model `openai-chat` or `openai-responses` wire override for mixed-wire gateways. Explicit entries beat registry defaults; DeepSeek's preset can select native Responses for `deepseek-v4-flash`, and GitHub Copilot declares Responses-only defaults for its GPT-5 family (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`) because those models reject `/chat/completions` for agent traffic. Models without a built-in default (for example `gpt-5.4-nano`) can be opted in here. Single-wire upstream pins and canonical ChatGPT forward reject overrides. | diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 93366a2ab..9f97b6ac1 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -30,7 +30,7 @@ import { type TranslatorBudget, } from "../lib/translator-budget"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; -import { mapReasoningEffort } from "../reasoning-effort"; +import { configuredReasoningEfforts, mapReasoningEffort } from "../reasoning-effort"; // Google-family models (Gemini/Vertex/Antigravity) tend to emit long running commentary between // tool calls. This steers them to keep the BETWEEN-STEP text to one line and reason internally @@ -340,12 +340,22 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (parsed.options.temperature !== undefined) generationConfig.temperature = parsed.options.temperature; if (parsed.options.topP !== undefined) generationConfig.topP = parsed.options.topP; if (parsed.options.stopSequences) generationConfig.stopSequences = parsed.options.stopSequences; - const directFlashThinking = provider.googleMode !== "vertex" - && provider.googleMode !== "cloud-code-assist" - && (parsed.modelId === "gemini-3.5-flash" || parsed.modelId === "gemini-3.6-flash") + // Effort → thinkingLevel follows the configured ladder: any model advertising reasoning + // efforts (registry preset or user config) sends the mapped level, so a picker-selected + // effort actually reaches the wire (gemini-3.1-pro-preview ships a ladder). The original + // gemini-3.5/3.6-flash direct-mode slice stays hardcoded so unladdered configs keep their + // current behavior; Vertex participates only through an explicitly configured ladder (the + // seed google-vertex entry ships none). Image models are excluded — thinkingConfig would + // suppress the responseModalities fallback below. CCA maps effort on its envelope path. + const thinkingEligible = provider.googleMode !== "cloud-code-assist" + && !isImageCapableModel(parsed.modelId) + && (configuredReasoningEfforts(provider, parsed.modelId) !== undefined + || (provider.googleMode !== "vertex" + && (parsed.modelId === "gemini-3.5-flash" || parsed.modelId === "gemini-3.6-flash"))); + const thinkingLevel = thinkingEligible ? mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning) : undefined; - if (directFlashThinking) generationConfig.thinkingConfig = { thinkingLevel: directFlashThinking }; + if (thinkingLevel) generationConfig.thinkingConfig = { thinkingLevel }; if (!generationConfig.thinkingConfig && isImageCapableModel(parsed.modelId)) { generationConfig.responseModalities = ["TEXT", "IMAGE"]; } diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index e1d4e31e5..3e39cdc78 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -314,6 +314,82 @@ describe("google provider hardening", () => { expect(JSON.parse(antigravity.body).request.generationConfig).toBeUndefined(); }); + test("effort ladder drives thinkingLevel beyond the flash slice", async () => { + const direct = createGoogleAdapter(provider({ + modelReasoningEfforts: { "gemini-3.1-pro-preview": ["low", "medium", "high"] }, + })); + const proHigh = await direct.buildRequest({ + ...parsed(), + modelId: "gemini-3.1-pro-preview", + options: { reasoning: "high" }, + }); + const proMinimal = await direct.buildRequest({ + ...parsed(), + modelId: "gemini-3.1-pro-preview", + options: { reasoning: "minimal" }, + }); + const proUnset = await direct.buildRequest({ + ...parsed(), + modelId: "gemini-3.1-pro-preview", + }); + const unladdered = await direct.buildRequest({ + ...parsed(), + modelId: "gemini-3.5-flash-lite", + options: { reasoning: "high" }, + }); + + expect(JSON.parse(proHigh.body).generationConfig.thinkingConfig).toEqual({ thinkingLevel: "high" }); + // minimal is not on the pro-preview ladder; the clamp lands on the nearest supported tier. + expect(JSON.parse(proMinimal.body).generationConfig.thinkingConfig).toEqual({ thinkingLevel: "low" }); + expect(JSON.parse(proUnset.body).generationConfig).toBeUndefined(); + expect(JSON.parse(unladdered.body).generationConfig).toBeUndefined(); + }); + + test("Vertex sends thinkingLevel only when a ladder is explicitly configured", async () => { + const frozen = createGoogleAdapter(provider({ googleMode: "vertex" })); + const opted = createGoogleAdapter(provider({ + googleMode: "vertex", + modelReasoningEfforts: { "gemini-3-pro": ["low", "medium", "high"] }, + })); + const withoutLadder = await frozen.buildRequest({ + ...parsed(), + modelId: "gemini-3.5-flash", + options: { reasoning: "high" }, + }); + const withLadder = await opted.buildRequest({ + ...parsed(), + modelId: "gemini-3-pro", + options: { reasoning: "high" }, + }); + + expect(JSON.parse(withoutLadder.body).generationConfig).toBeUndefined(); + expect(JSON.parse(withLadder.body).generationConfig.thinkingConfig).toEqual({ thinkingLevel: "high" }); + }); + + test("unladdered direct flash keeps its hardcoded thinking slice", async () => { + const bare = createGoogleAdapter(provider()); + const flash = await bare.buildRequest({ + ...parsed(), + modelId: "gemini-3.6-flash", + options: { reasoning: "medium" }, + }); + + expect(JSON.parse(flash.body).generationConfig.thinkingConfig).toEqual({ thinkingLevel: "medium" }); + }); + + test("image models keep responseModalities even with a provider-wide effort ladder", async () => { + const direct = createGoogleAdapter(provider({ reasoningEfforts: ["low", "high"] })); + const image = await direct.buildRequest({ + ...parsed(), + modelId: "gemini-3.1-flash-image", + options: { reasoning: "high" }, + }); + + const generationConfig = JSON.parse(image.body).generationConfig; + expect(generationConfig.thinkingConfig).toBeUndefined(); + expect(generationConfig.responseModalities).toEqual(["TEXT", "IMAGE"]); + }); + test("publishes audited AI Studio metadata while Vertex stays frozen", () => { const google = PROVIDER_REGISTRY.find(entry => entry.id === "google"); const vertex = PROVIDER_REGISTRY.find(entry => entry.id === "google-vertex");