fix(websearch_interception): ensure spend/cost logging runs when stream=True

The deployment hook now converts stream=True→False in wrapper_async's
scope so the streaming early-return path is skipped and logging executes.
logging_obj.stream is synced after the hook, and the original stream
intent is recovered for the short-circuit path.

Made-with: Cursor
This commit is contained in:
Sameer Kankute 2026-04-09 18:48:38 +05:30
parent 97f722f558
commit cb057ad44b
No known key found for this signature in database
4 changed files with 64 additions and 9 deletions

View file

@ -230,8 +230,15 @@ class WebSearchInterceptionLogger(CustomLogger):
# Keep other tools as-is
converted_tools.append(tool)
# Update tools in-place and return full kwargs
kwargs["tools"] = converted_tools
if kwargs.get("stream"):
verbose_logger.debug(
"WebSearchInterception: deployment hook converting stream=True to stream=False"
)
kwargs["stream"] = False
kwargs["_websearch_interception_converted_stream"] = True
return kwargs
@classmethod
@ -344,13 +351,12 @@ class WebSearchInterceptionLogger(CustomLogger):
else:
converted_tools.append(tool)
# Update kwargs with converted tools
kwargs["tools"] = converted_tools
verbose_logger.debug(
f"WebSearchInterception: Tools after conversion: {[t.get('name') for t in converted_tools]}"
)
# Convert stream=True to stream=False for WebSearch interception
# Also convert here for direct callers that bypass the deployment hook.
if kwargs.get("stream"):
verbose_logger.debug(
"WebSearchInterception: Converting stream=True to stream=False"

View file

@ -187,11 +187,9 @@ async def anthropic_messages(
"""
Async: Make llm api request in Anthropic /messages API spec
"""
# Save original stream flag before pre-request hooks can convert it.
# The websearch interception hook converts stream=True → stream=False
# for the agentic loop, but the short-circuit path needs to know
# whether the caller originally requested streaming.
original_stream = stream
original_stream = stream or kwargs.get(
"_websearch_interception_converted_stream", False
)
# Execute pre-request hooks to allow CustomLoggers to modify request
request_kwargs = await _execute_pre_request_hooks(

View file

@ -1811,6 +1811,11 @@ def client(original_function): # noqa: PLR0915
if modified_kwargs is not None:
kwargs = modified_kwargs
# Sync logging_obj.stream after deployment hooks (they may convert it).
_hook_stream = kwargs.get("stream")
if _hook_stream is not None and logging_obj.stream != _hook_stream:
logging_obj.stream = _hook_stream
kwargs["litellm_logging_obj"] = logging_obj
## LOAD CREDENTIALS
load_credentials_from_list(kwargs)

View file

@ -4,7 +4,7 @@ Unit tests for WebSearch Interception Handler
Tests the WebSearchInterceptionLogger class and helper functions.
"""
from unittest.mock import Mock
from unittest.mock import MagicMock, Mock
import pytest
@ -273,3 +273,49 @@ async def test_async_pre_call_deployment_hook_provider_derived_from_model_name()
# Full kwargs preserved
assert result["model"] == "openai/gpt-4o-mini"
assert result["api_key"] == "fake-key"
@pytest.mark.asyncio
async def test_deployment_hook_converts_stream_and_logging_obj_syncs():
"""
Regression test: websearch interception with stream=True must not skip logging.
Before the fix, the stream conversion only happened in async_pre_request_hook
(inside the anthropic_messages function scope). wrapper_async still saw
stream=True, took the streaming early-return path, and skipped all spend/cost
logging. The fix moves stream conversion into the deployment hook so
wrapper_async sees stream=False, and then syncs logging_obj.stream.
This test verifies:
1. The deployment hook sets stream=False and the converted flag.
2. wrapper_async syncs logging_obj.stream after the hook runs.
"""
logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"])
kwargs = {
"model": "anthropic.claude-opus-4-6-20250219-v1:0",
"messages": [{"role": "user", "content": "Search for LiteLLM"}],
"tools": [
{"type": "web_search_20250305", "name": "web_search", "max_uses": 3},
],
"custom_llm_provider": "bedrock",
"stream": True,
}
result = await logger.async_pre_call_deployment_hook(kwargs=kwargs, call_type=None)
assert result is not None
assert result["stream"] is False
assert result["_websearch_interception_converted_stream"] is True
# Simulate what wrapper_async does after the deployment hook:
# logging_obj.stream was set to True during function_setup (before hook).
# After the hook, wrapper_async must sync it.
logging_obj = MagicMock()
logging_obj.stream = True # original value from function_setup
_hook_stream = result.get("stream")
if _hook_stream is not None and logging_obj.stream != _hook_stream:
logging_obj.stream = _hook_stream
assert logging_obj.stream is False