Skip to content

response_schema silently drops per-field descriptions on nested Pydantic models when resolving $ref #3007

Description

@hmisawa0429

Summary

Passing a nested Pydantic model to GenerateContentConfig(response_schema=...) silently discards per-field Field(description=...) annotations when the fields reference a shared model. Both outgoing properties receive the shared model docstring instead. This reproduces in google-genai 2.25.0.

This report concerns SDK conversion, not model compliance: the reproducer captures the HTTP request with httpx.MockTransport, uses a dummy key, and never contacts an API.

Environment

  • macOS, Python 3.13.3
  • google-genai 2.25.0, pydantic 2.13.5, httpx 0.28.1
  • Also reproduced with Python 3.12.10, google-genai 1.67.0, pydantic 2.12.5
  • Gemini Developer API request conversion

Reproducer

"""Capture synthetic structured-output requests without contacting any server."""

import json
import platform
from importlib.metadata import version
from typing import Any

import httpx
from google import genai
from google.genai import types
from pydantic import BaseModel, Field


class Score(BaseModel):
    """A generic score container."""

    point: int


class Review(BaseModel):
    """Two criteria sharing the same value shape."""

    clarity: Score = Field(description="Rate clarity only.")
    accuracy: Score = Field(description="Rate factual accuracy only.")


def capture(use_json_schema: bool) -> dict[str, Any]:
    """Return the request body captured at the HTTP transport boundary."""
    captured: dict[str, Any] = {}

    def handler(request: httpx.Request) -> httpx.Response:
        captured.update(json.loads(request.content))
        return httpx.Response(
            200,
            json={
                "candidates": [{
                    "content": {"role": "model", "parts": [{
                        "text": '{"clarity":{"point":3},"accuracy":{"point":4}}'
                    }]},
                    "finishReason": "STOP",
                }]
            },
        )

    config = types.GenerateContentConfig(response_mime_type="application/json")
    if use_json_schema:
        config.response_json_schema = Review.model_json_schema()
    else:
        config.response_schema = Review
    with genai.Client(
        api_key="synthetic-not-a-real-key",
        vertexai=False,
        http_options=types.HttpOptions(
            client_args={"transport": httpx.MockTransport(handler), "trust_env": False}
        ),
    ) as client:
        client.models.generate_content(
            model="gemini-3.1-flash-lite", contents="Rate this text.", config=config
        )
    return captured["generationConfig"]


if __name__ == "__main__":
    result = {
        "python": platform.python_version(),
        "google_genai": version("google-genai"),
        "pydantic": version("pydantic"),
        "before": Review.model_json_schema(),
        "response_schema": capture(False),
        "response_json_schema": capture(True),
    }
    print(json.dumps(result, indent=2))

Expected / actual

Before SDK conversion, Pydantic generates:

{"clarity":{"$ref":"#/$defs/Score","description":"Rate clarity only."},"accuracy":{"$ref":"#/$defs/Score","description":"Rate factual accuracy only."}}

In the outgoing responseSchema, both property descriptions are instead A generic score container.. Their object shapes remain valid, so this loss is silent.

I would expect the Pydantic adapter to preserve these field-specific annotations when inlining the referenced object, or explicitly warn/reject the unsupported annotation rather than silently replace it. This matters when two properties share a value shape but have distinct instructions.

Relevant implementation

In _transformers.process_schema, _recurse replaces a schema containing $ref with the referenced definition (sub_schema = defs[...]), discarding sibling annotations. A separate path uses schema.update(defs[...]), which also gives the referenced description precedence. This report is limited to descriptive annotations, not a proposal to blindly merge arbitrary validation keywords.

Scope / documented limitations

I understand that response_schema represents an OpenAPI subset rather than arbitrary JSON Schema. The SDK recommends trying response_json_schema when conversion is unsuitable, and the current README shows Model.model_json_schema() with that parameter.

The reproducer's control confirms that response_json_schema preserves both descriptions in the outbound JSON. This is not proof of server-side support or a complete workaround: its documented restrictions say $ref cannot have non-$ siblings, and the generated Pydantic description is such a sibling. No live API behavior is claimed here.

Could maintainers clarify whether per-field descriptions on nested Pydantic models are intended to be supported through response_schema? If so, could reference inlining retain these annotations? If not, could the limitation and a supported way to retain distinct field descriptions be documented explicitly?

Related: #1992 also loses description metadata, but in nullable Schema.from_json_schema unwrapping; this example uses required, non-nullable fields and the response_schema reference-inlining path.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

priority: p2Moderately-important priority. Fix may not be included in next release.type: bugError or flaw in code with unintended results or allowing sub-optimal usage patterns.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions