diff --git a/src/google/adk/models/_generate_content_body.py b/src/google/adk/models/_generate_content_body.py new file mode 100644 index 0000000000..06343ddf3d --- /dev/null +++ b/src/google/adk/models/_generate_content_body.py @@ -0,0 +1,132 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Turn an ``LlmRequest`` into a ``generateContent`` request body. + +``LlmRequest.config`` is a ``types.GenerateContentConfig``: one flat namespace +of 35 fields. On the wire those fields go to three different places. Some sit at +the top level of the request, most belong inside ``generationConfig``, and a few +mean something only to the client library and are rejected by the endpoint. + +Nothing on the type says which is which, and getting it wrong does not raise. +Bury ``tools`` inside ``generationConfig`` and the call still returns 200 with a +well formed response in it. The tools are simply absent, so the model answers +from memory instead of calling anything, and the failure looks like a model that +chose not to call the tool. + +Rather than keep a hand written list of which field goes where, this delegates +to the same converter ``google-genai`` uses for its own built-in models. A hand +written list is a second copy of the mapping and it goes stale the moment +``GenerateContentConfig`` gains a field: every custom ``BaseLlm`` in the wild +then starts silently dropping that field. Delegating means there is one copy and +it moves when the type moves. +""" + +from __future__ import annotations + +from typing import Any +from typing import Optional + +from google.genai import models as _genai_models +from google.genai import types + +__all__ = ["to_generate_content_body"] + + +class _ApiMode: + """The one thing the genai converters need from an API client. + + ``_GenerateContentParameters_to_vertex`` touches exactly one attribute on the + client it is handed, ``vertexai``, by way of ``t_model``. Passing a real + client works and is preferred when the caller has one. This stands in when the + caller does not, so the helper stays usable from a ``BaseLlm`` that talks REST + directly and never builds a genai client at all. + """ + + __slots__ = ("vertexai",) + + def __init__(self, vertexai: bool): + self.vertexai = vertexai + + +def to_generate_content_body( + llm_request: Any, + *, + vertexai: bool = True, + api_client: Optional[Any] = None, +) -> dict[str, Any]: + """Return the request body for ``llm_request``. + + Args: + llm_request: the ``LlmRequest`` to convert. + vertexai: whether the body is bound for Vertex AI rather than the Gemini + Developer API. The two differ in more than the URL: some config fields + exist on one and not the other, so this is not cosmetic. Ignored when + ``api_client`` is given. + api_client: a genai API client, if the caller has one. Preferred over + ``vertexai`` because it is the real thing rather than a stand-in. + + Returns: + A dict ready to send as the request body. The model name is not in it; the + caller already knows the model, and on Vertex it belongs in the URL. + + Raises: + ValueError: if a config field is not valid for the target API. The message + names the ``GenerateContentConfig`` field so the error points at the line + that set it rather than at the converter. + """ + client = api_client if api_client is not None else _ApiMode(vertexai) + + is_vertex = getattr(client, "vertexai", True) + + params = types._GenerateContentParameters( + model=llm_request.model, + contents=llm_request.contents, + config=llm_request.config, + ) + + convert = ( + _genai_models._GenerateContentParameters_to_vertex + if is_vertex + else _genai_models._GenerateContentParameters_to_mldev + ) + + try: + body = convert(client, params) + except ValueError as exc: + raise ValueError(_annotate(str(exc), vertexai=is_vertex)) from exc + + # The converters return the model under a private "_url" key, because the + # caller is normally the genai client, which uses it to build the path. It is + # routing information, not body content. Sending it is a 400. + body.pop("_url", None) + return body + + +def _annotate(message: str, *, vertexai: bool) -> str: + """Point a genai validation error back at the ADK field that caused it. + + The converters raise messages that start with the offending field name, e.g. + "enable_enhanced_civic_answers parameter is only supported in ...". That is + accurate and still hard to act on from a ``BaseLlm``, because the reader has + no reason to connect it to the config they built several layers up. + """ + first = message.split(" ", 1)[0] + if first in types.GenerateContentConfig.model_fields: + mode = "Vertex AI" if vertexai else "the Gemini Developer API" + return ( + f"{message} (set as LlmRequest.config.{first}; this request targets" + f" {mode}.)" + ) + return message diff --git a/src/google/adk/models/llm_request.py b/src/google/adk/models/llm_request.py index bf53fad23c..1aa7dd5acd 100644 --- a/src/google/adk/models/llm_request.py +++ b/src/google/adk/models/llm_request.py @@ -15,6 +15,7 @@ from __future__ import annotations import logging +from typing import Any from typing import Optional from typing import Union @@ -399,3 +400,37 @@ def set_output_schema( self.config.response_schema = schema self.config.response_mime_type = "application/json" + + def to_generate_content_body( + self, + *, + vertexai: bool = True, + api_client: Optional[Any] = None, + ) -> dict[str, Any]: + """Returns this request as a ``generateContent`` request body. + + ``config`` is one flat namespace, but its fields go to three different + places on the wire. Some sit at the top level, most belong inside + ``generationConfig``, and a few are consumed by the client library and + rejected by the endpoint. Getting that split wrong does not raise: bury + ``tools`` in ``generationConfig`` and the call returns 200 with no function + call in it, because none was ever offered. + + Custom ``BaseLlm`` implementations that build their own request body should + use this rather than flattening ``config`` by hand. + + Args: + vertexai: whether the body is bound for Vertex AI rather than the Gemini + Developer API. Ignored when ``api_client`` is given. + api_client: a genai API client, if the caller has one. + + Returns: + A dict ready to send as the request body. + """ + # Imported here rather than at module scope: this pulls in google.genai's + # model converters, which nothing else in this module needs. + from ._generate_content_body import to_generate_content_body + + return to_generate_content_body( + self, vertexai=vertexai, api_client=api_client + ) diff --git a/tests/unittests/models/test_generate_content_body.py b/tests/unittests/models/test_generate_content_body.py new file mode 100644 index 0000000000..f789038bdc --- /dev/null +++ b/tests/unittests/models/test_generate_content_body.py @@ -0,0 +1,293 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the LlmRequest -> generateContent body conversion.""" + +import pytest +from google.adk.models.llm_request import LlmRequest +from google.genai import types + +from google.adk.models._generate_content_body import to_generate_content_body + +# Fields that sit at the top level of a Vertex generateContent request, as of +# google-genai 2.18.1. This list exists to be asserted against, not to be used: +# the helper derives the split rather than reading it. If a release moves a +# field, test_every_config_field_is_classified fails first and names it. +TOP_LEVEL = { + "cachedContent", + "labels", + "modelArmorConfig", + "safetySettings", + "serviceTier", + "systemInstruction", + "toolConfig", + "tools", +} + +# Config fields the client library consumes. The endpoint rejects them. +CLIENT_ONLY = { + "httpOptions", + "automaticFunctionCalling", + "shouldReturnHttpResponse", +} + + +def _tool() -> types.Tool: + return types.Tool( + function_declarations=[ + types.FunctionDeclaration( + name="a_tool", + description="Look up the weather in a city.", + parameters=types.Schema( + type=types.Type.OBJECT, + properties={"city": types.Schema(type=types.Type.STRING)}, + ), + ) + ] + ) + + +def _request(**config_kwargs) -> LlmRequest: + return LlmRequest( + model="gemini-3.5-flash", + contents=[types.Content(role="user", parts=[types.Part(text="hi")])], + config=types.GenerateContentConfig(**config_kwargs), + ) + + +def _full_config_kwargs() -> dict: + """Every field that can be set at once on a Vertex-bound request. + + Two of the 35 are left out on purpose. response_json_schema is mutually + exclusive with response_schema, and enable_enhanced_civic_answers is Developer + API only, which test_developer_api_only_field_is_named covers separately. + """ + return dict( + system_instruction="You are a research agent.", + tools=[_tool()], + tool_config=types.ToolConfig( + function_calling_config=types.FunctionCallingConfig(mode="AUTO") + ), + safety_settings=[ + types.SafetySetting( + category="HARM_CATEGORY_HATE_SPEECH", threshold="BLOCK_ONLY_HIGH" + ) + ], + cached_content="projects/p/locations/us-central1/cachedContents/123", + labels={"team": "research"}, + http_options=types.HttpOptions(timeout=60_000), + automatic_function_calling=types.AutomaticFunctionCallingConfig( + disable=True + ), + should_return_http_response=True, + temperature=0.2, + top_p=0.9, + top_k=40, + candidate_count=1, + max_output_tokens=8192, + stop_sequences=["STOP"], + response_logprobs=True, + logprobs=3, + presence_penalty=0.1, + frequency_penalty=0.1, + seed=42, + response_mime_type="application/json", + response_schema=types.Schema( + type=types.Type.OBJECT, + properties={"answer": types.Schema(type=types.Type.STRING)}, + ), + routing_config=types.GenerationConfigRoutingConfig( + auto_mode=types.GenerationConfigRoutingConfigAutoRoutingMode( + model_routing_preference="BALANCED" + ) + ), + model_selection_config=types.ModelSelectionConfig( + feature_selection_preference="BALANCED" + ), + response_modalities=["TEXT"], + media_resolution="MEDIA_RESOLUTION_MEDIUM", + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="Kore") + ) + ), + audio_timestamp=True, + thinking_config=types.ThinkingConfig( + include_thoughts=True, thinking_budget=4096 + ), + image_config=types.ImageConfig(aspect_ratio="1:1"), + model_armor_config=types.ModelArmorConfig( + prompt_template_name="projects/p/locations/us-central1/templates/t" + ), + service_tier="standard", + audio_transcription_config=types.AudioTranscriptionConfig(), + ) + + +# The bug this whole module exists to prevent. +def test_tools_are_top_level_not_buried_in_generation_config(): + body = to_generate_content_body(_request(temperature=0.2, tools=[_tool()])) + + assert "tools" in body + assert "tools" not in body["generationConfig"] + + +def test_temperature_stays_in_generation_config(): + body = to_generate_content_body(_request(temperature=0.2, tools=[_tool()])) + + assert body["generationConfig"]["temperature"] == pytest.approx(0.2) + assert "temperature" not in body + + +def test_client_only_fields_never_reach_the_wire(): + body = to_generate_content_body( + _request( + temperature=0.2, + http_options=types.HttpOptions(timeout=60_000), + automatic_function_calling=types.AutomaticFunctionCallingConfig( + disable=True + ), + should_return_http_response=True, + ) + ) + + emitted = set(body) | set(body.get("generationConfig", {})) + assert not (CLIENT_ONLY & emitted) + + +def test_url_routing_key_is_stripped(): + # The converter returns the model under "_url" for the genai client to build + # a path with. It is not body content and Vertex 400s on it. + body = to_generate_content_body(_request(temperature=0.2)) + + assert "_url" not in body + + +def test_full_field_set_splits_eight_twentytwo_three(): + body = to_generate_content_body(_request(**_full_config_kwargs())) + + top = set(body) - {"contents", "generationConfig"} + generation = set(body["generationConfig"]) + + assert top == TOP_LEVEL + assert not (TOP_LEVEL & generation), "a top-level field was buried" + assert not (CLIENT_ONLY & (top | generation)) + # 33 set, 8 up top, 3 dropped, so 22 sampling settings and nothing lost. + assert len(generation) == 22 + + +def test_model_selection_config_is_renamed_to_model_config(): + # The only field that changes name in transit. Anyone diffing their own config + # keys against the emitted body gets a false miss on this one. + body = to_generate_content_body( + _request( + model_selection_config=types.ModelSelectionConfig( + feature_selection_preference="BALANCED" + ) + ) + ) + + assert "modelConfig" in body["generationConfig"] + assert "modelSelectionConfig" not in body["generationConfig"] + + +def test_developer_api_only_field_is_named_in_the_error(): + request = _request(enable_enhanced_civic_answers=True) + + with pytest.raises(ValueError) as excinfo: + to_generate_content_body(request, vertexai=True) + + message = str(excinfo.value) + assert "enable_enhanced_civic_answers" in message + # The point of the annotation: say where it was set, not just that it is bad. + assert "LlmRequest.config" in message + assert "Vertex AI" in message + + +def test_developer_api_accepts_what_vertex_rejects(): + body = to_generate_content_body( + _request(enable_enhanced_civic_answers=True), vertexai=False + ) + + assert body is not None + + +def test_string_system_instruction_becomes_a_content_block(): + body = to_generate_content_body(_request(system_instruction="Be terse.")) + + assert body["systemInstruction"]["parts"][0]["text"] == "Be terse." + + +def test_no_config_still_produces_a_body(): + request = LlmRequest( + model="gemini-3.5-flash", + contents=[types.Content(role="user", parts=[types.Part(text="hi")])], + ) + + body = to_generate_content_body(request) + + assert body["contents"][0]["parts"][0]["text"] == "hi" + + +# The test that keeps the other tests honest. +def test_every_config_field_is_classified(): + """Fail when GenerateContentConfig gains a field nobody has classified. + + This is the whole argument for the helper. A hand written mapping goes stale + silently: the new field lands in generationConfig, the endpoint ignores it, + and no one finds out until a feature quietly stops working. This turns that + into a red test naming the field. + """ + known = ( + TOP_LEVEL + | CLIENT_ONLY + | {"enableEnhancedCivicAnswers", "responseJsonSchema"} + ) + body = to_generate_content_body(_request(**_full_config_kwargs())) + classified = ( + (set(body) - {"contents", "generationConfig"}) + | set(body["generationConfig"]) + | known + ) + + # Aliases as the wire spells them, which is how the body is keyed. + declared = { + field.alias or name + for name, field in types.GenerateContentConfig.model_fields.items() + } + # modelSelectionConfig is emitted as modelConfig; see the rename test. + declared.discard("modelSelectionConfig") + classified.add("modelSelectionConfig") + + unclassified = declared - classified + assert not unclassified, ( + "GenerateContentConfig gained field(s) with no known destination:" + f" {sorted(unclassified)}. Add them to TOP_LEVEL or CLIENT_ONLY and" + " confirm where the converter puts them." + ) + + +def test_method_matches_function(): + """LlmRequest.to_generate_content_body is a thin delegate, not a fork.""" + request = _request(**_full_config_kwargs()) + + assert request.to_generate_content_body() == to_generate_content_body(request) + + +def test_method_passes_the_api_mode_through(): + request = _request(enable_enhanced_civic_answers=True) + + assert request.to_generate_content_body(vertexai=False) is not None + with pytest.raises(ValueError): + request.to_generate_content_body(vertexai=True)