Skip to content

Commit a4f3799

Browse files
committed
feat(models): add LlmRequest.to_generate_content_body()
LlmRequest.config is one flat GenerateContentConfig namespace, but its fields go to three places on the wire: 8 at the top level, 22 inside generationConfig, and 3 that are client-side only and rejected by the endpoint. Nothing on the type says which is which, and getting it wrong does not raise. A custom BaseLlm that buries tools in generationConfig gets a 200 back with no function call in it, because none was offered. Rather than document the split, delegate to the same converter google-genai uses for its own built-in models, so there is one copy of the mapping and it moves when GenerateContentConfig moves. A hand written list is a second copy that goes stale the moment the type gains a field. Also handles three things that bite anyone calling the converter raw: strips the private "_url" routing key, which is a 400 if sent as body; covers the model_selection_config -> modelConfig rename, the only field that changes name in transit; and re-raises genai's ValueError with the LlmRequest.config field that caused it, so the error points at the line that set it rather than at the converter. Closes google#6880
1 parent 460715b commit a4f3799

3 files changed

Lines changed: 460 additions & 0 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Turn an ``LlmRequest`` into a ``generateContent`` request body.
16+
17+
``LlmRequest.config`` is a ``types.GenerateContentConfig``: one flat namespace
18+
of 35 fields. On the wire those fields go to three different places. Some sit at
19+
the top level of the request, most belong inside ``generationConfig``, and a few
20+
mean something only to the client library and are rejected by the endpoint.
21+
22+
Nothing on the type says which is which, and getting it wrong does not raise.
23+
Bury ``tools`` inside ``generationConfig`` and the call still returns 200 with a
24+
well formed response in it. The tools are simply absent, so the model answers
25+
from memory instead of calling anything, and the failure looks like a model that
26+
chose not to call the tool.
27+
28+
Rather than keep a hand written list of which field goes where, this delegates
29+
to the same converter ``google-genai`` uses for its own built-in models. A hand
30+
written list is a second copy of the mapping and it goes stale the moment
31+
``GenerateContentConfig`` gains a field: every custom ``BaseLlm`` in the wild
32+
then starts silently dropping that field. Delegating means there is one copy and
33+
it moves when the type moves.
34+
"""
35+
36+
from __future__ import annotations
37+
38+
from typing import Any
39+
from typing import Optional
40+
41+
from google.genai import models as _genai_models
42+
from google.genai import types
43+
44+
__all__ = ["to_generate_content_body"]
45+
46+
47+
class _ApiMode:
48+
"""The one thing the genai converters need from an API client.
49+
50+
``_GenerateContentParameters_to_vertex`` touches exactly one attribute on the
51+
client it is handed, ``vertexai``, by way of ``t_model``. Passing a real
52+
client works and is preferred when the caller has one. This stands in when the
53+
caller does not, so the helper stays usable from a ``BaseLlm`` that talks REST
54+
directly and never builds a genai client at all.
55+
"""
56+
57+
__slots__ = ("vertexai",)
58+
59+
def __init__(self, vertexai: bool):
60+
self.vertexai = vertexai
61+
62+
63+
def to_generate_content_body(
64+
llm_request: Any,
65+
*,
66+
vertexai: bool = True,
67+
api_client: Optional[Any] = None,
68+
) -> dict[str, Any]:
69+
"""Return the request body for ``llm_request``.
70+
71+
Args:
72+
llm_request: the ``LlmRequest`` to convert.
73+
vertexai: whether the body is bound for Vertex AI rather than the Gemini
74+
Developer API. The two differ in more than the URL: some config fields
75+
exist on one and not the other, so this is not cosmetic. Ignored when
76+
``api_client`` is given.
77+
api_client: a genai API client, if the caller has one. Preferred over
78+
``vertexai`` because it is the real thing rather than a stand-in.
79+
80+
Returns:
81+
A dict ready to send as the request body. The model name is not in it; the
82+
caller already knows the model, and on Vertex it belongs in the URL.
83+
84+
Raises:
85+
ValueError: if a config field is not valid for the target API. The message
86+
names the ``GenerateContentConfig`` field so the error points at the line
87+
that set it rather than at the converter.
88+
"""
89+
client = api_client if api_client is not None else _ApiMode(vertexai)
90+
91+
is_vertex = getattr(client, "vertexai", True)
92+
93+
params = types._GenerateContentParameters(
94+
model=llm_request.model,
95+
contents=llm_request.contents,
96+
config=llm_request.config,
97+
)
98+
99+
convert = (
100+
_genai_models._GenerateContentParameters_to_vertex
101+
if is_vertex
102+
else _genai_models._GenerateContentParameters_to_mldev
103+
)
104+
105+
try:
106+
body = convert(client, params)
107+
except ValueError as exc:
108+
raise ValueError(_annotate(str(exc), vertexai=is_vertex)) from exc
109+
110+
# The converters return the model under a private "_url" key, because the
111+
# caller is normally the genai client, which uses it to build the path. It is
112+
# routing information, not body content. Sending it is a 400.
113+
body.pop("_url", None)
114+
return body
115+
116+
117+
def _annotate(message: str, *, vertexai: bool) -> str:
118+
"""Point a genai validation error back at the ADK field that caused it.
119+
120+
The converters raise messages that start with the offending field name, e.g.
121+
"enable_enhanced_civic_answers parameter is only supported in ...". That is
122+
accurate and still hard to act on from a ``BaseLlm``, because the reader has
123+
no reason to connect it to the config they built several layers up.
124+
"""
125+
first = message.split(" ", 1)[0]
126+
if first in types.GenerateContentConfig.model_fields:
127+
mode = "Vertex AI" if vertexai else "the Gemini Developer API"
128+
return (
129+
f"{message} (set as LlmRequest.config.{first}; this request targets"
130+
f" {mode}.)"
131+
)
132+
return message

‎src/google/adk/models/llm_request.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from __future__ import annotations
1616

1717
import logging
18+
from typing import Any
1819
from typing import Optional
1920
from typing import Union
2021

@@ -399,3 +400,37 @@ def set_output_schema(
399400

400401
self.config.response_schema = schema
401402
self.config.response_mime_type = "application/json"
403+
404+
def to_generate_content_body(
405+
self,
406+
*,
407+
vertexai: bool = True,
408+
api_client: Optional[Any] = None,
409+
) -> dict[str, Any]:
410+
"""Returns this request as a ``generateContent`` request body.
411+
412+
``config`` is one flat namespace, but its fields go to three different
413+
places on the wire. Some sit at the top level, most belong inside
414+
``generationConfig``, and a few are consumed by the client library and
415+
rejected by the endpoint. Getting that split wrong does not raise: bury
416+
``tools`` in ``generationConfig`` and the call returns 200 with no function
417+
call in it, because none was ever offered.
418+
419+
Custom ``BaseLlm`` implementations that build their own request body should
420+
use this rather than flattening ``config`` by hand.
421+
422+
Args:
423+
vertexai: whether the body is bound for Vertex AI rather than the Gemini
424+
Developer API. Ignored when ``api_client`` is given.
425+
api_client: a genai API client, if the caller has one.
426+
427+
Returns:
428+
A dict ready to send as the request body.
429+
"""
430+
# Imported here rather than at module scope: this pulls in google.genai's
431+
# model converters, which nothing else in this module needs.
432+
from ._generate_content_body import to_generate_content_body
433+
434+
return to_generate_content_body(
435+
self, vertexai=vertexai, api_client=api_client
436+
)

0 commit comments

Comments
 (0)