Skip to content

Commit 49633a7

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 a702e3c commit 49633a7

3 files changed

Lines changed: 459 additions & 0 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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+
40+
from google.genai import models as _genai_models
41+
from google.genai import types
42+
43+
__all__ = ["to_generate_content_body"]
44+
45+
46+
class _ApiMode:
47+
"""The one thing the genai converters need from an API client.
48+
49+
``_GenerateContentParameters_to_vertex`` touches exactly one attribute on the
50+
client it is handed, ``vertexai``, by way of ``t_model``. Passing a real
51+
client works and is preferred when the caller has one. This stands in when the
52+
caller does not, so the helper stays usable from a ``BaseLlm`` that talks REST
53+
directly and never builds a genai client at all.
54+
"""
55+
56+
__slots__ = ("vertexai",)
57+
58+
def __init__(self, vertexai: bool):
59+
self.vertexai = vertexai
60+
61+
62+
def to_generate_content_body(
63+
llm_request: Any,
64+
*,
65+
vertexai: bool = True,
66+
api_client: Any | None = None,
67+
) -> dict[str, Any]:
68+
"""Return the request body for ``llm_request``.
69+
70+
Args:
71+
llm_request: the ``LlmRequest`` to convert.
72+
vertexai: whether the body is bound for Vertex AI rather than the Gemini
73+
Developer API. The two differ in more than the URL: some config fields
74+
exist on one and not the other, so this is not cosmetic. Ignored when
75+
``api_client`` is given.
76+
api_client: a genai API client, if the caller has one. Preferred over
77+
``vertexai`` because it is the real thing rather than a stand-in.
78+
79+
Returns:
80+
A dict ready to send as the request body. The model name is not in it; the
81+
caller already knows the model, and on Vertex it belongs in the URL.
82+
83+
Raises:
84+
ValueError: if a config field is not valid for the target API. The message
85+
names the ``GenerateContentConfig`` field so the error points at the line
86+
that set it rather than at the converter.
87+
"""
88+
client = api_client if api_client is not None else _ApiMode(vertexai)
89+
90+
is_vertex = getattr(client, "vertexai", True)
91+
92+
params = types._GenerateContentParameters(
93+
model=llm_request.model,
94+
contents=llm_request.contents,
95+
config=llm_request.config,
96+
)
97+
98+
convert = (
99+
_genai_models._GenerateContentParameters_to_vertex
100+
if is_vertex
101+
else _genai_models._GenerateContentParameters_to_mldev
102+
)
103+
104+
try:
105+
body = convert(client, params)
106+
except ValueError as exc:
107+
raise ValueError(_annotate(str(exc), vertexai=is_vertex)) from exc
108+
109+
# The converters return the model under a private "_url" key, because the
110+
# caller is normally the genai client, which uses it to build the path. It is
111+
# routing information, not body content. Sending it is a 400.
112+
body.pop("_url", None)
113+
return body
114+
115+
116+
def _annotate(message: str, *, vertexai: bool) -> str:
117+
"""Point a genai validation error back at the ADK field that caused it.
118+
119+
The converters raise messages that start with the offending field name, e.g.
120+
"enable_enhanced_civic_answers parameter is only supported in ...". That is
121+
accurate and still hard to act on from a ``BaseLlm``, because the reader has
122+
no reason to connect it to the config they built several layers up.
123+
"""
124+
first = message.split(" ", 1)[0]
125+
if first in types.GenerateContentConfig.model_fields:
126+
mode = "Vertex AI" if vertexai else "the Gemini Developer API"
127+
return (
128+
f"{message} (set as LlmRequest.config.{first}; this request targets"
129+
f" {mode}.)"
130+
)
131+
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

1920
from google.genai import types
2021
from pydantic import BaseModel
@@ -405,3 +406,37 @@ def set_output_schema(
405406

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

0 commit comments

Comments
 (0)