Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<think>` / `<thinking>` / `<reasoning>` 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=<provider>` 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. |
Expand Down
25 changes: 18 additions & 7 deletions src/adapters/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"];
}
Expand Down
11 changes: 6 additions & 5 deletions structure/providers/google.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
199 changes: 199 additions & 0 deletions tests/adapters/google/google-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
modelId: string,
effort?: string,
hideThinkingSummary?: boolean,
): Promise<Record<string, unknown> | undefined> {
const { body } = await createGoogleAdapter(providerConfig as never)
.buildRequest(makeParsed(modelId, effort, hideThinkingSummary));
const parsedBody = JSON.parse(body) as {
generationConfig?: { thinkingConfig?: Record<string, unknown> };
};
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<typeof createGoogleAdapter>,
stream: boolean,
): Promise<AdapterEvent | undefined> {
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<typeof createGoogleAdapter>,
stream: boolean,
): Promise<AdapterEvent | undefined> {
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;
Expand Down
Loading