fix(anthropic_adapter): carry web search usage into /v1/messages cost breakdown

For non-Anthropic models served over /v1/messages, the outer wrapper recomputes
cost over the adapter-translated Anthropic response dict. That dict dropped every
web search usage signal, so the recompute overwrote the correct cost breakdown
with a token-only one: x-litellm-response-cost-tool-usage read 0.0 and
x-litellm-response-cost-original excluded the search cost, while the total kept it.

The adapter now maps web search request counts (from Usage.server_tool_use or
Gemini's prompt_tokens_details) into usage.server_tool_use.web_search_requests,
matching the Anthropic API shape, and the Gemini web search cost calculator falls
back to server_tool_use when prompt_tokens_details carries no count. The shared
get_web_search_requests helper is now public since five modules consume it.

Resolves LIT-6288
This commit is contained in:
mateo-berri 2026-08-26 18:14:10 -07:00
parent f677292901
commit 815fa0ff08
13 changed files with 230 additions and 36 deletions

View file

@ -84,7 +84,7 @@
"limit": 56
},
"reportPrivateUsage": {
"limit": 1810
"limit": 1808
},
"reportRedeclaration": {
"limit": 8
@ -135,7 +135,7 @@
"limit": 21
},
"reportUnusedFunction": {
"limit": 139
"limit": 138
},
"reportUnusedImport": {
"limit": 544

View file

@ -7,7 +7,7 @@ from typing import Any, Final, Literal
import litellm
from litellm.constants import OPENAI_FILE_SEARCH_COST_PER_1K_CALLS
from litellm.litellm_core_utils.llm_cost_calc.utils import _get_web_search_requests
from litellm.litellm_core_utils.llm_cost_calc.utils import get_web_search_requests
from litellm.types.llms.openai import (
FileSearchTool,
ResponsesAPIResponse,
@ -368,7 +368,7 @@ class StandardBuiltInToolCostTracking:
get_anthropic_web_search_requests_from_response,
)
if usage is not None and (_get_web_search_requests(getattr(usage, "server_tool_use", None)) is not None):
if usage is not None and (get_web_search_requests(getattr(usage, "server_tool_use", None)) is not None):
return usage
web_search_requests: Final = get_anthropic_web_search_requests_from_response(response_object)
if web_search_requests is None:
@ -416,7 +416,7 @@ class StandardBuiltInToolCostTracking:
# Anthropic Claude (direct API and Vertex AI) uses server_tool_use.web_search_requests.
# Without this check, Claude ModelResponse always falls through to return False
# and _handle_web_search_cost() is never called.
if hasattr(usage, "server_tool_use") and _get_web_search_requests(usage.server_tool_use) is not None:
if hasattr(usage, "server_tool_use") and get_web_search_requests(usage.server_tool_use) is not None:
return True
# xAI reports usage.server_side_tool_usage_details.web_search_calls; a searched
# answer with no url_citation annotations has no other chat-path signal
@ -431,7 +431,7 @@ class StandardBuiltInToolCostTracking:
elif usage is not None:
if (
hasattr(usage, "server_tool_use")
and _get_web_search_requests(usage.server_tool_use) is not None
and get_web_search_requests(usage.server_tool_use) is not None
or (
hasattr(usage, "prompt_tokens_details")
and usage.prompt_tokens_details is not None

View file

@ -72,7 +72,7 @@ def _get_token_detail_value(details: object, key: str) -> int | None:
return value if isinstance(value, int) else None
def _get_web_search_requests(server_tool_use: Any) -> int | None:
def get_web_search_requests(server_tool_use: Any) -> int | None:
"""
Tolerantly read ``web_search_requests`` from a ``server_tool_use`` value
that may be ``None``, a ``dict``, a ``ServerToolUse`` pydantic instance,

View file

@ -8,9 +8,9 @@ from typing import TYPE_CHECKING, Final, Optional
from pydantic import BaseModel, ValidationError
from litellm.litellm_core_utils.llm_cost_calc.utils import (
_get_web_search_requests,
generic_cost_per_token,
get_provider_specific_geo_multiplier,
get_web_search_requests,
)
if TYPE_CHECKING:
@ -104,7 +104,7 @@ def get_cost_for_anthropic_web_search(
if usage is None:
return 0.0
web_search_requests: Final = _get_web_search_requests(getattr(usage, "server_tool_use", None))
web_search_requests: Final = get_web_search_requests(getattr(usage, "server_tool_use", None))
if web_search_requests is None:
return 0.0

View file

@ -99,6 +99,7 @@ from litellm.types.llms.anthropic import (
ContextManagementResponse,
MessageBlockDelta,
MessageDelta,
ServerToolUsage,
StreamingContentBlockDeltaType,
UsageDelta,
UsageIteration,
@ -1354,10 +1355,24 @@ class LiteLLMAnthropicMessagesAdapter:
return explicit_value
return cls._first_positive_prompt_tokens_detail_value(usage, ("cache_creation_tokens", "cache_write_tokens"))
@classmethod
def _get_web_search_request_count(cls, usage: Usage) -> int:
from litellm.litellm_core_utils.llm_cost_calc.utils import (
get_web_search_requests,
)
from_server_tool_use: Final = cls._positive_int(
get_web_search_requests(getattr(usage, "server_tool_use", None))
)
if from_server_tool_use > 0:
return from_server_tool_use
return cls._first_positive_prompt_tokens_detail_value(usage, ("web_search_requests",))
@classmethod
def _translate_openai_usage_to_anthropic_usage_delta(cls, usage: Usage) -> UsageDelta:
cache_read_input_tokens: Final = cls._get_cache_read_input_tokens(usage)
cache_creation_input_tokens: Final = cls._get_cache_creation_input_tokens(usage)
web_search_requests: Final = cls._get_web_search_request_count(usage)
input_tokens: Final = max(
(usage.prompt_tokens or 0) - cache_read_input_tokens - cache_creation_input_tokens,
0,
@ -1371,6 +1386,11 @@ class LiteLLMAnthropicMessagesAdapter:
usage_delta["cache_creation_input_tokens"] = cache_creation_input_tokens
if cache_read_input_tokens > 0:
usage_delta["cache_read_input_tokens"] = cache_read_input_tokens
if web_search_requests > 0:
return UsageDelta(
**usage_delta,
server_tool_use=ServerToolUsage(web_search_requests=web_search_requests),
)
return usage_delta
@classmethod

View file

@ -38,29 +38,40 @@ def cost_per_web_search_request(usage: "Usage", model_info: "ModelInfo") -> floa
Reads the per-request cost from ``search_context_cost_per_query`` in
``model_info`` when available, falling back to $0.035 for models not
yet updated in the pricing JSON.
The request count comes from ``prompt_tokens_details.web_search_requests``
(the native Gemini field), falling back to ``server_tool_use.web_search_requests``
for usage reconstructed from an Anthropic-format response (the /v1/messages
adapter surface).
"""
from litellm.litellm_core_utils.llm_cost_calc.utils import get_web_search_requests
from litellm.types.utils import PromptTokensDetailsWrapper
_DEFAULT_COST: Final = 35e-3
search_costs: Final = model_info.get("search_context_cost_per_query") or {}
_cost: Final = search_costs.get("search_context_size_medium", _DEFAULT_COST)
number_of_web_search_requests = 0
if (
usage is not None
and usage.prompt_tokens_details is not None
and isinstance(usage.prompt_tokens_details, PromptTokensDetailsWrapper)
and hasattr(usage.prompt_tokens_details, "web_search_requests")
and usage.prompt_tokens_details.web_search_requests is not None
):
number_of_web_search_requests = usage.prompt_tokens_details.web_search_requests
requests_from_prompt_details: Final = (
usage.prompt_tokens_details.web_search_requests
if (
usage is not None
and usage.prompt_tokens_details is not None
and isinstance(usage.prompt_tokens_details, PromptTokensDetailsWrapper)
and hasattr(usage.prompt_tokens_details, "web_search_requests")
and usage.prompt_tokens_details.web_search_requests is not None
)
else None
)
requests_from_server_tool_use: Final = get_web_search_requests(getattr(usage, "server_tool_use", None))
number_of_web_search_requests: Final = requests_from_prompt_details or requests_from_server_tool_use or 0
# per_prompt billing: clamp to 1 (flat fee per grounded API call)
billing_mode: Final = model_info.get("web_search_billing_unit") or "per_prompt"
if number_of_web_search_requests > 0 and billing_mode == "per_prompt":
number_of_web_search_requests = 1
billable_requests: Final = (
1 if (number_of_web_search_requests > 0 and billing_mode == "per_prompt") else number_of_web_search_requests
)
return _cost * number_of_web_search_requests
return _cost * billable_requests
GOOGLE_MAPS_GROUNDING_DEFAULT_COST_PER_QUERY: Final = 14e-3

View file

@ -502,11 +502,16 @@ class MessageDelta(TypedDict, total=False):
stop_reason: str | None
class ServerToolUsage(TypedDict, total=False):
web_search_requests: ReadOnly[int]
class UsageDelta(TypedDict, total=False):
input_tokens: int
output_tokens: int
cache_creation_input_tokens: int
cache_read_input_tokens: int
server_tool_use: ReadOnly[ServerToolUsage]
class AppliedEdit(TypedDict, total=False):

View file

@ -1,11 +1,12 @@
from typing import Any, Literal, TypeAlias
from typing_extensions import NotRequired, TypedDict
from typing_extensions import NotRequired, ReadOnly, TypedDict
from litellm.types.llms.anthropic import (
AnthropicResponseContentBlockText,
AnthropicResponseContentBlockToolUse,
ContextManagementResponse,
ServerToolUsage,
)
@ -71,6 +72,11 @@ class AnthropicUsage(TypedDict, total=False):
cache_creation_input_tokens: int
cache_read_input_tokens: int
"""
Server-side tool usage (e.g. web search request counts)
"""
server_tool_use: NotRequired[ReadOnly[ServerToolUsage]]
class AnthropicMessagesResponse(TypedDict, total=False):
"""

View file

@ -8,10 +8,9 @@ See https://github.com/BerriAI/litellm/issues/26153.
import pytest
from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import (
StandardBuiltInToolCostTracking,
_get_web_search_requests,
get_web_search_requests,
)
from litellm.types.utils import ModelResponse, ServerToolUse, Usage
@ -28,25 +27,25 @@ class _UsageWithDictServerToolUse:
def test_get_web_search_requests_handles_none():
assert _get_web_search_requests(None) is None
assert get_web_search_requests(None) is None
def test_get_web_search_requests_handles_dict():
assert _get_web_search_requests({"web_search_requests": 5}) == 5
assert get_web_search_requests({"web_search_requests": 5}) == 5
def test_get_web_search_requests_handles_dict_missing_key():
assert _get_web_search_requests({}) is None
assert get_web_search_requests({}) is None
def test_get_web_search_requests_handles_pydantic():
stu = ServerToolUse(web_search_requests=7)
assert _get_web_search_requests(stu) == 7
assert get_web_search_requests(stu) == 7
def test_get_web_search_requests_handles_pydantic_with_none_value():
stu = ServerToolUse()
assert _get_web_search_requests(stu) is None
assert get_web_search_requests(stu) is None
def test_response_object_includes_web_search_call_with_dict_server_tool_use():

View file

@ -3997,3 +3997,98 @@ def test_translate_anthropic_messages_to_openai_carries_midturn_system_prompt_ca
assert result == [
{"role": "system", "content": [{"type": "text", "text": "fix", "prompt_cache_breakpoint": explicit}]}
]
def _openai_response_with_usage(usage: Usage) -> ModelResponse:
return ModelResponse(
id="resp_web_search",
model="gemini-3-flash-preview",
choices=[
Choices(
finish_reason="stop",
index=0,
message=Message(role="assistant", content="searched"),
)
],
usage=usage,
)
def test_translate_openai_response_to_anthropic_maps_gemini_web_search_usage():
from litellm.types.utils import PromptTokensDetailsWrapper
usage = Usage(
prompt_tokens=385,
completion_tokens=566,
total_tokens=951,
prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=385, web_search_requests=2),
)
anthropic_response = LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(
response=_openai_response_with_usage(usage)
)
assert anthropic_response["usage"]["server_tool_use"] == {"web_search_requests": 2}
def test_translate_openai_response_to_anthropic_maps_server_tool_use_web_search_usage():
from litellm.types.utils import ServerToolUse
usage = Usage(
prompt_tokens=100,
completion_tokens=40,
total_tokens=140,
server_tool_use=ServerToolUse(web_search_requests=3),
)
anthropic_response = LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(
response=_openai_response_with_usage(usage)
)
assert anthropic_response["usage"]["server_tool_use"] == {"web_search_requests": 3}
def test_translate_openai_response_to_anthropic_omits_server_tool_use_without_web_search():
usage = Usage(prompt_tokens=100, completion_tokens=40, total_tokens=140)
anthropic_response = LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(
response=_openai_response_with_usage(usage)
)
assert "server_tool_use" not in anthropic_response["usage"]
def test_completion_cost_on_translated_anthropic_response_includes_web_search():
from litellm.types.utils import PromptTokensDetailsWrapper
adapter = LiteLLMAnthropicMessagesAdapter()
with_search = adapter.translate_openai_response_to_anthropic(
response=_openai_response_with_usage(
Usage(
prompt_tokens=385,
completion_tokens=566,
total_tokens=951,
prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=385, web_search_requests=2),
)
)
)
without_search = adapter.translate_openai_response_to_anthropic(
response=_openai_response_with_usage(Usage(prompt_tokens=385, completion_tokens=566, total_tokens=951))
)
cost_with_search = litellm.completion_cost(
completion_response=with_search,
model="gemini/gemini-3-flash-preview",
call_type="anthropic_messages",
)
cost_without_search = litellm.completion_cost(
completion_response=without_search,
model="gemini/gemini-3-flash-preview",
call_type="anthropic_messages",
)
per_query_cost = litellm.model_cost["gemini/gemini-3-flash-preview"]["search_context_cost_per_query"][
"search_context_size_medium"
]
assert per_query_cost > 0
assert cost_with_search - cost_without_search == pytest.approx(2 * per_query_cost)

View file

@ -8,10 +8,9 @@ See https://github.com/BerriAI/litellm/issues/26153.
import pytest
from litellm.llms.anthropic.cost_calculation import (
_get_web_search_requests,
get_cost_for_anthropic_web_search,
get_web_search_requests,
)
from litellm.types.utils import ModelInfo, ServerToolUse
@ -33,19 +32,19 @@ def _make_model_info(cost_per_query: float = 0.01) -> ModelInfo:
def test_get_web_search_requests_handles_none():
assert _get_web_search_requests(None) is None
assert get_web_search_requests(None) is None
def test_get_web_search_requests_handles_dict():
assert _get_web_search_requests({"web_search_requests": 4}) == 4
assert get_web_search_requests({"web_search_requests": 4}) == 4
def test_get_web_search_requests_handles_dict_missing_key():
assert _get_web_search_requests({}) is None
assert get_web_search_requests({}) is None
def test_get_web_search_requests_handles_pydantic():
assert _get_web_search_requests(ServerToolUse(web_search_requests=2)) == 2
assert get_web_search_requests(ServerToolUse(web_search_requests=2)) == 2
def test_get_cost_for_anthropic_web_search_with_dict_server_tool_use():

View file

@ -84,6 +84,65 @@ def test_no_usage_details():
assert cost == 0.0
def _make_server_tool_use_usage(web_search_requests: int) -> Usage:
from litellm.types.utils import ServerToolUse
return Usage(
prompt_tokens=100,
completion_tokens=50,
total_tokens=150,
server_tool_use=ServerToolUse(web_search_requests=web_search_requests),
)
def test_server_tool_use_fallback_per_query_billing():
"""Usage reconstructed from an Anthropic-format response carries the count in
server_tool_use, not prompt_tokens_details; per_query billing prices each request."""
model_info = {
"key": "gemini/gemini-3-flash-preview",
"web_search_billing_unit": "per_query",
"search_context_cost_per_query": {
"search_context_size_medium": 0.014,
},
}
cost = cost_per_web_search_request(usage=_make_server_tool_use_usage(3), model_info=model_info)
assert cost == pytest.approx(0.014 * 3)
def test_server_tool_use_fallback_per_prompt_clamps_to_one():
"""per_prompt billing clamps the server_tool_use count to one grounded prompt."""
model_info = {
"key": "gemini/gemini-2.5-flash",
"search_context_cost_per_query": {
"search_context_size_medium": 0.035,
},
}
cost = cost_per_web_search_request(usage=_make_server_tool_use_usage(4), model_info=model_info)
assert cost == pytest.approx(0.035 * 1)
def test_prompt_tokens_details_take_precedence_over_server_tool_use():
"""The native Gemini field wins when both counts are present."""
from litellm.types.utils import ServerToolUse
model_info = {
"key": "gemini/gemini-3-flash-preview",
"web_search_billing_unit": "per_query",
"search_context_cost_per_query": {
"search_context_size_medium": 0.014,
},
}
usage = Usage(
prompt_tokens=100,
completion_tokens=50,
total_tokens=150,
prompt_tokens_details=PromptTokensDetailsWrapper(web_search_requests=2),
server_tool_use=ServerToolUse(web_search_requests=5),
)
cost = cost_per_web_search_request(usage=usage, model_info=model_info)
assert cost == pytest.approx(0.014 * 2)
def _make_maps_usage(google_maps_grounding_requests: int) -> Usage:
return Usage(
prompt_tokens=100,

View file

@ -27,7 +27,7 @@
"limit": 0
},
"LIT010": {
"limit": 16619
"limit": 16616
},
"LIT011": {
"limit": 5583