From 45594e150f81467631589c4145eaa8a2f5a41bf4 Mon Sep 17 00:00:00 2001 From: Eran Date: Sat, 12 Sep 2026 00:24:37 +0800 Subject: [PATCH 1/6] feat(responses): provider-opted visible thinking summaries via showThinkingSummary Codex omits reasoning.summary by default, so parseRequest hides all thinking in replay-only envelopes and genuine reasoning (e.g. Gemini thought parts on the google-antigravity CCA wire) never reaches the client. Add a provider-level showThinkingSummary flag, honored in applyFinalRouteRequestNormalization; an explicit client summary none still wins. Seed it true for the google-antigravity preset; operators can set false to opt back out. --- .../docs/reference/configuration/providers.md | 1 + scripts/test-layout/layout.json | 1 + src/providers/derive.ts | 4 + src/providers/registry.ts | 8 +- src/server/auth-cors.ts | 1 + src/server/responses/core.ts | 11 +++ src/types/provider.ts | 9 ++ tests/fixtures/test-layout-expected.json | 1 + .../responses-show-thinking-summary.test.ts | 94 +++++++++++++++++++ 9 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/responses/responses-show-thinking-summary.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 23b7e5e92d..eabf40dfa5 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -208,6 +208,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `preserveReasoningContentModels?` | `string[]` | Models requiring prior assistant `reasoning_content` in chat history. | | `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. | +| `showThinkingSummary?` | `boolean` | Opt-in: surface upstream thinking as visible reasoning summaries even when the client omits `reasoning.summary` (the Codex default, which otherwise keeps thinking in hidden replay envelopes). An explicit client `reasoning.summary: "none"` still wins. Seeded `true` for `google-antigravity`; set `false` to opt back out. | | `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. | | `noVisionModels?` | `string[]` | Text-only models sent through the vision sidecar; matching tolerates an Ollama `:size` tag. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7241f26266..bb89493127 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1101,6 +1101,7 @@ "responses-routed-web-search-fields.test.ts": "responses", "responses-self-named-namespace-scrub.test.ts": "responses", "responses-shadow-intercept.test.ts": "responses", + "responses-show-thinking-summary.test.ts": "responses", "responses-snapshot-repair-server.test.ts": "responses", "responses-snapshot-repair.test.ts": "responses", "responses-state-write-amplification.test.ts": "responses", diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 72a662aee4..3ab1d01500 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -44,6 +44,7 @@ export interface DerivedKeyLoginProvider { autoToolChoiceOnlyModels?: string[]; preserveReasoningContentModels?: string[]; requiresReasoningPlaceholderModels?: string[]; + showThinkingSummary?: boolean; reasoningSplitModels?: string[]; reasoningDetailsModels?: string[]; thinkingToggleModels?: string[]; @@ -271,6 +272,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}), ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}), ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}), + ...(entry.showThinkingSummary !== undefined ? { showThinkingSummary: entry.showThinkingSummary } : {}), ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}), ...(entry.reasoningDetailsModels ? { reasoningDetailsModels: [...entry.reasoningDetailsModels] } : {}), ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}), @@ -320,6 +322,7 @@ export function deriveKeyLoginMap(): Record { ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}), ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}), ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}), + ...(entry.showThinkingSummary !== undefined ? { showThinkingSummary: entry.showThinkingSummary } : {}), ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}), ...(entry.reasoningDetailsModels ? { reasoningDetailsModels: [...entry.reasoningDetailsModels] } : {}), ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}), @@ -574,6 +577,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (!prov.thinkingToggleModels && seed.thinkingToggleModels) prov.thinkingToggleModels = [...seed.thinkingToggleModels]; if (!prov.thinkingBudgetModels && seed.thinkingBudgetModels) prov.thinkingBudgetModels = [...seed.thinkingBudgetModels]; if (prov.escapeBuiltinToolNames === undefined && seed.escapeBuiltinToolNames !== undefined) prov.escapeBuiltinToolNames = seed.escapeBuiltinToolNames; + if (prov.showThinkingSummary === undefined && seed.showThinkingSummary !== undefined) prov.showThinkingSummary = seed.showThinkingSummary; if (prov.keyOptional === undefined && seed.keyOptional !== undefined) prov.keyOptional = seed.keyOptional; if (prov.freeTier === undefined && seed.freeTier !== undefined) prov.freeTier = seed.freeTier; if (prov.modelSuffixBracketStrip === undefined && seed.modelSuffixBracketStrip !== undefined) prov.modelSuffixBracketStrip = seed.modelSuffixBracketStrip; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index e2189254fc..d2bee8888c 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -356,6 +356,10 @@ export interface ProviderRegistryEntry { autoToolChoiceOnlyModels?: string[]; preserveReasoningContentModels?: string[]; requiresReasoningPlaceholderModels?: string[]; + /** + * Opt this provider into visible thinking summaries (see OcxProviderConfig.showThinkingSummary). + */ + showThinkingSummary?: boolean; reasoningSplitModels?: string[]; reasoningDetailsModels?: string[]; thinkingToggleModels?: string[]; @@ -380,7 +384,7 @@ export type ProviderConfigSeed = Pick< | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens" | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat" | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels" - | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance" + | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance" | "showThinkingSummary" | "googleMode" | "project" | "location" | "headers" >; @@ -2112,7 +2116,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // path must stay RELATIVE: this row sets `allowBaseUrlOverride`, and an absolute `url` would // retarget a user's custom base back to Google. A leading `./` is required because a bare // `v1internal:` reads as a URL scheme and `providerModelDiscoverySpecError` rejects it. - { id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"], modelDiscovery: { path: "./v1internal:fetchAvailableModels" } }, + { id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", showThinkingSummary: true, jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"], modelDiscovery: { path: "./v1internal:fetchAvailableModels" } }, { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 0910698a0c..e59029a3e2 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -887,6 +887,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { autoToolChoiceOnlyModels: "editor", preserveReasoningContentModels: "editor", requiresReasoningPlaceholderModels: "editor", + showThinkingSummary: "editor", retryOn429: "editor", transientRetryOn5xx: "editor", reasoningSplitModels: "editor", diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index a10d97d282..8860885e8c 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2514,6 +2514,17 @@ async function applyFinalRouteRequestNormalization(args: { // this request will actually use (#404). route.provider = resolveOpenCodeGoTransport(route.provider, getOrAllocateRequestSessionLane(req)); route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); + // Provider-opted visible thinking (e.g. google-antigravity): parseRequest hides thinking + // whenever the client omits reasoning.summary, which is the Codex default. A provider that + // serves genuine user-facing reasoning opts back into the summary channel here, so thought + // parts (Gemini thought, content-channel reasoning_text) reach the client instead of only + // the hidden replay envelopes. An explicit client reasoning.summary "none" still wins. + if (route.provider.showThinkingSummary === true && parsed.options.hideThinkingSummary === true) { + const rawReasoning = (parsed._rawBody as { reasoning?: { summary?: unknown } } | undefined)?.reasoning; + const explicitNone = typeof rawReasoning === "object" && rawReasoning !== null + && (rawReasoning as { summary?: unknown }).summary === "none"; + if (!explicitNone) parsed.options.hideThinkingSummary = false; + } if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId; logCtx.model = route.modelId; logCtx.provider = route.providerName; diff --git a/src/types/provider.ts b/src/types/provider.ts index d559ffef8f..d0eefc758a 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -773,6 +773,15 @@ export interface OcxProviderConfig { * out explicitly (e.g. MiniMax, where low effort disables thinking). */ requiresReasoningPlaceholderModels?: string[]; + /** + * Opt-in: surface upstream thinking as visible reasoning summaries even when the + * client did not send `reasoning.summary`. parseRequest hides thinking by default + * (Codex omits the field), which strands genuine reasoning — e.g. Gemini `thought` + * parts on the google-antigravity (Cloud Code Assist) wire — in hidden replay + * envelopes. An explicit client `reasoning.summary: "none"` still wins. Set `false` + * to opt a seeded preset back out. + */ + showThinkingSummary?: boolean; /** * Opt-in same-target 429 retry policy. Codex itself never retries 429 (it retries 5xx only, * openai/codex#30471), and single-key pools have no failover, so the proxy waits and replays diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 62724ffed2..fe7422dfb2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -936,6 +936,7 @@ "responses-routed-web-search-fields.test.ts": "responses", "responses-self-named-namespace-scrub.test.ts": "responses", "responses-shadow-intercept.test.ts": "responses", + "responses-show-thinking-summary.test.ts": "responses", "responses-snapshot-repair-server.test.ts": "responses", "responses-snapshot-repair.test.ts": "responses", "responses-state-write-amplification.test.ts": "responses", diff --git a/tests/responses/responses-show-thinking-summary.test.ts b/tests/responses/responses-show-thinking-summary.test.ts new file mode 100644 index 0000000000..5bd06b7658 --- /dev/null +++ b/tests/responses/responses-show-thinking-summary.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { handleResponses } from "../../src/server/responses/core"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +// Provider-opted visible thinking (showThinkingSummary): a provider that serves +// genuine user-facing reasoning surfaces it on the summary channel even when the +// client omits reasoning.summary (the Codex default, which otherwise hides all +// thinking in replay-only envelopes). An explicit client summary of "none" still +// wins and keeps thinking hidden. + +function shownSeed() { + const seed = providerConfigSeed(getProviderRegistryEntry("deepseek")!); + return { ...seed, apiKey: "sk-test", showThinkingSummary: true } as OcxProviderConfig; +} + +function sseFrame(payload: unknown): string { + return "data: " + JSON.stringify(payload) + "\n\n"; +} + +const SSE_UPSTREAM = [ + sseFrame({ type: "response.created", response: { id: "resp_1", status: "in_progress", output: [] } }), + sseFrame({ type: "response.output_item.added", output_index: 0, item: { type: "reasoning", id: "rs_1", status: "in_progress", content: [], summary: [] } }), + sseFrame({ type: "response.reasoning_text.delta", content_index: 0, delta: "think", item_id: "rs_1", output_index: 0 }), + sseFrame({ type: "response.reasoning_text.done", content_index: 0, text: "think", item_id: "rs_1", output_index: 0 }), + sseFrame({ type: "response.output_item.done", output_index: 0, item: { type: "reasoning", id: "rs_1", status: "completed", content: [{ type: "reasoning_text", text: "think" }], summary: [] } }), + sseFrame({ type: "response.completed", response: { id: "resp_1", status: "completed", output: [{ type: "reasoning", id: "rs_1", status: "completed", content: [{ type: "reasoning_text", text: "think" }], summary: [] }] } }), +].join(""); + +async function runHandleResponses(body: Record, seed: OcxProviderConfig) { + const encoder = new TextEncoder(); + globalThis.fetch = (async () => new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(SSE_UPSTREAM)); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + )) as typeof fetch; + const config = { providers: { deepseek: seed } } as unknown as OcxConfig; + return handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + config, + { model: "", provider: "" }, + { abortSignal: AbortSignal.timeout(5_000) }, + ); +} + +describe("showThinkingSummary provider option", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { globalThis.fetch = originalFetch; }); + + test("omitted client summary still surfaces thinking on the summary channel", async () => { + const response = await runHandleResponses( + { model: "deepseek-v4-flash", input: "ping", stream: true }, + shownSeed(), + ); + const text = await response.text(); + expect(text).toContain("response.reasoning_summary_text.delta"); + expect(text).toContain('"summary":[{"type":"summary_text","text":"think"}]'); + }); + + test("explicit client summary none keeps thinking hidden", async () => { + const response = await runHandleResponses( + { model: "deepseek-v4-flash", input: "ping", stream: true, reasoning: { summary: "none" } }, + shownSeed(), + ); + const text = await response.text(); + expect(text).not.toContain("response.reasoning_summary_text.delta"); + expect(text).toContain("response.reasoning_text.delta"); + }); + + test("without the provider option, omitted summary stays hidden", async () => { + const seed = { ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "sk-test" } as OcxProviderConfig; + const response = await runHandleResponses( + { model: "deepseek-v4-flash", input: "ping", stream: true }, + seed, + ); + const text = await response.text(); + expect(text).not.toContain("response.reasoning_summary_text.delta"); + expect(text).toContain("response.reasoning_text.delta"); + }); + + test("google-antigravity preset opts in", () => { + expect(providerConfigSeed(getProviderRegistryEntry("google-antigravity")!).showThinkingSummary).toBe(true); + expect(providerConfigSeed(getProviderRegistryEntry("deepseek")!).showThinkingSummary).toBeUndefined(); + }); +}); From 9d5876073901f892a8f9d24e4961dd5e7e69c945 Mon Sep 17 00:00:00 2001 From: Eran Date: Sat, 12 Sep 2026 00:37:38 +0800 Subject: [PATCH 2/6] fix(review): pin explicit-none guard in helper, add CCA raw-delta regression test Address review feedback: extract clientExplicitlyHidThinking with a pinned comment so the omitted-vs-none distinction cannot rot, and cover the real antigravity wire (google adapter thought parts as reasoning_raw_delta reaching the summary channel) instead of only native Responses shapes. --- src/server/responses/core.ts | 22 +++++-- .../responses-show-thinking-summary.test.ts | 63 +++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 8860885e8c..6bb826c566 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2473,6 +2473,20 @@ async function resolveSubagentFallbackModelEligibility(args: { }; } +/** + * Whether the client explicitly asked for hidden thinking (`reasoning.summary: "none"`). + * + * Pinned: parseRequest collapses "omitted" and "none" into one hideThinkingSummary + * flag, so the raw request body is the ONLY place that still distinguishes them. + * Provider opt-ins like showThinkingSummary must consult this — never the flag + * alone — or a future caller that copies only the flag would silently unlock an + * explicit opt-out. + */ +function clientExplicitlyHidThinking(parsed: OcxParsedRequest): boolean { + const rawReasoning = (parsed._rawBody as { reasoning?: { summary?: unknown } } | undefined)?.reasoning; + return typeof rawReasoning === "object" && rawReasoning !== null + && (rawReasoning as { summary?: unknown }).summary === "none"; +} /** * Apply every route-dependent request mutation against the final selected route. * Must run only after subagent fallback has settled the model/provider. @@ -2519,11 +2533,9 @@ async function applyFinalRouteRequestNormalization(args: { // serves genuine user-facing reasoning opts back into the summary channel here, so thought // parts (Gemini thought, content-channel reasoning_text) reach the client instead of only // the hidden replay envelopes. An explicit client reasoning.summary "none" still wins. - if (route.provider.showThinkingSummary === true && parsed.options.hideThinkingSummary === true) { - const rawReasoning = (parsed._rawBody as { reasoning?: { summary?: unknown } } | undefined)?.reasoning; - const explicitNone = typeof rawReasoning === "object" && rawReasoning !== null - && (rawReasoning as { summary?: unknown }).summary === "none"; - if (!explicitNone) parsed.options.hideThinkingSummary = false; + if (route.provider.showThinkingSummary === true && parsed.options.hideThinkingSummary === true + && !clientExplicitlyHidThinking(parsed)) { + parsed.options.hideThinkingSummary = false; } if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId; logCtx.model = route.modelId; diff --git a/tests/responses/responses-show-thinking-summary.test.ts b/tests/responses/responses-show-thinking-summary.test.ts index 5bd06b7658..d8bda03f4b 100644 --- a/tests/responses/responses-show-thinking-summary.test.ts +++ b/tests/responses/responses-show-thinking-summary.test.ts @@ -1,4 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { providerConfigSeed } from "../../src/providers/derive"; import { getProviderRegistryEntry } from "../../src/providers/registry"; import { handleResponses } from "../../src/server/responses/core"; @@ -91,4 +94,64 @@ describe("showThinkingSummary provider option", () => { expect(providerConfigSeed(getProviderRegistryEntry("google-antigravity")!).showThinkingSummary).toBe(true); expect(providerConfigSeed(getProviderRegistryEntry("deepseek")!).showThinkingSummary).toBeUndefined(); }); + + test("CCA thought parts surface on the summary channel", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-show-thinking-")); + const prevHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; + writeFileSync(join(home, "auth.json"), JSON.stringify({ + "google-antigravity": { + activeAccountId: "active", + accounts: [{ + id: "active", + credential: { + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 3_600_000, + projectId: "project-id", + }, + }], + }, + })); + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + seen.push(String(input)); + return Response.json({ + response: { + candidates: [{ + content: { parts: [{ thought: true, text: "cca-think" }, { text: "OK" }] }, + finishReason: "STOP", + }], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15, thoughtsTokenCount: 3 }, + }, + }); + }) as typeof fetch; + try { + const seed = { + ...providerConfigSeed(getProviderRegistryEntry("google-antigravity")!), + liveModels: false, + models: ["gemini-3.8-flash"], + } as OcxProviderConfig; + const config = { providers: { "google-antigravity": seed } } as unknown as OcxConfig; + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "google-antigravity/gemini-3.8-flash", input: "ping", stream: false, reasoning: { effort: "low" } }), + }), + config, + { model: "", provider: "" }, + { abortSignal: AbortSignal.timeout(10_000) }, + ); + const text = await response.text(); + expect(seen).toHaveLength(1); + expect(seen[0]).toContain("v1internal:generateContent"); + expect(text).toContain('"summary":[{"type":"summary_text","text":"cca-think"}]'); + expect(text).toContain("OK"); + } finally { + if (prevHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); + } + }); }); From 8250513fb5937db78137e2a663891b9351bbb79c Mon Sep 17 00:00:00 2001 From: Eran Date: Sat, 12 Sep 2026 00:50:40 +0800 Subject: [PATCH 3/6] docs(providers): name antigravity transport and auth mode in showThinkingSummary row --- docs-site/src/content/docs/reference/configuration/providers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index eabf40dfa5..164deacfd0 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -208,7 +208,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `preserveReasoningContentModels?` | `string[]` | Models requiring prior assistant `reasoning_content` in chat history. | | `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. | -| `showThinkingSummary?` | `boolean` | Opt-in: surface upstream thinking as visible reasoning summaries even when the client omits `reasoning.summary` (the Codex default, which otherwise keeps thinking in hidden replay envelopes). An explicit client `reasoning.summary: "none"` still wins. Seeded `true` for `google-antigravity`; set `false` to opt back out. | +| `showThinkingSummary?` | `boolean` | Opt-in: surface upstream thinking as visible reasoning summaries even when the client omits `reasoning.summary` (the Codex default, which otherwise keeps thinking in hidden replay envelopes). An explicit client `reasoning.summary: "none"` still wins. `google-antigravity` is an OAuth-only `google` provider using the Cloud Code Assist wire and is seeded `true`; set `false` to opt back out. | | `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. | | `noVisionModels?` | `string[]` | Text-only models sent through the vision sidecar; matching tolerates an Ollama `:size` tag. | From 25c652b3e6222154d6be43d630e5e4f5f2c52262 Mon Sep 17 00:00:00 2001 From: Eran Date: Sat, 12 Sep 2026 01:20:48 +0800 Subject: [PATCH 4/6] fix(review): backfill showThinkingSummary on the routed request path routedProviderConfig never calls enrichProviderFromRegistry, so saved rows predating the flag kept it undefined and the opt-in stayed dead. Backfill from the registry entry following the supportsOpenAiWebSearchToolFields pattern; explicit user values still win. Regression test now deletes the key from the seed to simulate an old persisted row. --- src/router.ts | 7 +++++++ tests/responses/responses-show-thinking-summary.test.ts | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/src/router.ts b/src/router.ts index bd8e9dc690..70e427b74b 100644 --- a/src/router.ts +++ b/src/router.ts @@ -413,6 +413,13 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined ? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent } : {}), + // The request path resolves through routedProviderConfig() and never calls + // enrichProviderFromRegistry(), so a saved provider row written before the + // registry learned this flag must be backfilled here or route.provider never + // carries it and the showThinkingSummary opt-in stays dead. + ...(provider.showThinkingSummary === undefined && registryEntry.showThinkingSummary !== undefined + ? { showThinkingSummary: registryEntry.showThinkingSummary } + : {}), // Registry-only client-facing repair policy (#938): fill only when the // saved provider has no explicit policy; clone so runtime never aliases // the registry constant. diff --git a/tests/responses/responses-show-thinking-summary.test.ts b/tests/responses/responses-show-thinking-summary.test.ts index d8bda03f4b..eab8343383 100644 --- a/tests/responses/responses-show-thinking-summary.test.ts +++ b/tests/responses/responses-show-thinking-summary.test.ts @@ -132,6 +132,10 @@ describe("showThinkingSummary provider option", () => { liveModels: false, models: ["gemini-3.8-flash"], } as OcxProviderConfig; + // Simulate a saved provider row written before the registry learned the flag: + // the request path must backfill it from the registry entry (routedProviderConfig), + // enrichProviderFromRegistry never runs there. + delete (seed as Record).showThinkingSummary; const config = { providers: { "google-antigravity": seed } } as unknown as OcxConfig; const response = await handleResponses( new Request("http://localhost/v1/responses", { From a16cda5859631af4206f28bc7b2be961650bde15 Mon Sep 17 00:00:00 2001 From: Eran Date: Sat, 12 Sep 2026 05:44:06 +0800 Subject: [PATCH 5/6] feat(google): request Gemini thought text on Cloud Code Assist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloud Code Assist serves thinking for Gemini either way — thoughtsTokenCount stays non-zero — but returns no `thought` text unless generationConfig.thinkingConfig .includeThoughts is set. Probed 2026-09-12 against the live CCA endpoint: gemini-3.8-flash-high, no thinkingConfig -> 0 thought parts, 321 thoughts tokens gemini-3.8-flash-high + includeThoughts -> 358 chars of reasoning gemini-3.7-flash-tiered + includeThoughts -> 652 chars of reasoning So showThinkingSummary surfaced nothing for Antigravity Gemini models: the summary channel had no text to carry. The adapter now sets the flag for Gemini wire ids when the provider opted into visible thinking and the request did not explicitly hide it, and the wire compiler keeps the key instead of stripping it as an unknown field. Scoped to Gemini: Claude-on-CCA accepts the flag but never returns thought parts, and gpt-oss rejects it outright (400 INVALID_ARGUMENT), so neither is asked. Image-capable models stay excluded so thinkingConfig cannot suppress the responseModalities fallback. Verified end to end through the proxy on gemini-3.8-flash: streamed response.reasoning_summary_text.delta carried 405 chars of chain-of-thought, and the non-streaming path returned a reasoning item whose summary holds the full text. --- .../docs/reference/configuration/providers.md | 2 +- src/adapters/google-wire-compiler.ts | 20 ++++-- src/adapters/google.ts | 20 +++++- tests/adapters/google/google-adapter.test.ts | 68 +++++++++++++++++++ .../google/google-wire-compiler.test.ts | 24 +++++++ 5 files changed, 125 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 164deacfd0..424adb4e9a 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -208,7 +208,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `preserveReasoningContentModels?` | `string[]` | Models requiring prior assistant `reasoning_content` in chat history. | | `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. | -| `showThinkingSummary?` | `boolean` | Opt-in: surface upstream thinking as visible reasoning summaries even when the client omits `reasoning.summary` (the Codex default, which otherwise keeps thinking in hidden replay envelopes). An explicit client `reasoning.summary: "none"` still wins. `google-antigravity` is an OAuth-only `google` provider using the Cloud Code Assist wire and is seeded `true`; set `false` to opt back out. | +| `showThinkingSummary?` | `boolean` | Opt-in: surface upstream thinking as visible reasoning summaries even when the client omits `reasoning.summary` (the Codex default, which otherwise keeps thinking in hidden replay envelopes). An explicit client `reasoning.summary: "none"` still wins. `google-antigravity` is an OAuth-only `google` provider using the Cloud Code Assist wire and is seeded `true`; set `false` to opt back out. On that wire the opt-in also sets `generationConfig.thinkingConfig.includeThoughts` for Gemini models — Cloud Code Assist reports `thoughtsTokenCount` either way but sends no `thought` text without it (Gemini only; Claude is never asked and `gpt-oss` rejects the field with `INVALID_ARGUMENT`). | | `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. | | `noVisionModels?` | `string[]` | Text-only models sent through the vision sidecar; matching tolerates an Ollama `:size` tag. | diff --git a/src/adapters/google-wire-compiler.ts b/src/adapters/google-wire-compiler.ts index 88c482ba7d..aa835e50b4 100644 --- a/src/adapters/google-wire-compiler.ts +++ b/src/adapters/google-wire-compiler.ts @@ -130,12 +130,20 @@ function compileGenerationConfig(value: unknown): JsonObject | undefined { ))].slice(0, 5); if (stopSequences.length > 0) out.stopSequences = stopSequences; } - if (isObject(value.thinkingConfig) && typeof value.thinkingConfig.thinkingLevel === "string") { - const raw = value.thinkingConfig.thinkingLevel.toLowerCase(); - const thinkingLevel = GOOGLE_THINKING_LEVELS.has(raw) - ? raw - : (["xhigh", "max", "ultra"].includes(raw) ? "high" : undefined); - if (thinkingLevel) out.thinkingConfig = { thinkingLevel }; + if (isObject(value.thinkingConfig)) { + const thinking: JsonObject = {}; + if (typeof value.thinkingConfig.thinkingLevel === "string") { + const raw = value.thinkingConfig.thinkingLevel.toLowerCase(); + const thinkingLevel = GOOGLE_THINKING_LEVELS.has(raw) + ? raw + : (["xhigh", "max", "ultra"].includes(raw) ? "high" : undefined); + if (thinkingLevel) thinking.thinkingLevel = thinkingLevel; + } + // The one key that makes Google return `thought: true` text. Cloud Code Assist serves + // thinking either way (thoughtsTokenCount stays non-zero) but withholds the text unless the + // request opts in, so dropping it here silently reinstates the missing-thinking behavior. + if (value.thinkingConfig.includeThoughts === true) thinking.includeThoughts = true; + if (Object.keys(thinking).length > 0) out.thinkingConfig = thinking; } if (Array.isArray(value.responseModalities)) { const valid = value.responseModalities.filter((m): m is string => typeof m === "string" && ["TEXT", "IMAGE", "AUDIO"].includes(m)); diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 7fcc88ba59..5a6675f6e4 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -866,11 +866,27 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte ); antigravityModel = wireModelId; antigravitySession = sessionId; + // Gemini returns no chain-of-thought TEXT unless the request opts in. Probed against CCA + // 2026-09-12: `gemini-3.8-flash-high` answered with thoughtsTokenCount=321 and zero + // `thought` parts, then 358-652 chars of genuine reasoning once includeThoughts was set. + // Scoped to Gemini wire ids — Claude-on-CCA accepts the flag but never returns thought + // parts, and gpt-oss rejects it outright (400 INVALID_ARGUMENT, which would break every + // gpt-oss turn). Gated on the provider's visible-thinking opt-in so a user who wants + // thinking hidden does not pay conversation-history tokens for text nobody renders; + // `hideThinkingSummary !== true` is the same per-request gate the response path uses, so + // a client that explicitly asked for hidden thinking is not billed for the text either. + const includeThoughts = provider.showThinkingSummary === true + && parsed.options.hideThinkingSummary !== true + && /^gemini-/.test(wireModelId) + && !isImageCapableModel(parsed.modelId); // Effort → thinkingConfig for CCA (CLIProxyAPI proven: request.generationConfig.thinkingConfig). // Suffix/compat IDs return thinkingLevel=undefined — the suffix IS the effort, no contradiction. - if (thinkingLevel) { + if (thinkingLevel || includeThoughts) { const gc = (body.generationConfig ?? {}) as Record; - gc.thinkingConfig = { thinkingLevel }; + gc.thinkingConfig = { + ...(thinkingLevel ? { thinkingLevel } : {}), + ...(includeThoughts ? { includeThoughts: true } : {}), + }; body.generationConfig = gc; } // Reasoning continuity: Gemini models re-inject cached thoughtSignatures; Claude-on-Antigravity diff --git a/tests/adapters/google/google-adapter.test.ts b/tests/adapters/google/google-adapter.test.ts index 61e1d15d4b..b6c495a387 100644 --- a/tests/adapters/google/google-adapter.test.ts +++ b/tests/adapters/google/google-adapter.test.ts @@ -583,3 +583,71 @@ describe("google adapter — direct -tiered wire renames", () => { } }); }); + +describe("google adapter — Antigravity thought-text opt-in", () => { + // CCA keeps generating thinking either way (thoughtsTokenCount stays non-zero) but returns + // NO `thought` text unless the request sets generationConfig.thinkingConfig.includeThoughts. + // Probed 2026-09-12: gemini-3.8-flash-high answered with 0 thought parts and 321 thoughts + // tokens, then 358-652 chars of reasoning once the key was present. + const ccaProvider = { + adapter: "google", + googleMode: "cloud-code-assist", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + apiKey: "key", + project: "proj-123", + } as const; + const optedIn = { ...ccaProvider, showThinkingSummary: true } as const; + + function thoughtParsed(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; + } + + async function thinkingConfig( + providerConfig: Record, + modelId: string, + effort?: string, + hideThinkingSummary?: boolean, + ): Promise | undefined> { + const { body } = await createGoogleAdapter(providerConfig as never) + .buildRequest(thoughtParsed(modelId, effort, hideThinkingSummary)); + const envelope = JSON.parse(body) as { + request: { generationConfig?: { thinkingConfig?: Record } }; + }; + return envelope.request.generationConfig?.thinkingConfig; + } + + test("asks CCA for thought text on the Gemini wire families", async () => { + // Suffix tier ids deliberately state no level — the suffix IS the effort — so the opt-in + // has to stand on its own for those. + expect(await thinkingConfig(optedIn, "gemini-3.8-flash", "high")).toEqual({ includeThoughts: true }); + expect(await thinkingConfig(optedIn, "gemini-3.8-flash-medium")).toEqual({ includeThoughts: true }); + expect(await thinkingConfig(optedIn, "gemini-3.7-flash", "high")) + .toEqual({ thinkingLevel: "high", includeThoughts: true }); + expect(await thinkingConfig(optedIn, "gemini-3.1-pro", "high")) + .toEqual({ thinkingLevel: "high", includeThoughts: true }); + }); + + test("never sends the flag to models that reject or ignore it", async () => { + // gpt-oss answers 400 INVALID_ARGUMENT with the key present, so it would break the turn. + expect(await thinkingConfig(optedIn, "gpt-oss-120b-medium")).toBeUndefined(); + // Claude-on-CCA accepts the key but returns no thought parts, so it stays off that wire. + expect(await thinkingConfig(optedIn, "claude-sonnet-4-6", "high")).toEqual({ thinkingLevel: "high" }); + }); + + test("a provider without the opt-in keeps the CCA wire unchanged", async () => { + expect(await thinkingConfig(ccaProvider, "gemini-3.8-flash", "high")).toBeUndefined(); + expect(await thinkingConfig(ccaProvider, "gemini-3.7-flash", "high")).toEqual({ thinkingLevel: "high" }); + }); + + test("an explicit client opt-out stops the thought text at the source", async () => { + // Same per-request gate the response path uses: hideThinkingSummary is set for an explicit + // reasoning.summary "none", and paying upstream for text the client refused is waste. + expect(await thinkingConfig(optedIn, "gemini-3.8-flash", "high", true)).toBeUndefined(); + expect(await thinkingConfig(optedIn, "gemini-3.7-flash", "high", true)).toEqual({ thinkingLevel: "high" }); + }); +}); diff --git a/tests/adapters/google/google-wire-compiler.test.ts b/tests/adapters/google/google-wire-compiler.test.ts index 1523d11d85..482aa809e6 100644 --- a/tests/adapters/google/google-wire-compiler.test.ts +++ b/tests/adapters/google/google-wire-compiler.test.ts @@ -132,4 +132,28 @@ describe("Google wire compiler", () => { const repaired = JSON.parse(repairGoogleInvalidRequestBody(body, error)!); expect(repaired.request.generationConfig).toEqual({ maxOutputTokens: 4096 }); }); + + test("keeps the includeThoughts opt-in while still dropping unknown thinking keys", () => { + const withOptIn = compileGoogleWireBody({ + generationConfig: { + thinkingConfig: { includeThoughts: true, thinkingLevel: "max", futureThinkingField: true }, + }, + }); + expect(withOptIn.body.generationConfig).toEqual({ + thinkingConfig: { thinkingLevel: "high", includeThoughts: true }, + }); + + // The flag has to survive on its own too: suffix tier ids deliberately carry no + // thinkingLevel, so an includeThoughts-only config is the whole request. + const optInOnly = compileGoogleWireBody({ + generationConfig: { thinkingConfig: { includeThoughts: true } }, + }); + expect(optInOnly.body.generationConfig).toEqual({ thinkingConfig: { includeThoughts: true } }); + + // Non-boolean / absent values must not invent the key. + const notRequested = compileGoogleWireBody({ + generationConfig: { thinkingConfig: { includeThoughts: "yes", thinkingLevel: "high" } }, + }); + expect(notRequested.body.generationConfig).toEqual({ thinkingConfig: { thinkingLevel: "high" } }); + }); }); From 088af339e696374cefc3074a516f21cb85941f68 Mon Sep 17 00:00:00 2001 From: Eran Date: Sat, 12 Sep 2026 13:05:30 +0800 Subject: [PATCH 6/6] test(responses): pin visible thinking, not a channel showThinkingSummary's job is to take a provider's genuine reasoning out of the hidden replay envelope. Which channel carries the visible text is the bridge's decision, not this flag's: #4301 moves raw reasoning from the summary channel to the content channel (the native gpt-oss shape), so asserting the summary channel here would pin the opposite of whichever behaviour is current. Rewritten around the Cloud Code Assist path the flag exists for. That also lets the request-side half be asserted in the same file: includeThoughts reaching the wire when a provider opts in, and not being bought at all for a turn the client asked to hide. The passthrough-based cases are dropped -- #4301 deletes the content-to-summary rewrite they exercised, and with it their subject. Comment-only edits keep provider.ts, registry.ts and core.ts from claiming the summary channel as the contract; the docs row says the same and documents the explicit false. --- .../docs/reference/configuration/providers.md | 2 +- src/providers/registry.ts | 2 +- src/server/responses/core.ts | 5 +- src/types/provider.ts | 12 +- .../responses-show-thinking-summary.test.ts | 248 ++++++++---------- 5 files changed, 124 insertions(+), 145 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 424adb4e9a..507ca994fb 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -208,7 +208,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `preserveReasoningContentModels?` | `string[]` | Models requiring prior assistant `reasoning_content` in chat history. | | `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. | -| `showThinkingSummary?` | `boolean` | Opt-in: surface upstream thinking as visible reasoning summaries even when the client omits `reasoning.summary` (the Codex default, which otherwise keeps thinking in hidden replay envelopes). An explicit client `reasoning.summary: "none"` still wins. `google-antigravity` is an OAuth-only `google` provider using the Cloud Code Assist wire and is seeded `true`; set `false` to opt back out. On that wire the opt-in also sets `generationConfig.thinkingConfig.includeThoughts` for Gemini models — Cloud Code Assist reports `thoughtsTokenCount` either way but sends no `thought` text without it (Gemini only; Claude is never asked and `gpt-oss` rejects the field with `INVALID_ARGUMENT`). | +| `showThinkingSummary?` | `boolean` | Opt-in: surface upstream thinking as visible reasoning even when the client omits `reasoning.summary` (the Codex default, which otherwise keeps thinking in hidden replay envelopes). An explicit client `reasoning.summary: "none"` still wins, and `false` opts a seeded preset back out. Which channel carries the visible text is the bridge's decision, not this flag's. `google-antigravity` is an OAuth-only `google` provider using the Cloud Code Assist wire and is seeded `true`. On that wire the opt-in also sets `generationConfig.thinkingConfig.includeThoughts` for Gemini models — Cloud Code Assist reports `thoughtsTokenCount` either way but sends no `thought` text without it (Gemini only; Claude is never asked and `gpt-oss` rejects the field with `INVALID_ARGUMENT`). | | `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. | | `noVisionModels?` | `string[]` | Text-only models sent through the vision sidecar; matching tolerates an Ollama `:size` tag. | diff --git a/src/providers/registry.ts b/src/providers/registry.ts index d2bee8888c..1bd9367b7e 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -357,7 +357,7 @@ export interface ProviderRegistryEntry { preserveReasoningContentModels?: string[]; requiresReasoningPlaceholderModels?: string[]; /** - * Opt this provider into visible thinking summaries (see OcxProviderConfig.showThinkingSummary). + * Opt this provider into visible thinking (see OcxProviderConfig.showThinkingSummary). */ showThinkingSummary?: boolean; reasoningSplitModels?: string[]; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 6bb826c566..3b616c0af2 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2530,9 +2530,10 @@ async function applyFinalRouteRequestNormalization(args: { route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); // Provider-opted visible thinking (e.g. google-antigravity): parseRequest hides thinking // whenever the client omits reasoning.summary, which is the Codex default. A provider that - // serves genuine user-facing reasoning opts back into the summary channel here, so thought + // serves genuine user-facing reasoning opts back into visible reasoning here, so thought // parts (Gemini thought, content-channel reasoning_text) reach the client instead of only - // the hidden replay envelopes. An explicit client reasoning.summary "none" still wins. + // the hidden replay envelopes. Which channel carries them is the bridge's decision, not + // this flag's. An explicit client reasoning.summary "none" still wins. if (route.provider.showThinkingSummary === true && parsed.options.hideThinkingSummary === true && !clientExplicitlyHidThinking(parsed)) { parsed.options.hideThinkingSummary = false; diff --git a/src/types/provider.ts b/src/types/provider.ts index d0eefc758a..b7d9149ff6 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -774,12 +774,12 @@ export interface OcxProviderConfig { */ requiresReasoningPlaceholderModels?: string[]; /** - * Opt-in: surface upstream thinking as visible reasoning summaries even when the - * client did not send `reasoning.summary`. parseRequest hides thinking by default - * (Codex omits the field), which strands genuine reasoning — e.g. Gemini `thought` - * parts on the google-antigravity (Cloud Code Assist) wire — in hidden replay - * envelopes. An explicit client `reasoning.summary: "none"` still wins. Set `false` - * to opt a seeded preset back out. + * Opt-in: surface upstream thinking as visible reasoning even when the client did not + * send `reasoning.summary`. parseRequest hides thinking by default (Codex omits the + * field), which strands genuine reasoning — e.g. Gemini `thought` parts on the + * google-antigravity (Cloud Code Assist) wire — in hidden replay envelopes. An explicit + * client `reasoning.summary: "none"` still wins. Which channel carries the visible text + * is the bridge's decision, not this flag's. Set `false` to opt a seeded preset back out. */ showThinkingSummary?: boolean; /** diff --git a/tests/responses/responses-show-thinking-summary.test.ts b/tests/responses/responses-show-thinking-summary.test.ts index eab8343383..e3fb0d3648 100644 --- a/tests/responses/responses-show-thinking-summary.test.ts +++ b/tests/responses/responses-show-thinking-summary.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -7,155 +7,133 @@ import { getProviderRegistryEntry } from "../../src/providers/registry"; import { handleResponses } from "../../src/server/responses/core"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; -// Provider-opted visible thinking (showThinkingSummary): a provider that serves -// genuine user-facing reasoning surfaces it on the summary channel even when the -// client omits reasoning.summary (the Codex default, which otherwise hides all -// thinking in replay-only envelopes). An explicit client summary of "none" still -// wins and keeps thinking hidden. +// Provider-opted visible thinking (showThinkingSummary). parseRequest hides thinking whenever the +// client omits `reasoning.summary`, which is the Codex default, so genuine user-facing reasoning — +// Gemini `thought` parts on the Cloud Code Assist wire — would otherwise reach the client only as a +// hidden replay envelope. A provider opts in; an explicit client `reasoning.summary: "none"` still +// wins and keeps it hidden. +// +// The assertions deliberately do NOT pin which channel carries the text. That belongs to the +// bridge, not to this flag: today raw reasoning rides the summary channel, and #4301 moves it to +// the content channel (the native gpt-oss shape, where the desktop band shows the generic +// placeholder and the CLI gates raw display behind `show_raw_agent_reasoning`). Pinning a channel +// here would assert the opposite of whichever behaviour is current, so these tests pin what the +// flag actually owns: visible reasoning versus the hidden envelope. The companion request-side half +// — asking Cloud Code Assist for `includeThoughts` across the Gemini/non-Gemini wire families — is +// pinned in tests/adapters/google/google-adapter.test.ts. -function shownSeed() { - const seed = providerConfigSeed(getProviderRegistryEntry("deepseek")!); - return { ...seed, apiKey: "sk-test", showThinkingSummary: true } as OcxProviderConfig; -} +const THOUGHT = "cca-think"; -function sseFrame(payload: unknown): string { - return "data: " + JSON.stringify(payload) + "\n\n"; +function ccaUpstream(): Response { + return Response.json({ + response: { + candidates: [{ + content: { parts: [{ thought: true, text: THOUGHT }, { text: "OK" }] }, + finishReason: "STOP", + }], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15, thoughtsTokenCount: 3 }, + }, + }); } -const SSE_UPSTREAM = [ - sseFrame({ type: "response.created", response: { id: "resp_1", status: "in_progress", output: [] } }), - sseFrame({ type: "response.output_item.added", output_index: 0, item: { type: "reasoning", id: "rs_1", status: "in_progress", content: [], summary: [] } }), - sseFrame({ type: "response.reasoning_text.delta", content_index: 0, delta: "think", item_id: "rs_1", output_index: 0 }), - sseFrame({ type: "response.reasoning_text.done", content_index: 0, text: "think", item_id: "rs_1", output_index: 0 }), - sseFrame({ type: "response.output_item.done", output_index: 0, item: { type: "reasoning", id: "rs_1", status: "completed", content: [{ type: "reasoning_text", text: "think" }], summary: [] } }), - sseFrame({ type: "response.completed", response: { id: "resp_1", status: "completed", output: [{ type: "reasoning", id: "rs_1", status: "completed", content: [{ type: "reasoning_text", text: "think" }], summary: [] }] } }), -].join(""); +/** Whether the thought text is visible on whichever channel the bridge assigns it to. */ +function reasoningIsVisible(text: string): boolean { + return text.includes(`"summary":[{"type":"summary_text","text":"${THOUGHT}"}]`) + || text.includes(`"content":[{"type":"reasoning_text","text":"${THOUGHT}"}]`); +} -async function runHandleResponses(body: Record, seed: OcxProviderConfig) { - const encoder = new TextEncoder(); - globalThis.fetch = (async () => new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(SSE_UPSTREAM)); - controller.close(); - }, - }), - { status: 200, headers: { "content-type": "text/event-stream" } }, - )) as typeof fetch; - const config = { providers: { deepseek: seed } } as unknown as OcxConfig; - return handleResponses( - new Request("http://localhost/v1/responses", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }), - config, - { model: "", provider: "" }, - { abortSignal: AbortSignal.timeout(5_000) }, - ); +async function runCcaTurn(options: { + showThinkingSummary?: boolean; + reasoning?: Record; +} = {}): Promise<{ text: string; upstream: Array<{ url: string; body: string }> }> { + const home = mkdtempSync(join(tmpdir(), "ocx-show-thinking-")); + const prevHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; + writeFileSync(join(home, "auth.json"), JSON.stringify({ + "google-antigravity": { + activeAccountId: "active", + accounts: [{ + id: "active", + credential: { + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 3_600_000, + projectId: "project-id", + }, + }], + }, + })); + // Simulate a saved provider row written before the registry learned the flag: the routed request + // path backfills it from the registry entry (enrichProviderFromRegistry never runs there), while an + // explicit `false` still wins. + const seed = { + ...providerConfigSeed(getProviderRegistryEntry("google-antigravity")!), + liveModels: false, + models: ["gemini-3.8-flash"], + ...(options.showThinkingSummary === undefined ? {} : { showThinkingSummary: options.showThinkingSummary }), + } as OcxProviderConfig; + const upstream: Array<{ url: string; body: string }> = []; + const prevFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + upstream.push({ url: String(input), body: String(init?.body ?? "") }); + return ccaUpstream(); + }) as typeof fetch; + try { + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "google-antigravity/gemini-3.8-flash", + input: "ping", + stream: false, + reasoning: { effort: "low", ...(options.reasoning ?? {}) }, + }), + }), + { providers: { "google-antigravity": seed } } as unknown as OcxConfig, + { model: "", provider: "" }, + { abortSignal: AbortSignal.timeout(10_000) }, + ); + return { text: await response.text(), upstream }; + } finally { + globalThis.fetch = prevFetch; + if (prevHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); + } } describe("showThinkingSummary provider option", () => { - const originalFetch = globalThis.fetch; - afterEach(() => { globalThis.fetch = originalFetch; }); - - test("omitted client summary still surfaces thinking on the summary channel", async () => { - const response = await runHandleResponses( - { model: "deepseek-v4-flash", input: "ping", stream: true }, - shownSeed(), - ); - const text = await response.text(); - expect(text).toContain("response.reasoning_summary_text.delta"); - expect(text).toContain('"summary":[{"type":"summary_text","text":"think"}]'); + test("omitted client summary still surfaces CCA thinking as visible reasoning", async () => { + const { text, upstream } = await runCcaTurn(); + expect(upstream).toHaveLength(1); + expect(upstream[0]!.url).toContain("v1internal:generateContent"); + // Request-side half: Cloud Code Assist reports `thoughtsTokenCount` either way but sends no + // `thought` text at all unless the request opts in, so the flag has to reach the wire. + expect(upstream[0]!.body).toContain('"includeThoughts":true'); + expect(reasoningIsVisible(text)).toBe(true); + expect(text).toContain("OK"); + // The hidden envelope is exactly what this flag takes the turn out of. + expect(text).not.toContain("encrypted_content"); }); - test("explicit client summary none keeps thinking hidden", async () => { - const response = await runHandleResponses( - { model: "deepseek-v4-flash", input: "ping", stream: true, reasoning: { summary: "none" } }, - shownSeed(), - ); - const text = await response.text(); - expect(text).not.toContain("response.reasoning_summary_text.delta"); - expect(text).toContain("response.reasoning_text.delta"); + test("an explicit client summary none keeps thinking in the hidden envelope", async () => { + const { text, upstream } = await runCcaTurn({ reasoning: { summary: "none" } }); + expect(reasoningIsVisible(text)).toBe(false); + expect(text).toContain("encrypted_content"); + // ...and the turn does not pay for text nobody will render. + expect(upstream[0]!.body).not.toContain("includeThoughts"); }); - test("without the provider option, omitted summary stays hidden", async () => { - const seed = { ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "sk-test" } as OcxProviderConfig; - const response = await runHandleResponses( - { model: "deepseek-v4-flash", input: "ping", stream: true }, - seed, - ); - const text = await response.text(); - expect(text).not.toContain("response.reasoning_summary_text.delta"); - expect(text).toContain("response.reasoning_text.delta"); + test("an explicit false opts the provider back out", async () => { + const { text, upstream } = await runCcaTurn({ showThinkingSummary: false }); + expect(reasoningIsVisible(text)).toBe(false); + expect(text).toContain("encrypted_content"); + expect(upstream[0]!.body).not.toContain("includeThoughts"); }); - test("google-antigravity preset opts in", () => { + test("google-antigravity preset opts in, other providers stay untouched", () => { expect(providerConfigSeed(getProviderRegistryEntry("google-antigravity")!).showThinkingSummary).toBe(true); expect(providerConfigSeed(getProviderRegistryEntry("deepseek")!).showThinkingSummary).toBeUndefined(); }); - - test("CCA thought parts surface on the summary channel", async () => { - const home = mkdtempSync(join(tmpdir(), "ocx-show-thinking-")); - const prevHome = process.env.OPENCODEX_HOME; - process.env.OPENCODEX_HOME = home; - writeFileSync(join(home, "auth.json"), JSON.stringify({ - "google-antigravity": { - activeAccountId: "active", - accounts: [{ - id: "active", - credential: { - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 3_600_000, - projectId: "project-id", - }, - }], - }, - })); - const seen: string[] = []; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - seen.push(String(input)); - return Response.json({ - response: { - candidates: [{ - content: { parts: [{ thought: true, text: "cca-think" }, { text: "OK" }] }, - finishReason: "STOP", - }], - usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15, thoughtsTokenCount: 3 }, - }, - }); - }) as typeof fetch; - try { - const seed = { - ...providerConfigSeed(getProviderRegistryEntry("google-antigravity")!), - liveModels: false, - models: ["gemini-3.8-flash"], - } as OcxProviderConfig; - // Simulate a saved provider row written before the registry learned the flag: - // the request path must backfill it from the registry entry (routedProviderConfig), - // enrichProviderFromRegistry never runs there. - delete (seed as Record).showThinkingSummary; - const config = { providers: { "google-antigravity": seed } } as unknown as OcxConfig; - const response = await handleResponses( - new Request("http://localhost/v1/responses", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "google-antigravity/gemini-3.8-flash", input: "ping", stream: false, reasoning: { effort: "low" } }), - }), - config, - { model: "", provider: "" }, - { abortSignal: AbortSignal.timeout(10_000) }, - ); - const text = await response.text(); - expect(seen).toHaveLength(1); - expect(seen[0]).toContain("v1internal:generateContent"); - expect(text).toContain('"summary":[{"type":"summary_text","text":"cca-think"}]'); - expect(text).toContain("OK"); - } finally { - if (prevHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = prevHome; - rmSync(home, { recursive: true, force: true }); - } - }); });