Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions litellm/integrations/custom_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,27 @@ async def async_build_agentic_loop_plan(
"""
return AgenticLoopPlan(run_agentic_loop=False)

async def async_post_agentic_loop_response_hook(
self,
response: Any,
plan: AgenticLoopPlan,
kwargs: Dict,
) -> Any:
"""
Post-process the response returned by the agentic-loop follow-up call.

Called after BaseLLMHTTPHandler executes ``AgenticLoopPlan.request_patch``
and receives the final response from the provider. Lets callbacks shape
what the client sees without bypassing the loop's safety / observability
machinery (depth tracking, fingerprinting, etc.).

Use ``plan.metadata`` to carry whatever the build step decided to expose
for post-processing (e.g. native tool_result blocks to inject).

Default returns ``response`` unchanged.
"""
return response

async def async_should_run_chat_completion_agentic_loop(
self,
response: Any,
Expand Down
247 changes: 221 additions & 26 deletions litellm/integrations/websearch_interception/handler.py

Large diffs are not rendered by default.

47 changes: 44 additions & 3 deletions litellm/integrations/websearch_interception/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,27 @@ def is_web_search_tool_chat_completion(tool: Dict[str, Any]) -> bool:
return False


def is_anthropic_native_web_search_tool(tool: Dict[str, Any]) -> bool:
"""
Check if a tool is an Anthropic-native ``web_search_*`` tool.

Native clients (Anthropic SDK, Claude Desktop, Anthropic Console) send
tools like ``{"type": "web_search_20250305", "name": "web_search"}`` and
expect the response to contain ``web_search_tool_result`` content blocks
so that citations can be rendered. This helper identifies that contract
so the agentic loop can emit native-format blocks for those clients
without affecting clients that send the LiteLLM standard tool.

Returns False for the LiteLLM standard tool (``litellm_web_search``),
the OpenAI-shaped variant, the bare ``WebSearch`` legacy name, and the
bare ``web_search`` name (Claude Code style).
"""
tool_type = tool.get("type", "")
if not isinstance(tool_type, str):
return False
return tool_type.startswith("web_search_") and tool_type != "function"
Comment thread
greptile-apps[bot] marked this conversation as resolved.


def is_web_search_tool(tool: Dict[str, Any]) -> bool:
"""
Check if a tool is a web search tool (native or LiteLLM standard).
Expand All @@ -135,7 +156,22 @@ def is_web_search_tool(tool: Dict[str, Any]) -> bool:
- OpenAI format: type == "function" with function.name == "litellm_web_search"
- Anthropic native: type starts with "web_search_" (e.g., "web_search_20250305")
- Claude Code: name == "web_search" with a type field
- Custom: name == "WebSearch" (legacy format)
- Custom: name == "WebSearch" (legacy interception marker — only matched
when input_schema is absent; see note below)

Note on the legacy ``WebSearch`` name:
Clients like Claude Desktop / Cowork ship a *client-side* tool called
``WebSearch`` (a fully-formed Anthropic client tool with its own
``input_schema``) that they handle themselves. Treating that as our
interception marker hijacks it server-side and the client's own tool
handler never fires — which means Cowork's separate native
``web_search_20250305`` sub-request (where citation data actually
flows) never gets made.

Real Anthropic client tools always carry an ``input_schema`` (the API
rejects them otherwise), so a bare ``{name: "WebSearch"}`` with no
schema is the only thing that could be a legacy interception marker.
Gate the match on schema absence to keep both groups working.

Args:
tool: Tool dictionary to check
Expand All @@ -152,6 +188,10 @@ def is_web_search_tool(tool: Dict[str, Any]) -> bool:
True
>>> is_web_search_tool({"name": "calculator"})
False
>>> is_web_search_tool({"name": "WebSearch"}) # legacy interception marker
True
>>> is_web_search_tool({"name": "WebSearch", "input_schema": {"type": "object"}}) # Cowork client tool
False
"""
tool_name = tool.get("name", "")
tool_type = tool.get("type", "")
Expand All @@ -175,8 +215,9 @@ def is_web_search_tool(tool: Dict[str, Any]) -> bool:
if tool_name == "web_search" and tool_type:
return True

# Check for legacy WebSearch format
if tool_name == "WebSearch":
# Legacy "WebSearch" interception marker — only when no schema is
# present, so real client-side WebSearch tools (Cowork) pass through.
if tool_name == "WebSearch" and "input_schema" not in tool:
return True

return False
66 changes: 61 additions & 5 deletions litellm/integrations/websearch_interception/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,14 @@ def _detect_from_non_streaming_response(
block_id = getattr(block, "id", None)
block_input = getattr(block, "input", {})

# Check for LiteLLM standard or legacy web search tools
# Handles: litellm_web_search, WebSearch, web_search
# Detect tool_use blocks that came from interception. After
# pre-request conversion the model always sees
# ``litellm_web_search``; the bare ``web_search`` entry handles
# callers that bypass our pre-request hooks (e.g. direct
# litellm.acompletion). "WebSearch" is intentionally omitted —
# see is_web_search_tool for the Cowork rationale.
if block_type == "tool_use" and block_name in (
LITELLM_WEB_SEARCH_TOOL_NAME,
"WebSearch",
"web_search",
):
# Convert to dict for easier handling
Expand Down Expand Up @@ -190,10 +193,12 @@ def _detect_from_openai_response(
getattr(function, "arguments", None) if function else None
)

# Check for LiteLLM standard or legacy web search tools
# Detect function-style web search tool_calls. ``WebSearch`` is
# intentionally omitted — see is_web_search_tool for the Cowork
# rationale (clients ship their own client-side ``WebSearch`` and
# we must not hijack it).
if tool_type == "function" and function_name in (
LITELLM_WEB_SEARCH_TOOL_NAME,
"WebSearch",
"web_search",
):
# Parse arguments (might be JSON string)
Expand Down Expand Up @@ -350,6 +355,57 @@ def _transform_response_openai(

return assistant_message, tool_messages

@staticmethod
def build_web_search_tool_result_block(
tool_use_id: str,
search_response: Optional[SearchResponse],
) -> Dict[str, Any]:
"""
Build an Anthropic-native ``web_search_tool_result`` content block.

Native Anthropic clients (Claude Desktop, the Anthropic SDK, the
Anthropic Console) expect search-tool results to be returned as
structured ``web_search_tool_result`` blocks so that citations and
source links can be rendered. The agentic loop currently feeds the
model a flat text blob in the follow-up call (which is correct — the
model needs readable evidence). This helper produces the *additional*
block that should accompany the model's text reply when the original
request used a native ``web_search_*`` tool.

Spec reference:
https://docs.anthropic.com/en/api/web-search-tool

Args:
tool_use_id: The ``tool_use_id`` the model emitted on the first
turn. Must match exactly so the client can pair the result
with its tool_use block.
search_response: Structured ``SearchResponse`` from
``litellm.asearch()``. If None or empty, the block is still
emitted with an empty result list (signals "search ran, no
results" rather than "search did not run").
"""
items: List[Dict[str, Any]] = []
if search_response is not None:
results = getattr(search_response, "results", None) or []
for r in results:
url = getattr(r, "url", "") or ""
title = getattr(r, "title", "") or ""
page_age = getattr(r, "date", None) or getattr(r, "last_updated", None)
items.append(
{
"type": "web_search_result",
"url": url,
"title": title,
"page_age": page_age,
"encrypted_content": "",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Null page_age may break Anthropic client parsing

Low Severity

When a SearchResult has neither date nor last_updated, page_age resolves to None and is unconditionally included in the dict as "page_age": null. The real Anthropic API likely either includes page_age as a string or omits the key entirely. Since these blocks are meant to perfectly mimic native Anthropic web_search_result blocks for client-side parsing (Claude Desktop, Anthropic SDK), sending an explicit null could cause validation failures in strict SDK Pydantic models. The key could be conditionally included only when a non-None value is available.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2e9bf34. Configure here.

}
)
Comment on lines +393 to +402

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 page_age: None serialises as JSON null

When a search result carries no date, page_age is None and the dict entry becomes "page_age": null in the wire JSON. If Anthropic's client SDK models this field as a plain str (not Optional[str]), deserialisation will fail for every result that lacks a date, dropping the citation entry. Better to omit the key rather than emit a null sentinel. The encrypted_content field has the same structure concern — the Anthropic spec marks it as the actual (encrypted) page body; always sending "" means citation clients that rely on that field for source-text display won't get the content, though the PR screenshots confirm URL/title still render.

return {
"type": "web_search_tool_result",
"tool_use_id": tool_use_id,
"content": items,
}

@staticmethod
def format_search_response(result: SearchResponse) -> str:
"""
Expand Down
21 changes: 20 additions & 1 deletion litellm/llms/custom_httpx/llm_http_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -4634,6 +4634,7 @@ async def _execute_anthropic_agentic_plan(
fingerprints: List[str],
fingerprint: str,
stream: bool = False,
callback: Optional[Any] = None,
) -> Any:
from litellm.anthropic_interface import messages as anthropic_messages

Expand Down Expand Up @@ -4675,7 +4676,7 @@ async def _execute_anthropic_agentic_plan(
kwargs_for_followup["max_agentic_loops"] = max_loops
kwargs_for_followup["_agentic_loop_fingerprints"] = fingerprints + [fingerprint]

return await anthropic_messages.acreate(
response = await anthropic_messages.acreate(
**{
"max_tokens": max_tokens,
"messages": patch.messages,
Expand All @@ -4686,6 +4687,23 @@ async def _execute_anthropic_agentic_plan(
}
)

if callback is not None:
try:
response = await callback.async_post_agentic_loop_response_hook(
response=response, plan=plan, kwargs=kwargs
)
except Exception as e:
_call_id = getattr(logging_obj, "litellm_call_id", "unknown")
verbose_logger.exception(
"LiteLLM.AgenticHookError: Exception in "
"async_post_agentic_loop_response_hook [call_id=%s model=%s]: %s",
_call_id,
model,
str(e),
)

return response

async def _execute_chat_completion_agentic_plan(
self,
plan: AgenticLoopPlan,
Expand Down Expand Up @@ -4869,6 +4887,7 @@ async def _call_agentic_completion_hooks(
fingerprints=fingerprints,
fingerprint=fingerprint,
stream=stream,
callback=callback,
)
except Exception as e:
_call_id = getattr(logging_obj, "litellm_call_id", "unknown")
Expand Down
Loading
Loading