From 320e47919a4c9e17eab364ae1b80339501ab74e2 Mon Sep 17 00:00:00 2001 From: Charles Cheng Date: Wed, 26 Aug 2026 17:23:47 +0800 Subject: [PATCH 1/3] Python: send a Pydantic response_format to Gemini as response_schema #5893 taught the Gemini client to forward mapping-shaped response_format values as a Gemini response_schema, and scoped itself to those shapes. A Pydantic model class - the first shape the option's own documentation offers - still falls through _extract_response_schema, so the request carries response_mime_type="application/json" with no schema attached. Gemini is then asked for JSON but is not constrained by the model, and the free-form JSON it returns is handed to that same model for parsing on the way back. That is the failure mode #5888 described, on the shape #5893 did not cover. Convert the model class with model_json_schema(), matching what the Anthropic, Bedrock and Mistral clients already do for this option. --- .../agent_framework_gemini/_chat_client.py | 9 +++- .../gemini/tests/test_gemini_client.py | 45 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 515bab2f3f4..8860267c432 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -936,7 +936,14 @@ def _prepare_config( @staticmethod def _extract_response_schema(response_format: Any) -> dict[str, Any] | None: - """Extract a Gemini response schema from supported mapping response_format shapes.""" + """Extract a Gemini response schema from supported response_format shapes. + + Handles Pydantic model classes and the mapping shapes (raw JSON schema, + ``json_schema`` envelopes, ``format`` envelopes). + """ + if isinstance(response_format, type) and issubclass(response_format, BaseModel): + return response_format.model_json_schema() + if not isinstance(response_format, Mapping): return None mapping = cast("Mapping[str, Any]", response_format) diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index e23ae0a8c6e..ddcc22ddbc1 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -1466,6 +1466,51 @@ class Reply(BaseModel): assert config.response_mime_type == "application/json" +async def test_response_format_pydantic_model_sets_response_schema() -> None: + """A Pydantic model response_format must reach Gemini as response_schema, not just JSON mode. + + Without the schema, the model is asked for JSON but is not constrained by it, so it can + return arbitrary JSON that then fails to parse into the requested model - the failure mode + #5888 described for mapping-shaped schemas, on the shape #5893 left out of scope. + """ + from pydantic import BaseModel + + class Reply(BaseModel): + text: str + + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="{}")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"response_format": Reply}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.response_mime_type == "application/json" + assert config.response_schema == Reply.model_json_schema() + + +async def test_response_schema_option_wins_over_pydantic_response_format() -> None: + """An explicit response_schema still takes precedence, as it already does for mapping shapes.""" + from pydantic import BaseModel + + class Reply(BaseModel): + text: str + + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="{}")])) + explicit = {"type": "object", "properties": {"other": {"type": "string"}}} + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"response_format": Reply, "response_schema": explicit}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.response_schema == explicit + + async def test_response_format_populates_value_on_chat_response() -> None: """When response_format is a Pydantic model, ChatResponse.value must be parsed from the response text.""" from pydantic import BaseModel From 0b178e54358c4473e8a5ab6d318008be72d94ce4 Mon Sep 17 00:00:00 2001 From: Charles Cheng Date: Thu, 27 Aug 2026 11:44:33 +0800 Subject: [PATCH 2/3] Python: list every shape _extract_response_schema accepts The docstring named the format and json_schema envelopes but not the bare schema envelope the method also unwraps, so it documented a narrower contract than the code honours. --- .../packages/gemini/agent_framework_gemini/_chat_client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 8860267c432..1ebb9d9d9b4 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -938,8 +938,9 @@ def _prepare_config( def _extract_response_schema(response_format: Any) -> dict[str, Any] | None: """Extract a Gemini response schema from supported response_format shapes. - Handles Pydantic model classes and the mapping shapes (raw JSON schema, - ``json_schema`` envelopes, ``format`` envelopes). + Handles a Pydantic model class and, for mappings, a ``format`` envelope + (unwrapped recursively), a ``json_schema`` envelope, a bare ``schema`` + envelope, and a raw JSON schema mapping. Anything else returns ``None``. """ if isinstance(response_format, type) and issubclass(response_format, BaseModel): return response_format.model_json_schema() From db3f94d8f1cfcec07fe290b15e1dd720984d200a Mon Sep 17 00:00:00 2001 From: Charles Cheng Date: Fri, 28 Aug 2026 12:51:55 +0800 Subject: [PATCH 3/3] Python: cast options combining response_format with response_schema in test --- python/packages/gemini/tests/test_gemini_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index ddcc22ddbc1..20ff232c3ea 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -1504,7 +1504,9 @@ class Reply(BaseModel): await client.get_response( messages=[Message(role="user", contents=[Content.from_text("Hi")])], - options={"response_format": Reply, "response_schema": explicit}, + # response_format binds the options TypedDict to Reply while response_schema only + # exists on GeminiChatOptions[None]; no get_response overload accepts the combination. + options=cast(Any, {"response_format": Reply, "response_schema": explicit}), ) config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]