Skip to content

feat(go): send constrained output via ResponseJsonSchema, with LegacyResponseSchema opt-out - #6020

Draft
cabljac wants to merge 10 commits into
mainfrom
feat/go-googlegenai-response-json-schema
Draft

feat(go): send constrained output via ResponseJsonSchema, with LegacyResponseSchema opt-out#6020
cabljac wants to merge 10 commits into
mainfrom
feat/go-googlegenai-response-json-schema

Conversation

@cabljac

@cabljac cabljac commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Migrates the Go Gemini plugin from ResponseSchema, a limited OpenAPI 3.0 subset, to ResponseJsonSchema, which takes raw JSON Schema with $ref/$defs. Recursive Go output types currently collapse to a lossy {type: object, additionalProperties: true}; they now round-trip properly.

Fixes #5478.

Changes

  • go/internal/base: InferJSONSchema reflects with references enabled. New InlineAcyclicDefs inlines non-recursive definitions, leaving only genuinely recursive types as $ref/$defs.
  • go/plugins/googlegenai: path-scoped cycle guard in toGeminiSchema, shared with tool input schemas.
  • go/plugins/googlegenai: constrained output uses ResponseJsonSchema by default, with a LegacyResponseSchema flag restoring the old path, mirroring JS.
  • go/samples/recursive-structured: sample.

Refs by default with opt-in inlining means plugins whose APIs can't take $ref call InlineAcyclicDefs themselves. Inlining isn't cheaply reversible, so it belongs at the edge.

Worth a closer look

Enum output also moved to ResponseJsonSchema. The gate is Constrained && ResponseMIMEType != "", which catches text/x.enum; JS gates on jsonMode so enum stays on responseSchema there. Untested against the live API. Happy to narrow the gate to match JS.

Recursive schemas now reach plugins that can't express them. The root is a bare $ref, and other consumers don't handle that: MapToStruct into Anthropic's ToolInputSchemaParam drops it, EnforceStrict no-ops, MCP's server.go indexes properties directly so recursive tool inputs advertise none. Only googlegenai inlines. Flagging rather than burying it.

LegacyResponseSchema restores the wire field, not the old semantics. Validation stays strict while the model gets the laxer schema, and it doesn't cover tools, which still use toGeminiSchema. Migrating tools to ParametersJsonSchema felt like a separate change.

Incidental fixes

Enabling references surfaced latent issues in code that gained callers while this was in flight:

  • requestInputSchema splices the config schema under the request's config slot, so a recursive config's $defs nested while its $ref resolved against the root, failing every request to that action. Now hoisted. stripRequired/tolerateNulls didn't descend into $defs either.
  • inlineRefs replaced a $ref node wholesale, so a struct-typed field lost its description/title. Local keywords now win, per 2020-12.
  • refName misparsed generics whose names embed an import path; status.Error.JSONSchema() returned a $ref resolving against the wrong root.

Separately, toGeminiSchema panicked on valid JSON Schema it can't express ("items": true, boolean property schemas, draft-07 tuples). Pre-existing on main, reachable from any caller-supplied schema, now returns errors.

Testing

Unit coverage on the inlining, cycle guard, generic names, annotation preservation, malformed subschemas, both response branches, and a request-path regression test for recursive configs. go test ./... green, vet and gofmt clean. No live call (needs GEMINI_API_KEY); enum on the new path untested either way.

Predates the typed-config migration (#5849, #5862, #5869, #5874) and was brought up to date with a merge, so read the diff against main rather than the commits.

cabljac added 8 commits June 9, 2026 16:00
… inference

Schema inference collapsed self-referential Go types to a lossy
{type:object, additionalProperties:true} schema via DoNotReference plus an
in-progress cycle breaker. Reflect with references enabled instead and inline
only the acyclic definitions (InlineAcyclicDefs), so acyclic types stay fully
inlined while genuinely recursive types keep $ref/$defs and round-trip as
proper recursive JSON Schema.
Schema inference now emits $ref/$defs for recursive types, which the legacy
genai.Schema converter (also used for tool input schemas) would follow into
infinite recursion. Track the $ref names on the active resolution path and
collapse a re-entered reference to a generic object schema.
…nseSchema opt-out

Constrained output now sends the raw JSON schema via
GenerateContentConfig.ResponseJsonSchema, which supports recursion through
$ref/$defs, instead of converting to the limited ResponseSchema. Add a
LegacyResponseSchema flag on the GoogleAI and VertexAI plugins to restore the
previous ResponseSchema path, mirroring the JS plugin's legacyResponseSchema
option.
Demonstrates constrained output with a self-referential Go type (Employee
org chart), which is only expressible because the Gemini plugin now sends
the schema via ResponseJsonSchema with $ref/$defs.
… output

Adds live subtests that exercise the new ResponseJsonSchema default path with
a self-referential output type (an org chart), asserting the recursion
round-trips instead of collapsing to an "any" schema.

- GoogleAI: default path + a LegacyResponseSchema fallback sanity check.
- VertexAI: default path — Vertex historically lags GoogleAI on schema
  fields, so this surfaces any responseJsonSchema rejection rather than the
  default flip failing silently in production.

Env-gated like the existing live tests (skip without GEMINI_API_KEY /
GOOGLE_CLOUD_PROJECT), so no CI cost.
…rtex

Refines the live coverage added in the previous commit after running it
against both backends:

- VertexAI recursive constrained output: pinned to temperature 0, thinking
  off, a small output cap and a bounded prompt — confirmed PASS (the
  ResponseJsonSchema $ref/$defs path round-trips a self-referential type).
- GoogleAI: dropped the recursive subtest. gemini-2.5-flash deterministically
  degenerates into a repetition loop on this self-referential schema and
  truncates at MAX_TOKENS — reproducible against the raw genai client with no
  genkit involved, so a model-level quirk, not a plugin fault. The default
  ResponseJsonSchema path is already covered for GoogleAI by the flat
  "constrained generation" test; recursion is covered by the Vertex suite.
- GoogleAI: kept a flat LegacyResponseSchema fallback test (confirmed PASS).

Env-gated like the other live tests, so no CI cost.
Ports the ResponseJsonSchema work onto the typed-config refactor that
landed underneath it (#5849, #5862, #5869, #5874).

- googlegenai: newModel/generate/toGeminiRequest keep main's typed
  *genai.GenerateContentConfig shape and gain a legacyResponseSchema
  argument. It reaches them through main's per-instance `catalog`, which
  already threads from the plugin struct to every path that builds an
  action; LegacyResponseSchema itself stays a public field on GoogleAI
  and VertexAI, mirroring the JS plugin's option.
- base: main's new SchemaMapFor and ConvertToExact infer schemas as maps
  and walk them in place, so both go through InferJSONSchemaMap rather
  than the now-referenced InferJSONSchema.
- status: Error.JSONSchema is spliced into the documents that embed an
  Error, where a "#/$defs/..." reference does not resolve, so it returns
  the new inlined form.
- base: read a definition name by cutting the "#/$defs/" prefix instead
  of taking the last "/" segment. An instantiated generic is named after
  its type argument's import path, so the old reading left a dangling
  $ref for Operation[...] and AgentInit[...] once references were on.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request transitions the Google AI and Vertex AI plugins to use the newer ResponseJsonSchema field by default for constrained outputs, enabling support for recursive and self-referential Go types via $ref and $defs. It introduces helper functions to inline acyclic definitions while preserving recursive cycles, adds a LegacyResponseSchema configuration option for backward compatibility, and includes robust tests and a recursive structured output sample. The review feedback highlights critical opportunities to improve robustness in go/plugins/googlegenai/schema.go by replacing unsafe type assertions on the items and properties schema fields with safe type assertions to prevent potential runtime panics.

Comment thread go/plugins/googlegenai/schema.go Outdated
}
if v, ok := genkitSchema["items"]; ok {
items, err := toGeminiSchema(originalSchema, v.(map[string]any))
items, err := toGeminiSchemaRec(originalSchema, v.(map[string]any), visited)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The type assertion v.(map[string]any) is unsafe and will panic if the items field in the schema is not a JSON object (for example, if it is a boolean or an array of schemas, which are valid in some JSON Schema drafts). Using a safe type assertion prevents potential runtime panics.

Suggested change
items, err := toGeminiSchemaRec(originalSchema, v.(map[string]any), visited)
m, ok := v.(map[string]any)
if !ok {
return nil, fmt.Errorf("items field is not a map")
}
items, err := toGeminiSchemaRec(originalSchema, m, visited)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied in 7fc1a27, returning an error rather than panicking. Worth noting for anyone reading this thread: these assertions are pre-existing on main, not introduced here, and the same commit covers three more with the same problem that this review missed, the description/format/title string assertions at schema.go:145-153.

Comment on lines 184 to 190
if val, ok := genkitSchema["properties"]; ok {
props := map[string]*genai.Schema{}
for k, v := range val.(map[string]any) {
p, err := toGeminiSchema(originalSchema, v.(map[string]any))
p, err := toGeminiSchemaRec(originalSchema, v.(map[string]any), visited)
if err != nil {
return nil, err
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The type assertions val.(map[string]any) and v.(map[string]any) are unsafe and will panic if the properties field or any of its property definitions are not JSON objects. Performing safe type assertions prevents runtime panics and improves robustness when handling malformed or unexpected schemas.

	if val, ok := genkitSchema["properties"]; ok {
		propertiesMap, ok := val.(map[string]any)
		if !ok {
			return nil, fmt.Errorf("properties field is not a map")
		}
		props := map[string]*genai.Schema{}
		for k, v := range propertiesMap {
			m, ok := v.(map[string]any)
			if !ok {
				return nil, fmt.Errorf("property %q is not a map", k)
			}
			p, err := toGeminiSchemaRec(originalSchema, m, visited)
			if err != nil {
				return nil, err
			}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Covered by 7fc1a27, along with the items assertion above and the per-property value.

…n-object subschemas

toGeminiSchema asserted map[string]any on "items", "properties" and each
property value, and string on "description", "format" and "title". A boolean
schema ("items": true, {"x": false}) and a draft-07 items tuple are both valid
JSON Schema, and callers supply schemas directly through output schemas,
dotprompt files and tool inputs, so these reachable shapes took the process
down rather than failing the request.

Pre-existing on main; surfaced here because this change moves the recursion
through those same call sites.
…hemas

Two regressions from enabling references in schema inference:

requestInputSchema splices the config schema under the request's config slot,
so a recursive config's "$defs" ended up nested while its "$ref" resolved
against the document root. Every request to such an action failed validation
with "Object has no key". Definitions are now hoisted to the root, at any
depth, since the schema arrives already wrapped for null tolerance.

stripRequired and tolerateNulls walked properties, items and
additionalProperties but not "$defs", so a recursive config's fields stayed
required and null-intolerant, breaking the partial-config contract.

inlineRefs replaced a "$ref" node wholesale with its definition body, dropping
sibling keywords. A struct-typed field with a description or title tag lost
them, silently, in every inferred schema. Local keywords now win over the
inlined body, per JSON Schema 2020-12.

Adds regression coverage for the request path and for annotation preservation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Go] Migrate Gemini plugin to use new JSON schema field

1 participant