From 749fba3247344be76c33b17b22fb966bc1c80873 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 09:29:30 +0900 Subject: [PATCH] [agent] fix: send Google structured output on the generateContent wire and map Anthropic parallel=false Restacked onto the squashed #4535 landing; tree identical to pre-restack head dbb969d466. --- scripts/test-layout/layout.json | 2 + src/adapters/anthropic.ts | 16 +++ src/adapters/google-wire-compiler.ts | 8 ++ src/adapters/google.ts | 46 +++++++ structure/providers/chat-compat.md | 14 +++ structure/providers/google.md | 24 ++++ .../anthropic-parallel-tool-disable.test.ts | 87 ++++++++++++++ .../google/google-structured-output.test.ts | 112 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 + 9 files changed, 311 insertions(+) create mode 100644 tests/adapters/anthropic/anthropic-parallel-tool-disable.test.ts create mode 100644 tests/adapters/google/google-structured-output.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 04641cae4a..f8800ea61b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -208,6 +208,7 @@ "anthropic-image-normalize.test.ts": "adapters/anthropic", "anthropic-image-retry-e2e.test.ts": "adapters/anthropic", "anthropic-image-retry.test.ts": "adapters/anthropic", + "anthropic-parallel-tool-disable.test.ts": "adapters/anthropic", "anthropic-pool-toggle-copy.test.ts": "adapters/anthropic", "anthropic-quorum-cache.test.ts": "routing", "anthropic-quota-dispatch.test.ts": "adapters/anthropic", @@ -723,6 +724,7 @@ "google-output-clamp.test.ts": "adapters/google", "google-provider-metadata-roundtrip.test.ts": "adapters/google", "google-signature-history-roundtrip.test.ts": "adapters/google", + "google-structured-output.test.ts": "adapters/google", "google-tool-result-adjacency.test.ts": "adapters/google", "google-tool-schema.test.ts": "adapters/google", "google-vertex-http.test.ts": "adapters/google", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index e85cf64418..a953b2c532 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1019,6 +1019,22 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti else if (tc === "required") body.tool_choice = { type: "any" }; else if (isAllowedToolChoice(tc)) body.tool_choice = { type: tc.mode === "required" ? "any" : "auto" }; else if (typeof tc === "object" && "name" in tc) body.tool_choice = { type: "tool", name: toolNames.toWire(resolveToolChoiceWireName(parsed.context.tools, tc.name)) }; + } else if (tools && parsed.options.parallelToolCalls === false) { + // The caller asked for one tool call at a time but sent no explicit choice. + // Anthropic carries that intent INSIDE tool_choice, so the implicit default + // has to be stated before the flag has somewhere to live. + body.tool_choice = { type: "auto" }; + } + // disable_parallel_tool_use is nested in tool_choice and caps the model at one + // tool call for auto/any/tool. Under type "none" tool use is already off, so the + // flag is irrelevant there, and with no tools on the wire no tool_choice exists. + // This constrains the model's OUTPUT, not execution order: sequential tool use is + // enforced by the caller returning each tool_result before the next request. + const settledToolChoice = body.tool_choice as { type?: string } | undefined; + if (parsed.options.parallelToolCalls === false + && settledToolChoice !== undefined + && settledToolChoice.type !== "none") { + body.tool_choice = { ...settledToolChoice, disable_parallel_tool_use: true }; } const url = anthropicMessagesUrl(provider.baseUrl); diff --git a/src/adapters/google-wire-compiler.ts b/src/adapters/google-wire-compiler.ts index aa835e50b4..03cb6de7a3 100644 --- a/src/adapters/google-wire-compiler.ts +++ b/src/adapters/google-wire-compiler.ts @@ -149,6 +149,14 @@ function compileGenerationConfig(value: unknown): JsonObject | undefined { const valid = value.responseModalities.filter((m): m is string => typeof m === "string" && ["TEXT", "IMAGE", "AUDIO"].includes(m)); if (valid.length > 0) out.responseModalities = valid; } + // Structured output. This compiler is a whitelist, so without these two the adapter + // could set a schema and it would still be dropped before the wire. + if (typeof value.responseMimeType === "string" && value.responseMimeType.length > 0) { + out.responseMimeType = value.responseMimeType; + } + // Carried through unmodified: a caller-authored output schema is not a tool + // declaration, so sanitizeGeminiToolParameters must not touch it. + if (isObject(value.responseJsonSchema)) out.responseJsonSchema = value.responseJsonSchema; return Object.keys(out).length > 0 ? out : undefined; } diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 53206045b0..9617c1ac10 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -789,6 +789,37 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte : {}), async buildRequest(parsed: OcxParsedRequest) { + // Structured-output admission runs FIRST, before messagesToGeminiFormat writes + // lastInjectedCallIds/lastReasoningReplayScope: a refused request must not leave + // adapter-scoped replay state pointing at call ids that never went out. These + // refusals are local and precede any fetch, and carry no request content, schema + // body, URL or credential. + const requestedTextFormat = parsed.options.textFormat; + if (requestedTextFormat) { + if (provider.googleMode === "cloud-code-assist") { + // Not implemented or verified by opencodex for the Cloud Code Assist envelope, + // including Claude models served through it. This is not a claim that the + // upstream cannot do it — silence would return unconstrained prose as success, + // which is the failure this fix exists to remove. + throw new Error( + "google cloud-code-assist structured output is not implemented by opencodex — " + + "remove response_format or route this model through AI Studio or Vertex", + ); + } + if (isImageCapableModel(parsed.modelId)) { + // An image-output model is configured with responseModalities; constraining the + // same turn to JSON text is contradictory. Say so rather than dropping the schema. + throw new Error( + "google image-capable models cannot combine image output with structured output — " + + "remove response_format or select a text model", + ); + } + if (requestedTextFormat.type === "json_schema" && !requestedTextFormat.schema) { + // Downgrading a malformed json_schema to bare JSON mode would silently drop the + // constraint the caller asked for. + throw new Error("google structured output requires text.format.schema for type json_schema"); + } + } const routedModelId = provider.googleMode === "cloud-code-assist" ? resolveAntigravityEffortWireModel( parsed.modelId, @@ -846,6 +877,21 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (!generationConfig.thinkingConfig && isImageCapableModel(parsed.modelId)) { generationConfig.responseModalities = ["TEXT", "IMAGE"]; } + // Structured output travels in generationConfig on generateContent itself. + // responseJsonSchema takes ordinary JSON Schema (lowercase types), which is what + // options.textFormat.schema already holds; responseSchema would require Gemini's + // uppercase typed Schema form, and the docs require omitting it when + // responseJsonSchema is used. The response type does not change — the model + // returns text containing the conforming JSON — so response parsing is untouched. + // The tool-parameter sanitizer is deliberately NOT applied: it narrows a schema + // to the function-declaration subset and would corrupt a valid output schema. + const textFormat = parsed.options.textFormat; + if (textFormat) { + generationConfig.responseMimeType = "application/json"; + if (textFormat.type === "json_schema" && textFormat.schema) { + generationConfig.responseJsonSchema = textFormat.schema; + } + } if (Object.keys(generationConfig).length > 0) body.generationConfig = generationConfig; const method = parsed.stream ? "streamGenerateContent" : "generateContent"; diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 2362e46bcd..b5f073fea7 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -292,3 +292,17 @@ byte-limit boundaries. Canonical Spark Lite metadata follows the final serialized model and surviving nonempty Lite tool catalog; see [Responses transport](../transports/responses.md). Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. +## Anthropic parallel tool use + +`options.parallelToolCalls === false` maps onto Anthropic's nested +`tool_choice.disable_parallel_tool_use`. Because the flag lives inside +`tool_choice`, a request that carries only the parallel intent and no explicit +choice gets a synthesized `{type:"auto"}` so the flag has somewhere to live; +`required` maps to `{type:"any"}` and a named choice to `{type:"tool"}`, and both +accept it. `{type:"none"}` does not receive the flag because tool use is already off, +and a request with no tools on the wire emits no `tool_choice` at all. An unset or +true `parallelToolCalls` is byte-identical to previous behavior. + +The flag constrains the model's output, not execution ordering. Sequential tool use +is enforced by the caller's own loop returning each `tool_result` before issuing the +next request; this mapping does not provide that. diff --git a/structure/providers/google.md b/structure/providers/google.md index 1c1d5648f8..a403777ee3 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -47,3 +47,27 @@ mismatched, and standalone results become marked text instead of unpaired functi Representable data-URL images remain sibling `inline_data` parts in either case. > Decision record: [ADR-0058](../decisions/ADR-0058-google-tool-result-adjacency-repair.md) +## Structured output on generateContent + +A caller's Responses `text.format` reaches the Gemini wire as +`generationConfig.responseMimeType: "application/json"` plus, for `json_schema`, +`generationConfig.responseJsonSchema` carrying the schema unchanged. +`responseJsonSchema` takes ordinary JSON Schema with lowercase type names, which is +the shape `options.textFormat.schema` already holds; `responseSchema` takes Gemini's +uppercase typed `Schema` form and is omitted when `responseJsonSchema` is used. The +response type is unchanged — the model returns text containing conforming JSON — so +response parsing is untouched. + +The schema is carried verbatim. `sanitizeGeminiToolParameters` narrows a schema to +the function-declaration subset and must never be applied to a caller-authored output +schema. `compileGenerationConfig` in `google-wire-compiler.ts` is a whitelist, so +both keys are listed there as well; setting them in the adapter alone would drop them +before the wire. + +Three cases refuse explicitly rather than dropping the constraint silently: +cloud-code-assist, which opencodex does not implement or verify for this field +(including Claude models served through that envelope — this is not a claim about +what the upstream can do); an image-capable model, whose `responseModalities` +configuration contradicts JSON-constrained text; and a `json_schema` format carrying +no schema, which would otherwise downgrade to bare JSON mode. An image-capable model +with no structured-output request keeps its existing `responseModalities` behavior. diff --git a/tests/adapters/anthropic/anthropic-parallel-tool-disable.test.ts b/tests/adapters/anthropic/anthropic-parallel-tool-disable.test.ts new file mode 100644 index 0000000000..71903263e3 --- /dev/null +++ b/tests/adapters/anthropic/anthropic-parallel-tool-disable.test.ts @@ -0,0 +1,87 @@ +/** + * Audit F4 (2026-09-14): `options.parallelToolCalls === false` had no Anthropic + * consumer. The caller asked for one tool call at a time and the request went out + * unconstrained. + * + * Anthropic carries that intent as `disable_parallel_tool_use` nested INSIDE + * `tool_choice`. Per the tool-use docs its per-mode meaning is: + * auto -> at most one call; any -> exactly one; tool -> exactly one; + * none -> tool use already off, so the flag is irrelevant. + * The old code also emitted tool_choice only when an explicit choice was set, so a + * request carrying only parallel_tool_calls:false emitted nothing at all — the + * implicit default has to be stated for the flag to have somewhere to live. + * + * The flag constrains the model's OUTPUT, not execution order. + */ +import { describe, expect, test } from "bun:test"; +import { createAnthropicAdapter } from "../../../src/adapters/anthropic"; +import type { OcxParsedRequest, OcxProviderConfig, OcxTool } from "../../../src/types"; + +const provider = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "sk-x", authMode: "apiKey" } as unknown as OcxProviderConfig; + +const TOOL = { name: "lookup", description: "Look something up", parameters: { type: "object", properties: {} } } as OcxTool; + +async function toolChoiceOf(options: Record, withTools = true): Promise | undefined> { + const parsed = { + modelId: "anthropic/claude-sonnet-4.5", + stream: false, + options, + context: { messages: [{ role: "user", content: "hi", timestamp: 0 }], ...(withTools ? { tools: [TOOL] } : {}) }, + } as unknown as OcxParsedRequest; + const { body } = await createAnthropicAdapter(provider).buildRequest(parsed); + const parsedBody = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as { tool_choice?: Record }; + return parsedBody.tool_choice; +} + +describe("F4 parallel=false maps onto nested disable_parallel_tool_use", () => { + test("implicit auto is synthesized so the intent has somewhere to live", async () => { + expect(await toolChoiceOf({ parallelToolCalls: false })) + .toEqual({ type: "auto", disable_parallel_tool_use: true }); + }); + + test("an explicit auto carries the flag", async () => { + expect(await toolChoiceOf({ toolChoice: "auto", parallelToolCalls: false })) + .toEqual({ type: "auto", disable_parallel_tool_use: true }); + }); + + test("required maps to any and carries the flag", async () => { + expect(await toolChoiceOf({ toolChoice: "required", parallelToolCalls: false })) + .toEqual({ type: "any", disable_parallel_tool_use: true }); + }); + + test("a named tool choice carries the flag", async () => { + const choice = await toolChoiceOf({ toolChoice: { name: "lookup" }, parallelToolCalls: false }); + + expect(choice).toMatchObject({ type: "tool", disable_parallel_tool_use: true }); + expect(choice!.name).toBe("lookup"); + }); + + test("allowed-tools auto and required both carry the flag", async () => { + // The IR shape is { allowedTools, mode } (src/types/tools.ts:294-299), which + // isAllowedToolChoice detects by the allowedTools key. + expect(await toolChoiceOf({ toolChoice: { allowedTools: ["lookup"], mode: "auto" }, parallelToolCalls: false })) + .toEqual({ type: "auto", disable_parallel_tool_use: true }); + expect(await toolChoiceOf({ toolChoice: { allowedTools: ["lookup"], mode: "required" }, parallelToolCalls: false })) + .toEqual({ type: "any", disable_parallel_tool_use: true }); + }); +}); + +describe("F4 cases that must not change", () => { + test("none stays bare — tool use is already off, so the flag is irrelevant", async () => { + expect(await toolChoiceOf({ toolChoice: "none", parallelToolCalls: false })).toEqual({ type: "none" }); + }); + + test("no tools on the wire means no tool_choice at all", async () => { + expect(await toolChoiceOf({ parallelToolCalls: false }, false)).toBeUndefined(); + }); + + test("parallel unset is byte-identical to today", async () => { + expect(await toolChoiceOf({ toolChoice: "auto" })).toEqual({ type: "auto" }); + expect(await toolChoiceOf({})).toBeUndefined(); + }); + + test("parallel true never attaches the flag", async () => { + expect(await toolChoiceOf({ toolChoice: "auto", parallelToolCalls: true })).toEqual({ type: "auto" }); + expect(await toolChoiceOf({ parallelToolCalls: true })).toBeUndefined(); + }); +}); diff --git a/tests/adapters/google/google-structured-output.test.ts b/tests/adapters/google/google-structured-output.test.ts new file mode 100644 index 0000000000..f569b0adc3 --- /dev/null +++ b/tests/adapters/google/google-structured-output.test.ts @@ -0,0 +1,112 @@ +/** + * Audit F3 (2026-09-14): the Google adapter never read `options.textFormat`, and its + * wire compiler whitelists generationConfig keys — so a caller's structured-output + * request was dropped twice over and the model returned unconstrained prose as + * success. + * + * Contract (https://ai.google.dev/api/generate-content): structured output travels in + * generationConfig on generateContent itself. `responseJsonSchema` takes ordinary + * JSON Schema with lowercase type names — which is exactly the shape + * options.textFormat.schema already holds — alongside + * `responseMimeType: "application/json"`. `responseSchema` takes Gemini's uppercase + * typed Schema form instead and is omitted when responseJsonSchema is used. + */ +import { describe, expect, test } from "bun:test"; +import { createGoogleAdapter } from "../../../src/adapters/google"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; + +const aiStudio = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "key" } as unknown as OcxProviderConfig; +const vertex = { adapter: "google", googleMode: "vertex", baseUrl: "https://aiplatform.googleapis.com", apiKey: "key" } as unknown as OcxProviderConfig; +const cca = { adapter: "google", googleMode: "cloud-code-assist", baseUrl: "https://cloudcode-pa.googleapis.com", apiKey: "token" } as unknown as OcxProviderConfig; + +const SCHEMA = { + type: "object", + properties: { answer: { type: "string" } }, + required: ["answer"], + additionalProperties: false, +}; + +function parsed(textFormat?: unknown, modelId = "gemini-3-pro"): OcxParsedRequest { + return { + modelId, + stream: false, + options: textFormat ? { textFormat } : {}, + context: { messages: [{ role: "user", content: "hi", timestamp: 0 }] }, + } as unknown as OcxParsedRequest; +} + +async function generationConfig(provider: OcxProviderConfig, req: OcxParsedRequest): Promise> { + const { body } = await createGoogleAdapter(provider).buildRequest(req); + return (JSON.parse(typeof body === "string" ? body : JSON.stringify(body)).generationConfig ?? {}) as Record; +} + +describe("F3 Google structured output reaches the generateContent wire", () => { + test("a json_schema format sets responseMimeType and responseJsonSchema on AI Studio", async () => { + const config = await generationConfig(aiStudio, parsed({ type: "json_schema", name: "answer", schema: SCHEMA, strict: true })); + + expect(config.responseMimeType).toBe("application/json"); + expect(config.responseJsonSchema).toEqual(SCHEMA); + // responseSchema takes Gemini's uppercase typed form and must be omitted here. + expect(config.responseSchema).toBeUndefined(); + }); + + test("the same holds on Vertex", async () => { + const config = await generationConfig(vertex, parsed({ type: "json_schema", name: "answer", schema: SCHEMA })); + + expect(config.responseMimeType).toBe("application/json"); + expect(config.responseJsonSchema).toEqual(SCHEMA); + }); + + test("the schema survives compilation byte-for-byte, unsanitized", async () => { + const nested = { + type: "object", + properties: { items: { type: "array", items: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } } }, + required: ["items"], + additionalProperties: false, + }; + const config = await generationConfig(aiStudio, parsed({ type: "json_schema", schema: nested })); + + // The tool-parameter sanitizer would strip additionalProperties and nested required. + expect(config.responseJsonSchema).toEqual(nested); + }); + + test("json_object sets only the mime type", async () => { + const config = await generationConfig(aiStudio, parsed({ type: "json_object" })); + + expect(config.responseMimeType).toBe("application/json"); + expect(config.responseJsonSchema).toBeUndefined(); + }); + + test("no textFormat leaves generationConfig free of structured-output keys", async () => { + const config = await generationConfig(aiStudio, parsed()); + + expect(config.responseMimeType).toBeUndefined(); + expect(config.responseJsonSchema).toBeUndefined(); + }); +}); + +describe("F3 unsupported modes refuse explicitly instead of dropping the schema", () => { + test("cloud-code-assist reports that opencodex does not implement it", async () => { + const promise = createGoogleAdapter(cca).buildRequest(parsed({ type: "json_schema", schema: SCHEMA })); + await expect(promise).rejects.toThrow(/not implemented by opencodex/); + }); + + test("an image-capable model refuses rather than silently losing the schema", async () => { + const promise = createGoogleAdapter(aiStudio).buildRequest( + parsed({ type: "json_schema", schema: SCHEMA }, "gemini-3-pro-image-preview"), + ); + await expect(promise).rejects.toThrow(/cannot combine image output with structured output/); + }); + + test("an image-capable model with NO schema keeps its image behavior", async () => { + const config = await generationConfig(aiStudio, parsed(undefined, "gemini-3-pro-image-preview")); + + expect(config.responseModalities).toEqual(["TEXT", "IMAGE"]); + expect(config.responseMimeType).toBeUndefined(); + }); + + test("a json_schema format with no schema refuses rather than downgrading to JSON mode", async () => { + const promise = createGoogleAdapter(aiStudio).buildRequest(parsed({ type: "json_schema", name: "answer" })); + await expect(promise).rejects.toThrow(/requires text.format.schema/); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 34baef2a15..ce295bdeac 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -40,6 +40,7 @@ "anthropic-image-normalize.test.ts": "adapters/anthropic", "anthropic-image-retry-e2e.test.ts": "adapters/anthropic", "anthropic-image-retry.test.ts": "adapters/anthropic", + "anthropic-parallel-tool-disable.test.ts": "adapters/anthropic", "anthropic-pool-toggle-copy.test.ts": "adapters/anthropic", "anthropic-quorum-cache.test.ts": "routing", "anthropic-quota-dispatch.test.ts": "adapters/anthropic", @@ -1255,6 +1256,7 @@ "devin-cli-authmode-migration.test.ts": "providers", "devin-login.test.ts": "providers", "devin-provider-merge-migration.test.ts": "providers", + "google-structured-output.test.ts": "adapters/google", "usage-log-ws-stage.test.ts": "usage", "main-device-reauth.test.ts": "codex-integration", "main-device-reauth-api.test.ts": "codex-integration",