Skip to content

feat(provider/google): support JSON Schema for structured outputs via useResponseJsonSchema - #18325

Open
onatozmenn wants to merge 2 commits into
vercel:mainfrom
onatozmenn:feat/google-response-json-schema
Open

feat(provider/google): support JSON Schema for structured outputs via useResponseJsonSchema#18325
onatozmenn wants to merge 2 commits into
vercel:mainfrom
onatozmenn:feat/google-response-json-schema

Conversation

@onatozmenn

@onatozmenn onatozmenn commented Aug 2, 2026

Copy link
Copy Markdown

Background

Fixes #6494.

Gemini structured outputs currently go out as generationConfig.responseSchema, which is a subset of the OpenAPI 3.0 spec rather than JSON Schema. That subset trips on unions and records: anyOf itself is accepted by the API, but the schema this provider builds for a z.union carries no type on the union node, and the API requires one. So z.union / z.record schemas either fail with errors like response_schema.properties[occupation].type: must be specified or force users to disable structured outputs entirely (structuredOutputs: false), losing constrained decoding.

Gemini 2.5 and later support submitting JSON Schema through generationConfig.responseJsonSchema, which is mutually exclusive with responseSchema and requires responseMimeType (API reference, guide).

Summary

Adds a useResponseJsonSchema provider option to @ai-sdk/google:

  • when true, the response format schema is sent unchanged as generationConfig.responseJsonSchema instead of being converted to the OpenAPI subset for generationConfig.responseSchema
  • the two fields are never sent together, and responseMimeType keeps being set as before
  • defaults to false, so existing requests are unchanged
  • structuredOutputs: false still wins and suppresses both fields

This unblocks unions, records and recursive schemas for object generation on Gemini 2.5+ without giving up structured outputs.

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(),
      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.',
});

The schema is forwarded as-is (including $schema), matching how the official js-genai SDK routes JSON Schema payloads to this field.

What the flag costs

The two fields support different subsets, so this is a trade-off rather than an upgrade, and the docs now say so. Gemini's supported keyword list for responseJsonSchema has no pattern, minLength or maxLength, and unsupported keywords there are ignored rather than rejected. Of those, minLength is the only one the OpenAPI conversion forwards today, so it is the one that goes from enforced to silently ignored. In the other direction the conversion drops minimum, maximum, minItems and maxItems, which responseJsonSchema keeps.

Contributor Credit

End-to-End Verification

Verified through the provider request-body tests, which assert the exact JSON sent to :generateContent:

  • useResponseJsonSchema: true produces generationConfig.responseJsonSchema with the untouched anyOf union schema and no responseSchema
  • useResponseJsonSchema: true combined with structuredOutputs: false sends neither schema field
  • a constraint-bearing schema is snapshotted on both paths, so the trade-off documented above is pinned rather than asserted
  • all pre-existing structured-output snapshots are unchanged, confirming the default path is untouched

pnpm --filter @ai-sdk/google exec vitest --config vitest.node.config.js --run → 739 passed. tsc --build and oxlint on the touched files are clean.

Checklist

  • All commits are signed (PRs with unsigned commits cannot be merged)
  • Tests have been added / updated (for bug fixes / features)
  • Documentation has been added / updated (for bug fixes / features)
  • A patch changeset for relevant packages has been added (for bug fixes / features - run pnpm changeset in the project root)
  • I have reviewed this pull request (self-review)

Future Work

The Gemini API exposes the same escape hatch for tool inputs via FunctionDeclaration.parametersJsonSchema. Wiring that up would let tool input schemas use unions and records too; it was left out here to keep this change focused on structured outputs.

@percymcn

percymcn commented Aug 9, 2026

Copy link
Copy Markdown

The routing here looks right to me — mutual exclusivity with responseSchema, keeping responseMimeType, defaulting to false, and letting structuredOutputs: false win are all the correct shape, and putting it behind an explicit provider option rather than sniffing the schema is the right call (@google/genai auto-routes on a top-level $schema, which is invisible and depends on which client you use).

One thing I'd suggest adding to the two .mdx files, because it isn't visible from the diff and there's no error to discover it from: the two fields are complementary, not nested. responseJsonSchema is not a superset of the OpenAPI subset — it accepts some things the subset can't express, and the subset accepts some things it doesn't.

Google's google-genai (2.17.0) documents the accepted set on the field itself:

While the full JSON Schema may be sent, not all features are supported. Specifically, only the following properties are supported: $id, $defs, $ref, $anchor, type, format, title, description, enum (for strings and numbers), items, prefixItems, minItems, maxItems, minimum, maximum, anyOf, oneOf (interpreted the same as anyOf), properties, additionalProperties, required. The non-standard propertyOrdering property may also be set.

Absent from that list, and accepted by the narrow responseSchema proto: pattern, minLength, maxLength, minProperties, maxProperties, default, nullable. I checked the narrow side today against the live v1beta endpoint — it validates the request payload before authenticating, so a dummy key still returns a real verdict — and {"type":"string","pattern":"^x","minLength":3,"maxLength":9,"default":"xyz"} with minProperties is accepted, with {"type":"frobnicate"} rejected in the same run as a control.

So for a schema like

z.object({ slug: z.string().min(3).max(50).regex(/^[a-z-]+$/) })

flipping useResponseJsonSchema: true to unblock a union elsewhere in the same schema silently stops min/max/regex being enforced. And it is silent by design: that field's contract is that the full schema may be sent and merely that not all features are supported, i.e. unsupported keywords are ignored rather than rejected. Nothing 400s, nothing warns — you just quietly lose constrained decoding for those fields while believing you still have it. A sentence in the docs ("this trades string/size constraints for union and recursion support") would save people a genuinely nasty debugging session.

Two smaller notes:

The new tests pass either way. Both fixtures use bare { type: 'string' } properties, so they'd be identical with and without the constraint loss. Worth saying that a unit test can't catch this at all — the assertion is that the body is forwarded unchanged, and forwarding unchanged is exactly what's correct here — which is precisely why it has to be a docs note rather than a test.

"That subset does not cover unions" is slightly narrower than it reads. oneOf, allOf and not are not unknown fields on responseSchema: I sent all three through the same pre-auth check today and they're accepted, at the root and nested, while eleven other keywords ($ref, $schema, const, uniqueItems, patternProperties, propertyNames, if, contains, dependentRequired, multipleOf, exclusiveMinimum) come back Unknown name "X" ... Cannot find field. What doesn't declare them is the client types — @google/genai's Schema, Python's types.Schema (which is extra="forbid"), the Go struct — none of which @ai-sdk/google goes through, since it builds the request itself.

Caveat on that last point, because it cuts both ways: the pre-auth check is a proto-shape oracle. It catches unknown field names and bad enum values, and it runs before the semantic checks, so the response_schema.properties[occupation].type: must be specified error in #6494 is a real constraint it can't see. I'm not claiming unions work on the narrow path — the missing type on the union node is a genuine blocker and this PR is a legitimate fix for it. Just that the reason is "the union node has no type" rather than "the field doesn't exist," which matters for how the docs explain when to reach for the flag.

@onatozmenn

onatozmenn commented Aug 10, 2026

Copy link
Copy Markdown
Author

Thanks, this is a good catch, and it's in both .mdx files now (6091b9b).

One thing shifted while I was checking it though. I ran your keyword list through the SDK's own converter before writing the note, and most of those never reach Gemini on the default path either. convertJSONSchemaToOpenAPISchema forwards type, format, description, required, properties, items, enum, const (as enum), anyOf/allOf/oneOf and minLength, plus a synthesised nullable, and quietly drops the rest. Same schema, both paths:

in:                  slug  { type: string, pattern: ^[a-z-]+$, minLength: 3, maxLength: 50 }
                     count { type: integer, minimum: 1, maximum: 9 }

responseSchema:      slug  { type: string, minLength: 3 }
                     count { type: integer }

responseJsonSchema:  both forwarded unchanged

So pattern and maxLength were already gone before this flag existed, and flipping it doesn't lose them. The one that does regress is minLength: it's the only string constraint the converter forwards, and it isn't on Gemini's supported list for responseJsonSchema, so it goes from enforced to silently ignored. It cuts the other way too. minimum, maximum, minItems and maxItems are on that list but the OpenAPI conversion drops them, so the flag actually gains those. That's what the note says now, and I credited you in the description.

You're right that a unit test can't catch Gemini ignoring a keyword. It can catch the SDK changing which keywords it sends, which is the half that's ours, so the two new tests snapshot a constraint-bearing schema on both paths instead of the bare { type: 'string' } fixtures.

On unions, agreed, and I've reworded that sentence. I get the same results you do: oneOf, allOf and not are accepted on response_schema, while $ref, $schema, const, uniqueItems, patternProperties, propertyNames, if, contains, dependentRequired, multipleOf and exclusiveMinimum all come back as Cannot find field. The blocker is the one you named, the union node going out as { anyOf: [...] } with no type. Worth repeating your caveat as well, since it's easy to over-read what that oracle proves: the type: must be specified check runs after auth, so a dummy key can't reach it.

Investigated with AI assistance.

Send structured output schemas via generationConfig.responseJsonSchema instead of the OpenAPI 3.0 subset, enabling unions, records and recursive schemas on Gemini 2.5+.
…de-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.
@onatozmenn
onatozmenn force-pushed the feat/google-response-json-schema branch from cb85d21 to 6091b9b Compare August 10, 2026 03:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support JSON schema for Gemini 2.5

2 participants