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..14d9d691f22f 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,47 @@ 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, 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. + ### 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..cc00eff1e8e9 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,47 @@ 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, 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. + ### 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..8b94112b945c 100644 --- a/packages/google/src/google-language-model-options.ts +++ b/packages/google/src/google-language-model-options.ts @@ -87,6 +87,22 @@ 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. + * + * 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(), + /** * 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..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', @@ -1619,6 +1629,194 @@ 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 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'); + + 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, }),