mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
f677292901
commit
815fa0ff08
13 changed files with 230 additions and 36 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
"limit": 0
|
||||
},
|
||||
"LIT010": {
|
||||
"limit": 16619
|
||||
"limit": 16616
|
||||
},
|
||||
"LIT011": {
|
||||
"limit": 5583
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue