fix(google_genai): read one-object and null spellings of repeated fields in countTokens and generateContent (#48771)

* fix(count_tokens): normalize Google system parts and null tools

* fix(count_tokens): preserve typed validator return

* fix(count_tokens): validate single system part without typing.cast

* fix(count_tokens): accept every Google spelling of a repeated field

Google countTokens now reads a repeated field sent as one object, null, or a
list with null items the same way as its list form, for system parts, tools,
and function declarations, matching what Vertex accepts.

* fix(count_tokens): read every Google spelling of contents and skip null system text

contents and each content's parts now read one object, null, and null items like their list form, the same as system parts and tools. A one-object parts used to translate to no messages, so non-Google models counted it as empty. A system part whose text is null is skipped, as Vertex AI does.

* fix(google_genai): read null-text media system parts as media and one-object content parts on generateContent

* fix(google_proxy): count null-text system parts that carry only part metadata, like Vertex AI

A system part with null or missing text and only metadata such as thought or thoughtSignature, or an empty part, now counts as empty instead of answering 400. Parts that carry an image or file with null text still answer 400.

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
zoveki 2026-10-28 21:46:06 -07:00 • committed by GitHub
parent 764a7a6536
commit a11e76fe8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 172 additions and 10 deletions

View file

@ -198,6 +198,11 @@ def _genai_message_part(part: object) -> _GenAIMessagePart | None:
return ChatCompletionTextObject(type="text", text=text) if isinstance(text, str) else _genai_media_part(fields)
def _genai_content_parts(value: object) -> Sequence[object]:
single_part: Final = _validated(_CONFIG_FIELDS, value)
return (single_part,) if single_part is not None else _validated(_GENAI_CONTENT_PARTS, value) or ()
def _genai_message_parts(parts: object) -> tuple[_GenAIMessagePart, ...]:
items: Final = _validated(_GENAI_CONTENT_PARTS, parts) or ()
return tuple(converted for part in items if (converted := _genai_message_part(part)) is not None)
@ -248,7 +253,7 @@ def _genai_content_messages(content: object) -> tuple[AllMessageValues, ...]:
fields: Final = _validated(_CONFIG_FIELDS, content)
if fields is None:
return ()
parts: Final = _validated(_GENAI_CONTENT_PARTS, fields.get("parts", ())) or ()
parts: Final = _genai_content_parts(fields.get("parts", ()))
converted: Final = _genai_message_parts(parts)
role: Final = fields.get("role", "user")
if role == "user":

View file

@ -1,11 +1,11 @@
from collections.abc import Mapping
from itertools import chain
from types import MappingProxyType
from typing import Final
from typing import Annotated, Final
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse, ORJSONResponse
from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, ValidationError
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, JsonValue, TypeAdapter, ValidationError
from litellm.litellm_core_utils.json_validation_rule import normalize_json_schema_types
from litellm.proxy._types import *
@ -27,14 +27,67 @@ from litellm.proxy.common_utils.token_count_settings import count_source_fields
from litellm.types.llms.vertex_ai import TokenCountDetailsResponse
def _google_repeated_field(value: JsonValue) -> JsonValue:
match value:
case None:
return []
case dict():
return [value]
case list():
return [item for item in value if item is not None]
case _:
return value
def _google_content(value: JsonValue) -> JsonValue:
match value:
case {"parts": parts}:
return {**value, "parts": _google_repeated_field(parts)}
case _:
return value
def _google_contents(value: JsonValue) -> JsonValue:
match _google_repeated_field(value):
case list() as contents:
return [_google_content(content) for content in contents]
case other:
return other
_GOOGLE_PART_TEXT_AND_METADATA_FIELDS: Final = frozenset(
{"text", "thought", "thoughtSignature", "thought_signature", "partMetadata", "part_metadata"}
)
def _google_part_has_non_text_content(part: Mapping[str, JsonValue]) -> bool:
return any(field is not None for key, field in part.items() if key not in _GOOGLE_PART_TEXT_AND_METADATA_FIELDS)
def _google_system_part(value: JsonValue) -> JsonValue:
match value:
case dict() if value.get("text") is None and _google_part_has_non_text_content(value):
return {key: field for key, field in value.items() if key != "text"}
case dict():
return {"text": None, **value}
case _:
return value
_GOOGLE_REPEATED_FIELD: Final = BeforeValidator(_google_repeated_field)
class _GoogleCountTokensTextPart(BaseModel):
model_config = ConfigDict(frozen=True)
text: str
text: str | None
class _GoogleCountTokensSystemInstruction(BaseModel):
model_config = ConfigDict(frozen=True)
parts: tuple[_GoogleCountTokensTextPart, ...]
parts: Annotated[
tuple[Annotated[_GoogleCountTokensTextPart, BeforeValidator(_google_system_part)], ...],
_GOOGLE_REPEATED_FIELD,
] = ()
class _GoogleCountTokensFunctionDeclaration(BaseModel):
@ -47,16 +100,16 @@ class _GoogleCountTokensFunctionDeclaration(BaseModel):
class _GoogleCountTokensTool(BaseModel):
model_config = ConfigDict(frozen=True, populate_by_name=True)
function_declarations: tuple[_GoogleCountTokensFunctionDeclaration, ...] = Field(
function_declarations: Annotated[tuple[_GoogleCountTokensFunctionDeclaration, ...], _GOOGLE_REPEATED_FIELD] = Field(
default=(), alias="functionDeclarations"
)
class _GoogleCountTokensRequest(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore", populate_by_name=True)
contents: tuple[Mapping[str, JsonValue], ...] = ()
contents: Annotated[tuple[Mapping[str, JsonValue], ...], BeforeValidator(_google_contents)] = ()
system_instruction: _GoogleCountTokensSystemInstruction | None = Field(default=None, alias="systemInstruction")
tools: tuple[_GoogleCountTokensTool, ...] = ()
tools: Annotated[tuple[_GoogleCountTokensTool, ...], _GOOGLE_REPEATED_FIELD] = ()
router: Final = APIRouter(
@ -112,7 +165,11 @@ def _google_count_tokens_request(model_name: str, data: object) -> TokenCountReq
GoogleGenAIAdapter().translate_generate_content_to_completion(model_name, contents).get("messages", [])
)
system: Final = (
[{"type": "text", "text": part.text} for part in validated_data.system_instruction.parts]
[
{"type": "text", "text": part.text}
for part in validated_data.system_instruction.parts
if part.text is not None
]
if validated_data.system_instruction is not None
else None
)

View file

@ -158,6 +158,18 @@ def test_native_function_response_preserves_json_when_media_is_malformed(invalid
assert messages == [{"role": "tool", "tool_call_id": "call_lookup", "content": '{"ok": true}'}]
@pytest.mark.parametrize("role", ["user", "model"])
def test_one_object_content_parts_translate_like_their_list_form(role: str) -> None:
part: Final = {"text": "Reply with OK"}
def messages(parts: object) -> object:
return GoogleGenAIAdapter().translate_generate_content_to_completion(
model="anthropic/claude-haiku-5-5", contents=[{"role": role, "parts": parts}]
)["messages"]
assert messages(part) == messages([part]) != []
@pytest.mark.parametrize(
("args", "expected_arguments"),
[(None, "null"), ('{"query":"x"}', '"{\\"query\\":\\"x\\"}"')],

View file

@ -6,7 +6,7 @@ from typing import Final
import pytest
from dotenv import load_dotenv
from pydantic import JsonValue
from pydantic import JsonValue, ValidationError
from starlette.requests import Request
from litellm.proxy.google_endpoints.endpoints import _google_count_tokens_request, google_count_tokens
@ -105,3 +105,91 @@ async def test_proxy_gemini_to_openai_like_model_token_counting():
)
assert response.get("totalTokens") > 0
_DECLARATION: Final = {"name": "lookup", "description": "Find a record"}
_SYSTEM: Final = {"systemInstruction": {"parts": [{"text": "Follow these rules"}]}}
_NO_SYSTEM_PARTS: Final = {"systemInstruction": {"parts": []}}
_TOOLS: Final = {"tools": [{"functionDeclarations": [_DECLARATION]}]}
_TURN: Final = {"role": "user", "parts": [{"text": "Count this request"}]}
_CONTENTS: Final = {"contents": [_TURN]}
@pytest.mark.parametrize(
("spelled", "canonical"),
(
({"systemInstruction": {"parts": {"text": "Follow these rules"}}}, _SYSTEM),
({"system_instruction": {"parts": {"text": "Follow these rules"}}}, _SYSTEM),
({"systemInstruction": {"parts": [{"text": "Follow these rules"}, None]}}, _SYSTEM),
({"systemInstruction": {"parts": None}}, _NO_SYSTEM_PARTS),
({"systemInstruction": {"parts": [{"text": None}, {"text": "Follow these rules"}]}}, _SYSTEM),
({"systemInstruction": {"parts": {"text": None}}}, _NO_SYSTEM_PARTS),
({"systemInstruction": {"parts": {"text": None, "inlineData": None}}}, _NO_SYSTEM_PARTS),
({"systemInstruction": {"parts": {"text": None, "thought": False}}}, _NO_SYSTEM_PARTS),
({"systemInstruction": {"parts": [{"thoughtSignature": "c2ln"}, {"text": "Follow these rules"}]}}, _SYSTEM),
({"systemInstruction": {"parts": [{}]}}, _NO_SYSTEM_PARTS),
({"systemInstruction": {}}, _NO_SYSTEM_PARTS),
({"tools": None}, {}),
({"tools": [None]}, {}),
({"tools": [{"functionDeclarations": None}]}, {}),
({"tools": {"functionDeclarations": [_DECLARATION]}}, _TOOLS),
({"tools": [{"functionDeclarations": _DECLARATION}]}, _TOOLS),
({"tools": {"functionDeclarations": _DECLARATION}}, _TOOLS),
({"contents": _TURN}, _CONTENTS),
({"contents": [None, _TURN]}, _CONTENTS),
({"contents": None}, {"contents": []}),
({"contents": [{"role": "user", "parts": {"text": "Count this request"}}]}, _CONTENTS),
({"contents": [{"role": "user", "parts": [None, {"text": "Count this request"}]}]}, _CONTENTS),
({"contents": [{"role": "user", "parts": None}]}, {"contents": [{"role": "user", "parts": []}]}),
),
)
def test_google_count_tokens_counts_every_google_spelling_of_a_repeated_field_like_its_list_form(
spelled: dict[str, JsonValue], canonical: dict[str, JsonValue]
) -> None:
contents: Final = [{"role": "user", "parts": [{"text": "Count this request"}]}]
assert _google_count_tokens_request("counted", {"contents": contents, **spelled}) == _google_count_tokens_request(
"counted", {"contents": contents, **canonical}
)
@pytest.mark.parametrize(
"malformed",
(
{"tools": "lookup"},
{"tools": [{"functionDeclarations": "lookup"}]},
{"systemInstruction": {"parts": 7}},
{"contents": 5},
{"contents": ["Count this request"]},
),
)
def test_google_count_tokens_rejects_a_scalar_where_google_expects_an_object_or_list(
malformed: dict[str, JsonValue],
) -> None:
with pytest.raises(ValidationError):
_google_count_tokens_request("counted", {"contents": [_TURN], **malformed})
@pytest.mark.parametrize(
"parts",
(
{"inlineData": {"mimeType": "image/png", "data": "aGVsbG8="}},
[{"inlineData": {"mimeType": "image/png", "data": "aGVsbG8="}}],
{"text": None, "inlineData": {"mimeType": "image/png", "data": "aGVsbG8="}},
[{"text": "Follow these rules"}, {"text": None, "fileData": {"fileUri": "gs://bucket/rules.pdf"}}],
),
)
def test_google_count_tokens_rejects_media_only_system_parts(parts: JsonValue) -> None:
with pytest.raises(ValidationError):
_google_count_tokens_request(
"counted",
{"contents": [{"role": "user", "parts": [{"text": "Hello"}]}], "systemInstruction": {"parts": parts}},
)
def test_google_count_tokens_keeps_counting_system_text_that_sits_beside_media() -> None:
text_with_media: Final = {"text": "Follow these rules", "inlineData": {"mimeType": "image/png", "data": "aGVsbG8="}}
assert _google_count_tokens_request(
"counted", {**_CONTENTS, "systemInstruction": {"parts": [text_with_media]}}
) == _google_count_tokens_request("counted", {**_CONTENTS, **_SYSTEM})