fix(anthropic)!: answer 400 for a malformed tool_result text on /v1/messages and count_tokens (#47347)

* fix(anthropic): answer 400 for a malformed tool_result text on the Responses bridge and count_tokens

A tool_result text block whose text is null, a number, or missing made
/v1/messages and /v1/messages/count_tokens answer 500 on OpenAI and Azure
models. Both routes now answer 400 naming the block, and drop the block
when drop_params is on.

* fix(anthropic): keep a missing tool_result text as empty text, bound the error, honor body drop_params on count_tokens

* fix(anthropic): apply the tool_result text check on the chat/completions bridge and every count route

The chat/completions bridge used for Azure, Gemini and other providers now answers the same 400 for a
null or numeric tool_result text and drops the block under drop_params, so a model group that mixes
OpenAI with another provider answers the same way on every deployment. Gemini and Vertex count_tokens
apply the same check, count_tokens turns on drop_params for Claude Code and Codex the way /v1/messages
does, and a tool_result without a tool_use_id is checked too

* fix(proxy): answer 400 for a malformed tool_result text on count_tokens models without a count API

Providers with no token counting API fall back to the local tokenizer, which counted a null or numeric tool_result text as fine while /v1/messages on the same model answers 400. The fallback now runs the same check, unless drop_params applies to the deployment or the request

* fix(proxy): honor request drop_params on the local count fallback when no deployment resolves

* test(integration): cover non-string tool_result text on both /v1/messages bridges and count routes

* fix(anthropic): keep a dropped tool_result on Mistral and reject it before a guardrail rewrite

Under drop_params a tool_result whose only text block was null or a number became an
empty tool message, and Mistral's empty-message filter removed it, so Mistral answered
400 for a missing tool reply. The filter now only removes empty assistant messages,
which is what Mistral rejects.

A guardrail that rewrites structured_messages wrote the dropped text back as "", so a
null or numeric text was silently blanked even without drop_params. The rewrite path now
answers the same 400 as the bridges unless the request or global drop_params is set.

* test(integration): cover the Mistral empty tool reply and the guardrail rewrite tool_result text check

* fix(anthropic): keep a malformed tool_result text through a guardrail rewrite and keep Mistral's null-message drop

A guardrail that rewrites structured_messages now writes the caller's null or numeric
tool_result text back in place, so the route's bridge and the deployment's drop_params
decide after routing, the same as with no guardrail. The rewrite path no longer answers
its own 400 before routing.

Mistral drops a null-content system or user message again, as main does, and only a tool
reply is exempt from that filter so a dropped tool_result keeps its tool call.

* test(mistral): expect empty system and user turns dropped and the empty tool reply kept

* perf(anthropic): restore kept tool_result text parts in one linear pass

* test(integration): cover the tool_result text restore across tool_results, the chat bridge, and a batch upload behind a rewriting guardrail

* fix(anthropic): let guardrails scan malformed tool_result text and honor drop_params on native Anthropic

A rewriting guardrail now sees a non-string tool_result text as its JSON text and the write-back
restores the caller's value by the sanitized tool_use_id when the guardrail left it unchanged.
Native Anthropic drops the bad block under drop_params on /v1/messages and count_tokens, and
Mistral keeps an empty tool reply only when it answers a tool call, sent as empty text.

* fix(anthropic): compare a kept tool_result text to its scanned form once and align the wire tests with drop_params

* test(integration): cover native Anthropic drop_params resolution, guardrail scanning of numeric tool_result text, and the Mistral orphan tool message

* fix(count_tokens): honor the router's default drop_params when counting a malformed tool_result text

* test(count_tokens): send the tool_result count tests through a real request body instead of a mocked parser

* test(count_tokens): send the native Claude count test through a real request body

* fix(anthropic): keep each non-string tool_result text when tool_use_ids sanitize to the same id

* fix(anthropic): keep non-ASCII characters in the guardrail's view of a non-string tool_result text

* test(gemini): mock the count_tokens upstream with respx instead of patching the client factory

* fix(mistral): keep an empty tool reply only for an open call, and forward non-object message items on the native drop

An empty tool reply re-answering a call the conversation already moved past made Mistral reject the request, and a tool call id that is not a string crashed the filter. Native Anthropic under drop_params assumed every message item was an object.

* fix(mistral): find answering tool replies in one pass instead of copying the open call set per reply

* fix(mistral): ignore dropped empty placeholders when tracking open tool calls

* fix(mistral): drop an empty tool reply when another reply to the same call carries the result

* refactor(anthropic): share tool_result text helpers to shrink the diff

* fix(anthropic): answer 400 for a non-string tool_result text on the OpenAI Responses bridge
This commit is contained in:
Mateo Wang 2026-10-19 21:13:46 +00:00 • committed by GitHub
parent b2f0f4d54c
commit b51b9b7d43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 2484 additions and 65 deletions

View file

@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, cast, runtime_c
import httpx import httpx
from pydantic import TypeAdapter, ValidationError from pydantic import TypeAdapter, ValidationError
import litellm
from litellm._logging import verbose_logger from litellm._logging import verbose_logger
from litellm.constants import KUBERNETES_POD_ROUTING_KEY from litellm.constants import KUBERNETES_POD_ROUTING_KEY
from litellm.litellm_core_utils.llm_response_utils.get_headers import get_provider_request_id from litellm.litellm_core_utils.llm_response_utils.get_headers import get_provider_request_id
@ -93,6 +94,10 @@ def normalize_drop_params(value: object) -> bool | None:
return None return None
def drops_unsupported_params(drop_params: object) -> bool:
return litellm.drop_params is True or normalize_drop_params(drop_params) is True
def drop_params_flag(value: object, source: str, logger: logging.Logger) -> bool: def drop_params_flag(value: object, source: str, logger: logging.Logger) -> bool:
normalized: Final = normalize_drop_params(value) normalized: Final = normalize_drop_params(value)
if normalized is None and value is not None: if normalized is None and value is not None:

View file

@ -1669,7 +1669,7 @@ def _replace_invalid_tool_use_id_chars(tool_use_id: str, invalid_chars: re.Patte
return invalid_chars.sub("_", tool_use_id) or _TOOL_USE_ID_FALLBACK return invalid_chars.sub("_", tool_use_id) or _TOOL_USE_ID_FALLBACK
def _sanitize_anthropic_tool_use_id(tool_use_id: str) -> str: def sanitize_anthropic_tool_use_id(tool_use_id: str) -> str:
"""Anthropic requires tool_use_id to match ^[a-zA-Z0-9_-]+$.""" """Anthropic requires tool_use_id to match ^[a-zA-Z0-9_-]+$."""
return _replace_invalid_tool_use_id_chars(tool_use_id, _ANTHROPIC_TOOL_USE_ID_INVALID_CHARS) return _replace_invalid_tool_use_id_chars(tool_use_id, _ANTHROPIC_TOOL_USE_ID_INVALID_CHARS)
@ -1815,7 +1815,7 @@ def convert_to_anthropic_tool_result(
tool_message: Final[ChatCompletionToolMessage] = message tool_message: Final[ChatCompletionToolMessage] = message
tool_call_id: str = tool_message["tool_call_id"] tool_call_id: str = tool_message["tool_call_id"]
# Sanitize tool_use_id to match Anthropic's pattern requirement: ^[a-zA-Z0-9_-]+$ # Sanitize tool_use_id to match Anthropic's pattern requirement: ^[a-zA-Z0-9_-]+$
sanitized_tool_use_id = _sanitize_anthropic_tool_use_id(tool_call_id) sanitized_tool_use_id = sanitize_anthropic_tool_use_id(tool_call_id)
# We can't determine from openai message format whether it's a successful or # We can't determine from openai message format whether it's a successful or
# error call result so default to the successful result template # error call result so default to the successful result template
@ -1829,7 +1829,7 @@ def convert_to_anthropic_tool_result(
function_message: Final[ChatCompletionFunctionMessage] = message function_message: Final[ChatCompletionFunctionMessage] = message
tool_call_id = function_message.get("tool_call_id") or str(uuid.uuid4()) tool_call_id = function_message.get("tool_call_id") or str(uuid.uuid4())
# Sanitize tool_use_id to match Anthropic's pattern requirement: ^[a-zA-Z0-9_-]+$ # Sanitize tool_use_id to match Anthropic's pattern requirement: ^[a-zA-Z0-9_-]+$
sanitized_tool_use_id = _sanitize_anthropic_tool_use_id(tool_call_id) sanitized_tool_use_id = sanitize_anthropic_tool_use_id(tool_call_id)
anthropic_tool_result = AnthropicMessagesToolResultParam( anthropic_tool_result = AnthropicMessagesToolResultParam(
type="tool_result", type="tool_result",
tool_use_id=sanitized_tool_use_id, tool_use_id=sanitized_tool_use_id,
@ -2037,7 +2037,7 @@ def convert_to_anthropic_tool_invoke(
elif is_anthropic_tool_search_use(tool_id, tool_name) and tool_id not in answered_tool_ids: elif is_anthropic_tool_search_use(tool_id, tool_name) and tool_id not in answered_tool_ids:
continue continue
else: else:
sanitized_tool_id = _sanitize_anthropic_tool_use_id(tool_id) sanitized_tool_id = sanitize_anthropic_tool_use_id(tool_id)
_anthropic_tool_use_param = AnthropicMessagesToolUseParam( _anthropic_tool_use_param = AnthropicMessagesToolUseParam(
type="tool_use", type="tool_use",
id=sanitized_tool_id, id=sanitized_tool_id,
@ -5900,21 +5900,21 @@ def _optional_tool_name(value: object) -> str | None:
def _split_tool_call_ids(calls: Sequence[tuple[str | None, int]]) -> tuple[tuple[str | None, ...], ...]: def _split_tool_call_ids(calls: Sequence[tuple[str | None, int]]) -> tuple[tuple[str | None, ...], ...]:
taken: Final = frozenset(_sanitize_anthropic_tool_use_id(call_id) for call_id, _ in calls if call_id) taken: Final = frozenset(sanitize_anthropic_tool_use_id(call_id) for call_id, _ in calls if call_id)
def fresh(call_id: str) -> Iterator[str]: def fresh(call_id: str) -> Iterator[str]:
return filter( return filter(
lambda candidate: _sanitize_anthropic_tool_use_id(candidate) not in taken, lambda candidate: sanitize_anthropic_tool_use_id(candidate) not in taken,
(f"{call_id}__concat_{n}" for n in itertools.count(1)), (f"{call_id}__concat_{n}" for n in itertools.count(1)),
) )
suffixes: Final = MappingProxyType( suffixes: Final = MappingProxyType(
{_sanitize_anthropic_tool_use_id(call_id): fresh(call_id) for call_id, count in calls if call_id and count > 1} {sanitize_anthropic_tool_use_id(call_id): fresh(call_id) for call_id, count in calls if call_id and count > 1}
) )
return tuple( return tuple(
( (
call_id, call_id,
*(next(suffixes[_sanitize_anthropic_tool_use_id(call_id)]) for _ in range(count - 1)), *(next(suffixes[sanitize_anthropic_tool_use_id(call_id)]) for _ in range(count - 1)),
) )
if call_id if call_id
else (None,) * count else (None,) * count

View file

@ -30,6 +30,10 @@ from litellm.llms.anthropic.pass_through.adapters.transformation import (
LiteLLMAnthropicMessagesAdapter, LiteLLMAnthropicMessagesAdapter,
is_provider_native_tool_dict, is_provider_native_tool_dict,
) )
from litellm.llms.anthropic.pass_through.convertible_blocks import (
keep_non_string_tool_result_texts,
scannable_tool_result_request,
)
from litellm.llms.anthropic.pass_through.messages.utils import filtered_safeguard_results, redacted_stop_details from litellm.llms.anthropic.pass_through.messages.utils import filtered_safeguard_results, redacted_stop_details
from litellm.llms.anthropic.pass_through.stream_assembly import ( from litellm.llms.anthropic.pass_through.stream_assembly import (
build_complete_streaming_response, build_complete_streaming_response,
@ -493,8 +497,8 @@ class AnthropicMessagesHandler(BaseTranslation):
``get_streaming_string_so_far``): raw SSE ``bytes`` -- which may bundle ``get_streaming_string_so_far``): raw SSE ``bytes`` -- which may bundle
several events separated by a blank line -- and an already-parsed event several events separated by a blank line -- and an already-parsed event
``dict``.""" ``dict``."""
if isinstance(item, dict): if (event := as_str_mapping(item)) is not None:
return (_as_str_mapping(item),) return (event,)
if not isinstance(item, (bytes, bytearray)): if not isinstance(item, (bytes, bytearray)):
return () return ()
return tuple( return tuple(
@ -510,7 +514,7 @@ class AnthropicMessagesHandler(BaseTranslation):
chat_completion_compatible_request, chat_completion_compatible_request,
_tool_name_mapping, _tool_name_mapping,
) = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai( ) = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(
anthropic_message_request=cast(AnthropicMessagesRequest, data.copy()), anthropic_message_request=cast(AnthropicMessagesRequest, scannable_tool_result_request(data)),
preserve_midturn_system=True, preserve_midturn_system=True,
) )
return chat_completion_compatible_request return chat_completion_compatible_request
@ -832,7 +836,7 @@ class AnthropicMessagesHandler(BaseTranslation):
@staticmethod @staticmethod
def _write_back_structured_messages( def _write_back_structured_messages(
data: dict, # mutable-ok: API message payload data: dict[str, object], # mutable-ok: API message payload
structured_messages: list, # mutable-ok: API message payload structured_messages: list, # mutable-ok: API message payload
hoisted_system_message: object = None, hoisted_system_message: object = None,
preserve_system_messages: bool = False, preserve_system_messages: bool = False,
@ -899,7 +903,7 @@ class AnthropicMessagesHandler(BaseTranslation):
for block in content: for block in content:
if isinstance(block, dict) and block.get("type") == "thinking": if isinstance(block, dict) and block.get("type") == "thinking":
block.pop("cache_control", None) block.pop("cache_control", None)
data["messages"] = converted data["messages"] = keep_non_string_tool_result_texts(converted, data.get("messages"))
@staticmethod @staticmethod
def _extract_midturn_system_text( def _extract_midturn_system_text(
@ -1601,8 +1605,8 @@ class AnthropicMessagesHandler(BaseTranslation):
@staticmethod @staticmethod
def _rewrite_buffered_item(item: object, rewrite_event: _SSEEventRewriter) -> object: def _rewrite_buffered_item(item: object, rewrite_event: _SSEEventRewriter) -> object:
if isinstance(item, dict): if (event := as_str_mapping(item)) is not None:
return _rewritten_event(_as_str_mapping(item), rewrite_event) return _rewritten_event(event, rewrite_event)
if isinstance(item, (bytes, bytearray)): if isinstance(item, (bytes, bytearray)):
return AnthropicMessagesHandler._rewrite_sse_events(bytes(item), rewrite_event) return AnthropicMessagesHandler._rewrite_sse_events(bytes(item), rewrite_event)
return item return item

View file

@ -22,6 +22,7 @@ from litellm.llms.anthropic.pass_through.context_management import (
PolyfillResult, PolyfillResult,
apply_context_management, apply_context_management,
) )
from litellm.llms.anthropic.pass_through.convertible_blocks import reject_malformed_tool_result_text
from litellm.llms.anthropic.pass_through.safeguards import ( from litellm.llms.anthropic.pass_through.safeguards import (
SafeguardsEvaluator, SafeguardsEvaluator,
build_safeguards_evaluator, build_safeguards_evaluator,
@ -731,6 +732,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
proxy_litellm_metadata, user_api_key_auth = _extract_proxy_litellm_metadata(kwargs) proxy_litellm_metadata, user_api_key_auth = _extract_proxy_litellm_metadata(kwargs)
request_kwargs: Final = _REQUEST_KWARGS.validate_python(kwargs) request_kwargs: Final = _REQUEST_KWARGS.validate_python(kwargs)
reject_malformed_tool_result_text(messages, model, request_kwargs)
sub_call_logging_kwargs: Final = proxy_sub_call_logging_kwargs(request_kwargs) sub_call_logging_kwargs: Final = proxy_sub_call_logging_kwargs(request_kwargs)
sub_call_trace_id: Final = proxy_sub_call_trace_id(request_kwargs) sub_call_trace_id: Final = proxy_sub_call_trace_id(request_kwargs)
@ -853,6 +855,8 @@ class LiteLLMMessagesToCompletionTransformationHandler:
**kwargs, **kwargs,
) )
reject_malformed_tool_result_text(messages, model, _REQUEST_KWARGS.validate_python(kwargs))
# Run the context_management polyfill on the sync path too so that # Run the context_management polyfill on the sync path too so that
# ``litellm.messages.create()`` callers don't silently lose edits like # ``litellm.messages.create()`` callers don't silently lose edits like
# ``clear_tool_uses_20250919``. The dispatcher is async (so the # ``clear_tool_uses_20250919``. The dispatcher is async (so the

View file

@ -9,6 +9,7 @@ from pydantic import JsonValue, TypeAdapter
import litellm import litellm
from litellm.litellm_core_utils.hidden_params import get_hidden_params from litellm.litellm_core_utils.hidden_params import get_hidden_params
from litellm.llms.anthropic.pass_through.convertible_blocks import tool_result_text_part
from litellm.llms.anthropic.pass_through.utils import ( from litellm.llms.anthropic.pass_through.utils import (
is_reasoning_auto_summary_enabled, is_reasoning_auto_summary_enabled,
json_copy_or_original, json_copy_or_original,
@ -1334,7 +1335,8 @@ class LiteLLMAnthropicMessagesAdapter:
block: Final = cast(Mapping[str, object], item) # cast-ok: untrusted client payload block: Final = cast(Mapping[str, object], item) # cast-ok: untrusted client payload
match block.get("type"): match block.get("type"):
case "text": case "text":
return ChatCompletionTextObject(type="text", text=str(block.get("text") or "")) text: Final = tool_result_text_part(block)
return None if text is None else ChatCompletionTextObject(type="text", text=text)
case "image" | "document": case "image" | "document":
return self._tool_result_image_part(block.get("source")) return self._tool_result_image_part(block.get("source"))
case "tool_reference": case "tool_reference":

View file

@ -1,11 +1,18 @@
from collections.abc import Iterator, Mapping, Sequence import json
from itertools import chain from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from functools import partial
from itertools import chain, groupby
from operator import itemgetter
from types import MappingProxyType from types import MappingProxyType
from typing import Final from typing import Final, TypeAlias
from pydantic import JsonValue, TypeAdapter, ValidationError from pydantic import JsonValue, TypeAdapter, ValidationError
from typing_extensions import assert_never from typing_extensions import assert_never
import litellm
from litellm.litellm_core_utils.core_helpers import as_str_mapping, drops_unsupported_params
from litellm.litellm_core_utils.prompt_templates.factory import sanitize_anthropic_tool_use_id
from litellm.llms.anthropic.pass_through.messages.utils import normalize_native_messages from litellm.llms.anthropic.pass_through.messages.utils import normalize_native_messages
from litellm.types.utils import TokenCountMessagesFormat from litellm.types.utils import TokenCountMessagesFormat
@ -104,3 +111,245 @@ def unconvertible_blocks_error(
) )
case _: case _:
assert_never(messages_format) assert_never(messages_format)
_OBJECTS: Final = TypeAdapter(tuple[object, ...])
def _list_items(value: object) -> tuple[object, ...]:
return _OBJECTS.validate_python(value) if isinstance(value, list) else ()
@dataclass(frozen=True, slots=True)
class _KeptPart:
block: object
scanned_text: str
_KeptParts: TypeAlias = Mapping[int, _KeptPart]
_NO_KEPT_PARTS: Final[_KeptParts] = MappingProxyType({})
def _scannable_text(text: object) -> str:
return json.dumps(text, default=str, ensure_ascii=False)
def _non_str_text(block: object) -> _KeptPart | None:
match as_str_mapping(block):
case {"type": "text", "text": str()}:
return None
case {"type": "text", "text": text}:
return _KeptPart(block=block, scanned_text=_scannable_text(text))
case _:
return None
def tool_result_text_part(block: object) -> str | None:
match block:
case {"type": "text", "text": str(text)}:
return text
case {"type": "text", "text": object()}:
return None
case {"type": "text"}:
return ""
case _:
return None
def tool_result_text_parts(tool_result: Mapping[str, object]) -> tuple[str, ...]:
parts: Final = map(tool_result_text_part, _list_items(tool_result.get("content")))
return tuple(part for part in parts if part is not None)
def _user_content_blocks(message: object) -> tuple[object, ...]:
match as_str_mapping(message):
case {"role": "user", "content": content}:
return _list_items(content)
case _:
return ()
def _with_user_blocks(message: object, transform: Callable[[object], object]) -> object:
match as_str_mapping(message):
case {"role": "user", "content": list()} as user_message:
return {**user_message, "content": [transform(block) for block in _user_content_blocks(user_message)]}
case _:
return message
def _non_string_text_errors(label: str, content: object) -> Iterator[str]:
return (
f"{label} content.{index}.text: Input should be a valid string"
for index, text_block in enumerate(_list_items(content))
if _non_str_text(text_block) is not None
)
def _tool_result_text_errors(block: object) -> Iterator[str]:
match block:
case {"type": "tool_result", "tool_use_id": object() as tool_use_id, "content": object() as content}:
return _non_string_text_errors(f"tool_result {tool_use_id}", content)
case {"type": "tool_result", "content": object() as content}:
return _non_string_text_errors("tool_result", content)
case _:
return iter(())
def malformed_tool_result_text_error(messages: Iterable[object]) -> str | None:
blocks: Final = chain.from_iterable(map(_user_content_blocks, messages))
return next(chain.from_iterable(map(_tool_result_text_errors, blocks)), None)
def malformed_tool_result_count_error(
messages: Iterable[Mapping[str, object]] | None,
messages_format: TokenCountMessagesFormat,
deployment: Mapping[str, object] | None,
) -> str | None:
if messages_format != "anthropic" or deployment_drops_params(deployment):
return None
return malformed_tool_result_text_error(messages or ())
def _scannable_part(part: object) -> object:
match as_str_mapping(part):
case {"type": "text", "text": str()}:
return part
case {"type": "text", "text": text} as text_block:
return {**text_block, "text": _scannable_text(text)}
case _:
return part
def _scannable_block(block: object) -> object:
match as_str_mapping(block):
case {"type": "tool_result", "content": list()} as tool_result:
return {
**tool_result,
"content": [_scannable_part(part) for part in _list_items(tool_result.get("content"))],
}
case _:
return block
def _scannable_messages(messages: object) -> object:
items: Final = _list_items(messages)
return [_with_user_blocks(message, _scannable_block) for message in items] if items else messages
def scannable_tool_result_request(request: object) -> object:
match as_str_mapping(request):
case {"messages": messages} as request_fields:
return {**request_fields, "messages": _scannable_messages(messages)}
case None:
return request
case request_fields:
return {**request_fields}
def _non_string_text_parts(content: object) -> _KeptParts:
return MappingProxyType(
{index: part for index, block in enumerate(_list_items(content)) if (part := _non_str_text(block)) is not None}
)
def _tool_result_non_string_texts(block: object) -> tuple[tuple[str, _KeptParts], ...]:
match as_str_mapping(block):
case {"type": "tool_result", "tool_use_id": str(tool_use_id)} as tool_result:
return ((sanitize_anthropic_tool_use_id(tool_use_id), _non_string_text_parts(tool_result.get("content"))),)
case _:
return ()
def _content_blocks(content: object) -> tuple[object, ...]:
match content:
case str() if content:
return ({"type": "text", "text": content},)
case _:
return _list_items(content)
def _restored_part(block: object, kept: _KeptPart | None) -> object:
return kept.block if kept is not None and tool_result_text_part(block) == kept.scanned_text else block
def _with_kept_parts(block: object, tool_result: Mapping[str, object], parts: _KeptParts) -> object:
if not parts:
return block
blocks: Final = _content_blocks(tool_result.get("content"))
return {**tool_result, "content": [_restored_part(part, parts.get(index)) for index, part in enumerate(blocks)]}
def _restored_block(block: object, kept: Mapping[str, Iterator[_KeptParts]]) -> object:
match as_str_mapping(block):
case {"type": "tool_result", "tool_use_id": str(tool_use_id)} as tool_result if (
sanitize_anthropic_tool_use_id(tool_use_id) in kept
):
return _with_kept_parts(
block, tool_result, next(kept[sanitize_anthropic_tool_use_id(tool_use_id)], _NO_KEPT_PARTS)
)
case _:
return block
def keep_non_string_tool_result_texts(rewritten: Sequence[object], original: object) -> Sequence[object]:
blocks: Final = chain.from_iterable(map(_user_content_blocks, _list_items(original)))
by_id: Final = tuple(sorted(chain.from_iterable(map(_tool_result_non_string_texts, blocks)), key=itemgetter(0)))
if not any(parts for _, parts in by_id):
return rewritten
kept_in_order: Final = MappingProxyType(
{
tool_use_id: iter(tuple(map(itemgetter(1), group)))
for tool_use_id, group in groupby(by_id, key=itemgetter(0))
}
)
return [_with_user_blocks(message, partial(_restored_block, kept=kept_in_order)) for message in rewritten]
def _without_non_string_text(block: object) -> object:
match as_str_mapping(block):
case {"type": "tool_result", "content": list()} as tool_result:
parts: Final = _list_items(tool_result.get("content"))
return {**tool_result, "content": [part for part in parts if _non_str_text(part) is None]}
case _:
return block
def _without_non_string_texts(
message: dict[str, object], # mutable-ok: AnthropicMessagesRequest.messages is typed list[dict]
) -> dict[str, object]: # mutable-ok: AnthropicMessagesRequest.messages is typed list[dict]
match message:
case {"role": "user", "content": list()}:
return {**message, "content": [_without_non_string_text(block) for block in _user_content_blocks(message)]}
case _:
return message
def drop_non_string_tool_result_texts(
messages: Sequence[dict[str, object]], # mutable-ok: AnthropicMessagesRequest.messages is typed list[dict]
drop_params: object,
) -> Sequence[dict[str, object]]: # mutable-ok: AnthropicMessagesRequest.messages is typed list[dict]
if not drops_unsupported_params(drop_params) or malformed_tool_result_text_error(messages) is None:
return messages
return [_without_non_string_texts(message) for message in messages]
def deployment_drops_params(deployment: Mapping[str, object] | None) -> bool:
match deployment:
case {"litellm_params": {"drop_params": object() as drop_params}}:
return drops_unsupported_params(drop_params)
case _:
return drops_unsupported_params(None)
def reject_malformed_tool_result_text(
messages: Iterable[Mapping[str, object]], model: str, forwarded_kwargs: Mapping[str, object]
) -> None:
if drops_unsupported_params(forwarded_kwargs.get("drop_params")):
return
error: Final = malformed_tool_result_text_error(messages)
if error is None:
return
provider: Final = forwarded_kwargs.get("custom_llm_provider")
raise litellm.BadRequestError(
message=error, model=model, llm_provider=provider if isinstance(provider, str) else ""
)

View file

@ -8,6 +8,7 @@ from litellm._logging import verbose_logger
from litellm.anthropic_beta_headers_manager import get_unsupported_headers, is_beta_header_supported_by_bundled_config from litellm.anthropic_beta_headers_manager import get_unsupported_headers, is_beta_header_supported_by_bundled_config
from litellm.exceptions import AuthenticationError from litellm.exceptions import AuthenticationError
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.anthropic.pass_through.convertible_blocks import drop_non_string_tool_result_texts
from litellm.llms.anthropic.pass_through.safeguards import proxy_serves_safeguards from litellm.llms.anthropic.pass_through.safeguards import proxy_serves_safeguards
from litellm.llms.base_llm.anthropic_messages.transformation import ( from litellm.llms.base_llm.anthropic_messages.transformation import (
BaseAnthropicMessagesConfig, BaseAnthropicMessagesConfig,
@ -692,7 +693,9 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
messages = strip_advisor_blocks_from_messages(messages) messages = strip_advisor_blocks_from_messages(messages)
anthropic_messages_request: Final[AnthropicMessagesRequest] = AnthropicMessagesRequest( anthropic_messages_request: Final[AnthropicMessagesRequest] = AnthropicMessagesRequest(
messages=strip_encrypted_reasoning_blocks_from_anthropic_messages(messages), messages=strip_encrypted_reasoning_blocks_from_anthropic_messages(
drop_non_string_tool_result_texts(messages, getattr(litellm_params, "drop_params", None))
),
max_tokens=max_tokens, max_tokens=max_tokens,
model=model, model=model,
**anthropic_messages_optional_request_params, **anthropic_messages_optional_request_params,

View file

@ -14,7 +14,7 @@ from litellm.integrations.anthropic_cache_control_hook import (
AnthropicCacheControlHook, AnthropicCacheControlHook,
supports_openai_prompt_cache_breakpoint, supports_openai_prompt_cache_breakpoint,
) )
from litellm.litellm_core_utils.core_helpers import normalize_drop_params from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
from litellm.litellm_core_utils.prompt_templates.common_utils import cache_control_as_prompt_cache_breakpoint from litellm.litellm_core_utils.prompt_templates.common_utils import cache_control_as_prompt_cache_breakpoint
from litellm.responses.utils import auto_reasoning_summary from litellm.responses.utils import auto_reasoning_summary
from litellm.types.integrations.custom_logger import CONVERTED_STREAM_KEYS from litellm.types.integrations.custom_logger import CONVERTED_STREAM_KEYS
@ -32,6 +32,7 @@ from litellm.types.llms.openai import ResponsesAPIResponse
from litellm.utils import ProviderConfigManager from litellm.utils import ProviderConfigManager
from ..adapters.handler import request_safeguards_evaluator from ..adapters.handler import request_safeguards_evaluator
from ..convertible_blocks import reject_malformed_tool_result_text
from ..safeguards import with_safeguard_results from ..safeguards import with_safeguard_results
from ..utils import litellm_logging_obj_from_kwargs, local_model_name from ..utils import litellm_logging_obj_from_kwargs, local_model_name
from .streaming_iterator import AnthropicResponsesStreamWrapper from .streaming_iterator import AnthropicResponsesStreamWrapper
@ -96,7 +97,7 @@ def _prompt_cache_block_rewrite(model: str, forwarded_kwargs: Mapping[str, objec
return cache_control_as_prompt_cache_breakpoint return cache_control_as_prompt_cache_breakpoint
if _model_takes_prompt_cache_breakpoint(model, forwarded_kwargs): if _model_takes_prompt_cache_breakpoint(model, forwarded_kwargs):
return None return None
if normalize_drop_params(forwarded_kwargs.get("drop_params")) is True or litellm.drop_params is True: if drops_unsupported_params(forwarded_kwargs.get("drop_params")):
return _without_prompt_cache_breakpoint return _without_prompt_cache_breakpoint
return None return None
@ -142,6 +143,7 @@ def _build_responses_kwargs(
Build the kwargs dict to pass directly to litellm.responses() / litellm.aresponses(). Build the kwargs dict to pass directly to litellm.responses() / litellm.aresponses().
""" """
forwarded_kwargs: Final = _forwarded_kwargs(extra_kwargs) forwarded_kwargs: Final = _forwarded_kwargs(extra_kwargs)
reject_malformed_tool_result_text(messages, model, forwarded_kwargs)
block_rewrite: Final = _prompt_cache_block_rewrite(model, forwarded_kwargs) block_rewrite: Final = _prompt_cache_block_rewrite(model, forwarded_kwargs)
request_messages: Final[AnthropicRequestMessages] = ( request_messages: Final[AnthropicRequestMessages] = (
[ [

View file

@ -20,6 +20,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
from litellm.litellm_core_utils.reasoning_effort_utils import ( from litellm.litellm_core_utils.reasoning_effort_utils import (
reasoning_effort_from_thinking_budget, reasoning_effort_from_thinking_budget,
) )
from litellm.llms.anthropic.pass_through.convertible_blocks import tool_result_text_parts
from litellm.llms.anthropic.pass_through.messages.utils import ( from litellm.llms.anthropic.pass_through.messages.utils import (
refusal_stop_details, refusal_stop_details,
responses_output_refusal_text, responses_output_refusal_text,
@ -371,10 +372,9 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
elif btype == "tool_result": elif btype == "tool_result":
tool_use_id = block.get("tool_use_id", "") tool_use_id = block.get("tool_use_id", "")
inner = block.get("content") inner = block.get("content")
tool_result_block = cast(Mapping[str, object], block) # cast-ok: TypedDict upcast
tool_breakpoint, covered_image_parts, later_image_parts = ( tool_breakpoint, covered_image_parts, later_image_parts = (
self._tool_result_images_by_breakpoint( self._tool_result_images_by_breakpoint(tool_result_block)
cast(Mapping[str, object], block) # cast-ok: TypedDict upcast
)
) )
document_candidates = ( document_candidates = (
tuple( tuple(
@ -391,10 +391,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
elif isinstance(inner, str): elif isinstance(inner, str):
output_text = inner output_text = inner
elif isinstance(inner, list): elif isinstance(inner, list):
parts = [ output_text = "\n".join(tool_result_text_parts(tool_result_block))
c.get("text", "") for c in inner if isinstance(c, dict) and c.get("type") == "text"
]
output_text = "\n".join(parts)
if covered_image_parts or later_image_parts: if covered_image_parts or later_image_parts:
output_text = ( output_text = (
f"{output_text}\n{TOOL_RESULT_IMAGE_PLACEHOLDER}" f"{output_text}\n{TOOL_RESULT_IMAGE_PLACEHOLDER}"

View file

@ -508,6 +508,7 @@ class GoogleAIStudioTokenCounter(BaseTokenCounter):
count_context_error_response, count_context_error_response,
gemini_count_contents, gemini_count_contents,
inference_request_view, inference_request_view,
malformed_tool_result_text_count_error,
provider_error_response, provider_error_response,
unconvertible_messages_error, unconvertible_messages_error,
vertex_gemini_count_context, vertex_gemini_count_context,
@ -524,7 +525,9 @@ class GoogleAIStudioTokenCounter(BaseTokenCounter):
count_context: Final = vertex_gemini_count_context(system=request_view.system, tools=tools) count_context: Final = vertex_gemini_count_context(system=request_view.system, tools=tools)
if isinstance(count_context, VertexGeminiCountContextError): if isinstance(count_context, VertexGeminiCountContextError):
return count_context_error_response(count_context, request_model, model_to_use) return count_context_error_response(count_context, request_model, model_to_use)
blocks_error: Final = unconvertible_messages_error(request_view.messages, messages_format) blocks_error: Final = unconvertible_messages_error(
request_view.messages, messages_format
) or malformed_tool_result_text_count_error(messages, messages_format, deployment)
if blocks_error is not None: if blocks_error is not None:
return count_context_error_response(blocks_error, request_model, model_to_use) return count_context_error_response(blocks_error, request_model, model_to_use)

View file

@ -7,6 +7,7 @@ Docs - https://docs.mistral.ai/api/
""" """
from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping
from itertools import accumulate, count
from typing import TYPE_CHECKING, Final, Literal, cast, get_type_hints, overload from typing import TYPE_CHECKING, Final, Literal, cast, get_type_hints, overload
import httpx import httpx
@ -53,6 +54,9 @@ def _accepted_reasoning_effort(model: str, requested: str, custom_llm_provider:
return accepted return accepted
_NO_TOOL_CALL_TURN: Final[tuple[int, frozenset[str]]] = (-1, frozenset())
class MistralConfig(OpenAIGPTConfig): class MistralConfig(OpenAIGPTConfig):
""" """
Reference: https://docs.mistral.ai/api/ Reference: https://docs.mistral.ai/api/
@ -273,14 +277,30 @@ class MistralConfig(OpenAIGPTConfig):
messages = handle_messages_with_content_list_to_str_conversion(messages) messages = handle_messages_with_content_list_to_str_conversion(messages)
## 3. Handle name in message ## 3. Handle name in message
new_messages: Final[list[AllMessageValues]] = [] prepared_messages: Final = tuple(
for m in messages: MistralConfig._handle_tool_call_message(MistralConfig._handle_name_in_message(m)) for m in messages
m = MistralConfig._handle_name_in_message(m) )
m = MistralConfig._handle_tool_call_message(m) empty_flags: Final = tuple(map(MistralConfig._is_empty_assistant_message, prepared_messages))
if MistralConfig._is_empty_assistant_message(m): tool_call_turns: Final = accumulate(
continue zip(count(), prepared_messages, empty_flags, strict=False),
m = strip_none_values_from_message(m) # prevents 'extra_forbidden' error MistralConfig._tool_call_turn,
new_messages.append(m) initial=_NO_TOOL_CALL_TURN,
)
tool_reply_keys: Final = tuple(map(MistralConfig._tool_reply_key, prepared_messages, tool_call_turns))
first_reply_positions: Final = {
key: position for position, key in reversed(tuple(enumerate(tool_reply_keys))) if key is not None
}
keys_answered_with_content: Final = frozenset(
key for key, is_empty in zip(tool_reply_keys, empty_flags, strict=True) if not is_empty
)
answering_positions: Final = frozenset(
position for key, position in first_reply_positions.items() if key not in keys_answered_with_content
)
new_messages: Final[list[AllMessageValues]] = [
strip_none_values_from_message(MistralConfig._with_tool_reply_content(m))
for position, (m, is_empty) in enumerate(zip(prepared_messages, empty_flags, strict=True))
if not is_empty or position in answering_positions
]
if is_async: if is_async:
return super()._transform_messages(new_messages, model, True) return super()._transform_messages(new_messages, model, True)
@ -469,6 +489,44 @@ class MistralConfig(OpenAIGPTConfig):
message["tool_calls"] = mistral_tool_calls message["tool_calls"] = mistral_tool_calls
return message return message
@staticmethod
def _tool_call_id(tool_call: object) -> str | None:
match tool_call:
case {"id": str(tool_call_id)}:
return tool_call_id
case _:
return None
@staticmethod
def _tool_call_turn(
turn: tuple[int, frozenset[str]], message_entry: tuple[int, AllMessageValues, bool]
) -> tuple[int, frozenset[str]]:
match message_entry:
case (position, {"role": "assistant", "tool_calls": list(tool_calls)}, _):
return position, frozenset(filter(None, map(MistralConfig._tool_call_id, tool_calls)))
case (_, {"role": "tool"}, _) | (_, _, True):
return turn
case _:
return _NO_TOOL_CALL_TURN
@staticmethod
def _tool_reply_key(message: AllMessageValues, turn: tuple[int, frozenset[str]]) -> tuple[int, str] | None:
match message, turn:
case {"role": "tool", "tool_call_id": str(tool_call_id)}, (position, tool_call_ids) if (
tool_call_id in tool_call_ids
):
return position, tool_call_id
case _:
return None
@staticmethod
def _with_tool_reply_content(message: AllMessageValues) -> AllMessageValues:
match message:
case {"role": "tool"} if not message.get("content"):
return {**message, "content": ""}
case _:
return message
@classmethod @classmethod
def _is_empty_assistant_message(cls, message: AllMessageValues) -> bool: def _is_empty_assistant_message(cls, message: AllMessageValues) -> bool:
""" """

View file

@ -11,7 +11,7 @@ from typing_extensions import assert_never
from litellm._logging import verbose_logger from litellm._logging import verbose_logger
from litellm.litellm_core_utils.asyncify import asyncify from litellm.litellm_core_utils.asyncify import asyncify
from litellm.llms.anthropic.pass_through.convertible_blocks import unconvertible_blocks_error from litellm.llms.anthropic.pass_through.convertible_blocks import deployment_drops_params, unconvertible_blocks_error
from litellm.llms.anthropic.pass_through.responses_adapters.transformation import ( from litellm.llms.anthropic.pass_through.responses_adapters.transformation import (
LiteLLMAnthropicToResponsesAPIAdapter, LiteLLMAnthropicToResponsesAPIAdapter,
) )
@ -110,7 +110,9 @@ class OpenAITokenCounter(BaseTokenCounter):
return None return None
malformed_error: Final = ( malformed_error: Final = (
OpenAICountTokensConfig.malformed_anthropic_messages_error(messages) OpenAICountTokensConfig.malformed_anthropic_messages_error(
messages, drop_params=deployment_drops_params(deployment)
)
if messages_format == "anthropic" if messages_format == "anthropic"
else None else None
) )

View file

@ -10,6 +10,7 @@ from typing import Any, Final, Literal, TypeAlias
from pydantic import JsonValue, TypeAdapter, ValidationError from pydantic import JsonValue, TypeAdapter, ValidationError
from typing_extensions import ReadOnly, TypedDict from typing_extensions import ReadOnly, TypedDict
from litellm.llms.anthropic.pass_through.convertible_blocks import malformed_tool_result_text_error
from litellm.llms.anthropic.pass_through.messages.utils import normalize_native_messages from litellm.llms.anthropic.pass_through.messages.utils import normalize_native_messages
from litellm.llms.anthropic.pass_through.responses_adapters.transformation import ( from litellm.llms.anthropic.pass_through.responses_adapters.transformation import (
LiteLLMAnthropicToResponsesAPIAdapter, LiteLLMAnthropicToResponsesAPIAdapter,
@ -295,14 +296,16 @@ class OpenAICountTokensConfig:
return input_items, instructions return input_items, instructions
@staticmethod @staticmethod
def malformed_anthropic_messages_error(messages: Sequence[Mapping[str, object]]) -> str | None: def malformed_anthropic_messages_error(
messages: Sequence[Mapping[str, object]], *, drop_params: bool
) -> str | None:
try: try:
_ANTHROPIC_COUNT_MESSAGES.validate_python(messages) _ANTHROPIC_COUNT_MESSAGES.validate_python(messages)
except ValidationError as e: except ValidationError as e:
return "; ".join( return "; ".join(
f"messages.{'.'.join(str(part) for part in error['loc'])}: {error['msg']}" for error in e.errors() f"messages.{'.'.join(str(part) for part in error['loc'])}: {error['msg']}" for error in e.errors()
) )
return None return None if drop_params else malformed_tool_result_text_error(messages)
@staticmethod @staticmethod
def anthropic_messages_to_responses_input( def anthropic_messages_to_responses_input(

View file

@ -1375,6 +1375,7 @@ class VertexAITokenCounter(BaseTokenCounter):
count_context_error_response, count_context_error_response,
gemini_count_contents, gemini_count_contents,
inference_request_view, inference_request_view,
malformed_tool_result_text_count_error,
provider_error_response, provider_error_response,
unconvertible_messages_error, unconvertible_messages_error,
vertex_gemini_count_context, vertex_gemini_count_context,
@ -1392,7 +1393,9 @@ class VertexAITokenCounter(BaseTokenCounter):
count_context: Final = vertex_gemini_count_context(system=request_view.system, tools=tools) count_context: Final = vertex_gemini_count_context(system=request_view.system, tools=tools)
if isinstance(count_context, VertexGeminiCountContextError): if isinstance(count_context, VertexGeminiCountContextError):
return count_context_error_response(count_context, request_model, model_to_use) return count_context_error_response(count_context, request_model, model_to_use)
blocks_error: Final = unconvertible_messages_error(request_view.messages, messages_format) blocks_error: Final = unconvertible_messages_error(
request_view.messages, messages_format
) or malformed_tool_result_text_count_error(messages, messages_format, deployment)
if blocks_error is not None: if blocks_error is not None:
return count_context_error_response(blocks_error, request_model, model_to_use) return count_context_error_response(blocks_error, request_model, model_to_use)

View file

@ -132,6 +132,17 @@ def unconvertible_messages_error(
return None if error_message is None else VertexGeminiCountContextError(error_message=error_message) return None if error_message is None else VertexGeminiCountContextError(error_message=error_message)
def malformed_tool_result_text_count_error(
messages: Sequence[Mapping[str, object]] | None,
messages_format: TokenCountMessagesFormat,
deployment: Mapping[str, object] | None,
) -> VertexGeminiCountContextError | None:
from litellm.llms.anthropic.pass_through.convertible_blocks import malformed_tool_result_count_error
error_message: Final = malformed_tool_result_count_error(messages, messages_format, deployment)
return None if error_message is None else VertexGeminiCountContextError(error_message=error_message)
def count_context_error_response( def count_context_error_response(
error: VertexGeminiCountContextError, request_model: str, model_used: str error: VertexGeminiCountContextError, request_model: str, model_used: str
) -> TokenCountResponse: ) -> TokenCountResponse:

View file

@ -368,6 +368,11 @@ from litellm.litellm_core_utils.sensitive_data_masker import (
SensitiveDataMasker, SensitiveDataMasker,
mask_sensitive_keys, mask_sensitive_keys,
) )
from litellm.llms.anthropic.pass_through.convertible_blocks import (
deployment_drops_params,
drop_non_string_tool_result_texts,
malformed_tool_result_count_error,
)
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.llms.openai.live import parse_live_session_start from litellm.llms.openai.live import parse_live_session_start
from litellm.llms.openai_like.model_info import MODEL_INFO_REFRESH_SECONDS from litellm.llms.openai_like.model_info import MODEL_INFO_REFRESH_SECONDS
@ -14716,6 +14721,32 @@ def _get_provider_token_counter(
return None, None, None return None, None, None
_DEPLOYMENT_LITELLM_PARAMS: Final = TypeAdapter(Mapping[str, object])
def _deployment_with_request_drop_params(
deployment: dict[str, object] | None, # mutable-ok: BaseTokenCounter.count_tokens takes a dict deployment
drop_params: object,
) -> dict[str, object] | None: # mutable-ok: BaseTokenCounter.count_tokens takes a dict deployment
if drop_params is None:
return deployment
resolved_deployment: Final = deployment or {}
litellm_params: Final = _DEPLOYMENT_LITELLM_PARAMS.validate_python(resolved_deployment.get("litellm_params") or {})
return {**resolved_deployment, "litellm_params": {**litellm_params, "drop_params": drop_params}}
def _counted_request_messages(
messages: Sequence[dict[str, object]] | None, # mutable-ok: TokenCountRequest.messages is typed list[dict]
messages_format: TokenCountMessagesFormat,
deployment: Mapping[str, object] | None,
) -> list[dict[str, object]] | None: # mutable-ok: BaseTokenCounter.count_tokens takes list messages
if messages is None:
return None
if messages_format != "anthropic":
return list(messages)
return list(drop_non_string_tool_result_texts(messages, deployment_drops_params(deployment)))
async def _try_provider_token_count( async def _try_provider_token_count(
provider_counter: "BaseTokenCounter", provider_counter: "BaseTokenCounter",
custom_llm_provider: str | None, custom_llm_provider: str | None,
@ -15082,11 +15113,6 @@ _PROVIDERS_COUNTING_WITHOUT_MESSAGES: Final = frozenset(
) )
def _drops_params(deployment: Mapping[str, object] | None) -> bool:
litellm_params: Final = as_str_mapping(None if deployment is None else deployment.get("litellm_params"))
return litellm.drop_params is True or (litellm_params is not None and litellm_params.get("drop_params") is True)
_ESTIMABLE_GOOGLE_PART_KEYS: Final = frozenset({"text", "functionCall", "functionResponse", "thoughtSignature"}) _ESTIMABLE_GOOGLE_PART_KEYS: Final = frozenset({"text", "functionCall", "functionResponse", "thoughtSignature"})
@ -15095,7 +15121,7 @@ def _estimable_contents(contents: Sequence[Mapping[str, object]]) -> bool:
return all(set(as_str_mapping(part) or ()) <= _ESTIMABLE_GOOGLE_PART_KEYS for part in parts) return all(set(as_str_mapping(part) or ()) <= _ESTIMABLE_GOOGLE_PART_KEYS for part in parts)
def _router_default_litellm_params(router: Router) -> Mapping[str, object]: def _router_default_litellm_params(router: Router | None) -> Mapping[str, object]:
defaults: Final[Mapping[str, object] | None] = getattr(router, "default_litellm_params", None) defaults: Final[Mapping[str, object] | None] = getattr(router, "default_litellm_params", None)
if defaults is None: if defaults is None:
return {} return {}
@ -15216,8 +15242,10 @@ async def count_request_tokens(
if _model is not None: if _model is not None:
model_to_use = _model model_to_use = _model
sent_params: Final[Mapping[str, object]] = {**_router_default_litellm_params(llm_router), **request_litellm_params}
count_deployment: Final = _deployment_with_request_drop_params(deployment, sent_params.get("drop_params"))
conversation_empty: Final = not messages and not contents conversation_empty: Final = not messages and not contents
if prompt and not conversation_empty and not _drops_params(deployment): if prompt and not conversation_empty and not deployment_drops_params(count_deployment):
raise ProxyException( raise ProxyException(
message="prompt cannot be sent with messages or contents. Set drop_params to count them without the prompt", message="prompt cannot be sent with messages or contents. Set drop_params to count them without the prompt",
type="invalid_request_error", type="invalid_request_error",
@ -15225,6 +15253,7 @@ async def count_request_tokens(
code=400, code=400,
) )
prompt_only: Final = bool(prompt) and conversation_empty prompt_only: Final = bool(prompt) and conversation_empty
request_messages: Final = _counted_request_messages(messages, messages_format, count_deployment)
contents_estimable: Final = not contents or (bool(messages) and _estimable_contents(contents)) contents_estimable: Final = not contents or (bool(messages) and _estimable_contents(contents))
prompt_as_message: Final = prompt_only and custom_llm_provider in _PROVIDERS_COUNTING_WITHOUT_MESSAGES prompt_as_message: Final = prompt_only and custom_llm_provider in _PROVIDERS_COUNTING_WITHOUT_MESSAGES
ladder: Final = ( ladder: Final = (
@ -15235,9 +15264,9 @@ async def count_request_tokens(
_try_provider_token_count, _try_provider_token_count,
provider_counter, provider_counter,
custom_llm_provider, custom_llm_provider,
messages=[{"role": "user", "content": prompt}] if prompt_as_message else messages, messages=[{"role": "user", "content": prompt}] if prompt_as_message else request_messages,
contents=None if prompt_as_message else contents, contents=None if prompt_as_message else contents,
deployment=deployment, deployment=count_deployment,
request_model=request.model, request_model=request.model,
tools=tools, tools=tools,
system=system, system=system,
@ -15253,6 +15282,10 @@ async def count_request_tokens(
if isinstance(ladder, TokenCountResponse): if isinstance(ladder, TokenCountResponse):
return ladder return ladder
local_count_error: Final = malformed_tool_result_count_error(request_messages, messages_format, count_deployment)
if local_count_error is not None:
raise ProxyException(message=local_count_error, type="token_counting_error", param="messages", code=400)
# Check if token counter is disabled before fallback # Check if token counter is disabled before fallback
if litellm.disable_token_counter is True: if litellm.disable_token_counter is True:
raise ProxyException( raise ProxyException(
@ -15273,7 +15306,7 @@ async def count_request_tokens(
prompt_turn: Final = prompt_only and not (system_message is None and not tools) prompt_turn: Final = prompt_only and not (system_message is None and not tools)
typed_messages: Final = cast( # cast-ok: request messages are raw chat-shaped dicts that token_counter normalizes typed_messages: Final = cast( # cast-ok: request messages are raw chat-shaped dicts that token_counter normalizes
Sequence[AllMessageValues] | None, Sequence[AllMessageValues] | None,
[{"role": "user", "content": prompt}] if prompt_turn else (None if prompt_only else messages), [{"role": "user", "content": prompt}] if prompt_turn else (None if prompt_only else request_messages),
) )
counted_messages: Final = ( counted_messages: Final = (
typed_messages if typed_messages is None or system_message is None else (system_message, *typed_messages) typed_messages if typed_messages is None or system_message is None else (system_message, *typed_messages)

View file

@ -45,7 +45,7 @@ from litellm.constants import (
REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER, REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER,
) )
from litellm.integrations.anthropic_cache_control_hook import MAX_CACHE_CONTROL_BLOCKS, AnthropicCacheControlHook from litellm.integrations.anthropic_cache_control_hook import MAX_CACHE_CONTROL_BLOCKS, AnthropicCacheControlHook
from litellm.litellm_core_utils.core_helpers import normalize_drop_params from litellm.litellm_core_utils.core_helpers import drops_unsupported_params, normalize_drop_params
from litellm.litellm_core_utils.get_supported_openai_params import ( from litellm.litellm_core_utils.get_supported_openai_params import (
get_supported_openai_params, get_supported_openai_params,
) )
@ -326,10 +326,6 @@ def _dropped_cache_control(_: object) -> None:
return None return None
def drops_unsupported_params(drop_params: object) -> bool:
return litellm.drop_params is True or normalize_drop_params(drop_params) is True
def _without_cache_control_key(item: object) -> object: def _without_cache_control_key(item: object) -> object:
if not isinstance(item, dict): if not isinstance(item, dict):
return item return item

View file

@ -0,0 +1,868 @@
import json
import re
import uuid
from collections.abc import Callable, Mapping, Sequence
from email.parser import BytesParser
from email.policy import HTTP
from types import MappingProxyType
from typing import Final
import anthropic
import httpx
import pytest
from integration._support.client import Gateway, Scenario, eventually, string_value
from integration._support.vertex import service_account_json
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue, TypeAdapter
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_JSON_OBJECTS: Final = TypeAdapter(tuple[dict[str, JsonValue], ...])
_TOOL_USE_ID: Final = "toolu_01"
_WEATHER: Final = "sunny, 21C"
_FIELD_ERROR: Final = f"tool_result {_TOOL_USE_ID} content.0.text: Input should be a valid string"
_CLAUDE_CODE: Final = MappingProxyType({"user-agent": "claude-cli/2.1.0 (external, cli)"})
_OPENAI_COUNT: Final = 4242
_GOOGLE_COUNT: Final = 777
_ANTHROPIC_COUNT: Final = 58
_BEDROCK_COUNT: Final = 1345
_GEMINI: Final = "gemini-4-argon"
_VERTEX_PROJECT: Final = "scripted-project"
_CITY: Final = "Paris"
_REDACTED_CITY: Final = "[CITY]"
_TOOLS: Final[list[JsonValue]] = [
{
"name": "get_weather",
"description": "Get the weather for a city",
"input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
}
]
def _messages(*blocks: JsonValue) -> list[JsonValue]:
return [
{"role": "user", "content": "What is the weather in Paris?"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": _TOOL_USE_ID, "name": "get_weather", "input": {"city": "Paris"}}],
},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": _TOOL_USE_ID, "content": list(blocks)}]},
]
_NULL_TEXT: Final = _messages({"type": "text", "text": None})
_NUMBER_THEN_WEATHER: Final = _messages({"type": "text", "text": 5}, {"type": "text", "text": _WEATHER})
_WEATHER_ONLY: Final = _messages({"type": "text", "text": _WEATHER})
_WEATHER_THEN_MISSING: Final = _messages({"type": "text", "text": _WEATHER}, {"type": "text"})
def _body(model: str, messages: list[JsonValue], **extra: JsonValue) -> dict[str, JsonValue]:
return {"model": model, "messages": messages, "tools": _TOOLS, **extra}
def _responses_reply() -> bytes:
return json.dumps(
{
"id": f"resp_{uuid.uuid4().hex}",
"object": "response",
"created_at": 1789788253,
"status": "completed",
"model": "gpt-5.4-mini",
"output": [
{
"type": "message",
"id": f"msg_{uuid.uuid4().hex}",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "It is sunny.", "annotations": []}],
}
],
"usage": {"input_tokens": 41, "output_tokens": 5, "total_tokens": 46},
}
).encode()
def _chat_reply() -> bytes:
return json.dumps(
{
"id": f"chatcmpl-{uuid.uuid4().hex}",
"object": "chat.completion",
"created": 1,
"model": "mistral-small-latest",
"choices": [
{"index": 0, "message": {"role": "assistant", "content": "It is sunny."}, "finish_reason": "stop"}
],
"usage": {"prompt_tokens": 9, "completion_tokens": 4, "total_tokens": 13},
}
).encode()
def _anthropic_reply() -> bytes:
return json.dumps(
{
"id": f"msg_{uuid.uuid4().hex}",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-6",
"content": [{"type": "text", "text": "It is sunny."}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 9, "output_tokens": 4},
}
).encode()
def _openai_peer(request: Request) -> Reply:
if request.target.endswith("/responses/input_tokens"):
return Reply(body=json.dumps({"input_tokens": _OPENAI_COUNT}).encode())
if request.target.endswith("/responses"):
return Reply(body=_responses_reply())
return Reply(status=404, body=json.dumps({"error": request.target}).encode())
def _chat_peer(request: Request) -> Reply:
if request.target.endswith("/chat/completions"):
return Reply(body=_chat_reply())
return Reply(status=404, body=json.dumps({"error": request.target}).encode())
def _google_peer(request: Request) -> Reply:
if request.target.endswith(":countTokens"):
return Reply(body=json.dumps({"totalTokens": _GOOGLE_COUNT}).encode())
return Reply(status=404, body=json.dumps({"error": request.target}).encode())
def _anthropic_peer(request: Request) -> Reply:
if request.target.endswith("/v1/messages/count_tokens"):
return Reply(body=json.dumps({"input_tokens": _ANTHROPIC_COUNT}).encode())
if request.target.endswith("/v1/messages"):
return Reply(body=_anthropic_reply())
return Reply(status=404, body=json.dumps({"error": request.target}).encode())
def _bedrock_peer(request: Request) -> Reply:
if request.target.endswith("/count-tokens"):
return Reply(body=json.dumps({"inputTokens": _BEDROCK_COUNT}).encode())
return Reply(status=404, body=json.dumps({"error": request.target}).encode())
def _openai_model(scenario: Scenario, wire: Wire, **parameters: JsonValue) -> str:
return scenario.model(model="openai/gpt-5.4-mini", api_base=wire.url, api_key="synthetic-openai-key", **parameters)
def _mistral_model(scenario: Scenario, wire: Wire, **parameters: JsonValue) -> str:
return scenario.model(
model="mistral/mistral-small-latest", api_base=wire.url, api_key="synthetic-mistral-key", **parameters
)
def _anthropic_model(scenario: Scenario, wire: Wire, **parameters: JsonValue) -> str:
return scenario.model(
model="anthropic/claude-sonnet-4-6", api_base=wire.url, api_key="synthetic-anthropic-key", **parameters
)
def _google_model(gateway: Gateway, scenario: Scenario, wire: Wire, provider: str, **parameters: JsonValue) -> str:
if provider == "gemini":
return scenario.model(model=f"gemini/{_GEMINI}", api_base=wire.url, api_key="scripted-gemini-key", **parameters)
return scenario.model(
model=f"vertex_ai/{_GEMINI}",
api_base=wire.url,
api_key=None,
vertex_project=_VERTEX_PROJECT,
vertex_location="us-east5",
vertex_credentials=service_account_json(_VERTEX_PROJECT, gateway.upstream_url.rstrip("/")),
**parameters,
)
def _json(request: Request) -> dict[str, JsonValue]:
return _JSON_OBJECT.validate_json(request.body)
def _only(requests: Sequence[Request], suffix: str) -> dict[str, JsonValue]:
matching: Final = tuple(request for request in requests if request.target.endswith(suffix))
assert len(matching) == 1, [request.target for request in requests]
return _json(matching[0])
def _function_call_output(body: Mapping[str, JsonValue]) -> JsonValue:
outputs: Final = tuple(
item["output"]
for item in _JSON_OBJECTS.validate_python(body["input"])
if item.get("type") == "function_call_output"
)
assert len(outputs) == 1, body
return outputs[0]
def _tool_message_text(body: Mapping[str, JsonValue]) -> str:
contents: Final = tuple(
message.get("content")
for message in _JSON_OBJECTS.validate_python(body["messages"])
if message.get("role") == "tool"
)
assert len(contents) == 1, body
content: Final = contents[0]
if isinstance(content, str):
return content
return "".join(str(part["text"]) for part in _JSON_OBJECTS.validate_python(content))
def _anthropic_error_message(status: int, text: str, payload: bytes) -> str:
assert status == 400, text
error: Final = _JSON_OBJECT.validate_json(payload)
assert error["type"] == "error", text
detail: Final = _JSON_OBJECT.validate_python(error["error"])
assert detail["type"] == "invalid_request_error", text
return str(detail["message"])
def _assert_field_rejection(gateway: Gateway, path: str, body: Mapping[str, JsonValue], **headers: str) -> None:
response: Final = gateway.request("POST", path, body, headers=headers)
assert _FIELD_ERROR in _anthropic_error_message(response.status_code, response.text, response.content), (
response.text
)
def _sdk(gateway: Gateway) -> anthropic.Anthropic:
return anthropic.Anthropic(
api_key=gateway.key, base_url=str(gateway.client.base_url), max_retries=0, http_client=gateway.client
)
def _assert_sdk_bad_request(raised: pytest.ExceptionInfo[anthropic.BadRequestError]) -> None:
error: Final = raised.value
assert error.status_code == 400, error
assert _FIELD_ERROR in _anthropic_error_message(error.status_code, error.message, error.response.content), error
def test_openai_rejects_a_non_string_tool_result_text_on_both_routes(gateway: Gateway) -> None:
client: Final = _sdk(gateway)
with wire_server(_openai_peer) as wire, gateway.scenario() as scenario:
model: Final = _openai_model(scenario, wire)
with pytest.raises(anthropic.BadRequestError) as created:
client.messages.create(model=model, max_tokens=64, messages=_NULL_TEXT, tools=_TOOLS)
_assert_sdk_bad_request(created)
with pytest.raises(anthropic.BadRequestError) as streamed:
client.messages.create(model=model, max_tokens=64, messages=_NULL_TEXT, tools=_TOOLS, stream=True)
_assert_sdk_bad_request(streamed)
with pytest.raises(anthropic.BadRequestError) as counted:
client.messages.count_tokens(model=model, messages=_NULL_TEXT, tools=_TOOLS)
_assert_sdk_bad_request(counted)
assert wire.drain() == ()
_DROP_SOURCES: Final = MappingProxyType(
{
"deployment": (MappingProxyType({"drop_params": True}), MappingProxyType({}), MappingProxyType({})),
"body": (MappingProxyType({}), MappingProxyType({"drop_params": True}), MappingProxyType({})),
"claude_code": (MappingProxyType({}), MappingProxyType({}), _CLAUDE_CODE),
}
)
@pytest.mark.parametrize("source", sorted(_DROP_SOURCES))
def test_openai_drop_params_drops_the_malformed_text(gateway: Gateway, source: str) -> None:
deployment, extra, headers = _DROP_SOURCES[source]
with wire_server(_openai_peer) as wire, gateway.scenario() as scenario:
model: Final = _openai_model(scenario, wire, **deployment)
body: Final = _body(model, _NUMBER_THEN_WEATHER, **extra)
created: Final = gateway.request("POST", "/v1/messages", {**body, "max_tokens": 64}, headers=headers)
counted: Final = gateway.request("POST", "/v1/messages/count_tokens", body, headers=headers)
assert created.status_code == 200, created.text
assert counted.status_code == 200, counted.text
assert _JSON_OBJECT.validate_json(counted.content)["input_tokens"] == _OPENAI_COUNT, counted.text
requests: Final = wire.drain()
assert _function_call_output(_only(requests, "/responses")) == _WEATHER
assert _function_call_output(_only(requests, "/responses/input_tokens")) == _WEATHER
def test_explicit_drop_params_false_wins_on_both_routes(gateway: Gateway) -> None:
with wire_server(_openai_peer) as wire, gateway.scenario() as scenario:
model: Final = _openai_model(scenario, wire, drop_params=True)
body: Final = _body(model, _NULL_TEXT, drop_params=False)
_assert_field_rejection(gateway, "/v1/messages", {**body, "max_tokens": 64}, **_CLAUDE_CODE)
_assert_field_rejection(gateway, "/v1/messages/count_tokens", body, **_CLAUDE_CODE)
assert wire.drain() == ()
def test_chat_bridge_and_local_count_reject_a_non_string_tool_result_text(gateway: Gateway) -> None:
with wire_server(_chat_peer) as wire, gateway.scenario() as scenario:
model: Final = _mistral_model(scenario, wire)
body: Final = _body(model, _NULL_TEXT)
_assert_field_rejection(gateway, "/v1/messages", {**body, "max_tokens": 64})
_assert_field_rejection(gateway, "/v1/messages", {**body, "max_tokens": 64, "stream": True})
_assert_field_rejection(gateway, "/v1/messages/count_tokens", body)
assert wire.drain() == ()
@pytest.mark.parametrize("source", ("deployment", "body"))
def test_chat_bridge_drop_params_drops_the_malformed_text(gateway: Gateway, source: str) -> None:
deployment, extra, _ = _DROP_SOURCES[source]
with wire_server(_chat_peer) as wire, gateway.scenario() as scenario:
model: Final = _mistral_model(scenario, wire, **deployment)
body: Final = _body(model, _NUMBER_THEN_WEATHER, **extra)
created: Final = gateway.request("POST", "/v1/messages", {**body, "max_tokens": 64})
counted: Final = gateway.request("POST", "/v1/messages/count_tokens", body)
assert created.status_code == 200, created.text
assert counted.status_code == 200, counted.text
assert _tool_message_text(_only(wire.drain(), "/chat/completions")) == _WEATHER
def test_count_without_a_deployment_rejects_and_honors_body_drop_params(gateway: Gateway) -> None:
model: Final = f"integration-unrouted-{uuid.uuid4().hex}"
_assert_field_rejection(gateway, "/v1/messages/count_tokens", _body(model, _NULL_TEXT))
dropped: Final = gateway.request("POST", "/v1/messages/count_tokens", _body(model, _NULL_TEXT, drop_params=True))
assert dropped.status_code == 200, dropped.text
tokens: Final = _JSON_OBJECT.validate_json(dropped.content)["input_tokens"]
assert isinstance(tokens, int) and tokens > 0, dropped.text
def _counted_contents(body: Mapping[str, JsonValue]) -> JsonValue:
if "contents" in body:
return body["contents"]
return _JSON_OBJECT.validate_python(body["generateContentRequest"])["contents"]
@pytest.mark.parametrize("provider", ("gemini", "vertex_ai"))
def test_google_count_rejects_and_drops_a_non_string_tool_result_text(gateway: Gateway, provider: str) -> None:
with wire_server(_google_peer) as wire, gateway.scenario() as scenario:
strict: Final = _google_model(gateway, scenario, wire, provider)
dropping: Final = _google_model(gateway, scenario, wire, provider, drop_params=True)
_assert_field_rejection(gateway, "/v1/messages/count_tokens", _body(strict, _NULL_TEXT))
assert tuple(request for request in wire.drain() if request.target.endswith(":countTokens")) == ()
dropped: Final = gateway.request("POST", "/v1/messages/count_tokens", _body(dropping, _NUMBER_THEN_WEATHER))
assert dropped.status_code == 200, dropped.text
dropped_body: Final = _only(wire.drain(), ":countTokens")
clean: Final = gateway.request("POST", "/v1/messages/count_tokens", _body(dropping, _WEATHER_ONLY))
assert clean.status_code == 200, clean.text
assert _JSON_OBJECT.validate_json(dropped.content)["input_tokens"] == _GOOGLE_COUNT, dropped.text
assert _counted_contents(dropped_body) == _counted_contents(_only(wire.drain(), ":countTokens"))
def _forwarded_tool_result(body: Mapping[str, JsonValue]) -> JsonValue:
last: Final = _JSON_OBJECTS.validate_python(body["messages"])[-1]
return _JSON_OBJECTS.validate_python(last["content"])[0]["content"]
@pytest.mark.parametrize(
("headers", "forwarded"),
((MappingProxyType({}), [{"type": "text", "text": None}]), (_CLAUDE_CODE, [])),
ids=("no-drop-params", "claude-code-drop-params"),
)
def test_native_anthropic_forwards_a_null_tool_result_text_unless_drop_params_drops_it(
gateway: Gateway, headers: Mapping[str, str], forwarded: list[JsonValue]
) -> None:
with wire_server(_anthropic_peer) as wire, gateway.scenario() as scenario:
model: Final = _anthropic_model(scenario, wire)
body: Final = _body(model, _NULL_TEXT)
created: Final = gateway.request("POST", "/v1/messages", {**body, "max_tokens": 64}, headers=headers)
counted: Final = gateway.request("POST", "/v1/messages/count_tokens", body, headers=headers)
assert created.status_code == 200, created.text
assert counted.status_code == 200, counted.text
assert _JSON_OBJECT.validate_json(counted.content)["input_tokens"] == _ANTHROPIC_COUNT, counted.text
requests: Final = wire.drain()
assert _forwarded_tool_result(_only(requests, "/v1/messages")) == forwarded
assert _forwarded_tool_result(_only(requests, "/v1/messages/count_tokens")) == forwarded
def test_bedrock_count_with_a_claude_code_user_agent_still_counts(gateway: Gateway) -> None:
with wire_server(_bedrock_peer) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(
model="bedrock/anthropic.claude-sonnet-4-6",
api_base=wire.url,
api_key=None,
aws_access_key_id="AKIAINTEGRATIONTOOLRESULT",
aws_secret_access_key="integration-tool-result-secret",
aws_region_name="us-east-1",
)
counted: Final = gateway.request(
"POST", "/v1/messages/count_tokens", _body(model, _WEATHER_ONLY), headers=_CLAUDE_CODE
)
assert counted.status_code == 200, counted.text
assert _JSON_OBJECT.validate_json(counted.content)["input_tokens"] == _BEDROCK_COUNT, counted.text
assert len(tuple(request for request in wire.drain() if request.target.endswith("/count-tokens"))) == 1
@pytest.mark.parametrize("provider", ("openai", "mistral"))
def test_valid_tool_result_text_reaches_the_upstream(gateway: Gateway, provider: str) -> None:
peer: Final = _openai_peer if provider == "openai" else _chat_peer
with wire_server(peer) as wire, gateway.scenario() as scenario:
model: Final = (_openai_model if provider == "openai" else _mistral_model)(scenario, wire)
body: Final = _body(model, _WEATHER_THEN_MISSING)
created: Final = gateway.request("POST", "/v1/messages", {**body, "max_tokens": 64})
counted: Final = gateway.request("POST", "/v1/messages/count_tokens", body)
assert created.status_code == 200, created.text
assert counted.status_code == 200, counted.text
requests: Final = wire.drain()
if provider == "openai":
assert _function_call_output(_only(requests, "/responses")) == f"{_WEATHER}\n"
assert _function_call_output(_only(requests, "/responses/input_tokens")) == f"{_WEATHER}\n"
else:
assert _tool_message_text(_only(requests, "/chat/completions")) == _WEATHER
def _rewritten(content: JsonValue, rewrite: Callable[[str], str]) -> JsonValue:
if isinstance(content, str):
return rewrite(content)
if isinstance(content, list):
return [_rewritten(part, rewrite) for part in content]
if isinstance(content, dict) and isinstance(text := content.get("text"), str):
return {**content, "text": rewrite(text)}
return content
def _redacted(content: JsonValue) -> JsonValue:
return _rewritten(content, lambda text: text.replace(_CITY, _REDACTED_CITY))
def _redacted_row(row: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
return {**row, "content": _redacted(row["content"])} if "content" in row else dict(row)
def _digits_masked_row(row: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
if "content" not in row:
return dict(row)
return {**row, "content": _rewritten(row["content"], lambda text: re.sub(r"\d", "#", text))}
def _joined_tool_row(row: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
redacted: Final = _redacted_row(row)
content: Final = redacted.get("content")
if row.get("role") != "tool" or not isinstance(content, list):
return redacted
texts: Final = (
part["text"] for part in _JSON_OBJECTS.validate_python(content) if isinstance(part.get("text"), str)
)
return {**redacted, "content": " | ".join(str(text) for text in texts)}
_ROW_REWRITES: Final = MappingProxyType({"join": _joined_tool_row, "digits": _digits_masked_row})
def _guardrail_peer(request: Request) -> Reply:
rows: Final = _JSON_OBJECTS.validate_python(_json(request).get("structured_messages") or [])
if request.target.startswith("/none/") or not rows:
return Reply(body=json.dumps({"action": "NONE"}).encode())
rewrite: Final = _ROW_REWRITES.get(request.target.split("/")[1], _redacted_row)
verdict: Final = {"action": "GUARDRAIL_INTERVENED", "structured_messages": [rewrite(row) for row in rows]}
return Reply(body=json.dumps(verdict).encode())
def _delete_guardrail(gateway: Gateway, identity: str) -> None:
deleted: Final = gateway.request("DELETE", f"/guardrails/{identity}")
assert deleted.status_code == 200, deleted.text
def _guardrail(gateway: Gateway, scenario: Scenario, wire: Wire, mode: str) -> str:
name: Final = f"tool-result-{mode}-{uuid.uuid4().hex}"
params: Final[dict[str, JsonValue]] = {
"guardrail": "generic_guardrail_api",
"mode": "pre_call",
"default_on": False,
"api_base": f"{wire.url}/{mode}",
"api_key": "synthetic-guardrail-key",
}
created: Final = gateway.post("/guardrails", {"guardrail": {"guardrail_name": name, "litellm_params": params}})
scenario.cleanups.callback(_delete_guardrail, gateway, string_value(created["guardrail_id"]))
return name
def _first_turn(messages: JsonValue) -> str:
return json.dumps(_JSON_OBJECTS.validate_python(messages)[0])
def test_rewriting_guardrail_leaves_the_rejection_to_the_openai_route(gateway: Gateway) -> None:
with (
wire_server(_openai_peer) as provider,
wire_server(_guardrail_peer) as guard,
gateway.scenario() as scenario,
):
model: Final = _openai_model(scenario, provider)
body: Final = _body(model, _NULL_TEXT, max_tokens=64, guardrails=[_guardrail(gateway, scenario, guard, "rows")])
_assert_field_rejection(gateway, "/v1/messages", body)
_assert_field_rejection(gateway, "/v1/messages", {**body, "stream": True})
assert provider.drain() == ()
@pytest.mark.parametrize("source", sorted(_DROP_SOURCES))
def test_rewriting_guardrail_then_drop_params_drops_the_malformed_text(gateway: Gateway, source: str) -> None:
deployment, extra, headers = _DROP_SOURCES[source]
with (
wire_server(_openai_peer) as provider,
wire_server(_guardrail_peer) as guard,
gateway.scenario() as scenario,
):
model: Final = _openai_model(scenario, provider, **deployment)
guardrail: Final = _guardrail(gateway, scenario, guard, "rows")
body: Final = _body(model, _NUMBER_THEN_WEATHER, max_tokens=64, guardrails=[guardrail], **extra)
created: Final = gateway.request("POST", "/v1/messages", body, headers=headers)
assert created.status_code == 200, created.text
forwarded: Final = _only(provider.drain(), "/responses")
first_turn: Final = _first_turn(forwarded["input"])
assert _REDACTED_CITY in first_turn and _CITY not in first_turn, first_turn
assert _function_call_output(forwarded) == _WEATHER, forwarded
@pytest.mark.parametrize(
("deployment", "forwarded"),
(
(MappingProxyType({}), [{"type": "text", "text": 5}, {"type": "text", "text": _WEATHER}]),
(MappingProxyType({"drop_params": True}), [{"type": "text", "text": _WEATHER}]),
),
ids=("no-drop-params", "deployment-drop-params"),
)
def test_rewriting_guardrail_forwards_the_malformed_text_to_anthropic_unless_drop_params_drops_it(
gateway: Gateway, deployment: Mapping[str, JsonValue], forwarded: list[JsonValue]
) -> None:
with (
wire_server(_anthropic_peer) as provider,
wire_server(_guardrail_peer) as guard,
gateway.scenario() as scenario,
):
model: Final = _anthropic_model(scenario, provider, **deployment)
guardrail: Final = _guardrail(gateway, scenario, guard, "rows")
body: Final = _body(model, _NUMBER_THEN_WEATHER, max_tokens=64, guardrails=[guardrail])
created: Final = gateway.request("POST", "/v1/messages", body)
assert created.status_code == 200, created.text
forwarded_body: Final = _only(provider.drain(), "/v1/messages")
first_turn: Final = _first_turn(forwarded_body["messages"])
assert _REDACTED_CITY in first_turn and _CITY not in first_turn, first_turn
assert _forwarded_tool_result(forwarded_body) == forwarded
_MORNING: Final = f"{_CITY} morning: sunny"
_EVENING: Final = f"{_CITY} evening: 18C"
_TOMORROW: Final = f"{_CITY} tomorrow: rain"
_WEEKEND: Final = f"{_CITY} weekend: clear"
def _text(value: JsonValue) -> dict[str, JsonValue]:
return {"type": "text", "text": value}
def _tool_use(tool_use_id: str) -> dict[str, JsonValue]:
return {"type": "tool_use", "id": tool_use_id, "name": "get_weather", "input": {"city": _CITY}}
def _tool_result(tool_use_id: str, *parts: JsonValue) -> dict[str, JsonValue]:
return {"type": "tool_result", "tool_use_id": tool_use_id, "content": [_text(part) for part in parts]}
_SEVERAL_TOOL_RESULTS: Final[list[JsonValue]] = [
{"role": "user", "content": f"What is the weather in {_CITY}?"},
{"role": "assistant", "content": [_tool_use("toolu_01"), _tool_use("toolu_02"), _tool_use("toolu_03")]},
{
"role": "user",
"content": [
_tool_result("toolu_01", None, _MORNING, 5, _EVENING),
_tool_result("toolu_02", _TOMORROW, 7.5),
_tool_result("toolu_03", _WEEKEND),
],
},
]
def _forwarded_tool_results(body: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
last: Final = _JSON_OBJECTS.validate_python(body["messages"])[-1]
return {
string_value(block["tool_use_id"]): block["content"]
for block in _JSON_OBJECTS.validate_python(last["content"])
if block.get("type") == "tool_result"
}
def _texts(content: JsonValue) -> tuple[JsonValue, ...]:
if isinstance(content, str):
return (content,)
return tuple(part.get("text") for part in _JSON_OBJECTS.validate_python(content))
_RESTORED_TOOL_RESULTS: Final = MappingProxyType(
{
"rows": (
[_text(None), _text(_redacted(_MORNING)), _text(5), _text(_redacted(_EVENING))],
[_text(_redacted(_TOMORROW)), _text(7.5)],
),
"join": (
[_text(f"null | {_redacted(_MORNING)} | 5 | {_redacted(_EVENING)}")],
[_text(f"{_redacted(_TOMORROW)} | 7.5")],
),
}
)
@pytest.mark.parametrize("mode", sorted(_RESTORED_TOOL_RESULTS))
def test_rewriting_guardrail_puts_back_each_malformed_text_it_left_unchanged(gateway: Gateway, mode: str) -> None:
with (
wire_server(_anthropic_peer) as provider,
wire_server(_guardrail_peer) as guard,
gateway.scenario() as scenario,
):
model: Final = _anthropic_model(scenario, provider)
guardrail: Final = _guardrail(gateway, scenario, guard, mode)
body: Final = _body(model, _SEVERAL_TOOL_RESULTS, max_tokens=64, guardrails=[guardrail])
created: Final = gateway.request("POST", "/v1/messages", body)
assert created.status_code == 200, created.text
forwarded: Final = _only(provider.drain(), "/v1/messages")
first_turn: Final = _first_turn(forwarded["messages"])
assert _REDACTED_CITY in first_turn and _CITY not in first_turn, first_turn
tool_results: Final = _forwarded_tool_results(forwarded)
assert _CITY not in json.dumps(tool_results), tool_results
assert (tool_results["toolu_01"], tool_results["toolu_02"]) == _RESTORED_TOOL_RESULTS[mode], tool_results
assert _texts(tool_results["toolu_03"]) == (_redacted(_WEEKEND),), tool_results
def _null_text_answering(tool_use_id: str) -> list[JsonValue]:
return [
{"role": "user", "content": f"What is the weather in {_CITY}?"},
{"role": "assistant", "content": [_tool_use(tool_use_id)]},
{"role": "user", "content": [_tool_result(tool_use_id, None)]},
]
@pytest.mark.parametrize("tool_use_id", ("toolu_01", "functions.get_weather:0"))
def test_rewriting_guardrail_leaves_the_rejection_to_the_chat_bridge_for_any_tool_use_id(
gateway: Gateway, tool_use_id: str
) -> None:
with (
wire_server(_chat_peer) as provider,
wire_server(_guardrail_peer) as guard,
gateway.scenario() as scenario,
):
model: Final = _mistral_model(scenario, provider)
guardrail: Final = _guardrail(gateway, scenario, guard, "rows")
body: Final = _body(model, _null_text_answering(tool_use_id), max_tokens=64, guardrails=[guardrail])
response: Final = gateway.request("POST", "/v1/messages", body)
message: Final = _anthropic_error_message(response.status_code, response.text, response.content)
assert "content.0.text: Input should be a valid string" in message, message
assert provider.drain() == ()
def _files_peer(request: Request) -> Reply:
if request.method == "POST" and request.target.endswith("/files"):
uploaded: Final = {
"id": f"file-{uuid.uuid4().hex}",
"object": "file",
"bytes": len(request.body),
"created_at": 1789788253,
"filename": "batch.jsonl",
"purpose": "batch",
"status": "processed",
}
return Reply(body=json.dumps(uploaded).encode())
if request.target.endswith("/models"):
return Reply(body=json.dumps({"object": "list", "data": []}).encode())
return Reply(status=404, body=json.dumps({"error": request.target}).encode())
def _uploaded_records(request: Request) -> tuple[dict[str, JsonValue], ...]:
envelope: Final = f"content-type: {request.headers['content-type']}\r\n\r\n".encode() + request.body
parts: Final = BytesParser(policy=HTTP).parsebytes(envelope).iter_parts()
files: Final = tuple(part.get_payload(decode=True) for part in parts if part.get_filename() is not None)
assert len(files) == 1, request.headers
payload: Final = files[0]
assert isinstance(payload, bytes), payload
return tuple(_JSON_OBJECT.validate_json(line) for line in payload.splitlines() if line.strip())
def _listed_models(gateway: Gateway) -> frozenset[str]:
with httpx.Client(base_url=gateway.client.base_url, timeout=15, trust_env=False) as client:
listed: Final = client.get("/v1/models", headers={"Authorization": f"Bearer {gateway.key}"})
assert listed.status_code == 200, listed.text
entries: Final = _JSON_OBJECTS.validate_python(_JSON_OBJECT.validate_json(listed.content)["data"])
return frozenset(string_value(entry["id"]) for entry in entries)
def _uploaded_batch_body(
gateway: Gateway, scenario: Scenario, guard: Wire, mode: str, messages: list[JsonValue]
) -> dict[str, JsonValue]:
with wire_server(_files_peer) as provider:
model: Final = _openai_model(scenario, provider)
key: Final = scenario.key(metadata={"guardrails": [_guardrail(gateway, scenario, guard, mode)]})
eventually(
lambda: tuple(_listed_models(gateway) for _ in range(8)),
lambda listings: all(model in listing for listing in listings),
seconds=90,
)
record: Final = {
"custom_id": f"weather-{uuid.uuid4().hex}",
"method": "POST",
"url": "/v1/messages",
"body": _body(model, messages, max_tokens=64),
}
uploaded: Final = gateway.request_multipart(
"/v1/files",
{"purpose": "batch", "model": model},
{"file": ("batch.jsonl", json.dumps(record).encode() + b"\n", "application/jsonl")},
key=key,
)
assert uploaded.status_code == 200, uploaded.text
uploads: Final = tuple(request for request in provider.drain() if request.target.endswith("/files"))
assert len(uploads) == 1, [request.target for request in uploads]
(forwarded,) = _uploaded_records(uploads[0])
assert forwarded["custom_id"] == record["custom_id"], forwarded
return _JSON_OBJECT.validate_python(forwarded["body"])
def test_batch_upload_behind_a_rewriting_guardrail_keeps_the_malformed_text(gateway: Gateway) -> None:
with wire_server(_guardrail_peer) as guard, gateway.scenario() as scenario:
forwarded_body: Final = _uploaded_batch_body(gateway, scenario, guard, "rows", _NUMBER_THEN_WEATHER)
first_turn: Final = _first_turn(forwarded_body["messages"])
assert _REDACTED_CITY in first_turn and _CITY not in first_turn, first_turn
assert _forwarded_tool_result(forwarded_body) == [_text(5), _text(_WEATHER)], forwarded_body
def test_guardrail_that_rewrites_nothing_forwards_a_null_tool_result_text_unchanged(gateway: Gateway) -> None:
with (
wire_server(_anthropic_peer) as provider,
wire_server(_guardrail_peer) as guard,
gateway.scenario() as scenario,
):
model: Final = _anthropic_model(scenario, provider)
guardrail: Final = _guardrail(gateway, scenario, guard, "none")
created: Final = gateway.request(
"POST", "/v1/messages", _body(model, _NULL_TEXT, max_tokens=64, guardrails=[guardrail])
)
assert created.status_code == 200, created.text
assert len(guard.drain()) == 1
assert _forwarded_tool_result(_only(provider.drain(), "/v1/messages")) == [{"type": "text", "text": None}]
def _forwarded_chat_turns(wire: Wire) -> tuple[dict[str, JsonValue], ...]:
return _JSON_OBJECTS.validate_python(_only(wire.drain(), "/chat/completions")["messages"])
def test_mistral_receives_the_tool_reply_whose_only_text_drop_params_dropped(gateway: Gateway) -> None:
with wire_server(_chat_peer) as wire, gateway.scenario() as scenario:
model: Final = _mistral_model(scenario, wire)
created: Final = gateway.request(
"POST", "/v1/messages", _body(model, _NULL_TEXT, max_tokens=64, drop_params=True)
)
assert created.status_code == 200, created.text
turns: Final = _forwarded_chat_turns(wire)
assert [turn["role"] for turn in turns] == ["user", "assistant", "tool"], turns
calls: Final = _JSON_OBJECTS.validate_python(turns[1]["tool_calls"])
assert [call["id"] for call in calls] == [turns[2]["tool_call_id"]], turns
assert _tool_message_text({"messages": list(turns)}) == "", turns
_WEATHER_CALL: Final[dict[str, JsonValue]] = {
"id": "call_weather",
"type": "function",
"function": {"name": "get_weather", "arguments": json.dumps({"city": _CITY})},
}
def test_mistral_chat_completions_keeps_an_empty_tool_reply(gateway: Gateway) -> None:
ask: Final = f"What is the weather in {_CITY}? {uuid.uuid4().hex}"
messages: Final[list[JsonValue]] = [
{"role": "system", "content": ""},
{"role": "user", "content": ask},
{"role": "assistant", "content": ""},
{"role": "assistant", "content": None, "tool_calls": [_WEATHER_CALL]},
{"role": "tool", "tool_call_id": "call_weather", "content": ""},
{"role": "user", "content": ""},
]
with wire_server(_chat_peer) as wire, gateway.scenario() as scenario:
model: Final = _mistral_model(scenario, wire)
response: Final = gateway.request("POST", "/v1/chat/completions", {"model": model, "messages": messages})
assert response.status_code == 200, response.text
turns: Final = _forwarded_chat_turns(wire)
assert [(turn["role"], turn.get("content")) for turn in turns] == [
("user", ask),
("assistant", None),
("tool", ""),
], turns
assert turns[2]["tool_call_id"] == "call_weather", turns
def test_mistral_chat_completions_drops_an_orphan_tool_message_and_sends_a_null_tool_reply_as_empty_text(
gateway: Gateway,
) -> None:
ask: Final = f"What is the weather in {_CITY}? {uuid.uuid4().hex}"
messages: Final[list[JsonValue]] = [
{"role": "user", "content": ask},
{"role": "tool", "tool_call_id": "call_orphan", "content": ""},
{"role": "assistant", "content": None, "tool_calls": [_WEATHER_CALL]},
{"role": "tool", "tool_call_id": "call_weather", "content": None},
]
with wire_server(_chat_peer) as wire, gateway.scenario() as scenario:
model: Final = _mistral_model(scenario, wire)
response: Final = gateway.request("POST", "/v1/chat/completions", {"model": model, "messages": messages})
assert response.status_code == 200, response.text
turns: Final = _forwarded_chat_turns(wire)
assert [(turn["role"], turn.get("tool_call_id"), turn.get("content")) for turn in turns] == [
("user", None, ask),
("assistant", None, None),
("tool", "call_weather", ""),
], turns
_NATIVE_DROP_PARAMS_CASES: Final = MappingProxyType(
{
"body": (_DROP_SOURCES["body"], [_text(_WEATHER)]),
"deployment": (_DROP_SOURCES["deployment"], [_text(_WEATHER)]),
"explicit-false": (
(MappingProxyType({"drop_params": True}), MappingProxyType({"drop_params": False}), _CLAUDE_CODE),
[_text(5), _text(_WEATHER)],
),
}
)
@pytest.mark.parametrize("case", sorted(_NATIVE_DROP_PARAMS_CASES))
def test_native_anthropic_resolves_drop_params_the_same_way_on_both_routes(gateway: Gateway, case: str) -> None:
(deployment, extra, headers), forwarded = _NATIVE_DROP_PARAMS_CASES[case]
with wire_server(_anthropic_peer) as wire, gateway.scenario() as scenario:
model: Final = _anthropic_model(scenario, wire, **deployment)
body: Final = _body(model, _NUMBER_THEN_WEATHER, **extra)
created: Final = gateway.request("POST", "/v1/messages", {**body, "max_tokens": 64}, headers=headers)
counted: Final = gateway.request("POST", "/v1/messages/count_tokens", body, headers=headers)
assert created.status_code == 200, created.text
assert counted.status_code == 200, counted.text
assert _JSON_OBJECT.validate_json(counted.content)["input_tokens"] == _ANTHROPIC_COUNT, counted.text
requests: Final = wire.drain()
assert _forwarded_tool_result(_only(requests, "/v1/messages")) == forwarded
assert _forwarded_tool_result(_only(requests, "/v1/messages/count_tokens")) == forwarded
_CARD_NUMBER: Final = 4111111111111111
_CARD_NULL_WEATHER: Final = _messages(_text(_CARD_NUMBER), _text(None), _text(_WEATHER))
_SCANNED_CARD_NULL_WEATHER: Final = ("4111111111111111", "null", _WEATHER)
_MASKED_CARD_NULL_WEATHER: Final = [_text("################"), _text(None), _text("sunny, ##C")]
def _scanned_tool_texts(guard: Wire) -> tuple[JsonValue, ...]:
requests: Final = guard.drain()
assert len(requests) == 1, [request.target for request in requests]
rows: Final = _JSON_OBJECTS.validate_python(_json(requests[0])["structured_messages"])
tool_rows: Final = tuple(row for row in rows if row.get("role") == "tool")
assert len(tool_rows) == 1, rows
return _texts(tool_rows[0]["content"])
def test_masking_guardrail_scans_a_numeric_tool_result_text_before_anthropic_gets_it(gateway: Gateway) -> None:
with (
wire_server(_anthropic_peer) as provider,
wire_server(_guardrail_peer) as guard,
gateway.scenario() as scenario,
):
model: Final = _anthropic_model(scenario, provider)
guardrail: Final = _guardrail(gateway, scenario, guard, "digits")
created: Final = gateway.request(
"POST", "/v1/messages", _body(model, _CARD_NULL_WEATHER, max_tokens=64, guardrails=[guardrail])
)
assert created.status_code == 200, created.text
scanned: Final = _scanned_tool_texts(guard)
forwarded: Final = _forwarded_tool_result(_only(provider.drain(), "/v1/messages"))
assert scanned == _SCANNED_CARD_NULL_WEATHER, scanned
assert forwarded == _MASKED_CARD_NULL_WEATHER, forwarded
def test_masking_guardrail_scans_a_numeric_tool_result_text_in_a_batch_upload(gateway: Gateway) -> None:
with wire_server(_guardrail_peer) as guard, gateway.scenario() as scenario:
forwarded_body: Final = _uploaded_batch_body(gateway, scenario, guard, "digits", _CARD_NULL_WEATHER)
scanned: Final = _scanned_tool_texts(guard)
assert scanned == _SCANNED_CARD_NULL_WEATHER, scanned
assert _forwarded_tool_result(forwarded_body) == _MASKED_CARD_NULL_WEATHER, forwarded_body

View file

@ -25,7 +25,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
_bedrock_tools_pt, _bedrock_tools_pt,
_rename_duplicate_bedrock_document_names, _rename_duplicate_bedrock_document_names,
_convert_to_bedrock_tool_call_invoke, _convert_to_bedrock_tool_call_invoke,
_sanitize_anthropic_tool_use_id, sanitize_anthropic_tool_use_id,
_convert_to_bedrock_tool_call_result, _convert_to_bedrock_tool_call_result,
anthropic_messages_pt, anthropic_messages_pt,
convert_to_anthropic_tool_result, convert_to_anthropic_tool_result,
@ -4503,7 +4503,7 @@ def test_get_tool_calls_from_response_keeps_sanitized_concat_ids_distinct():
) )
ids: Final = [call["id"] for call in get_tool_calls_from_response(response)] ids: Final = [call["id"] for call in get_tool_calls_from_response(response)]
sanitized: Final = [_sanitize_anthropic_tool_use_id(call_id) for call_id in ids if isinstance(call_id, str)] sanitized: Final = [sanitize_anthropic_tool_use_id(call_id) for call_id in ids if isinstance(call_id, str)]
assert len(sanitized) == len(set(sanitized)) assert len(sanitized) == len(set(sanitized))
assert ids == ["a:b", "a:b__concat_2", "a_b__concat_1"] assert ids == ["a:b", "a:b__concat_2", "a_b__concat_1"]
@ -4517,7 +4517,7 @@ def test_get_tool_calls_from_response_bumps_suffix_when_sibling_sanitizes_onto_i
) )
ids: Final = [call["id"] for call in get_tool_calls_from_response(response)] ids: Final = [call["id"] for call in get_tool_calls_from_response(response)]
sanitized: Final = [_sanitize_anthropic_tool_use_id(call_id) for call_id in ids if isinstance(call_id, str)] sanitized: Final = [sanitize_anthropic_tool_use_id(call_id) for call_id in ids if isinstance(call_id, str)]
assert len(ids) == len(sanitized) assert len(ids) == len(sanitized)
assert len(sanitized) == len(set(sanitized)) assert len(sanitized) == len(set(sanitized))

View file

@ -155,6 +155,17 @@ class MockStructuredMaskingGuardrail(CustomGuardrail):
return masked return masked
class MockNumberMaskingGuardrail(MockStructuredMaskingGuardrail):
@staticmethod
def _mask(text: str) -> str:
return (
text.replace("bob@example.com", "<EMAIL>")
.replace("5", "<NUMBER>")
.replace("null", "<NULL>")
.replace("山田太郎", "<NAME>")
)
class TestAnthropicMessagesHandlerStreamingRequestData: class TestAnthropicMessagesHandlerStreamingRequestData:
"""Post-call guardrails on streaming /v1/messages receive the response and identity metadata""" """Post-call guardrails on streaming /v1/messages receive the response and identity metadata"""
@ -2499,7 +2510,7 @@ class TestStructuredWriteBackKeepsToolResults:
"""A guardrail rewrite must never leave a tool_use without its tool_result (Claude Code ToolSearch, LIT-6103).""" """A guardrail rewrite must never leave a tool_use without its tool_result (Claude Code ToolSearch, LIT-6103)."""
@staticmethod @staticmethod
def _claude_code_tool_search_turns(tool_result_content): def _claude_code_tool_search_turns(tool_result_content, tool_use_id="toolu_01"):
return [ return [
{"role": "user", "content": "load WebFetch for bob@example.com"}, {"role": "user", "content": "load WebFetch for bob@example.com"},
{ {
@ -2507,7 +2518,7 @@ class TestStructuredWriteBackKeepsToolResults:
"content": [ "content": [
{ {
"type": "tool_use", "type": "tool_use",
"id": "toolu_01", "id": tool_use_id,
"name": "ToolSearch", "name": "ToolSearch",
"input": {"query": "select:WebFetch"}, "input": {"query": "select:WebFetch"},
} }
@ -2516,7 +2527,7 @@ class TestStructuredWriteBackKeepsToolResults:
{ {
"role": "user", "role": "user",
"content": [ "content": [
{"type": "tool_result", "tool_use_id": "toolu_01", "content": tool_result_content}, {"type": "tool_result", "tool_use_id": tool_use_id, "content": tool_result_content},
{"type": "text", "text": "Now fetch the page."}, {"type": "text", "text": "Now fetch the page."},
], ],
}, },
@ -2563,6 +2574,124 @@ class TestStructuredWriteBackKeepsToolResults:
later_blocks = [b for m in messages[tool_use_index + 1 :] for b in self._blocks(m)] later_blocks = [b for m in messages[tool_use_index + 1 :] for b in self._blocks(m)]
assert {"type": "text", "text": "Now fetch the page."} in later_blocks assert {"type": "text", "text": "Now fetch the page."} in later_blocks
@pytest.mark.parametrize(
("tool_result_content", "expected_written_back_content"),
[
(
[{"type": "text", "text": None}, {"type": "text", "text": "log for bob@example.com"}],
[{"type": "text", "text": None}, {"type": "text", "text": "log for <EMAIL>"}],
),
(
[{"type": "text", "text": "log for bob@example.com"}, {"type": "text", "text": 73}],
[{"type": "text", "text": "log for <EMAIL>"}, {"type": "text", "text": 73}],
),
([{"type": "text", "text": None}], [{"type": "text", "text": None}]),
(
[
{"type": "text", "text": None},
{"type": "text", "text": "log for bob@example.com"},
{"type": "text", "text": 5},
{"type": "text", "text": "bob@example.com again"},
],
[
{"type": "text", "text": None},
{"type": "text", "text": "log for <EMAIL>"},
{"type": "text", "text": 5},
{"type": "text", "text": "<EMAIL> again"},
],
),
],
ids=["null-first", "number-last", "null-only", "interleaved"],
)
async def test_rewrite_keeps_a_non_string_tool_result_text_in_place(
self, tool_result_content, expected_written_back_content
):
data = {"model": "claude-fable-5", "messages": self._claude_code_tool_search_turns(tool_result_content)}
await AnthropicMessagesHandler().process_input_messages(
data=data, guardrail_to_apply=MockStructuredMaskingGuardrail()
)
assert "bob@example.com" not in json.dumps(data["messages"])
assert data["messages"][2]["content"][0] == {
"type": "tool_result",
"tool_use_id": "toolu_01",
"content": expected_written_back_content,
}
async def test_rewrite_keeps_a_non_string_tool_result_text_whose_tool_use_id_gets_sanitized(self):
tool_result_content = [{"type": "text", "text": None}, {"type": "text", "text": "log for bob@example.com"}]
data = {
"model": "claude-fable-5",
"messages": self._claude_code_tool_search_turns(tool_result_content, tool_use_id="functions.get_weather:0"),
}
await AnthropicMessagesHandler().process_input_messages(
data=data, guardrail_to_apply=MockStructuredMaskingGuardrail()
)
assert data["messages"][2]["content"][0]["content"] == [
{"type": "text", "text": None},
{"type": "text", "text": "log for <EMAIL>"},
]
async def test_rewrite_keeps_each_non_string_text_when_two_tool_use_ids_sanitize_alike(self):
data = {
"model": "claude-fable-5",
"messages": [
{"role": "user", "content": "check both logs for bob@example.com"},
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "read:log", "name": "Read", "input": {"path": "a.log"}},
{"type": "tool_use", "id": "read_log", "name": "Read", "input": {"path": "b.log"}},
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "read:log",
"content": [{"type": "text", "text": "ok"}, {"type": "text", "text": None}],
},
{
"type": "tool_result",
"tool_use_id": "read_log",
"content": [{"type": "text", "text": 5}, {"type": "text", "text": "bob@example.com"}],
},
],
},
],
}
await AnthropicMessagesHandler().process_input_messages(
data=data, guardrail_to_apply=MockStructuredMaskingGuardrail()
)
assert [block["content"] for block in data["messages"][2]["content"]] == [
[{"type": "text", "text": "ok"}, {"type": "text", "text": None}],
[{"type": "text", "text": 5}, {"type": "text", "text": "<EMAIL>"}],
]
@pytest.mark.parametrize(
("text", "masked"),
[(5, "<NUMBER>"), (None, "<NULL>"), ({"owner": "山田太郎"}, '{"owner": "<NAME>"}')],
ids=["number", "null", "non-ascii-object"],
)
async def test_guardrail_scans_and_masks_a_non_string_tool_result_text(self, text, masked):
tool_result_content = [{"type": "text", "text": text}, {"type": "text", "text": "log for bob@example.com"}]
data = {"model": "claude-fable-5", "messages": self._claude_code_tool_search_turns(tool_result_content)}
await AnthropicMessagesHandler().process_input_messages(
data=data, guardrail_to_apply=MockNumberMaskingGuardrail()
)
assert data["messages"][2]["content"][0]["content"] == [
{"type": "text", "text": masked},
{"type": "text", "text": "log for <EMAIL>"},
]
class TestAnthropicMessagesHandlerStreamingScanKey: class TestAnthropicMessagesHandlerStreamingScanKey:
"""get_streaming_scan_key mirrors what process_output_streaming_response would scan""" """get_streaming_scan_key mirrors what process_output_streaming_response would scan"""

View file

@ -1,10 +1,13 @@
import base64 import base64
import functools import functools
import json import json
from collections.abc import Mapping
from types import MappingProxyType from types import MappingProxyType
from typing import Any, Final, cast from typing import Any, Final, cast
import httpx
import pytest import pytest
import respx
import litellm import litellm
@ -5235,3 +5238,153 @@ def test_eager_input_streaming_tool_reaches_bedrock_converse_as_beta():
assert data["additionalModelRequestFields"]["anthropic_beta"] == ["fine-grained-tool-streaming-2025-05-14"] assert data["additionalModelRequestFields"]["anthropic_beta"] == ["fine-grained-tool-streaming-2025-05-14"]
assert data["toolConfig"]["tools"][0]["toolSpec"]["inputSchema"]["json"] == EAGER_INPUT_SCHEMA assert data["toolConfig"]["tools"][0]["toolSpec"]["inputSchema"]["json"] == EAGER_INPUT_SCHEMA
_CHAT_BRIDGE_MODEL: Final = "azure/gpt-5.6"
_CHAT_BRIDGE_API_BASE: Final = "https://tool-result-text.openai.azure.com"
_CHAT_BRIDGE_URL: Final = r".*tool-result-text\.openai\.azure\.com/openai/deployments/gpt-5\.6/chat/completions.*"
_CHAT_BRIDGE_REPLY: Final = {
"id": "chatcmpl-tool-result-text",
"object": "chat.completion",
"created": 1700000000,
"model": "gpt-5.6",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "build passed"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 12, "completion_tokens": 3, "total_tokens": 15},
}
_MALFORMED_TOOL_RESULT_TEXTS: Final = (pytest.param(None, id="null"), pytest.param(5, id="number"))
_MALFORMED_TOOL_RESULT_TEXT_ERROR: Final = (
"litellm.BadRequestError: tool_result toolu_log content.1.text: Input should be a valid string"
)
def _tool_result_with_text_blocks(text_block: Mapping[str, object]) -> list[dict[str, object]]:
blocks: Final = [{"type": "text", "text": "build ok"}, text_block, {"type": "text", "text": "done"}, text_block]
return [
{"role": "user", "content": "run the build"},
dict(_anthropic_tool_use_turn("toolu_log")),
dict(_anthropic_tool_result_turn({"toolu_log": blocks})),
]
def _chat_bridge_route(respx_mock: respx.MockRouter) -> respx.Route:
return respx_mock.post(url__regex=_CHAT_BRIDGE_URL).mock(return_value=httpx.Response(200, json=_CHAT_BRIDGE_REPLY))
def _sent_tool_messages(route: respx.Route) -> list[object]:
sent: Final = json.loads(route.calls.last.request.read())
return [message for message in sent["messages"] if message["role"] == "tool"]
@pytest.fixture
def _chat_bridge_httpx(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.in_memory_llm_clients_cache.flush_cache()
@pytest.mark.asyncio
@pytest.mark.usefixtures("_chat_bridge_httpx")
@pytest.mark.parametrize("stream", [False, True], ids=["non_streaming", "streaming"])
@pytest.mark.parametrize("text", _MALFORMED_TOOL_RESULT_TEXTS)
async def test_chat_bridge_async_malformed_tool_result_text_answers_400_before_any_provider_call(
respx_mock: respx.MockRouter, text: object, stream: bool
) -> None:
route: Final = _chat_bridge_route(respx_mock)
with pytest.raises(litellm.BadRequestError) as rejected:
await litellm.anthropic.messages.acreate(
model=_CHAT_BRIDGE_MODEL,
api_base=_CHAT_BRIDGE_API_BASE,
api_key="fake-key",
max_tokens=64,
stream=stream,
messages=_tool_result_with_text_blocks({"type": "text", "text": text}),
)
assert rejected.value.status_code == 400
assert rejected.value.llm_provider == "azure"
assert rejected.value.message == _MALFORMED_TOOL_RESULT_TEXT_ERROR
assert route.call_count == 0
@pytest.mark.usefixtures("_chat_bridge_httpx")
@pytest.mark.parametrize("stream", [False, True], ids=["non_streaming", "streaming"])
@pytest.mark.parametrize("text", _MALFORMED_TOOL_RESULT_TEXTS)
def test_chat_bridge_sync_malformed_tool_result_text_answers_400_before_any_provider_call(
respx_mock: respx.MockRouter, text: object, stream: bool
) -> None:
route: Final = _chat_bridge_route(respx_mock)
with pytest.raises(litellm.BadRequestError) as rejected:
litellm.anthropic.messages.create(
model=_CHAT_BRIDGE_MODEL,
api_base=_CHAT_BRIDGE_API_BASE,
api_key="fake-key",
max_tokens=64,
stream=stream,
messages=_tool_result_with_text_blocks({"type": "text", "text": text}),
)
assert rejected.value.status_code == 400
assert rejected.value.llm_provider == "azure"
assert rejected.value.message == _MALFORMED_TOOL_RESULT_TEXT_ERROR
assert route.call_count == 0
@pytest.mark.asyncio
@pytest.mark.usefixtures("_chat_bridge_httpx")
@pytest.mark.parametrize("drop_params_source", ["request", "global"])
@pytest.mark.parametrize("text", _MALFORMED_TOOL_RESULT_TEXTS)
async def test_chat_bridge_drop_params_drops_a_malformed_tool_result_text(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, text: object, drop_params_source: str
) -> None:
route: Final = _chat_bridge_route(respx_mock)
if drop_params_source == "global":
monkeypatch.setattr(litellm, "drop_params", True)
response: Final = await litellm.anthropic.messages.acreate(
model=_CHAT_BRIDGE_MODEL,
api_base=_CHAT_BRIDGE_API_BASE,
api_key="fake-key",
max_tokens=64,
messages=_tool_result_with_text_blocks({"type": "text", "text": text}),
**({"drop_params": True} if drop_params_source == "request" else {}),
)
assert _sent_tool_messages(route) == [
{
"role": "tool",
"tool_call_id": "toolu_log",
"content": [{"type": "text", "text": "build ok"}, {"type": "text", "text": "done"}],
}
]
assert isinstance(response, dict)
assert response["content"] == [{"type": "text", "text": "build passed"}]
@pytest.mark.asyncio
@pytest.mark.usefixtures("_chat_bridge_httpx")
async def test_chat_bridge_tool_result_text_block_without_text_reaches_the_model_as_empty_text(
respx_mock: respx.MockRouter,
) -> None:
route: Final = _chat_bridge_route(respx_mock)
await litellm.anthropic.messages.acreate(
model=_CHAT_BRIDGE_MODEL,
api_base=_CHAT_BRIDGE_API_BASE,
api_key="fake-key",
max_tokens=64,
messages=_tool_result_with_text_blocks({"type": "text"}),
)
assert _sent_tool_messages(route) == [
{
"role": "tool",
"tool_call_id": "toolu_log",
"content": [
{"type": "text", "text": "build ok"},
{"type": "text", "text": ""},
{"type": "text", "text": "done"},
{"type": "text", "text": ""},
],
}
]

View file

@ -300,3 +300,67 @@ async def test_a_json_provider_gets_safeguards_and_the_beta_together_unless_drop
assert sent.headers["anthropic-beta"] == forwarded_beta assert sent.headers["anthropic-beta"] == forwarded_beta
assert "safeguard_results" not in response assert "safeguard_results" not in response
assert response["content"][0]["text"] == "hello" assert response["content"][0]["text"] == "hello"
@pytest.mark.parametrize(
("drop_params", "expected_tool_result_content"),
[
(True, [{"type": "text", "text": "sunny"}]),
(None, [{"type": "text", "text": 5}, {"type": "text", "text": "sunny"}]),
],
ids=["drop_params", "no_drop_params"],
)
def test_drop_params_drops_a_non_string_tool_result_text_on_the_native_request(
drop_params, expected_tool_result_content
):
messages = [
{"role": "user", "content": "What is the weather?"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {}}],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01",
"content": [{"type": "text", "text": 5}, {"type": "text", "text": "sunny"}],
}
],
},
]
request = AnthropicMessagesConfig().transform_anthropic_messages_request(
model="claude-sonnet-4-5",
messages=messages,
anthropic_messages_optional_request_params={"max_tokens": 64},
litellm_params=GenericLiteLLMParams(drop_params=drop_params),
headers={},
)
assert request["messages"][2]["content"][0]["content"] == expected_tool_result_content
def test_drop_params_forwards_a_non_object_message_item_as_sent_on_the_native_request():
tool_result_turn = {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01",
"content": [{"type": "text", "text": None}, {"type": "text", "text": "sunny"}],
}
],
}
request = AnthropicMessagesConfig().transform_anthropic_messages_request(
model="claude-sonnet-4-5",
messages=[{"role": "user", "content": "What is the weather?"}, 7, tool_result_turn],
anthropic_messages_optional_request_params={"max_tokens": 64},
litellm_params=GenericLiteLLMParams(drop_params=True),
headers={},
)
assert request["messages"][1] == 7
assert request["messages"][2]["content"][0]["content"] == [{"type": "text", "text": "sunny"}]

View file

@ -617,3 +617,88 @@ async def test_tool_search_forwards_deferred_tools_and_beta_header(
assert [block for block in response["content"] if block["type"] == "tool_use"] == [ assert [block for block in response["content"] if block["type"] == "tool_use"] == [
{"type": "tool_use", "id": "toolu_1", "name": tool_name, "input": tool_input} {"type": "tool_use", "id": "toolu_1", "name": tool_name, "input": tool_input}
] ]
_MIXED_GROUP: Final = "gpt-5.6-mixed"
_MIXED_AZURE_API_BASE: Final = "https://mixed-group.openai.azure.com"
_MIXED_AZURE_CHAT_URL: Final = r".*mixed-group\.openai\.azure\.com/openai/deployments/.*/chat/completions.*"
_MIXED_DEPLOYMENT_IDS: Final = ("mixed-openai-responses-bridge", "mixed-azure-chat-bridge")
_MIXED_AZURE_CHAT_BODY: Final = {
"id": "chatcmpl-mixed",
"object": "chat.completion",
"created": 1700000000,
"model": "gpt-5.6",
"choices": [{"index": 0, "message": {"role": "assistant", "content": _JOKE}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
}
def _mixed_openai_azure_router() -> Router:
return Router(
model_list=[
{
"model_name": _MIXED_GROUP,
"litellm_params": {"model": "openai/gpt-5.6", "api_key": "fake-key"},
"model_info": {"id": _MIXED_DEPLOYMENT_IDS[0]},
},
{
"model_name": _MIXED_GROUP,
"litellm_params": {"model": "azure/gpt-5.6", "api_key": "fake-key", "api_base": _MIXED_AZURE_API_BASE},
"model_info": {"id": _MIXED_DEPLOYMENT_IDS[1]},
},
],
num_retries=0,
)
async def _null_tool_result_text_rejection(router: Router, deployment_id: str) -> litellm.BadRequestError:
with pytest.raises(litellm.BadRequestError) as rejected:
await router.aanthropic_messages(
model=deployment_id,
max_tokens=64,
messages=[
{"role": "user", "content": "run the build"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "toolu_build", "name": "bash", "input": {"cmd": "make"}}],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_build",
"content": [{"type": "text", "text": "build ok"}, {"type": "text", "text": None}],
}
],
},
],
)
return rejected.value
@pytest.mark.asyncio
async def test_mixed_openai_azure_group_answers_the_same_400_for_a_null_tool_result_text_on_either_deployment(
respx_mock,
):
openai_route: Final = respx_mock.post(url__regex=_OPENAI_RESPONSES_URL).mock(
return_value=httpx.Response(200, json=_OPENAI_BODY)
)
azure_route: Final = respx_mock.post(url__regex=_MIXED_AZURE_CHAT_URL).mock(
return_value=httpx.Response(200, json=_MIXED_AZURE_CHAT_BODY)
)
router: Final = _mixed_openai_azure_router()
rejections: Final = [
await _null_tool_result_text_rejection(router, deployment_id) for deployment_id in _MIXED_DEPLOYMENT_IDS
]
assert [(rejection.status_code, rejection.llm_provider) for rejection in rejections] == [
(400, "openai"),
(400, "azure"),
]
assert {rejection.message.partition("\n\n")[0] for rejection in rejections} == {
"litellm.BadRequestError: tool_result toolu_build content.1.text: Input should be a valid string"
}
assert openai_route.call_count == 0
assert azure_route.call_count == 0

View file

@ -576,6 +576,91 @@ def test_client_cache_control_changes_nothing_for_a_deployment_that_does_not_rea
assert marked == unmarked assert marked == unmarked
MALFORMED_TEXT_BLOCKS: Final = [
pytest.param({"type": "text", "text": None}, id="null"),
pytest.param({"type": "text", "text": 7}, id="number"),
]
def _tool_result_turn_with(text_block: Mapping[str, object]) -> list[dict[str, object]]:
return [
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_log",
"content": [{"type": "text", "text": "build ok"}, text_block, {"type": "text", "text": "done"}],
}
],
}
]
@pytest.mark.parametrize("text_block", MALFORMED_TEXT_BLOCKS)
def test_malformed_tool_result_text_answers_400_naming_the_block(text_block: Mapping[str, object]) -> None:
with pytest.raises(litellm.BadRequestError) as rejected:
_build_responses_kwargs(
model="openai/gpt-5.6",
extra_kwargs=OPENAI_API,
max_tokens=1024,
messages=_tool_result_turn_with(text_block),
)
assert rejected.value.status_code == 400
assert "tool_result toolu_log content.1.text: Input should be a valid string" in rejected.value.message
def test_many_malformed_tool_result_texts_answer_one_bounded_error() -> None:
tool_use_id: Final = "toolu_" + "x" * 4096
messages: Final = [
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": [{"type": "text", "text": None}] * 2048,
}
],
}
]
with pytest.raises(litellm.BadRequestError) as rejected:
_build_responses_kwargs(model="openai/gpt-5.6", extra_kwargs=OPENAI_API, max_tokens=1024, messages=messages)
assert rejected.value.status_code == 400
assert f"tool_result {tool_use_id} content.0.text: Input should be a valid string" in rejected.value.message
assert rejected.value.message.count(tool_use_id) == 1
@pytest.mark.parametrize("text_block", MALFORMED_TEXT_BLOCKS)
def test_drop_params_drops_a_malformed_tool_result_text(text_block: Mapping[str, object]) -> None:
responses_kwargs: Final = _build_responses_kwargs(
model="openai/gpt-5.6",
extra_kwargs={**OPENAI_API, "drop_params": True},
max_tokens=1024,
messages=_tool_result_turn_with(text_block),
)
assert responses_kwargs["input"] == [
{"type": "function_call_output", "call_id": "toolu_log", "output": "build ok\ndone"},
]
def test_tool_result_text_block_without_text_still_reaches_the_model_as_empty_text() -> None:
responses_kwargs: Final = _build_responses_kwargs(
model="openai/gpt-5.6",
extra_kwargs=OPENAI_API,
max_tokens=1024,
messages=_tool_result_turn_with({"type": "text"}),
)
assert responses_kwargs["input"] == [
{"type": "function_call_output", "call_id": "toolu_log", "output": "build ok\n\ndone"},
]
REASONING_MODEL: Final = "gpt-bridge-reasoning-cost" REASONING_MODEL: Final = "gpt-bridge-reasoning-cost"
REASONING_RATES: Final = { REASONING_RATES: Final = {
"input_cost_per_token": 2e-06, "input_cost_per_token": 2e-06,

View file

@ -8,6 +8,7 @@ from pydantic import JsonValue, TypeAdapter
from litellm.llms.anthropic.pass_through.adapters.transformation import LiteLLMAnthropicMessagesAdapter from litellm.llms.anthropic.pass_through.adapters.transformation import LiteLLMAnthropicMessagesAdapter
from litellm.llms.anthropic.pass_through.convertible_blocks import ( from litellm.llms.anthropic.pass_through.convertible_blocks import (
malformed_tool_result_text_error,
unconvertible_anthropic_blocks, unconvertible_anthropic_blocks,
unconvertible_blocks_error, unconvertible_blocks_error,
) )
@ -190,3 +191,22 @@ def test_an_encrypted_web_search_inference_cannot_send_is_named() -> None:
error: Final = unconvertible_blocks_error(_replayed_web_search("opaque"), "anthropic") error: Final = unconvertible_blocks_error(_replayed_web_search("opaque"), "anthropic")
assert error is not None assert error is not None
assert error.endswith("server_tool_use, web_search_tool_result") assert error.endswith("server_tool_use, web_search_tool_result")
@pytest.mark.parametrize(
("tool_result", "error"),
(
(
{"type": "tool_result", "tool_use_id": "toolu_01", "content": [{"type": "text", "text": None}]},
"tool_result toolu_01 content.0.text: Input should be a valid string",
),
(
{"type": "tool_result", "content": [{"type": "text", "text": "ok"}, {"type": "text", "text": 5}]},
"tool_result content.1.text: Input should be a valid string",
),
),
)
def test_a_non_string_tool_result_text_is_named_with_or_without_a_tool_use_id(
tool_result: Mapping[str, object], error: str
) -> None:
assert malformed_tool_result_text_error([{"role": "user", "content": [tool_result]}]) == error

View file

@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, patch
import httpx import httpx
import pytest import pytest
import respx
from pydantic import JsonValue, TypeAdapter from pydantic import JsonValue, TypeAdapter
import litellm import litellm
@ -153,6 +154,83 @@ async def test_google_ai_studio_count_tokens_answers_with_the_status_google_reje
assert "Invalid function name" in (result.error_message or "") assert "Invalid function name" in (result.error_message or "")
def _weather_tool_result_messages(text: JsonValue) -> list[dict[str, object]]:
return [
{"role": "user", "content": "What is the weather in Paris?"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "Paris"}}],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01",
"content": [{"type": "text", "text": text}, {"type": "text", "text": "partly cloudy"}],
}
],
},
]
_COUNT_TOKENS_URL: Final = "https://count-tokens.test/v1beta/models/gemini-2.5-flash:countTokens"
@pytest.mark.asyncio
@pytest.mark.parametrize("text", (None, 5))
async def test_google_ai_studio_count_tokens_rejects_a_non_string_tool_result_text_without_request(
text: JsonValue, respx_mock: respx.MockRouter
) -> None:
count_route: Final = respx_mock.post(_COUNT_TOKENS_URL).respond(200, json={"totalTokens": 11})
result: Final = await GoogleAIStudioTokenCounter().count_tokens(
model_to_use="gemini-2.5-flash",
messages=_weather_tool_result_messages(text),
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 result.error_message == "tool_result toolu_01 content.0.text: Input should be a valid string"
assert not count_route.called
@pytest.mark.asyncio
async def test_google_ai_studio_count_tokens_leaves_out_a_numeric_tool_result_text_under_drop_params(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
count_route: Final = respx_mock.post(_COUNT_TOKENS_URL).respond(200, json={"totalTokens": 11})
result: Final = await GoogleAIStudioTokenCounter().count_tokens(
model_to_use="gemini-2.5-flash",
messages=_weather_tool_result_messages(5),
contents=None,
deployment={
"litellm_params": {"api_key": "test-key", "api_base": "https://count-tokens.test", "drop_params": True}
},
request_model="gemini/gemini-2.5-flash",
messages_format="anthropic",
)
assert result is not None
assert result.error is False
assert result.total_tokens == 11
assert _JSON_BODY.validate_json(count_route.calls.last.request.content)["contents"] == [
{"role": "user", "parts": [{"text": "What is the weather in Paris?"}]},
{"role": "model", "parts": [{"function_call": {"name": "get_weather", "args": {"city": "Paris"}}}]},
{
"role": "user",
"parts": [{"function_response": {"name": "get_weather", "response": {"content": "partly cloudy"}}}],
},
]
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize( @pytest.mark.parametrize(
("system_instruction", "tools", "expected_body"), ("system_instruction", "tools", "expected_body"),

View file

@ -920,6 +920,85 @@ class TestMistralStripsOutputOnlyFields:
assert "reasoning_content" not in result[-1] assert "reasoning_content" not in result[-1]
def test_mistral_transform_request_keeps_an_empty_tool_message_after_its_tool_call():
messages: List[AllMessageValues] = cast(
List[AllMessageValues],
[
{"role": "user", "content": "read the log"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "read", "arguments": "{}"}}
],
},
{"role": "tool", "tool_call_id": "call_1", "content": ""},
],
)
request = MistralConfig().transform_request(
model="mistral-small-latest", messages=messages, optional_params={}, litellm_params={}, headers={}
)
result = request["messages"]
assert [m.get("role") for m in result] == ["user", "assistant", "tool"]
assert result[2].get("tool_call_id") == "call_1"
@pytest.mark.parametrize("reply", [{"content": None}, {}], ids=["null", "missing"])
def test_mistral_transform_request_sends_a_null_tool_reply_as_empty_text(reply):
messages: List[AllMessageValues] = cast(
List[AllMessageValues],
[
{"role": "user", "content": "read the log"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "read", "arguments": "{}"}}
],
},
{"role": "tool", "tool_call_id": "call_1", **reply},
],
)
request = MistralConfig().transform_request(
model="mistral-small-latest", messages=messages, optional_params={}, litellm_params={}, headers={}
)
assert request["messages"][2] == {"role": "tool", "tool_call_id": "call_1", "content": ""}
def test_mistral_transform_request_drops_an_empty_tool_message_no_tool_call_asked_for():
messages: List[AllMessageValues] = cast(
List[AllMessageValues],
[
{"role": "user", "content": "hi"},
{"role": "tool", "tool_call_id": "call_9", "content": ""},
],
)
request = MistralConfig().transform_request(
model="mistral-small-latest", messages=messages, optional_params={}, litellm_params={}, headers={}
)
assert request["messages"] == [{"role": "user", "content": "hi"}]
@pytest.mark.parametrize("role", ["system", "user"])
def test_mistral_transform_request_drops_a_message_with_null_content(role):
messages: List[AllMessageValues] = cast(
List[AllMessageValues],
[{"role": role, "content": None}, {"role": "user", "content": "hi"}],
)
request = MistralConfig().transform_request(
model="mistral-small-latest", messages=messages, optional_params={}, litellm_params={}, headers={}
)
assert request["messages"] == [{"role": "user", "content": "hi"}]
def test_mistral_transform_request_hoists_tool_message_image(): def test_mistral_transform_request_hoists_tool_message_image():
"""Images inside role:"tool" messages must be moved to a following user """Images inside role:"tool" messages must be moved to a following user
message (Mistral rejects/ignores non-text tool content), including when message (Mistral rejects/ignores non-text tool content), including when
@ -957,3 +1036,129 @@ def test_mistral_transform_request_hoists_tool_message_image():
{"type": "text", "text": TOOL_RESULT_IMAGE_BOUNDARY}, {"type": "text", "text": TOOL_RESULT_IMAGE_BOUNDARY},
{"type": "image_url", "image_url": {"url": data_uri}}, {"type": "image_url", "image_url": {"url": data_uri}},
] ]
def test_mistral_transform_request_drops_an_empty_tool_message_for_a_call_already_answered_earlier():
messages: List[AllMessageValues] = cast(
List[AllMessageValues],
[
{"role": "user", "content": "weather?"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "weather", "arguments": "{}"}}
],
},
{"role": "tool", "tool_call_id": "call_1", "content": "sunny"},
{"role": "assistant", "content": "It is sunny"},
{"role": "tool", "tool_call_id": "call_1", "content": ""},
{"role": "user", "content": "thanks"},
],
)
request = MistralConfig().transform_request(
model="mistral-small-latest", messages=messages, optional_params={}, litellm_params={}, headers={}
)
assert [m.get("role") for m in request["messages"]] == ["user", "assistant", "tool", "assistant", "user"]
def test_mistral_transform_request_drops_an_empty_tool_message_when_a_later_reply_carries_the_result():
messages: List[AllMessageValues] = cast(
List[AllMessageValues],
[
{"role": "user", "content": "weather?"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "weather", "arguments": "{}"}}
],
},
{"role": "tool", "tool_call_id": "call_1", "content": ""},
{"role": "tool", "tool_call_id": "call_1", "content": "sunny"},
{"role": "user", "content": "thanks"},
],
)
request = MistralConfig().transform_request(
model="mistral-small-latest", messages=messages, optional_params={}, litellm_params={}, headers={}
)
assert [m.get("role") for m in request["messages"]] == ["user", "assistant", "tool", "user"]
assert request["messages"][2]["content"] == "sunny"
def test_mistral_transform_request_keeps_an_empty_tool_message_answering_a_call_in_the_first_message():
messages: List[AllMessageValues] = cast(
List[AllMessageValues],
[
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "weather", "arguments": "{}"}}
],
},
{"role": "tool", "tool_call_id": "call_1", "content": ""},
{"role": "user", "content": "thanks"},
],
)
request = MistralConfig().transform_request(
model="mistral-small-latest", messages=messages, optional_params={}, litellm_params={}, headers={}
)
assert [m.get("role") for m in request["messages"]] == ["assistant", "tool", "user"]
assert request["messages"][1]["content"] == ""
@pytest.mark.parametrize("placeholder_role", ["assistant", "user"])
def test_mistral_transform_request_keeps_an_empty_tool_message_after_a_dropped_empty_placeholder(placeholder_role):
messages: List[AllMessageValues] = cast(
List[AllMessageValues],
[
{"role": "user", "content": "weather?"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "weather", "arguments": "{}"}}
],
},
{"role": placeholder_role, "content": ""},
{"role": "tool", "tool_call_id": "call_1", "content": ""},
{"role": "user", "content": "thanks"},
],
)
request = MistralConfig().transform_request(
model="mistral-small-latest", messages=messages, optional_params={}, litellm_params={}, headers={}
)
assert [m.get("role") for m in request["messages"]] == ["user", "assistant", "tool", "user"]
assert request["messages"][2]["content"] == ""
def test_mistral_transform_request_drops_an_empty_tool_message_whose_call_id_is_not_a_string():
messages: List[AllMessageValues] = cast(
List[AllMessageValues],
[
{"role": "user", "content": "read the log"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": {"nested": "call_1"}, "type": "function", "function": {"name": "read", "arguments": "{}"}}
],
},
{"role": "tool", "tool_call_id": {"nested": "call_1"}, "content": ""},
],
)
request = MistralConfig().transform_request(
model="mistral-small-latest", messages=messages, optional_params={}, litellm_params={}, headers={}
)
assert [m.get("role") for m in request["messages"]] == ["user", "assistant"]

View file

@ -1,5 +1,6 @@
import json import json
import operator import operator
from collections.abc import Mapping
from typing import Final from typing import Final
import httpx import httpx
@ -395,6 +396,92 @@ async def test_undecodable_count_body_becomes_a_500_error_response_carrying_the_
) )
MALFORMED_TEXT_BLOCKS: Final = [
pytest.param({"type": "text", "text": None}, id="null"),
pytest.param({"type": "text", "text": 7}, id="number"),
]
def _tool_result_turns_with(text_block: Mapping[str, object]) -> list[dict[str, object]]:
return [
{"role": "user", "content": "run the build"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "toolu_log", "name": "bash", "input": {"cmd": "make"}}],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_log",
"content": [{"type": "text", "text": "build ok"}, text_block, {"type": "text", "text": "done"}],
}
],
},
]
async def _count_tool_result_turns(
monkeypatch: pytest.MonkeyPatch, text_block: Mapping[str, object], litellm_params: dict[str, object]
) -> tuple[TokenCountResponse | None, respx.Route]:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
with respx.mock:
route: Final = respx.post(COUNT_URL).mock(return_value=httpx.Response(200, json={"input_tokens": 9}))
result: Final = await OpenAITokenCounter().count_tokens(
model_to_use="gpt-4o",
messages=_tool_result_turns_with(text_block),
contents=None,
deployment={"litellm_params": litellm_params},
request_model="openai/gpt-4o",
messages_format="anthropic",
)
return result, route
@pytest.mark.asyncio
@pytest.mark.parametrize("text_block", MALFORMED_TEXT_BLOCKS)
async def test_anthropic_format_malformed_tool_result_text_answers_400_without_calling_openai(
monkeypatch: pytest.MonkeyPatch, text_block: Mapping[str, object]
) -> None:
result, route = await _count_tool_result_turns(monkeypatch, text_block, {"api_key": "sk-test"})
assert route.called is False
assert result is not None
assert (result.error, result.status_code, result.error_message) == (
True,
400,
"tool_result toolu_log content.1.text: Input should be a valid string",
)
@pytest.mark.asyncio
@pytest.mark.parametrize("text_block", MALFORMED_TEXT_BLOCKS)
async def test_anthropic_format_drop_params_counts_the_tool_result_without_the_malformed_text(
monkeypatch: pytest.MonkeyPatch, text_block: Mapping[str, object]
) -> None:
result, route = await _count_tool_result_turns(
monkeypatch, text_block, {"api_key": "sk-test", "drop_params": True}
)
assert result is not None
assert (result.error, result.total_tokens) == (False, 9)
sent: Final = json.loads(route.calls.last.request.content)
assert {"type": "function_call_output", "call_id": "toolu_log", "output": "build ok\ndone"} in sent["input"]
@pytest.mark.asyncio
async def test_anthropic_format_tool_result_text_block_without_text_still_counts_as_empty_text(
monkeypatch: pytest.MonkeyPatch,
) -> None:
result, route = await _count_tool_result_turns(monkeypatch, {"type": "text"}, {"api_key": "sk-test"})
assert result is not None
assert (result.error, result.total_tokens) == (False, 9)
sent: Final = json.loads(route.calls.last.request.content)
assert {"type": "function_call_output", "call_id": "toolu_log", "output": "build ok\n\ndone"} in sent["input"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_count_uses_the_global_openai_key_chat_would_use(monkeypatch: pytest.MonkeyPatch) -> None: async def test_count_uses_the_global_openai_key_chat_would_use(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)

View file

@ -117,6 +117,7 @@ def _vertex_count_tokens_peer(status_code: int) -> Iterator[_VertexCountTokensPe
_VERTEX_DEPLOYMENT: Final = {"litellm_params": {"vertex_project": "test-project", "vertex_location": "us-central1"}} _VERTEX_DEPLOYMENT: Final = {"litellm_params": {"vertex_project": "test-project", "vertex_location": "us-central1"}}
_JSON_BODY: Final = TypeAdapter(dict[str, JsonValue])
@pytest.mark.asyncio @pytest.mark.asyncio
@ -164,6 +165,77 @@ async def test_vertex_gemini_count_rejects_an_image_url_it_refuses_to_fetch_with
assert peer.requests == () assert peer.requests == ()
def _weather_tool_result_messages(text: JsonValue) -> list[dict[str, object]]:
return [
{"role": "user", "content": "What is the weather in Paris?"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "Paris"}}],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01",
"content": [{"type": "text", "text": text}, {"type": "text", "text": "partly cloudy"}],
}
],
},
]
@pytest.mark.asyncio
@pytest.mark.parametrize("text", (None, 5))
async def test_vertex_gemini_count_rejects_a_non_string_tool_result_text_without_request(text: JsonValue) -> None:
with _vertex_count_tokens_peer(200) as peer:
result: Final = await VertexAITokenCounter().count_tokens(
model_to_use="gemini-2.5-flash",
messages=_weather_tool_result_messages(text),
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 result.error_message == "tool_result toolu_01 content.0.text: Input should be a valid string"
assert peer.requests == ()
@pytest.mark.asyncio
async def test_vertex_gemini_count_leaves_out_a_numeric_tool_result_text_when_the_deployment_drops_params() -> None:
with _vertex_count_tokens_peer(200) as peer:
result: Final = await VertexAITokenCounter().count_tokens(
model_to_use="gemini-2.5-flash",
messages=_weather_tool_result_messages(5),
contents=None,
deployment={
"litellm_params": {
"vertex_project": "test-project",
"vertex_location": "us-central1",
"drop_params": True,
}
},
request_model="vertex_ai/gemini-2.5-flash",
messages_format="anthropic",
)
assert result is not None
assert result.error is False
assert result.total_tokens == 37
assert _JSON_BODY.validate_json(peer.requests[0].content)["contents"] == [
{"role": "user", "parts": [{"text": "What is the weather in Paris?"}]},
{"role": "model", "parts": [{"function_call": {"name": "get_weather", "args": {"city": "Paris"}}}]},
{
"role": "user",
"parts": [{"function_response": {"name": "get_weather", "response": {"content": "partly cloudy"}}}],
},
]
_TOOL_OUTPUT: Final = "line of a long file that the agent read earlier. " * 180 _TOOL_OUTPUT: Final = "line of a long file that the agent read earlier. " * 180

View file

@ -21,6 +21,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import anyio import anyio
import httpx import httpx
import pytest import pytest
import respx
import tiktoken import tiktoken
from dotenv import load_dotenv from dotenv import load_dotenv
from pydantic import BaseModel, ConfigDict, JsonValue from pydantic import BaseModel, ConfigDict, JsonValue
@ -36,6 +37,7 @@ load_dotenv()
from fastapi import Request from fastapi import Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from starlette.types import Message
import litellm import litellm
from litellm import Router from litellm import Router
@ -56,7 +58,7 @@ from litellm.llms.bedrock.count_tokens.kimi_k3 import (
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.llms.gemini.count_tokens import handler as gemini_count_tokens_handler from litellm.llms.gemini.count_tokens import handler as gemini_count_tokens_handler
from litellm.llms.vertex_ai.vertex_ai_partner_models.count_tokens.handler import VertexAIPartnerModelsTokenCounter from litellm.llms.vertex_ai.vertex_ai_partner_models.count_tokens.handler import VertexAIPartnerModelsTokenCounter
from litellm.proxy._types import ProxyException, TokenCountRequest from litellm.proxy._types import ProxyException, TokenCountRequest, UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.endpoints import ( from litellm.proxy.anthropic_endpoints.endpoints import (
count_tokens as anthropic_count_tokens, count_tokens as anthropic_count_tokens,
) )
@ -1275,6 +1277,202 @@ async def test_anthropic_endpoint_429_rate_limit_error_format():
proxy_server.count_request_tokens = original_token_counter proxy_server.count_request_tokens = original_token_counter
def _json_request(body: Mapping[str, object], user_agent: str) -> Request:
payload: Final = json.dumps(body).encode()
async def receive() -> Message:
return {"type": "http.request", "body": payload, "more_body": False}
return Request(
{
"type": "http",
"method": "POST",
"path": "/v1/messages/count_tokens",
"query_string": b"",
"headers": [(b"content-type", b"application/json"), (b"user-agent", user_agent.encode())],
},
receive,
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("request_drop_params", "user_agent"),
(({"drop_params": True}, "python-httpx/0.28.1"), ({}, "claude-cli/2.1.0 (external, cli)")),
)
async def test_anthropic_count_tokens_drops_a_malformed_tool_result_text_when_drop_params_applies(
monkeypatch: pytest.MonkeyPatch, request_drop_params: dict[str, bool], user_agent: str
) -> None:
import litellm.proxy.proxy_server as proxy_server
body: Final = {
"model": "gpt-tool-result",
**request_drop_params,
"messages": [
{"role": "user", "content": "run the build"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "toolu_log", "name": "bash", "input": {"cmd": "make"}}],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_log",
"content": [{"type": "text", "text": "build ok"}, {"type": "text", "text": None}],
}
],
},
],
}
router: Final = Router(
model_list=[
{"model_name": "gpt-tool-result", "litellm_params": {"model": "openai/gpt-5.6", "api_key": "sk-test"}}
]
)
request: Final = _json_request(body, user_agent)
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(proxy_server, "proxy_config", proxy_server.ProxyConfig())
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
with respx.mock:
route: Final = respx.post("https://api.openai.com/v1/responses/input_tokens").mock(
return_value=httpx.Response(200, json={"input_tokens": 9})
)
response: Final = await anthropic_count_tokens(request, UserAPIKeyAuth())
assert response["input_tokens"] == 9
sent: Final = json.loads(route.calls.last.request.content)
assert {"type": "function_call_output", "call_id": "toolu_log", "output": "build ok"} in sent["input"]
def _build_log_messages(log_text: JsonValue) -> list[dict[str, JsonValue]]:
return [
{"role": "user", "content": "run the build"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "toolu_log", "name": "bash", "input": {"cmd": "make"}}],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_log",
"content": [{"type": "text", "text": "build ok"}, {"type": "text", "text": log_text}],
}
],
},
]
async def _count_on_a_model_without_a_count_api(
monkeypatch: pytest.MonkeyPatch, body: Mapping[str, object], router_defaults: dict[str, JsonValue] | None = None
) -> JSONResponse | dict[str, JsonValue]:
import litellm.proxy.proxy_server as proxy_server
router: Final = Router(
model_list=[
{
"model_name": "mistral-tool-result",
"litellm_params": {"model": "mistral/mistral-small-latest", "api_key": "sk-test"},
}
],
default_litellm_params=router_defaults,
)
request: Final = _json_request(body, "python-httpx/0.28.1")
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(proxy_server, "proxy_config", proxy_server.ProxyConfig())
with respx.mock:
return await anthropic_count_tokens(request, UserAPIKeyAuth())
@pytest.mark.asyncio
@pytest.mark.parametrize("log_text", (None, 5))
async def test_count_tokens_without_a_count_api_rejects_a_non_string_tool_result_text(
monkeypatch: pytest.MonkeyPatch, log_text: JsonValue
) -> None:
response: Final = await _count_on_a_model_without_a_count_api(
monkeypatch, {"model": "mistral-tool-result", "messages": _build_log_messages(log_text)}
)
assert isinstance(response, JSONResponse)
assert response.status_code == 400
assert json.loads(bytes(response.body))["error"] == {
"type": "invalid_request_error",
"message": "tool_result toolu_log content.1.text: Input should be a valid string",
}
@pytest.mark.asyncio
@pytest.mark.parametrize("model", ("mistral-tool-result", "model-not-in-the-router"))
async def test_count_tokens_without_a_count_api_counts_a_non_string_tool_result_text_under_drop_params(
monkeypatch: pytest.MonkeyPatch, model: str
) -> None:
response: Final = await _count_on_a_model_without_a_count_api(
monkeypatch, {"model": model, "drop_params": True, "messages": _build_log_messages(None)}
)
assert isinstance(response, dict)
assert isinstance(response["input_tokens"], int) and response["input_tokens"] > 0
@pytest.mark.asyncio
@pytest.mark.parametrize(("body_drop_params", "expected_status"), (({}, None), ({"drop_params": False}, 400)))
async def test_count_tokens_honors_router_default_drop_params_unless_the_body_overrides_it(
monkeypatch: pytest.MonkeyPatch, body_drop_params: dict[str, JsonValue], expected_status: int | None
) -> None:
response: Final = await _count_on_a_model_without_a_count_api(
monkeypatch,
{"model": "mistral-tool-result", "messages": _build_log_messages(None), **body_drop_params},
router_defaults={"drop_params": True},
)
match response:
case JSONResponse():
assert response.status_code == expected_status
case _:
assert expected_status is None
assert isinstance(response["input_tokens"], int) and response["input_tokens"] > 0
@pytest.mark.asyncio
async def test_anthropic_count_tokens_on_a_claude_deployment_drops_a_malformed_tool_result_text_under_drop_params(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import litellm.proxy.proxy_server as proxy_server
router: Final = Router(
model_list=[
{
"model_name": "claude-native-drop",
"litellm_params": {
"model": "anthropic/claude-sonnet-5-5",
"api_key": "sk-ant-test",
"drop_params": True,
},
}
]
)
request: Final = _json_request(
{"model": "claude-native-drop", "messages": _build_log_messages(5)}, "python-httpx/0.28.1"
)
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(proxy_server, "proxy_config", proxy_server.ProxyConfig())
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
with respx.mock:
route: Final = respx.post("https://api.anthropic.com/v1/messages/count_tokens").mock(
return_value=httpx.Response(200, json={"input_tokens": 7})
)
response: Final = await anthropic_count_tokens(request, UserAPIKeyAuth())
assert response["input_tokens"] == 7
sent: Final = json.loads(route.calls.last.request.content)
assert sent["messages"][2]["content"][0]["content"] == [{"type": "text", "text": "build ok"}]
class _ScriptedCounter(BaseTokenCounter): class _ScriptedCounter(BaseTokenCounter):
def __init__(self, replies: dict[str, TokenCountResponse], burst: int = 1) -> None: def __init__(self, replies: dict[str, TokenCountResponse], burst: int = 1) -> None:
self._replies = replies self._replies = replies