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
34 changes: 33 additions & 1 deletion src/adapters/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown> | 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<string, number> | undefined): OcxUsage | undefined {
if (!usage) return undefined;
return {
Expand Down Expand Up @@ -307,6 +329,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
const body: Record<string, unknown> = { 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;
Comment on lines +334 to +335

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor tool_choice none for Claude Antigravity

When googleMode === "cloud-code-assist" routes to a Claude model, a request with tool_choice: "none" and a non-empty tool catalog gets mode: "NONE" here, but the later Claude Antigravity block overwrites that mode to VALIDATED while leaving body.tools intact. In that scenario the upstream still receives callable declarations and may emit tool calls even though the client explicitly disabled tools; preserve the no-tools contract by suppressing declarations for none or otherwise avoiding the VALIDATED override for that case.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.


const generationConfig: Record<string, unknown> = {};
if (parsed.options.maxOutputTokens) generationConfig.maxOutputTokens = parsed.options.maxOutputTokens;
Expand Down Expand Up @@ -358,6 +384,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
const draftRequest: Record<string, unknown> = { ...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<string, unknown>;
const fcc = (existing.functionCallingConfig ?? {}) as Record<string, unknown>;
draftRequest.toolConfig = { ...existing, functionCallingConfig: { ...fcc, mode: "VALIDATED" } };
Expand Down
89 changes: 89 additions & 0 deletions tests/google-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,92 @@ 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 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<string, unknown>;
// 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<string, unknown>;
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" } });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

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<string, unknown>;
expect(request.toolConfig).toEqual({
functionCallingConfig: { mode: "VALIDATED", allowedFunctionNames: ["get_weather"] },
});
});
});
Loading