From 3f7e4cf3ffbc7da661bd8f9aa497eb7fcca9eaa1 Mon Sep 17 00:00:00 2001 From: devmello Date: Mon, 3 Aug 2026 13:11:02 -0700 Subject: [PATCH 1/4] fix(google): map tool_choice onto functionCallingConfig --- src/adapters/google.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index c9d7aedfc..082938a62 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -12,7 +12,7 @@ import type { OcxToolCall, OcxUsage, } from "../types"; -import { isAllowedToolChoice, namespacedToolName, toolAllowedByChoice } from "../types"; +import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types"; import { contentPartsToText, parseDataUrl } from "./image"; import { getVertexAccessToken } from "../lib/gcp-adc"; import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http"; @@ -232,6 +232,28 @@ function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined { }]; } +/** + * Client tool_choice enforcement on the wire. The catalog nudge states the same contract in + * prose, but without functionCallingConfig the model is free to ignore it. "auto" stays absent + * so the common case is byte-identical. The allowedTools variant already filters the + * declarations in toolsToGeminiFormat; only its "required" half needs a wire mode. + */ +function toolChoiceToGeminiToolConfig(parsed: OcxParsedRequest): Record | undefined { + const choice = parsed.options.toolChoice; + if (!choice || choice === "auto") return undefined; + if (choice === "none") return { functionCallingConfig: { mode: "NONE" } }; + if (choice === "required") return { functionCallingConfig: { mode: "ANY" } }; + if (isAllowedToolChoice(choice)) { + return choice.mode === "required" ? { functionCallingConfig: { mode: "ANY" } } : undefined; + } + return { + functionCallingConfig: { + mode: "ANY", + allowedFunctionNames: [resolveToolChoiceWireName(parsed.context.tools, choice.name)], + }, + }; +} + function usageFromGemini(usage: Record | undefined): OcxUsage | undefined { if (!usage) return undefined; return { @@ -307,6 +329,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const body: Record = { contents }; if (systemInstruction) body.systemInstruction = systemInstruction; if (tools) body.tools = tools; + // Only meaningful with declarations on the wire: mode ANY with an empty + // catalog is a guaranteed upstream 400. + const toolConfig = tools ? toolChoiceToGeminiToolConfig(parsed) : undefined; + if (toolConfig) body.toolConfig = toolConfig; const generationConfig: Record = {}; if (parsed.options.maxOutputTokens) generationConfig.maxOutputTokens = parsed.options.maxOutputTokens; From 46756f5e5004da56cde8dd8b08f1a58e7365b30b Mon Sep 17 00:00:00 2001 From: devmello Date: Mon, 3 Aug 2026 13:11:02 -0700 Subject: [PATCH 2/4] test(google): cover tool_choice wire enforcement --- tests/google-adapter.test.ts | 66 ++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/google-adapter.test.ts b/tests/google-adapter.test.ts index 4eecd1d48..d9dbe428e 100644 --- a/tests/google-adapter.test.ts +++ b/tests/google-adapter.test.ts @@ -189,3 +189,69 @@ describe("google adapter — tool-call ids on the wire", () => { expect(fc).toBe("call_xyz"); }); }); + +describe("google adapter — tool_choice on the wire", () => { + const TOOLS = [ + { name: "get_weather", parameters: { type: "object", properties: {} } }, + { name: "shot", namespace: "mcp__chrome", parameters: { type: "object", properties: {} } }, + ]; + + function parsedWithChoice(toolChoice: unknown, tools: unknown[] | null = TOOLS): OcxParsedRequest { + return { + modelId: "gemini-3-pro", + stream: false, + options: toolChoice === undefined ? {} : { toolChoice }, + context: { messages: [{ role: "user", content: "hi" }], tools: tools ?? undefined }, + } as unknown as OcxParsedRequest; + } + + test('"none" and "required" map to NONE and ANY', async () => { + expect((await geminiBody(parsedWithChoice("none"))).toolConfig) + .toEqual({ functionCallingConfig: { mode: "NONE" } }); + expect((await geminiBody(parsedWithChoice("required"))).toolConfig) + .toEqual({ functionCallingConfig: { mode: "ANY" } }); + }); + + test("a forced tool maps to ANY with its wire name allowed", async () => { + expect((await geminiBody(parsedWithChoice({ name: "get_weather" }))).toolConfig) + .toEqual({ functionCallingConfig: { mode: "ANY", allowedFunctionNames: ["get_weather"] } }); + // Dotted alias resolves to the namespaced declaration name. + expect((await geminiBody(parsedWithChoice({ name: "mcp__chrome.shot" }))).toolConfig) + .toEqual({ functionCallingConfig: { mode: "ANY", allowedFunctionNames: ["mcp__chrome__shot"] } }); + }); + + test('"auto", absent, and allowedTools+auto stay byte-identical (no toolConfig)', async () => { + expect((await geminiBody(parsedWithChoice("auto"))).toolConfig).toBeUndefined(); + expect((await geminiBody(parsedWithChoice(undefined))).toolConfig).toBeUndefined(); + expect((await geminiBody(parsedWithChoice({ allowedTools: ["get_weather"], mode: "auto" }))).toolConfig).toBeUndefined(); + }); + + test("allowedTools with mode required keeps the filtered catalog and adds ANY", async () => { + const body = await geminiBody(parsedWithChoice({ allowedTools: ["get_weather"], mode: "required" })); + const declared = (body.tools as { functionDeclarations: { name: string }[] }[])[0].functionDeclarations.map(d => d.name); + expect(declared).toEqual(["get_weather"]); + expect(body.toolConfig).toEqual({ functionCallingConfig: { mode: "ANY" } }); + }); + + test("no declared tools means no toolConfig even with a choice", async () => { + expect((await geminiBody(parsedWithChoice("none", null))).toolConfig).toBeUndefined(); + expect((await geminiBody(parsedWithChoice({ name: "get_weather" }, []))).toolConfig).toBeUndefined(); + }); + + test("claude-on-antigravity keeps VALIDATED mode over a client choice, allowed names survive", async () => { + const ccaProvider = { + adapter: "google", + googleMode: "cloud-code-assist", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + apiKey: "key", + project: "proj-123", + }; + const parsed = parsedWithChoice({ name: "get_weather" }); + (parsed as unknown as { modelId: string }).modelId = "claude-opus-4.8"; + const { body } = await createGoogleAdapter(ccaProvider).buildRequest(parsed); + const request = JSON.parse(body).request as Record; + expect(request.toolConfig).toEqual({ + functionCallingConfig: { mode: "VALIDATED", allowedFunctionNames: ["get_weather"] }, + }); + }); +}); From a366934d8ec1ce94918e5eea05327866021faf25 Mon Sep 17 00:00:00 2001 From: devmello Date: Mon, 3 Aug 2026 14:20:28 -0700 Subject: [PATCH 3/4] fix(google): honor tool_choice none for claude on antigravity --- src/adapters/google.ts | 6 ++++++ tests/google-adapter.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 082938a62..be861d4ce 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -384,6 +384,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const draftRequest: Record = { ...body, sessionId }; // Claude-on-Antigravity forces VALIDATED function calling (the real client always sets it). if (/claude/i.test(wireModelId)) { + // VALIDATED would defeat a client's tool_choice "none": honor it by dropping the + // declarations instead, the wire shape of a tool-less Claude turn. + if (parsed.options.toolChoice === "none") { + delete draftRequest.tools; + delete draftRequest.toolConfig; + } const existing = (draftRequest.toolConfig ?? {}) as Record; const fcc = (existing.functionCallingConfig ?? {}) as Record; draftRequest.toolConfig = { ...existing, functionCallingConfig: { ...fcc, mode: "VALIDATED" } }; diff --git a/tests/google-adapter.test.ts b/tests/google-adapter.test.ts index d9dbe428e..4fe84806c 100644 --- a/tests/google-adapter.test.ts +++ b/tests/google-adapter.test.ts @@ -238,6 +238,28 @@ describe("google adapter — tool_choice on the wire", () => { expect((await geminiBody(parsedWithChoice({ name: "get_weather" }, []))).toolConfig).toBeUndefined(); }); + test('claude-on-antigravity honors "none" by dropping the declarations', async () => { + const ccaProvider = { + adapter: "google", + googleMode: "cloud-code-assist", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + apiKey: "key", + project: "proj-123", + }; + const claudeParsed = parsedWithChoice("none"); + (claudeParsed as unknown as { modelId: string }).modelId = "claude-opus-4.8"; + const claudeRequest = JSON.parse((await createGoogleAdapter(ccaProvider).buildRequest(claudeParsed)).body).request as Record; + // VALIDATED would defeat NONE, so the declarations go instead; the config matches a tool-less turn. + expect(claudeRequest.tools).toBeUndefined(); + expect(claudeRequest.toolConfig).toEqual({ functionCallingConfig: { mode: "VALIDATED" } }); + + // Gemini on the same route has no VALIDATED override, so NONE rides with the catalog intact. + const geminiParsed = parsedWithChoice("none"); + const geminiRequest = JSON.parse((await createGoogleAdapter(ccaProvider).buildRequest(geminiParsed)).body).request as Record; + expect(geminiRequest.tools).toBeDefined(); + expect(geminiRequest.toolConfig).toEqual({ functionCallingConfig: { mode: "NONE" } }); + }); + test("claude-on-antigravity keeps VALIDATED mode over a client choice, allowed names survive", async () => { const ccaProvider = { adapter: "google", From 9ba66a18164baf4e79b2e89fc13f42217fc7a36e Mon Sep 17 00:00:00 2001 From: devmello Date: Mon, 3 Aug 2026 14:41:35 -0700 Subject: [PATCH 4/4] test(google): assert the exact catalog in the none case --- tests/google-adapter.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/google-adapter.test.ts b/tests/google-adapter.test.ts index 4fe84806c..467cf19c3 100644 --- a/tests/google-adapter.test.ts +++ b/tests/google-adapter.test.ts @@ -256,7 +256,8 @@ describe("google adapter — tool_choice on the wire", () => { // Gemini on the same route has no VALIDATED override, so NONE rides with the catalog intact. const geminiParsed = parsedWithChoice("none"); const geminiRequest = JSON.parse((await createGoogleAdapter(ccaProvider).buildRequest(geminiParsed)).body).request as Record; - expect(geminiRequest.tools).toBeDefined(); + const declared = (geminiRequest.tools as { functionDeclarations: { name: string }[] }[])[0].functionDeclarations.map(d => d.name); + expect(declared).toEqual(["get_weather", "mcp__chrome__shot"]); expect(geminiRequest.toolConfig).toEqual({ functionCallingConfig: { mode: "NONE" } }); });