fix(count_tokens): count Anthropic-format requests for Gemini the way /v1/messages inference converts them (#46767)

* fix(count_tokens): convert Anthropic-format messages for the OpenAI counter the way inference does

* test(count_tokens): check /v1/messages/count_tokens sends OpenAI the input /v1/messages inference sends

* fix(count_tokens): build the Anthropic count request without a cast and sync dashboard API types

* fix(count_tokens): count Anthropic-format requests for Gemini the way /v1/messages inference converts them

* test(count_tokens): compare tools by value in the public api test

* fix(count_tokens): count Anthropic bodies the way /v1/messages inference sends them to OpenAI

Unwrap affinity-encoded reasoning, count the structured-output schema, flatten replayed web search results, join a list system prompt, and answer a message without a role with a 400 before calling OpenAI. messages_format is now internal to the Anthropic count route instead of a public /utils/token_counter field.

* test(count_tokens): expect the image detail the adapter now sends after merging main

* fix(count_tokens): fetch Gemini remote media without blocking the event loop

* fix(count_tokens): type normalize_native_messages without the unimported Any

On Python 3.10 to 3.13 the annotation is evaluated at import, so the proxy failed to start with NameError

* chore(count_tokens): give the Gemini count helpers' list returns a reason

* fix(count_tokens): normalize Anthropic messages for Gemini counts the way /v1/messages does

* fix(count_tokens): count Gemini bodies after inference's history rewrites and answer converter errors with 400

* test(count_tokens): cover the Gemini count error paths and compaction with a text-block system

---------

Co-authored-by: Mrinal Chanshetty <mrinal@berri.ai>
This commit is contained in:
Mateo Wang 2026-10-16 02:32:24 +00:00 • committed by GitHub
parent dfb949d437
commit dba86bec28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 703 additions and 63 deletions

View file

@ -465,7 +465,7 @@ class LiteLLMAnthropicMessagesAdapter:
*,
custom_llm_provider: str | None = None,
preserve_midturn_system: bool = False,
) -> list:
) -> list[AllMessageValues]:
new_messages: Final[list[AllMessageValues]] = []
replayable_messages: Final = strip_encrypted_reasoning_blocks_from_anthropic_messages(messages)
leading_count: Final = next(

View file

@ -504,25 +504,41 @@ class GoogleAIStudioTokenCounter(BaseTokenCounter):
from litellm.llms.vertex_ai.count_tokens.context import (
VertexGeminiCountContextError,
count_context_error_response,
gemini_count_contents,
inference_request_view,
provider_error_response,
vertex_gemini_count_context,
)
count_context: Final = vertex_gemini_count_context(system=system, tools=tools)
request_view: Final = inference_request_view(messages or [], system, messages_format)
count_context: Final = vertex_gemini_count_context(system=request_view.system, tools=tools)
if isinstance(count_context, VertexGeminiCountContextError):
return count_context_error_response(count_context, request_model, model_to_use)
deployment = deployment or {}
count_tokens_params_request: Final = copy.deepcopy(deployment.get("litellm_params", {}))
resolved_contents: Final = (
contents
if contents is not None
else await gemini_count_contents(
request_view.messages, messages_format, model_to_use, "gemini", count_tokens_params_request
)
)
if isinstance(resolved_contents, VertexGeminiCountContextError):
return count_context_error_response(resolved_contents, request_model, model_to_use)
count_tokens_params: Final = {
"model": model_to_use,
"contents": contents,
"contents": resolved_contents,
"system_instruction": count_context.system_instruction,
"tools": count_context.tools,
}
count_tokens_params_request.update(count_tokens_params)
result: Final = await GoogleAIStudioTokenCounter().acount_tokens(
**count_tokens_params_request,
)
try:
result: Final = await GoogleAIStudioTokenCounter().acount_tokens(
**count_tokens_params_request,
)
except litellm.APIError as error:
return provider_error_response(error, request_model, model_to_use)
if result is not None:
return TokenCountResponse(

View file

@ -1359,24 +1359,27 @@ class VertexAITokenCounter(BaseTokenCounter):
from litellm.llms.vertex_ai.count_tokens.context import (
VertexGeminiCountContextError,
count_context_error_response,
gemini_count_contents,
inference_request_view,
provider_error_response,
vertex_gemini_count_context,
)
from litellm.llms.vertex_ai.count_tokens.handler import VertexAITokenCounter
from litellm.llms.vertex_ai.gemini.transformation import (
gemini_convert_messages_with_history, # pyright: ignore[reportPrivateUsage] # shared helper already used by gemini/chat, context_caching, and vertex_and_google_ai_studio_gemini
)
count_context: Final = vertex_gemini_count_context(system=system, tools=tools)
request_view: Final = inference_request_view(messages or [], system, messages_format)
count_context: Final = vertex_gemini_count_context(system=request_view.system, tools=tools)
if isinstance(count_context, VertexGeminiCountContextError):
return count_context_error_response(count_context, request_model, model_to_use)
resolved_contents: Final = (
contents
if contents is not None
else gemini_convert_messages_with_history(
messages=messages or [] # pyright: ignore[reportArgumentType] # preserve direct caller message handling
else await gemini_count_contents(
request_view.messages, messages_format, model_to_use, "vertex_ai", count_tokens_params_request
)
)
if isinstance(resolved_contents, VertexGeminiCountContextError):
return count_context_error_response(resolved_contents, request_model, model_to_use)
count_tokens_params: Final = {
"model": model_to_use,
@ -1390,15 +1393,7 @@ class VertexAITokenCounter(BaseTokenCounter):
**count_tokens_params_request,
)
except litellm.APIError as error:
return TokenCountResponse(
total_tokens=0,
request_model=request_model,
model_used=model_to_use,
tokenizer_type="gemini",
error=True,
error_message=error.message,
status_code=error.status_code,
)
return provider_error_response(error, request_model, model_to_use)
if "totalTokens" in result:
return TokenCountResponse(

View file

@ -1,12 +1,20 @@
from collections.abc import Mapping
import copy
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final
from typing import TYPE_CHECKING, Final, Literal
from pydantic import JsonValue, TypeAdapter, ValidationError
from typing_extensions import assert_never
from litellm.types.llms.anthropic import AnthropicMessagesTextParam
from litellm.types.utils import TokenCountResponse
from litellm.types.llms.openai import AllMessageValues
from litellm.types.llms.vertex_ai import ContentType
from litellm.types.utils import TokenCountMessagesFormat, TokenCountResponse
if TYPE_CHECKING:
from litellm.exceptions import APIError
from litellm.llms.anthropic.pass_through.context_management.result import PolyfillResult
@dataclass(frozen=True, slots=True)
@ -142,3 +150,103 @@ def count_context_error_response(
error_message=error.error_message,
status_code=400,
)
def provider_error_response(error: "APIError", request_model: str, model_used: str) -> TokenCountResponse:
return TokenCountResponse(
total_tokens=0,
request_model=request_model,
model_used=model_used,
tokenizer_type="gemini",
error=True,
error_message=error.message,
status_code=error.status_code,
)
@dataclass(frozen=True, slots=True)
class InferenceRequestView:
messages: Sequence[Mapping[str, object]]
system: object | None
def _client_compaction(messages: Sequence[Mapping[str, object]], system: object | None) -> "PolyfillResult | None":
from litellm.llms.anthropic.pass_through.context_management.editors.compact import (
apply_client_compaction_block_history,
)
chat_messages: Final = [dict(message) for message in messages]
if system is None or isinstance(system, str):
return apply_client_compaction_block_history(messages=chat_messages, system=system)
try:
blocks: Final = _SYSTEM_BLOCKS.validate_python(system)
except ValidationError:
return None
return apply_client_compaction_block_history(messages=chat_messages, system=[dict(block) for block in blocks])
def inference_request_view(
messages: Sequence[Mapping[str, object]], system: object | None, messages_format: TokenCountMessagesFormat
) -> InferenceRequestView:
"""Apply the history rewrites /v1/messages runs before it converts a body for Gemini."""
from litellm.llms.anthropic.pass_through.messages.utils import normalize_native_messages
match messages_format:
case "openai":
return InferenceRequestView(messages=messages, system=system)
case "anthropic":
normalized: Final = normalize_native_messages(messages)
compacted: Final = _client_compaction(normalized, system)
if compacted is None:
return InferenceRequestView(messages=normalized, system=system)
return InferenceRequestView(messages=compacted.messages, system=compacted.system)
case _:
assert_never(messages_format)
def _chat_messages(
messages: Sequence[Mapping[str, object]],
messages_format: TokenCountMessagesFormat,
model: str,
custom_llm_provider: Literal["vertex_ai", "gemini"],
) -> Sequence[AllMessageValues]:
from litellm.llms.anthropic.pass_through.adapters.transformation import LiteLLMAnthropicMessagesAdapter
match messages_format:
case "anthropic":
return LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(
messages=list(messages), # pyright: ignore[reportArgumentType] # raw Anthropic body, read the way /v1/messages inference reads it
model=model,
custom_llm_provider=custom_llm_provider,
)
case "openai":
return copy.deepcopy(list(messages)) # pyright: ignore[reportReturnType] # chat messages from the request, copied because the AI Studio conversion rewrites them in place
case _:
assert_never(messages_format)
async def gemini_count_contents(
messages: Sequence[Mapping[str, object]],
messages_format: TokenCountMessagesFormat,
model: str,
custom_llm_provider: Literal["vertex_ai", "gemini"],
litellm_params: Mapping[str, object],
) -> Sequence[ContentType] | VertexGeminiCountContextError:
from litellm.exceptions import BadRequestError
from litellm.litellm_core_utils.asyncify import asyncify
from litellm.litellm_core_utils.prompt_templates.image_handling import async_inline_remote_media
from litellm.llms.gemini.chat.transformation import GoogleAIStudioGeminiConfig
from litellm.llms.vertex_ai.gemini.transformation import should_inline_remote_media
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig
config: Final = GoogleAIStudioGeminiConfig() if custom_llm_provider == "gemini" else VertexGeminiConfig()
try:
chat_messages: Final = await async_inline_remote_media(
list(_chat_messages(messages, messages_format, model, custom_llm_provider)),
should_inline=should_inline_remote_media(custom_llm_provider),
)
return await asyncify(config.transform_messages)(
messages=chat_messages, model=model, litellm_params=dict(litellm_params)
)
except BadRequestError as error:
return VertexGeminiCountContextError(error_message=error.message)

View file

@ -6,7 +6,7 @@ Why separate file? Make it easy to see how transformation works
import os
import re
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from typing import TYPE_CHECKING, Any, Final, Literal, cast
from urllib.parse import quote
@ -1318,6 +1318,10 @@ def _vertex_inlines(media: RemoteMedia) -> bool:
)
def should_inline_remote_media(custom_llm_provider: str) -> Callable[[RemoteMedia], bool]:
return _ai_studio_inlines if custom_llm_provider == "gemini" else _vertex_inlines
async def async_transform_request_body(
gemini_api_key: str | None,
messages: list[AllMessageValues],
@ -1360,7 +1364,7 @@ async def async_transform_request_body(
)
inlined_messages: Final = await async_inline_remote_media(
messages, should_inline=_ai_studio_inlines if custom_llm_provider == "gemini" else _vertex_inlines
messages, should_inline=should_inline_remote_media(custom_llm_provider)
)
if _openai_messages_may_need_sync_gcs_metadata_fetch(inlined_messages):

View file

@ -167,6 +167,93 @@ def test_messages_and_count_tokens_send_matching_gemini_context(
assert count_body == _count_tokens_request_from_generate_content(generated_body)
_LOOKUP_TOOL: Final[JsonValue] = {
"name": "lookup",
"description": "Look up a value",
"input_schema": {"type": "object", "properties": {"query": {"type": "string"}}},
}
_CONTENT_BLOCK_BODIES: Final[Mapping[str, Mapping[str, JsonValue]]] = MappingProxyType(
{
"base64-pdf": {
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "Summarize the document."},
{
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": "JVBERi0xLjQK"},
},
],
}
],
},
"base64-image": {
"messages": [
{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "aW1hZ2U="}},
{"type": "text", "text": "What is this?"},
],
}
],
},
"tool-turns": {
"tools": [_LOOKUP_TOOL],
"messages": [
{"role": "user", "content": "Look up the value."},
{
"role": "assistant",
"content": [
{"type": "text", "text": "Looking it up."},
{"type": "tool_use", "id": "toolu_01", "name": "lookup", "input": {"query": "value"}},
],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": "42"}],
},
],
},
}
)
def _ai_studio_deployment(scenario: Scenario, api_base: str) -> str:
return scenario.model(model=f"gemini/{_MODEL}", api_base=api_base, api_key="scripted-gemini-key")
def _counted_contents(count_body: Mapping[str, JsonValue]) -> JsonValue:
if "contents" in count_body:
return count_body["contents"]
return _JSON_OBJECT.validate_python(count_body["generateContentRequest"])["contents"]
@pytest.mark.parametrize("provider", ("vertex_ai", "gemini"))
@pytest.mark.parametrize("case", sorted(_CONTENT_BLOCK_BODIES))
def test_count_tokens_sends_the_contents_messages_inference_sends(gateway: Gateway, provider: str, case: str) -> None:
peer: Final = _GeminiPeer()
with wire_server(peer) as wire, gateway.scenario() as scenario:
model: Final = (
_deployment(gateway, scenario, wire.url)
if provider == "vertex_ai"
else _ai_studio_deployment(scenario, wire.url)
)
body: Final = {"model": model, **_CONTENT_BLOCK_BODIES[case]}
generated: Final = gateway.request("POST", "/v1/messages", {**body, "max_tokens": 16})
counted: Final = gateway.request("POST", "/v1/messages/count_tokens", body)
assert generated.status_code == 200, generated.text
assert counted.status_code == 200, counted.text
assert _JSON_OBJECT.validate_json(counted.content)["input_tokens"] == _COUNT, counted.text
requests: Final = peer.drain()
generated_body: Final = _json_body(
next(request for request in requests if request.target.endswith(":generateContent"))
)
count_body: Final = _json_body(next(request for request in requests if request.target.endswith(":countTokens")))
assert _counted_contents(count_body) == generated_body["contents"]
def test_count_tokens_keeps_each_text_block_in_system_instruction(gateway: Gateway) -> None:
peer: Final = _GeminiPeer()
with wire_server(peer) as wire, gateway.scenario() as scenario:

View file

@ -5,6 +5,7 @@ import httpx
import pytest
from pydantic import JsonValue, TypeAdapter
import litellm
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.llms.gemini.common_utils import GeminiModelInfo, GoogleAIStudioTokenCounter
from litellm.llms.gemini.count_tokens import handler as gemini_count_tokens_handler
@ -100,6 +101,58 @@ async def test_google_ai_studio_count_tokens_rejects_image_system_without_reques
assert peer.request_body is None
@pytest.mark.asyncio
async def test_google_ai_studio_count_tokens_rejects_an_image_url_it_refuses_to_fetch_without_request(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "user_url_validation", True)
peer: Final = _GeminiCountTokensPeer()
client: Final = AsyncHTTPHandler(transport=httpx.MockTransport(peer))
with patch.object(gemini_count_tokens_handler, "get_async_httpx_client", return_value=client):
result: Final = await GoogleAIStudioTokenCounter().count_tokens(
model_to_use="gemini-2.5-flash",
messages=[
{
"role": "user",
"content": [{"type": "image", "source": {"type": "url", "url": "http://127.0.0.1:9/image.png"}}],
}
],
contents=None,
deployment={"litellm_params": {"api_key": "test-key", "api_base": "https://count-tokens.test"}},
request_model="gemini/gemini-2.5-flash",
messages_format="anthropic",
)
assert result is not None
assert result.error is True
assert result.status_code == 400
assert "http://127.0.0.1:9/image.png" in (result.error_message or "")
assert peer.request_body is None
@pytest.mark.asyncio
async def test_google_ai_studio_count_tokens_answers_with_the_status_google_rejected_it_with() -> None:
rejection: Final = '{"error": {"code": 400, "message": "Invalid function name", "status": "INVALID_ARGUMENT"}}'
client: Final = AsyncHTTPHandler(
transport=httpx.MockTransport(lambda request: httpx.Response(400, text=rejection, request=request))
)
with patch.object(gemini_count_tokens_handler, "get_async_httpx_client", return_value=client):
result: Final = await GoogleAIStudioTokenCounter().count_tokens(
model_to_use="gemini-2.5-flash",
messages=None,
contents=[{"role": "user", "parts": [{"text": "hello"}]}],
deployment={"litellm_params": {"api_key": "test-key", "api_base": "https://count-tokens.test"}},
request_model="gemini/gemini-2.5-flash",
)
assert result is not None
assert result.error is True
assert result.status_code == 400
assert "Invalid function name" in (result.error_message or "")
@pytest.mark.asyncio
@pytest.mark.parametrize(
("system_instruction", "tools", "expected_body"),
@ -129,9 +182,7 @@ async def test_gemini_count_tokens_sends_optional_request_fields(
expected_body: dict[str, JsonValue],
) -> None:
peer: Final = _GeminiCountTokensPeer()
token_counter: Final = GeminiCountTokensHandler(
client=AsyncHTTPHandler(transport=httpx.MockTransport(peer))
)
token_counter: Final = GeminiCountTokensHandler(client=AsyncHTTPHandler(transport=httpx.MockTransport(peer)))
result: Final = await token_counter.acount_tokens(
contents=[{"role": "user", "parts": [{"text": "hello"}]}],
@ -177,18 +228,10 @@ class TestGeminiModelInfo:
# Test edge cases where model names end with characters from "models/"
# These would be incorrectly processed if using strip("models/") instead of replace("models/", "")
models = [
{
"name": "models/gemini-1.5-pro"
}, # ends with 'o' - would become "gemini-1.5-pr" with strip()
{
"name": "models/test-model"
}, # ends with 'l' - would become "gemini/test-mode" with strip()
{
"name": "models/custom-models"
}, # ends with 's' - would become "gemini/custom-model" with strip()
{
"name": "models/demo"
}, # ends with 'o' - would become "gemini/dem" with strip()
{"name": "models/gemini-1.5-pro"}, # ends with 'o' - would become "gemini-1.5-pr" with strip()
{"name": "models/test-model"}, # ends with 'l' - would become "gemini/test-mode" with strip()
{"name": "models/custom-models"}, # ends with 's' - would become "gemini/custom-model" with strip()
{"name": "models/demo"}, # ends with 'o' - would become "gemini/dem" with strip()
]
result = gemini_model_info.process_model_name(models)
@ -239,16 +282,10 @@ class TestGoogleAIStudioTokenCounter:
token_counter = GoogleAIStudioTokenCounter()
# Test with gemini provider - should return True
assert (
token_counter.should_use_token_counting_api(LlmProviders.GEMINI.value)
is True
)
assert token_counter.should_use_token_counting_api(LlmProviders.GEMINI.value) is True
# Test with other providers - should return False
assert (
token_counter.should_use_token_counting_api(LlmProviders.OPENAI.value)
is False
)
assert token_counter.should_use_token_counting_api(LlmProviders.OPENAI.value) is False
assert token_counter.should_use_token_counting_api("anthropic") is False
assert token_counter.should_use_token_counting_api("vertex_ai") is False
@ -320,9 +357,7 @@ class TestGoogleAIStudioTokenCounter:
"functionResponse": {
"id": "read_many_files-1757526647518-730a691aac11c", # This should be removed
"name": "read_many_files",
"response": {
"output": "No files matching the criteria were found or all were skipped."
},
"response": {"output": "No files matching the criteria were found or all were skipped."},
}
}
],
@ -331,9 +366,7 @@ class TestGoogleAIStudioTokenCounter:
]
# Clean the contents
cleaned_contents = token_counter._clean_contents_for_gemini_api(
contents_with_id
)
cleaned_contents = token_counter._clean_contents_for_gemini_api(contents_with_id)
# Verify the 'id' field was removed
function_response = cleaned_contents[1]["parts"][0]["functionResponse"]
@ -342,8 +375,7 @@ class TestGoogleAIStudioTokenCounter:
assert "response" in function_response
assert function_response["name"] == "read_many_files"
assert (
function_response["response"]["output"]
== "No files matching the criteria were found or all were skipped."
function_response["response"]["output"] == "No files matching the criteria were found or all were skipped."
)
def test_clean_contents_for_gemini_api_preserves_other_fields(self):
@ -359,9 +391,7 @@ class TestGoogleAIStudioTokenCounter:
]
# Clean the contents
cleaned_contents = token_counter._clean_contents_for_gemini_api(
contents_without_function_response
)
cleaned_contents = token_counter._clean_contents_for_gemini_api(contents_without_function_response)
# Verify the contents are unchanged
assert cleaned_contents == contents_without_function_response

View file

@ -1,8 +1,18 @@
from typing import Final
import asyncio
import base64
import time
import uuid
from collections.abc import Generator
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from typing import Final, Literal
from unittest.mock import patch
import pytest
from pydantic import JsonValue
from pydantic import JsonValue, TypeAdapter
import litellm
from litellm.llms.anthropic.pass_through.adapters.transformation import (
LiteLLMAnthropicMessagesAdapter,
truncate_tool_name,
@ -10,6 +20,8 @@ from litellm.llms.anthropic.pass_through.adapters.transformation import (
from litellm.llms.vertex_ai.count_tokens.context import (
VertexGeminiCountContext,
VertexGeminiCountContextError,
gemini_count_contents,
inference_request_view,
vertex_gemini_count_context,
)
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig
@ -193,3 +205,302 @@ def test_invalid_tools_return_an_error_value(tools: JsonValue) -> None:
assert isinstance(context, VertexGeminiCountContextError)
assert "tools" in context.error_message
_ANTHROPIC_CONVERSATION: Final = (
{
"role": "user",
"content": [
{"type": "text", "text": "Summarize both"},
{"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": "JVBERi0="}},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "aW1hZ2U="}},
],
},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "toolu_01", "name": "lookup", "input": {"query": "x"}}],
},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": "42"}]},
)
@pytest.mark.parametrize("custom_llm_provider", ("vertex_ai", "gemini"))
async def test_anthropic_documents_images_and_tool_turns_become_gemini_parts(
custom_llm_provider: Literal["vertex_ai", "gemini"],
) -> None:
contents: Final = await gemini_count_contents(
_ANTHROPIC_CONVERSATION, "anthropic", "gemini-2.5-flash", custom_llm_provider, {}
)
assert contents == [
{
"role": "user",
"parts": [
{"text": "Summarize both"},
{"inline_data": {"data": "JVBERi0=", "mime_type": "application/pdf"}},
{"inline_data": {"data": "aW1hZ2U=", "mime_type": "image/png"}},
],
},
{"role": "model", "parts": [{"function_call": {"name": "lookup", "args": {"query": "x"}}}]},
{"role": "user", "parts": [{"function_response": {"name": "lookup", "response": {"content": "42"}}}]},
]
@pytest.mark.parametrize("custom_llm_provider", ("vertex_ai", "gemini"))
async def test_openai_messages_keep_their_chat_conversion(custom_llm_provider: Literal["vertex_ai", "gemini"]) -> None:
contents: Final = await gemini_count_contents(
({"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}),
"openai",
"gemini-2.5-flash",
custom_llm_provider,
{},
)
assert contents == [
{"role": "user", "parts": [{"text": "hello"}]},
{"role": "model", "parts": [{"text": "hi"}]},
]
_PNG_BYTES: Final = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
_DOWNLOAD_SECONDS: Final = 0.5
class _SlowPngHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
time.sleep(_DOWNLOAD_SECONDS)
self.send_response(200)
self.send_header("Content-Type", "image/png")
self.send_header("Content-Length", str(len(_PNG_BYTES)))
self.end_headers()
self.wfile.write(_PNG_BYTES)
def log_message(self, format: str, *args: object) -> None:
return None
@contextmanager
def _slow_png_server() -> Generator[str, None, None]:
with ThreadingHTTPServer(("127.0.0.1", 0), _SlowPngHandler) as server:
thread: Final = Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_address[1]}"
finally:
server.shutdown()
async def _event_loop_wake_delay(nap_seconds: float) -> float:
started: Final = time.monotonic()
await asyncio.sleep(nap_seconds)
return time.monotonic() - started
@pytest.mark.parametrize("custom_llm_provider", ("vertex_ai", "gemini"))
async def test_remote_image_downloads_without_blocking_the_event_loop(
custom_llm_provider: Literal["vertex_ai", "gemini"], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "user_url_validation", False)
with _slow_png_server() as base_url:
image_url: Final = f"{base_url}/{uuid.uuid4().hex}.png"
wake_delay, contents = await asyncio.gather(
_event_loop_wake_delay(0.05),
gemini_count_contents(
(
{
"role": "user",
"content": [
{"type": "text", "text": "Describe it"},
{"type": "image", "source": {"type": "url", "url": image_url}},
],
},
),
"anthropic",
"gemini-2.5-flash",
custom_llm_provider,
{},
),
)
assert contents == [
{
"role": "user",
"parts": [
{"text": "Describe it"},
{"inline_data": {"data": base64.b64encode(_PNG_BYTES).decode(), "mime_type": "image/png"}},
],
}
]
assert wake_delay < _DOWNLOAD_SECONDS / 2
class _MissingImageHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
self.send_response(404)
self.send_header("Content-Length", "0")
self.end_headers()
def log_message(self, format: str, *args: object) -> None:
return None
@pytest.mark.parametrize("custom_llm_provider", ("vertex_ai", "gemini"))
async def test_image_that_cannot_be_fetched_returns_an_error_value(
custom_llm_provider: Literal["vertex_ai", "gemini"], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "user_url_validation", False)
with ThreadingHTTPServer(("127.0.0.1", 0), _MissingImageHandler) as server:
Thread(target=server.serve_forever, daemon=True).start()
try:
contents = await gemini_count_contents(
(
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "url",
"url": f"http://127.0.0.1:{server.server_address[1]}/{uuid.uuid4().hex}.png",
},
}
],
},
),
"anthropic",
"gemini-2.5-flash",
custom_llm_provider,
{},
)
finally:
server.shutdown()
assert isinstance(contents, VertexGeminiCountContextError)
@pytest.mark.parametrize(
"system", ("Be brief.", [{"type": "text", "text": "Be brief."}]), ids=("string-system", "text-block-system")
)
def test_anthropic_view_keeps_only_the_turns_after_a_client_compaction_block(system: JsonValue) -> None:
view: Final = inference_request_view(
(
{"role": "user", "content": "first question " * 50},
{"role": "assistant", "content": [{"type": "compaction", "content": "Summary of the earlier turns."}]},
{"role": "user", "content": "latest question"},
),
system,
"anthropic",
)
assert view.messages == [{"role": "user", "content": "latest question"}]
assert "Summary of the earlier turns." in str(view.system)
assert "Be brief." in str(view.system)
def test_anthropic_view_flattens_a_replayed_web_search_result_into_text() -> None:
snippet: Final = "LiteLLM ships a weekly release candidate and a stable line."
view: Final = inference_request_view(
(
{"role": "user", "content": "What is new?"},
{
"role": "assistant",
"content": [
{"type": "server_tool_use", "id": "srvtoolu_01", "name": "web_search", "input": {"query": "news"}},
{
"type": "web_search_tool_result",
"tool_use_id": "srvtoolu_01",
"content": [
{
"type": "web_search_result",
"url": "https://docs.litellm.ai/release_notes",
"title": "Release notes",
"encrypted_content": "",
"snippet": snippet,
}
],
},
],
},
),
None,
"anthropic",
)
assistant_blocks: Final = TypeAdapter(list[dict[str, str]]).validate_python(view.messages[1]["content"])
assert [block["type"] for block in assistant_blocks] == ["text"]
assert snippet in assistant_blocks[0]["text"]
def test_openai_view_is_the_request_unchanged() -> None:
messages: Final = ({"role": "user", "content": [{"type": "compaction", "content": "kept"}]},)
view: Final = inference_request_view(messages, "Be brief.", "openai")
assert view.messages == messages
assert view.system == "Be brief."
async def test_gcs_media_type_lookup_uses_the_deployment_vertex_project() -> None:
with patch(
"litellm.llms.vertex_ai.gemini.transformation._get_gcs_object_content_type",
return_value="image/png",
) as lookup:
contents: Final = await gemini_count_contents(
(
{
"role": "user",
"content": [
{"type": "image", "source": {"type": "url", "url": "gs://bucket/image-without-extension"}}
],
},
),
"anthropic",
"gemini-2.5-flash",
"vertex_ai",
{"vertex_project": "deployment-project"},
)
assert lookup.call_args.kwargs.get("vertex_project") == "deployment-project"
assert not isinstance(contents, VertexGeminiCountContextError)
assert contents[0]["parts"][0] == {
"file_data": {"mime_type": "image/png", "file_uri": "gs://bucket/image-without-extension"}
}
@pytest.mark.parametrize("custom_llm_provider", ("vertex_ai", "gemini"))
def test_anthropic_replayed_web_search_result_is_counted_as_its_text(
custom_llm_provider: Literal["vertex_ai", "gemini"],
) -> None:
snippet: Final = "LiteLLM ships a weekly release candidate and a stable line."
messages: Final = (
{"role": "user", "content": "What is new?"},
{
"role": "assistant",
"content": [
{"type": "server_tool_use", "id": "srvtoolu_01", "name": "web_search", "input": {"query": "news"}},
{
"type": "web_search_tool_result",
"tool_use_id": "srvtoolu_01",
"content": [
{
"type": "web_search_result",
"url": "https://docs.litellm.ai/release_notes",
"title": "Release notes",
"encrypted_content": "",
"snippet": snippet,
}
],
},
],
},
{"role": "user", "content": "Thanks."},
)
view: Final = inference_request_view(messages, None, "anthropic")
contents: Final = asyncio.run(
gemini_count_contents(view.messages, "anthropic", "gemini-2.5-flash", custom_llm_provider, {})
)
assert snippet in str(contents)

View file

@ -1,5 +1,6 @@
import time
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Final
from unittest.mock import AsyncMock, patch
@ -7,6 +8,7 @@ import httpx
import pytest
from pydantic import JsonValue, TypeAdapter
import litellm
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.llms.gemini.count_tokens import handler as gemini_count_tokens_handler
@ -24,16 +26,21 @@ from litellm.llms.vertex_ai.common_utils import (
vertex_request_labels_from_litellm_params,
)
from litellm.llms.vertex_ai.count_tokens import handler as vertex_count_tokens_handler
from litellm.types.utils import TokenCountMessagesFormat
@pytest.mark.asyncio
async def test_vertex_gemini_rejects_non_text_system_blocks_before_request() -> None:
@pytest.mark.parametrize("messages_format", ("openai", "anthropic"))
async def test_vertex_gemini_rejects_non_text_system_blocks_before_request(
messages_format: TokenCountMessagesFormat,
) -> None:
result: Final = await VertexAITokenCounter().count_tokens(
model_to_use="gemini-4-argon",
messages=[{"role": "user", "content": "Count this"}],
contents=None,
request_model="vertex_ai/gemini-4-argon",
system=[{"type": "image", "source": {"type": "base64", "data": "AAAA"}}],
messages_format=messages_format,
)
assert result is not None
@ -42,6 +49,88 @@ async def test_vertex_gemini_rejects_non_text_system_blocks_before_request() ->
assert "text blocks" in (result.error_message or "")
class _VertexCountTokensPeer:
def __init__(self, status_code: int) -> None:
self.status_code: Final = status_code
self.requests: tuple[httpx.Request, ...] = ()
def __call__(self, request: httpx.Request) -> httpx.Response:
self.requests = (*self.requests, request)
if self.status_code == 200:
return httpx.Response(200, json={"totalTokens": 37}, request=request)
return httpx.Response(
self.status_code,
json={"error": {"code": self.status_code, "message": "Invalid function name", "status": "INVALID_ARGUMENT"}},
request=request,
)
@contextmanager
def _vertex_count_tokens_peer(status_code: int) -> Iterator[_VertexCountTokensPeer]:
peer: Final = _VertexCountTokensPeer(status_code)
with (
patch.object(
gemini_count_tokens_handler,
"get_async_httpx_client",
return_value=AsyncHTTPHandler(transport=httpx.MockTransport(peer)),
),
patch.object(
vertex_count_tokens_handler.VertexAITokenCounter,
"_ensure_access_token_async",
new=AsyncMock(return_value=("fake-token", "test-project")),
),
):
yield peer
_VERTEX_DEPLOYMENT: Final = {"litellm_params": {"vertex_project": "test-project", "vertex_location": "us-central1"}}
@pytest.mark.asyncio
async def test_vertex_gemini_count_answers_with_the_status_google_rejected_it_with() -> None:
with _vertex_count_tokens_peer(400) as peer:
result: Final = await VertexAITokenCounter().count_tokens(
model_to_use="gemini-2.5-flash",
messages=[{"role": "user", "content": "Hello"}],
contents=None,
deployment=_VERTEX_DEPLOYMENT,
request_model="vertex_ai/gemini-2.5-flash",
)
assert result is not None
assert result.error is True
assert result.status_code == 400
assert "Invalid function name" in (result.error_message or "")
assert len(peer.requests) == 1
@pytest.mark.asyncio
async def test_vertex_gemini_count_rejects_an_image_url_it_refuses_to_fetch_without_request(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "user_url_validation", True)
with _vertex_count_tokens_peer(200) as peer:
result: Final = await VertexAITokenCounter().count_tokens(
model_to_use="gemini-2.5-flash",
messages=[
{
"role": "user",
"content": [{"type": "image", "source": {"type": "url", "url": "http://127.0.0.1:9/image.png"}}],
}
],
contents=None,
deployment=_VERTEX_DEPLOYMENT,
request_model="vertex_ai/gemini-2.5-flash",
messages_format="anthropic",
)
assert result is not None
assert result.error is True
assert result.status_code == 400
assert "http://127.0.0.1:9/image.png" in (result.error_message or "")
assert peer.requests == ()
@pytest.mark.parametrize("location", ["us", "eu", "us-central1", "europe-west1", "global"])
def test_validate_vertex_location_accepts_valid(location):
assert validate_vertex_location(location) == location