From d96f76ca66cfa987377da70edd4de4e01361b903 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:24:53 -0700 Subject: [PATCH] fix(cost-tracking): bill web searches reported only in server_side_tool_usage_details --- .../llm_cost_calc/tool_call_cost_tracking.py | 15 ++++++++ .../test_tool_call_cost_tracking.py | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py b/litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py index fa4fc59ab77..2863c9c15cb 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py +++ b/litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py @@ -2,6 +2,7 @@ Helper utilities for tracking the cost of built-in tools. """ +from collections.abc import Mapping from typing import Any, Final, Literal import litellm @@ -23,6 +24,14 @@ from litellm.types.utils import ( ) +def _usage_reports_server_side_web_search_calls(usage: Usage) -> bool: + details: Final = getattr(usage, "server_side_tool_usage_details", None) + if not isinstance(details, Mapping): + return False + calls: Final = details.get("web_search_calls") + return isinstance(calls, int) and calls > 0 + + class StandardBuiltInToolCostTracking: """ Helper class for tracking the cost of built-in tools @@ -351,6 +360,10 @@ class StandardBuiltInToolCostTracking: # 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: 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 + if _usage_reports_server_side_web_search_calls(usage): + return True return False elif isinstance(response_object, ResponsesAPIResponse): # response api explicitly includes web_search_call in the output @@ -370,6 +383,8 @@ class StandardBuiltInToolCostTracking: ) ): return True + if _usage_reports_server_side_web_search_calls(usage): + return True return False diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py index 4d60d2acc14..7f735982129 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py @@ -632,3 +632,38 @@ def test_response_includes_output_type_reads_dict_output_items(): assert not StandardBuiltInToolCostTracking.response_includes_output_type( response_object=response, output_type="file_search_call" ) + + +def test_web_search_gate_reads_server_side_tool_usage_details_without_citations(): + """ + Regression: xAI chat responses bridged from the Responses API only carry + usage.server_side_tool_usage_details; a searched answer with no url_citation + annotations must still be billed for its web search calls. + """ + from litellm.llms.xai.cost_calculator import _DEFAULT_WEB_SEARCH_COST_PER_CALL + from litellm.types.utils import Usage + + usage = Usage( + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + server_side_tool_usage_details={"web_search_calls": 3}, + ) + response = ModelResponse(model="xai/grok-4.5") + + assert StandardBuiltInToolCostTracking.response_object_includes_web_search_call( + response_object=response, usage=usage + ) + assert not StandardBuiltInToolCostTracking.response_object_includes_web_search_call( + response_object=response, + usage=Usage(prompt_tokens=10, completion_tokens=20, total_tokens=30), + ) + + cost = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools( + model="xai/grok-4.5", + response_object=response, + usage=usage, + custom_llm_provider="xai", + standard_built_in_tools_params=None, + ) + assert cost == 3 * _DEFAULT_WEB_SEARCH_COST_PER_CALL