From e81bbed143972fdae1c49fab6ed3229ae414c1c1 Mon Sep 17 00:00:00 2001 From: onatozmenn Date: Sun, 2 Aug 2026 22:34:26 +0300 Subject: [PATCH 1/2] feat(provider/google): add useResponseJsonSchema provider option Send structured output schemas via generationConfig.responseJsonSchema instead of the OpenAPI 3.0 subset, enabling unions, records and recursive schemas on Gemini 2.5+. --- .changeset/brown-hoops-shout.md | 5 + .../01-ai-sdk-providers/15-google.mdx | 46 +++++++ .../01-ai-sdk-providers/16-google-vertex.mdx | 46 +++++++ .../src/google-language-model-options.ts | 13 ++ .../google/src/google-language-model.test.ts | 115 ++++++++++++++++++ packages/google/src/google-language-model.ts | 25 ++-- 6 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 .changeset/brown-hoops-shout.md diff --git a/.changeset/brown-hoops-shout.md b/.changeset/brown-hoops-shout.md new file mode 100644 index 000000000000..d6360a6026e3 --- /dev/null +++ b/.changeset/brown-hoops-shout.md @@ -0,0 +1,5 @@ +--- +'@ai-sdk/google': patch +--- + +feat (provider/google): add `useResponseJsonSchema` provider option to send structured output schemas as JSON Schema diff --git a/content/providers/01-ai-sdk-providers/15-google.mdx b/content/providers/01-ai-sdk-providers/15-google.mdx index 9d3841f5ada1..01506062d119 100644 --- a/content/providers/01-ai-sdk-providers/15-google.mdx +++ b/content/providers/01-ai-sdk-providers/15-google.mdx @@ -142,6 +142,17 @@ The following optional provider options are available for Google models: See [Troubleshooting: Schema Limitations](#schema-limitations) for more details. +- **useResponseJsonSchema** _boolean_ + + Optional. Send the response schema as JSON Schema instead of the OpenAPI 3.0 + subset that Google uses by default. Default is false. + + JSON Schema supports features that the OpenAPI subset does not, e.g. unions + (`z.union`), records (`z.record`) and recursive schemas. Requires Gemini 2.5 + or later. + + See [Troubleshooting: Schema Limitations](#schema-limitations) for more details. + - **safetySettings** _Array\<\{ category: string; threshold: string \}\>_ Optional. Safety settings for the model. @@ -1037,6 +1048,41 @@ The following Zod features are known to not work with Google: - `z.union` - `z.record` +Alternatively, you can keep structured outputs enabled and send the schema as +JSON Schema, which Gemini 2.5 and later models support: + +```ts highlight="5" +const { output } = await generateText({ + model: google('gemini-2.5-flash'), + providerOptions: { + google: { + useResponseJsonSchema: true, + } satisfies GoogleLanguageModelOptions, + }, + output: Output.object({ + schema: z.object({ + name: z.string(), + age: z.number(), + contact: z.union([ + z.object({ + type: z.literal('email'), + value: z.string(), + }), + z.object({ + type: z.literal('phone'), + value: z.string(), + }), + ]), + }), + }), + prompt: 'Generate an example person for testing.', +}); +``` + +JSON Schema also only supports a subset of the specification. See the +[Gemini structured output documentation](https://ai.google.dev/gemini-api/docs/structured-output) +for the supported schema features. + ### Model Capabilities | Model | Image Input | Object Generation | Tool Usage | Tool Streaming | Google Search | URL Context | diff --git a/content/providers/01-ai-sdk-providers/16-google-vertex.mdx b/content/providers/01-ai-sdk-providers/16-google-vertex.mdx index 824d0cd59641..6eaeb912d5c7 100644 --- a/content/providers/01-ai-sdk-providers/16-google-vertex.mdx +++ b/content/providers/01-ai-sdk-providers/16-google-vertex.mdx @@ -294,6 +294,17 @@ The following optional provider options are available for Google Vertex models: See [Troubleshooting: Schema Limitations](#schema-limitations) for more details. +- **useResponseJsonSchema** _boolean_ + + Optional. Send the response schema as JSON Schema instead of the OpenAPI 3.0 + subset that Google Vertex uses by default. Default is false. + + JSON Schema supports features that the OpenAPI subset does not, e.g. unions + (`z.union`), records (`z.record`) and recursive schemas. Requires Gemini 2.5 + or later. + + See [Troubleshooting: Schema Limitations](#schema-limitations) for more details. + - **safetySettings** _Array\<\{ category: string; threshold: string \}\>_ Optional. Safety settings for the model. @@ -904,6 +915,41 @@ The following Zod features are known to not work with Google Vertex: - `z.union` - `z.record` +Alternatively, you can keep structured outputs enabled and send the schema as +JSON Schema, which Gemini 2.5 and later models support: + +```ts highlight="4" +const result = await generateText({ + model: googleVertex('gemini-2.5-pro'), + providerOptions: { + vertex: { + useResponseJsonSchema: true, + } satisfies GoogleLanguageModelOptions, + }, + output: Output.object({ + schema: z.object({ + name: z.string(), + age: z.number(), + contact: z.union([ + z.object({ + type: z.literal('email'), + value: z.string(), + }), + z.object({ + type: z.literal('phone'), + value: z.string(), + }), + ]), + }), + }), + prompt: 'Generate an example person for testing.', +}); +``` + +JSON Schema also only supports a subset of the specification. See the +[Gemini structured output documentation](https://ai.google.dev/gemini-api/docs/structured-output) +for the supported schema features. + ### Model Capabilities | Model | Image Input | Object Generation | Tool Usage | Tool Streaming | diff --git a/packages/google/src/google-language-model-options.ts b/packages/google/src/google-language-model-options.ts index f05aa3d78395..5aead445162f 100644 --- a/packages/google/src/google-language-model-options.ts +++ b/packages/google/src/google-language-model-options.ts @@ -87,6 +87,19 @@ export const googleLanguageModelOptions = lazySchema(() => */ structuredOutputs: z.boolean().optional(), + /** + * Optional. Send the response schema as JSON Schema + * (`generationConfig.responseJsonSchema`) instead of the OpenAPI 3.0 + * subset (`generationConfig.responseSchema`). Default is false. + * + * JSON Schema supports features that the OpenAPI subset does not, + * e.g. unions (`anyOf`), records (`additionalProperties`) and + * recursive schemas (`$ref` / `$defs`). Requires Gemini 2.5 or later. + * + * https://ai.google.dev/gemini-api/docs/structured-output + */ + useResponseJsonSchema: z.boolean().optional(), + /** * Optional. A list of unique safety settings for blocking unsafe content. */ diff --git a/packages/google/src/google-language-model.test.ts b/packages/google/src/google-language-model.test.ts index f67d56b11452..6e107bb9c974 100644 --- a/packages/google/src/google-language-model.test.ts +++ b/packages/google/src/google-language-model.test.ts @@ -1619,6 +1619,121 @@ describe('doGenerate', () => { `); }); + it('should pass json schema with responseFormat and useResponseJsonSchema = true', async () => { + prepareJsonFixtureResponse('google-text'); + + await provider.languageModel('gemini-pro').doGenerate({ + providerOptions: { + google: { + useResponseJsonSchema: true, + }, + }, + responseFormat: { + type: 'json', + schema: { + type: 'object', + properties: { + contact: { + anyOf: [ + { type: 'object', properties: { email: { type: 'string' } } }, + { type: 'object', properties: { phone: { type: 'string' } } }, + ], + }, + }, + required: ['contact'], + additionalProperties: false, + }, + }, + prompt: TEST_PROMPT, + }); + + expect(await server.calls[0].requestBodyJson).toMatchInlineSnapshot(` + { + "contents": [ + { + "parts": [ + { + "text": "Hello", + }, + ], + "role": "user", + }, + ], + "generationConfig": { + "responseJsonSchema": { + "additionalProperties": false, + "properties": { + "contact": { + "anyOf": [ + { + "properties": { + "email": { + "type": "string", + }, + }, + "type": "object", + }, + { + "properties": { + "phone": { + "type": "string", + }, + }, + "type": "object", + }, + ], + }, + }, + "required": [ + "contact", + ], + "type": "object", + }, + "responseMimeType": "application/json", + }, + } + `); + }); + + it('should not pass any schema with useResponseJsonSchema = true and structuredOutputs = false', async () => { + prepareJsonFixtureResponse('google-text'); + + await provider.languageModel('gemini-pro').doGenerate({ + providerOptions: { + google: { + useResponseJsonSchema: true, + structuredOutputs: false, + }, + }, + responseFormat: { + type: 'json', + schema: { + type: 'object', + properties: { property1: { type: 'string' } }, + }, + }, + prompt: TEST_PROMPT, + }); + + expect(await server.calls[0].requestBodyJson).toMatchInlineSnapshot(` + { + "contents": [ + { + "parts": [ + { + "text": "Hello", + }, + ], + "role": "user", + }, + ], + "generationConfig": { + "responseMimeType": "application/json", + }, + } + `); + }); + it('should pass tools and toolChoice', async () => { prepareJsonFixtureResponse('google-text'); diff --git a/packages/google/src/google-language-model.ts b/packages/google/src/google-language-model.ts index 13639709bf54..e8d050450d7a 100644 --- a/packages/google/src/google-language-model.ts +++ b/packages/google/src/google-language-model.ts @@ -305,6 +305,17 @@ export class GoogleLanguageModel implements LanguageModelV4 { })) : undefined); + const structuredOutputSchema = + responseFormat?.type === 'json' && + responseFormat.schema != null && + // Google GenAI does not support all OpenAPI Schema features, + // so this is needed as an escape hatch: + // TODO convert into provider option + (googleOptions?.structuredOutputs ?? true) + ? responseFormat.schema + : undefined; + const useResponseJsonSchema = googleOptions?.useResponseJsonSchema ?? false; + const toolConfig = googleToolConfig || streamFunctionCallArguments || @@ -340,14 +351,14 @@ export class GoogleLanguageModel implements LanguageModelV4 { responseMimeType: responseFormat?.type === 'json' ? 'application/json' : undefined, responseSchema: - responseFormat?.type === 'json' && - responseFormat.schema != null && - // Google GenAI does not support all OpenAPI Schema features, - // so this is needed as an escape hatch: - // TODO convert into provider option - (googleOptions?.structuredOutputs ?? true) - ? convertJSONSchemaToOpenAPISchema(responseFormat.schema) + structuredOutputSchema != null && !useResponseJsonSchema + ? convertJSONSchemaToOpenAPISchema(structuredOutputSchema) : undefined, + // mutually exclusive with `responseSchema`: + ...(structuredOutputSchema != null && + useResponseJsonSchema && { + responseJsonSchema: structuredOutputSchema, + }), ...(googleOptions?.audioTimestamp && { audioTimestamp: googleOptions.audioTimestamp, }), From 6091b9b722148f5f21227af1027a4af3f813cd96 Mon Sep 17 00:00:00 2001 From: onatozmenn Date: Mon, 10 Aug 2026 06:43:03 +0300 Subject: [PATCH 2/2] docs(provider/google): document the responseJsonSchema constraint trade-off The two schema fields support different subsets, so useResponseJsonSchema is not a superset of the default path. Gemini's supported keyword list for responseJsonSchema has no pattern, minLength or maxLength, and unsupported keywords are ignored rather than rejected, so a z.string().min(3) constraint stops being enforced with no error. Going the other way, the OpenAPI conversion drops minimum, maximum, minItems and maxItems, which responseJsonSchema keeps. Two request-body tests pin both halves with a constraint-bearing schema, since the existing fixtures use bare string properties and read the same either way. --- .../01-ai-sdk-providers/15-google.mdx | 8 +- .../01-ai-sdk-providers/16-google-vertex.mdx | 8 +- .../src/google-language-model-options.ts | 3 + .../google/src/google-language-model.test.ts | 83 +++++++++++++++++++ 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/content/providers/01-ai-sdk-providers/15-google.mdx b/content/providers/01-ai-sdk-providers/15-google.mdx index 01506062d119..14d9d691f22f 100644 --- a/content/providers/01-ai-sdk-providers/15-google.mdx +++ b/content/providers/01-ai-sdk-providers/15-google.mdx @@ -1079,7 +1079,13 @@ const { output } = await generateText({ }); ``` -JSON Schema also only supports a subset of the specification. See the +JSON Schema also only supports a subset of the specification, and it is a +different subset, so switching is a trade-off rather than an upgrade. There is +no `pattern`, `minLength` or `maxLength` in it, and unsupported keywords are +ignored instead of rejected, so a `z.string().min(3)` constraint silently stops +being enforced. In the other direction, the conversion to the OpenAPI subset +drops `minimum`, `maximum`, `minItems` and `maxItems`, which +`responseJsonSchema` keeps. See the [Gemini structured output documentation](https://ai.google.dev/gemini-api/docs/structured-output) for the supported schema features. diff --git a/content/providers/01-ai-sdk-providers/16-google-vertex.mdx b/content/providers/01-ai-sdk-providers/16-google-vertex.mdx index 6eaeb912d5c7..cc00eff1e8e9 100644 --- a/content/providers/01-ai-sdk-providers/16-google-vertex.mdx +++ b/content/providers/01-ai-sdk-providers/16-google-vertex.mdx @@ -946,7 +946,13 @@ const result = await generateText({ }); ``` -JSON Schema also only supports a subset of the specification. See the +JSON Schema also only supports a subset of the specification, and it is a +different subset, so switching is a trade-off rather than an upgrade. There is +no `pattern`, `minLength` or `maxLength` in it, and unsupported keywords are +ignored instead of rejected, so a `z.string().min(3)` constraint silently stops +being enforced. In the other direction, the conversion to the OpenAPI subset +drops `minimum`, `maximum`, `minItems` and `maxItems`, which +`responseJsonSchema` keeps. See the [Gemini structured output documentation](https://ai.google.dev/gemini-api/docs/structured-output) for the supported schema features. diff --git a/packages/google/src/google-language-model-options.ts b/packages/google/src/google-language-model-options.ts index 5aead445162f..8b94112b945c 100644 --- a/packages/google/src/google-language-model-options.ts +++ b/packages/google/src/google-language-model-options.ts @@ -96,6 +96,9 @@ export const googleLanguageModelOptions = lazySchema(() => * e.g. unions (`anyOf`), records (`additionalProperties`) and * recursive schemas (`$ref` / `$defs`). Requires Gemini 2.5 or later. * + * It is a different subset rather than a superset: `pattern`, + * `minLength` and `maxLength` are ignored on this path. + * * https://ai.google.dev/gemini-api/docs/structured-output */ useResponseJsonSchema: z.boolean().optional(), diff --git a/packages/google/src/google-language-model.test.ts b/packages/google/src/google-language-model.test.ts index 6e107bb9c974..fc576645717d 100644 --- a/packages/google/src/google-language-model.test.ts +++ b/packages/google/src/google-language-model.test.ts @@ -1,5 +1,6 @@ import { LanguageModelV4ProviderTool, + type JSONSchema7, type LanguageModelV4Prompt, } from '@ai-sdk/provider'; import { createTestServer } from '@ai-sdk/test-server/with-vitest'; @@ -26,6 +27,15 @@ const TEST_PROMPT: LanguageModelV4Prompt = [ { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, ]; +const CONSTRAINED_TEST_SCHEMA: JSONSchema7 = { + type: 'object', + properties: { + slug: { type: 'string', pattern: '^[a-z-]+$', minLength: 3, maxLength: 50 }, + count: { type: 'integer', minimum: 1, maximum: 9 }, + }, + required: ['slug'], +}; + const SAFETY_RATINGS = [ { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', @@ -1695,6 +1705,79 @@ describe('doGenerate', () => { `); }); + it('should keep string and number constraints with useResponseJsonSchema = true', async () => { + prepareJsonFixtureResponse('google-text'); + + await provider.languageModel('gemini-pro').doGenerate({ + providerOptions: { + google: { + useResponseJsonSchema: true, + }, + }, + responseFormat: { type: 'json', schema: CONSTRAINED_TEST_SCHEMA }, + prompt: TEST_PROMPT, + }); + + expect( + (await server.calls[0].requestBodyJson).generationConfig, + ).toMatchInlineSnapshot(` + { + "responseJsonSchema": { + "properties": { + "count": { + "maximum": 9, + "minimum": 1, + "type": "integer", + }, + "slug": { + "maxLength": 50, + "minLength": 3, + "pattern": "^[a-z-]+$", + "type": "string", + }, + }, + "required": [ + "slug", + ], + "type": "object", + }, + "responseMimeType": "application/json", + } + `); + }); + + it('should drop most constraints when converting to the OpenAPI subset', async () => { + prepareJsonFixtureResponse('google-text'); + + await provider.languageModel('gemini-pro').doGenerate({ + responseFormat: { type: 'json', schema: CONSTRAINED_TEST_SCHEMA }, + prompt: TEST_PROMPT, + }); + + expect( + (await server.calls[0].requestBodyJson).generationConfig, + ).toMatchInlineSnapshot(` + { + "responseMimeType": "application/json", + "responseSchema": { + "properties": { + "count": { + "type": "integer", + }, + "slug": { + "minLength": 3, + "type": "string", + }, + }, + "required": [ + "slug", + ], + "type": "object", + }, + } + `); + }); + it('should not pass any schema with useResponseJsonSchema = true and structuredOutputs = false', async () => { prepareJsonFixtureResponse('google-text');