diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 3752356b52e..1c556d44899 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -357,7 +357,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `inlineThinkTagModels?` | `string[]` | Opt-in recovery for `openai-chat` gateways without a server-side reasoning parser. A leading `` / `` / `` block (optionally after whitespace) activates splitting in streamed and buffered replies. All answer whitespace is preserved. Subsequent tags are delimiters anywhere, including same-line interleaving and code fences; this mode does not interpret Markdown. Ordinary text or a code fence before the first tag keeps the whole reply untouched. Off by default; prefer structured upstream reasoning or `reasoningSplitModels` where supported. | | `reasoningDetailsModels?` | `string[]` | Models whose endpoint returns thinking as a structured `reasoning_details` array (MiniMax M-series with `reasoning_split`); stream deltas are cumulative snapshots that are prefix-diffed, and preserved reasoning replays as a `reasoning_details` array instead of a `reasoning_content` string. | | `requiresReasoningPlaceholderModels?` | `string[]` | Models whose upstream rejects a tool_call continuation missing `reasoning_content` (DeepSeek thinking mode); a minimal placeholder is injected when the replay cache misses. Defaults to `preserveReasoningContentModels`; set `[]` to opt out. A provider save that keeps the destination keeps the stored list, including `[]`; see [What a provider save keeps](#what-a-provider-save-keeps). `PATCH /api/providers?name=` accepts an array or `null` to clear it. | -| `showThinkingSummary?` | `boolean` | Display provider-authored summaries when a Responses client omits `reasoning.summary`. Explicit wire `"none"` wins; a client that serializes its preference as omission cannot be distinguished. Raw reasoning remains content and is never relabeled as a summary. The `google-antigravity` preset defaults to `true`; explicit `false` disables that default. CCA Gemini requests also opt into `generationConfig.thinkingConfig.includeThoughts` when display is enabled; image, Claude and gpt-oss requests do not. This does not change client configuration or global catalog summary defaults. | +| `showThinkingSummary?` | `boolean` | Display provider-authored summaries when a Responses client omits `reasoning.summary`. Explicit wire `"none"` wins; a client that serializes its preference as omission cannot be distinguished. Raw reasoning remains content and is never relabeled as a summary. The `google-antigravity` preset defaults to `true`; explicit `false` disables that default. Eligible CCA, Vertex and direct Gemini requests also opt into `generationConfig.thinkingConfig.includeThoughts` when display is enabled; image, Claude, gpt-oss and unladdered Vertex requests do not. This does not change client configuration or global catalog summary defaults. | | `hideRawReasoning?` | `boolean` | Keep a routed provider's raw chain of thought off the client-facing content channel (openai-chat `reasoning_content`, kiro tags, `response.reasoning_text.*`) while provider-authored summaries still stream on the summary channel. Explicit wire `reasoning.summary: "none"` keeps hiding both. This controls what a client displays; it does not keep the text from the client. A Responses bridge route still sends it inside the `ocxr1` replay envelope in the reasoning item's `encrypted_content`, which is base64 JSON rather than encryption, so the client can echo it back on the next turn. The direct Chat and Messages encoders have no envelope on their wire, so those clients receive no copy and replay comes from the server-side reasoning replay cache. `preserveReasoningContentModels` replay and DeepSeek-style continuations are unaffected either way. Off by default; a native passthrough route relays the upstream's own frames and ignores this option. A save of the same provider name keeps an omitted value even after a destination move; `PATCH` accepts a boolean or `null` to clear it. | | `thinkingToggleModels?` | `string[]` | Chat models using `thinking.enabled` rather than an effort ladder. | | `thinkingBudgetModels?` | `string[]` | Chat models using integer `thinking_budget`; effort maps to a budget fraction. | diff --git a/src/adapters/google.ts b/src/adapters/google.ts index ed55b5870f5..90e7e9fd504 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1026,7 +1026,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte : provider.googleMode === "vertex" || openCodeZenGeminiEndpoint(provider, parsed.modelId) !== undefined ? parsed.modelId : resolveDirectGeminiWireModelId(parsed.modelId, provider.directGeminiWireRenames !== false); - returnsThoughtSummaries = provider.googleMode === "cloud-code-assist" + const configuredEfforts = configuredReasoningEfforts(provider, parsed.modelId); + const thinkingEligible = provider.googleMode !== "cloud-code-assist" + && !isImageCapableModel(parsed.modelId) + && (configuredEfforts !== undefined + ? configuredEfforts.length > 0 + : (provider.googleMode !== "vertex" + && (parsed.modelId === "gemini-3.5-flash" || parsed.modelId === "gemini-3.6-flash"))); + returnsThoughtSummaries = (provider.googleMode === "cloud-code-assist" || (thinkingEligible && provider.showThinkingSummary === true)) && /^gemini-/.test(routedModelId) && !isImageCapableModel(parsed.modelId); // AI Studio's `-tiered` spelling is wire-only; CCA aliases may migrate to another generation. const identityModelId = provider.googleMode === "cloud-code-assist" ? routedModelId : parsed.modelId; @@ -1065,15 +1072,19 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // 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 (thinkingLevel) generationConfig.thinkingConfig = { thinkingLevel }; + const includeThoughts = thinkingEligible + && provider.showThinkingSummary === true + && parsed.options.hideThinkingSummary !== true + && /^gemini-/.test(routedModelId); + if (thinkingLevel || includeThoughts) { + generationConfig.thinkingConfig = { + ...(thinkingLevel ? { thinkingLevel } : {}), + ...(includeThoughts ? { includeThoughts: true } : {}), + }; + } if (!generationConfig.thinkingConfig && isImageCapableModel(parsed.modelId)) { generationConfig.responseModalities = ["TEXT", "IMAGE"]; } diff --git a/structure/providers/google.md b/structure/providers/google.md index 050021d8257..fd99d84bfbd 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -75,12 +75,13 @@ these fixtures do not attest live upstream availability. ## Google thought-text visibility boundary Google-family parts with `thought: true` stay separate from assistant output. After a CCA -Gemini request is built, the shared streaming/buffered classifier emits `thinking_delta` for -these provider-authored summaries. Other Google wires, non-Gemini CCA models and uninitialized -adapters retain `reasoning_raw_delta`. Model provenance is refreshed on every build. +Gemini request or a `showThinkingSummary`-opted-in Gemini request is built, the shared +streaming/buffered classifier emits `thinking_delta` for these provider-authored summaries. +Other Google wires without the opt-in, non-Gemini models and uninitialized adapters retain +`reasoning_raw_delta`. Model provenance is refreshed on every build. `showThinkingSummary` defaults on only for the Antigravity preset; explicit provider false and -explicit wire summary none win. Eligible CCA Gemini requests use `includeThoughts: true` only -when provider opt-in and per-request display both allow it. Thought signatures remain attached +explicit wire summary none win. Eligible CCA, Vertex and direct Gemini requests use +`includeThoughts: true` only when provider opt-in and per-request display both allow it. Thought signatures remain attached to their tool calls independently; they never become Anthropic thinking signatures. The visibility marker is the boolean `thought: true`, not the presence of a `thoughtSignature` diff --git a/tests/adapters/google/google-adapter.test.ts b/tests/adapters/google/google-adapter.test.ts index ad11c4a6c5a..8d54e0c442b 100644 --- a/tests/adapters/google/google-adapter.test.ts +++ b/tests/adapters/google/google-adapter.test.ts @@ -870,6 +870,205 @@ describe("google adapter — Antigravity thought-text opt-in", () => { }); +describe("google adapter — Vertex and direct Gemini thought-text opt-in", () => { + const vertexBase = { + adapter: "google", + googleMode: "vertex", + baseUrl: "https://aiplatform.googleapis.com", + apiKey: "vertex-key", + project: "fixture-proj", + location: "global", + modelReasoningEfforts: { "gemini-4-argon": ["low", "medium", "high"] }, + } as const; + const directBase = { + adapter: "google", + baseUrl: "https://generativelanguage.googleapis.com", + apiKey: "direct-key", + modelReasoningEfforts: { "gemini-4-argon": ["low", "medium", "high"] }, + } as const; + + /** Builds a minimal parsed request for Vertex/direct Gemini thought-opt-in tests. */ + function makeParsed(modelId: string, effort?: string, hideThinkingSummary?: boolean): OcxParsedRequest { + return { + modelId, + stream: false, + options: { ...(effort ? { reasoning: effort } : {}), ...(hideThinkingSummary ? { hideThinkingSummary } : {}) }, + context: { messages: [{ role: "user", content: "hi" }], tools: [] }, + } as unknown as OcxParsedRequest; + } + + /** Extracts the compiled generationConfig.thinkingConfig from a built request. */ + async function wireThinkingConfig( + providerConfig: Record, + modelId: string, + effort?: string, + hideThinkingSummary?: boolean, + ): Promise | undefined> { + const { body } = await createGoogleAdapter(providerConfig as never) + .buildRequest(makeParsed(modelId, effort, hideThinkingSummary)); + const parsedBody = JSON.parse(body) as { + generationConfig?: { thinkingConfig?: Record }; + }; + return parsedBody.generationConfig?.thinkingConfig; + } + + test("opts Vertex and direct Gemini into includeThoughts when showThinkingSummary is true", async () => { + const vertexOptIn = { ...vertexBase, showThinkingSummary: true }; + const directOptIn = { ...directBase, showThinkingSummary: true }; + expect(await wireThinkingConfig(vertexOptIn, "gemini-4-argon", "high")) + .toEqual({ thinkingLevel: "high", includeThoughts: true }); + expect(await wireThinkingConfig(vertexOptIn, "gemini-4-argon")) + .toEqual({ includeThoughts: true }); + expect(await wireThinkingConfig(directOptIn, "gemini-4-argon", "medium")) + .toEqual({ thinkingLevel: "medium", includeThoughts: true }); + expect(await wireThinkingConfig(directOptIn, "gemini-3.5-flash")) + .toEqual({ includeThoughts: true }); + }); + + test("omits includeThoughts when showThinkingSummary is absent, false, or client-suppressed", async () => { + const vertexOptIn = { ...vertexBase, showThinkingSummary: true }; + expect(await wireThinkingConfig(vertexBase, "gemini-4-argon", "high")) + .toEqual({ thinkingLevel: "high" }); + expect(await wireThinkingConfig({ ...vertexBase, showThinkingSummary: false }, "gemini-4-argon", "high")) + .toEqual({ thinkingLevel: "high" }); + expect(await wireThinkingConfig(vertexOptIn, "gemini-4-argon", "high", true)) + .toEqual({ thinkingLevel: "high" }); + expect(await wireThinkingConfig(vertexOptIn, "gemini-4-argon", undefined, true)) + .toBeUndefined(); + }); + + test("excludes unladdered Vertex models and image-capable models from includeThoughts", async () => { + const vertexOptIn = { ...vertexBase, showThinkingSummary: true }; + expect(await wireThinkingConfig(vertexOptIn, "gemini-2.5-flash", "high")).toBeUndefined(); + expect(await wireThinkingConfig(vertexOptIn, "gemini-3.1-flash-image", "high")).toBeUndefined(); + expect(await wireThinkingConfig({ ...vertexOptIn, noReasoningModels: ["gemini-4-argon"] }, "gemini-4-argon", "high")).toBeUndefined(); + expect(await wireThinkingConfig({ ...directBase, showThinkingSummary: true, noReasoningModels: ["gemini-3.5-flash"] }, "gemini-3.5-flash", "high")).toBeUndefined(); + }); + + test("emits thinking_delta for Vertex and direct Gemini only when showThinkingSummary is true", async () => { + const payload = { + candidates: [{ + content: { parts: [{ thought: true, text: "vertex summary" }, { text: "answer" }] }, + finishReason: "STOP", + }], + }; + + /** Reads the first adapter event from either a streaming SSE or buffered JSON response. */ + async function extractFirstEvent( + adapter: ReturnType, + stream: boolean, + ): Promise { + if (stream) { + for await (const event of adapter.parseStream( + new Response(`data: ${JSON.stringify(payload)} + +`, { + headers: { "content-type": "text/event-stream" }, + }), + )) { + return event; + } + return undefined; + } + const events = await adapter.parseResponse!(Response.json(payload)); + return events[0]; + } + + for (const stream of [false, true]) { + const optedInVertexAdapter = withTestTranslatorBudget( + createGoogleAdapter({ ...vertexBase, showThinkingSummary: true } as never), + ); + await optedInVertexAdapter.buildRequest(makeParsed("gemini-4-argon", "high")); + expect(await extractFirstEvent(optedInVertexAdapter, stream)).toEqual({ + type: "thinking_delta", + thinking: "vertex summary", + }); + + const optedInDirectAdapter = withTestTranslatorBudget( + createGoogleAdapter({ ...directBase, showThinkingSummary: true } as never), + ); + await optedInDirectAdapter.buildRequest(makeParsed("gemini-3.5-flash", "high")); + expect(await extractFirstEvent(optedInDirectAdapter, stream)).toEqual({ + type: "thinking_delta", + thinking: "vertex summary", + }); + + const defaultAdapter = withTestTranslatorBudget(createGoogleAdapter(vertexBase as never)); + await defaultAdapter.buildRequest(makeParsed("gemini-4-argon", "high")); + expect(await extractFirstEvent(defaultAdapter, stream)).toEqual({ + type: "reasoning_raw_delta", + text: "vertex summary", + }); + + const unladderedAdapter = withTestTranslatorBudget( + createGoogleAdapter({ ...vertexBase, showThinkingSummary: true } as never), + ); + await unladderedAdapter.buildRequest(makeParsed("gemini-2.5-flash", "high")); + expect(await extractFirstEvent(unladderedAdapter, stream)).toEqual({ + type: "reasoning_raw_delta", + text: "vertex summary", + }); + } + }); + + test("reused adapter resets summary provenance across eligible and ineligible Vertex and direct Gemini models", async () => { + const payload = { + candidates: [{ + content: { parts: [{ thought: true, text: "thought text" }, { text: "answer" }] }, + finishReason: "STOP", + }], + }; + + /** Reads the first adapter event from either a streaming SSE or buffered JSON response. */ + async function extractFirstEvent( + adapter: ReturnType, + stream: boolean, + ): Promise { + if (stream) { + for await (const event of adapter.parseStream( + new Response(`data: ${JSON.stringify(payload)} + +`, { + headers: { "content-type": "text/event-stream" }, + }), + )) { + return event; + } + return undefined; + } + const events = await adapter.parseResponse!(Response.json(payload)); + return events[0]; + } + + for (const stream of [false, true]) { + const vertexAdapter = withTestTranslatorBudget( + createGoogleAdapter({ ...vertexBase, showThinkingSummary: true } as never), + ); + for (const [modelId, expected] of [ + ["gemini-4-argon", { type: "thinking_delta", thinking: "thought text" }], + ["gemini-2.5-flash", { type: "reasoning_raw_delta", text: "thought text" }], + ["gemini-4-argon", { type: "thinking_delta", thinking: "thought text" }], + ["gemini-3.1-flash-image", { type: "reasoning_raw_delta", text: "thought text" }], + ] as const) { + await vertexAdapter.buildRequest(makeParsed(modelId, "high")); + expect(await extractFirstEvent(vertexAdapter, stream)).toEqual(expected); + } + + const directAdapter = withTestTranslatorBudget( + createGoogleAdapter({ ...directBase, showThinkingSummary: true } as never), + ); + for (const [modelId, expected] of [ + ["gemini-3.5-flash", { type: "thinking_delta", thinking: "thought text" }], + ["gemini-3.1-flash-image", { type: "reasoning_raw_delta", text: "thought text" }], + ["gemini-3.5-flash", { type: "thinking_delta", thinking: "thought text" }], + ] as const) { + await directAdapter.buildRequest(makeParsed(modelId, "high")); + expect(await extractFirstEvent(directAdapter, stream)).toEqual(expected); + } + } + }); +}); + describe("CCA thought summary provenance and replay", () => { const cca = { adapter: "google", googleMode: "cloud-code-assist", baseUrl: "https://daily-cloudcode-pa.googleapis.com", apiKey: "fixture-key", project: "fixture-project", showThinkingSummary: true } as const;