fix(websearch)!: keep the provider prefix on the agentic follow-up model (#38885)

* fix(websearch): keep the provider prefix on the agentic follow-up model

The agentic follow-up rebuilt its model string with "a slash means it is already
qualified". Handlers are handed the provider-stripped model, and for a provider
that routes through a sub-path the remainder still holds a slash, so
bedrock/mantle/anthropic.claude-sonnet-5 arrived as mantle/anthropic.claude-sonnet-5
and stayed that way. The follow-up acompletion could not resolve a provider from it
and raised "LLM Provider NOT provided".

Prefix unless the model is already qualified with that same provider, in one shared
helper used by both the handler and the websearch patch builder, which carried
copies of the same heuristic. This also fixes openrouter, whose stripped model
keeps a slash for the same reason, and the old startswith test that read openai as
a prefix of openai_like.

Fixes #38829

* test(websearch): cover the agentic follow-up call site

The helper had unit tests but the call site did not, so the two changed lines in
_execute_chat_completion_agentic_plan were uncovered. Drive the real follow-up with
mock_response instead of patching litellm, which keeps the test on the behaviour:
against the old code the sub-path case raises "LLM Provider NOT provided", and the
ordinary-model case passes either way as a guard.

* refactor(websearch): trim the comments on the provider-qualify helper

* refactor(websearch): cut the helper and test docstrings to one line

* fix(websearch): leave a hook-qualified follow-up model on its own provider

A hook that patches in its own model owns that choice, so re-qualifying it sent a
cross-provider follow-up to the original request's provider instead. Only a bare
patched model and the request's provider-stripped model take the prefix now, and
the sibling agentic-loop path resolves the model the same way.

* fix(websearch): fall back to the request model when a patch carries no model

An empty patched model dispatched a provider-only name instead of falling back, and a
provider-stripped sub-path copied into the patch lost its prefix. A patched model now
keeps its own provider only when its leading segment names one.

* fix(websearch): keep the provider prefix on the responses follow-up model too

The responses patch builder still used the old "a slash means already qualified" test, so
a provider-stripped sub-path model reached the follow-up unqualified on that surface. It
now resolves the model the same way the chat completion builder does.

* fix(websearch): read custom_llm_provider without a member call on untyped kwargs

* fix(websearch): type the chat patch builder kwargs instead of working around them

* test(websearch): run the real search path and flatten the qualify helper tests

* fix(websearch): route a hook's model on a JSON-configured provider and keep the request provider on an echoed model

* fix(websearch): leave a follow-up model alone when its prefix already routes to the request provider

* fix(websearch): send the agentic follow-up to the model the caller requested

completion() and responses() record the caller's model before routing strips
it, and the chat and Responses follow-up builders prefer that model. This keeps
bedrock/converse_like/, bedrock/claude_platform/ and openrouter/openrouter/*
deployments on their own route for the follow-up.

* test(websearch): cover the recorded caller model in Headroom's retrieval follow-up

* test(websearch): cover the recorded caller model in Compresr's retrieval follow-up

Also merges the repeated provider-prefix check in core_helpers into one helper
and drops a test docstring

* test(websearch): drive the real search path through a mocked Perplexity HTTP route

* refactor(websearch): read the provider from kwargs inside _caller_model

* test(http-handler): type the agentic follow-up provider tests

* refactor(websearch): record agentic loop params through a Logging method

Moving the recording out of completion() keeps it under basedpyright's
code-flow complexity limit after the merge with main.

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
Priyansh Nandwana 2026-10-29 17:17:18 +05:30 • committed by GitHub
parent 0cf2bb7296
commit 67ae3fb527
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 592 additions and 22 deletions

View file

@ -37,6 +37,7 @@ from litellm.integrations.websearch_interception.transformation import (
from litellm.litellm_core_utils.agentic_loop_settings import (
validated_max_agentic_loops,
)
from litellm.litellm_core_utils.core_helpers import qualify_provider_stripped_model
from litellm.llms.base_llm.search.transformation import SearchResponse
from litellm.types.integrations.custom_logger import (
CHAT_COMPLETION_AGENTIC_SURFACE,
@ -114,6 +115,18 @@ class _WebSearchSettingsView(TypedDict):
websearch_interception_params: WebSearchInterceptionConfig
def _caller_model(model: str, logging_obj: "LiteLLMLoggingObj | None", kwargs: Mapping[str, object]) -> str:
agentic_view: Final[_AgenticLoopParamsView] = {
"agentic_loop_params": logging_obj.model_call_details.get("agentic_loop_params", {}) if logging_obj else {}
}
recorded_model: Final = agentic_view["agentic_loop_params"].get("model")
custom_llm_provider: Final = kwargs.get("custom_llm_provider")
return qualify_provider_stripped_model(
recorded_model if isinstance(recorded_model, str) and recorded_model else model,
custom_llm_provider if isinstance(custom_llm_provider, str) else "",
)
class _SearchToolLitellmParams(TypedDict, total=False):
search_provider: ReadOnly[str | None]
@ -1155,6 +1168,7 @@ class WebSearchInterceptionLogger(CustomLogger):
tool_calls=tool_calls,
optional_params=optional_params,
kwargs=kwargs,
logging_obj=logging_obj,
response_format=response_format,
)
return AgenticLoopPlan(
@ -1181,6 +1195,7 @@ class WebSearchInterceptionLogger(CustomLogger):
tool_calls=tool_calls,
optional_params=optional_params,
kwargs=kwargs,
logging_obj=logging_obj,
)
return AgenticLoopPlan(
run_agentic_loop=True,
@ -1195,6 +1210,7 @@ class WebSearchInterceptionLogger(CustomLogger):
tool_calls: list[dict],
optional_params: dict,
kwargs: dict,
logging_obj: "LiteLLMLoggingObj | None",
) -> AgenticLoopRequestPatch:
"""Execute litellm.asearch() and build a Responses API rerun patch."""
search_tasks: Final = [
@ -1254,9 +1270,7 @@ class WebSearchInterceptionLogger(CustomLogger):
}
}
full_model_name = model
if "/" not in model and isinstance(kwargs.get("custom_llm_provider"), str):
full_model_name = f"{kwargs['custom_llm_provider']}/{model}"
full_model_name: Final = _caller_model(model, logging_obj, kwargs)
verbose_logger.debug(
"WebSearchInterception: Built responses request patch model=%s input_items=%d searches=%d",
@ -1814,6 +1828,7 @@ class WebSearchInterceptionLogger(CustomLogger):
tool_calls=tool_calls,
optional_params=dict[str, object](optional_params),
kwargs=dict[str, object](kwargs),
logging_obj=logging_obj,
response_format=response_format,
)
if request_patch.messages is None:
@ -1836,7 +1851,8 @@ class WebSearchInterceptionLogger(CustomLogger):
messages: list[dict],
tool_calls: list[dict],
optional_params: dict,
kwargs: dict,
kwargs: dict[str, object],
logging_obj: "LiteLLMLoggingObj | None",
response_format: str = "openai",
) -> AgenticLoopRequestPatch:
"""Execute litellm.search() and build chat-completion rerun patch."""
@ -1922,11 +1938,7 @@ class WebSearchInterceptionLogger(CustomLogger):
k: v for k, v in kwargs.items() if not k.startswith("_websearch_interception") and k not in internal_params
}
full_model_name = model
if "custom_llm_provider" in kwargs:
custom_llm_provider: Final = kwargs["custom_llm_provider"]
if not model.startswith(custom_llm_provider) and "/" not in model:
full_model_name = f"{custom_llm_provider}/{model}"
full_model_name: Final = _caller_model(model, logging_obj, kwargs)
verbose_logger.debug(
"WebSearchInterception: Built chat completion request patch model=%s messages=%d",

View file

@ -15,6 +15,7 @@ from litellm.litellm_core_utils.agentic_loop_settings import (
DEFAULT_MAX_AGENTIC_LOOPS,
validated_max_agentic_loops,
)
from litellm.litellm_core_utils.core_helpers import qualify_agentic_followup_model
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject
from litellm.llms.base_llm.base_model_iterator import MockResponseIterator
from litellm.types.integrations.custom_logger import (
@ -182,9 +183,7 @@ async def _execute_chat_completion_agentic_plan(
if patch.messages is None:
raise ValueError("Agentic loop plan missing patched messages")
full_model_name = patch.model or model
if "/" not in full_model_name:
full_model_name = f"{custom_llm_provider}/{full_model_name}"
full_model_name: Final = qualify_agentic_followup_model(patch.model, model, custom_llm_provider)
optional_params_for_followup: Final = {**optional_params, **patch.optional_params}
if patch.tools is not None:

View file

@ -34,6 +34,49 @@ def is_codex_user_agent(user_agent: str) -> bool:
return bool(_CODEX_CLIENT_PREFIX_RE.match(user_agent))
def qualify_provider_stripped_model(model: str, custom_llm_provider: str) -> str:
"""Put the provider prefix back on a provider-stripped model."""
if not custom_llm_provider or model.startswith(f"{custom_llm_provider}/"):
return model
if _already_routes_to(model, custom_llm_provider):
return model
return f"{custom_llm_provider}/{model}"
def _already_routes_to(model: str, custom_llm_provider: str) -> bool:
from litellm.litellm_core_utils.get_llm_provider_logic import inferred_provider
return _leads_with_provider(model) and inferred_provider(model) == custom_llm_provider
def _leads_with_provider(model: str) -> bool:
leading_segment, separator, _ = model.partition("/")
return bool(separator) and names_a_provider(leading_segment)
def names_a_provider(candidate: str) -> bool:
"""Whether a model name's leading segment is a provider rather than part of the model."""
import litellm
from litellm.litellm_core_utils.get_llm_provider_logic import is_registered_custom_provider
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
provider_names: Final = {getattr(provider, "value", provider) for provider in litellm.provider_list}
return (
candidate in provider_names
or JSONProviderRegistry.exists(candidate)
or is_registered_custom_provider(candidate)
)
def qualify_agentic_followup_model(patch_model: str | None, model: str, custom_llm_provider: str) -> str:
"""Resolve the model an agentic follow-up re-dispatches, keeping a hook's own provider."""
if not patch_model or patch_model == model:
return qualify_provider_stripped_model(model, custom_llm_provider)
if _leads_with_provider(patch_model):
return patch_model
return qualify_provider_stripped_model(patch_model, custom_llm_provider)
def safe_divide_seconds(seconds: float, denominator: float, default: float | None = None) -> float | None:
"""
Safely divide seconds by denominator, handling zero division.

View file

@ -165,6 +165,7 @@ from litellm.types.rerank import RerankResponse
from litellm.types.router import RouterRateLimitError
from litellm.types.utils import (
DEPLOYMENT_SCOPED_PRICING_FIELDS,
AgenticLoopParams,
CachingDetails,
CallTypes,
CostBreakdown,
@ -1158,6 +1159,9 @@ class Logging(LiteLLMLoggingBaseClass):
if "custom_llm_provider" in self.model_call_details:
self.custom_llm_provider = self.model_call_details["custom_llm_provider"]
def record_agentic_loop_params(self, params: AgenticLoopParams) -> None:
self.model_call_details["agentic_loop_params"] = params
def record_applied_timeout(self, timeout: float | Timeout) -> None:
self.applied_timeout = timeout
self.litellm_params["timeout"] = timeout

View file

@ -50,7 +50,11 @@ from litellm.litellm_core_utils.audio_utils.subtitle_utils import (
SUBTITLE_RESPONSE_FORMATS,
synthesize_subtitle_document,
)
from litellm.litellm_core_utils.core_helpers import as_str_mapping, set_provider_response_headers_in_hidden_params
from litellm.litellm_core_utils.core_helpers import (
as_str_mapping,
qualify_agentic_followup_model,
set_provider_response_headers_in_hidden_params,
)
from litellm.litellm_core_utils.dot_notation_indexing import delete_nested_value
from litellm.litellm_core_utils.get_litellm_params import AWS_CREDENTIAL_KWARGS_KEYS
from litellm.litellm_core_utils.get_llm_provider_logic import get_provider_affinity_header
@ -6275,9 +6279,7 @@ class BaseLLMHTTPHandler:
if patch.messages is None:
raise ValueError("Agentic loop plan missing patched messages")
full_model_name = patch.model or model
if "/" not in full_model_name:
full_model_name = f"{custom_llm_provider}/{full_model_name}"
full_model_name: Final = qualify_agentic_followup_model(patch.model, model, custom_llm_provider)
optional_params_for_followup: Final = dict(optional_params)
optional_params_for_followup.update(patch.optional_params)

View file

@ -155,6 +155,7 @@ from litellm.types.litellm_params import ControlOptions, RetryStrategy
from litellm.types.llms.openai import AllMessageValues
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import (
AgenticLoopParams,
BaseUrlRoutingHint,
CustomPricingLiteLLMParams,
ModelResponseStream,
@ -1272,6 +1273,16 @@ def _get_router_deployment_id(kwargs: dict) -> str | None:
return None
def _record_agentic_loop_params(
logging_obj: object, requested_model: str, custom_llm_provider: str, owns_agentic_loop: bool
) -> None:
if not owns_agentic_loop or not isinstance(logging_obj, LiteLLMLoggingObj):
return
logging_obj.record_agentic_loop_params(
AgenticLoopParams(model=requested_model, custom_llm_provider=custom_llm_provider)
)
def _register_custom_pricing_for_request(
model: str,
custom_llm_provider: str,
@ -5691,6 +5702,7 @@ def completion(
**{k: kwargs[k] for k in OPTIONAL_KWARGS_KEYS | {"provider_specific_header"} if k in kwargs},
"extra_headers": extra_headers,
}
requested_model: Final = model
model, custom_llm_provider, dynamic_api_key, api_base = get_llm_provider(
model=model,
custom_llm_provider=custom_llm_provider,
@ -5702,6 +5714,12 @@ def completion(
else None
),
)
_record_agentic_loop_params(
logging_obj=logging,
requested_model=requested_model,
custom_llm_provider=custom_llm_provider,
owns_agentic_loop=not skip_responses_api_bridge,
)
deployment_model_info: Final = str_keyed_mapping(
cast(object, model_info) # cast-ok: model_info is the untyped dict kwarg

View file

@ -90,6 +90,7 @@ from litellm.types.responses.streaming_websocket import ResponsesWebSocketReques
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import (
ADDRESSED_RESPONSE_ID_FIELD,
AgenticLoopParams,
CallTypes,
CostPerToken,
CustomPricingLiteLLMParams,
@ -1580,6 +1581,7 @@ def responses(
local_vars=local_vars,
use_chat_completions_api=use_chat_completions_api,
)
litellm_logging_obj.record_agentic_loop_params(AgenticLoopParams(model=model))
from litellm.litellm_core_utils.core_helpers import attribute_or_none

View file

@ -263,10 +263,10 @@ class AgenticLoopParams(TypedDict, total=False):
"""
model: str
"""The model string with provider prefix (e.g., 'bedrock/invoke/...')"""
"""The model as the caller requested it, usually with its provider prefix (e.g., 'bedrock/invoke/...')"""
custom_llm_provider: str
"""The LLM provider name (e.g., 'bedrock', 'anthropic')"""
"""The LLM provider name (e.g., 'bedrock', 'anthropic'), when the recorder resolved one"""
class OffPeakWindow(TypedDict, total=False):

View file

@ -0,0 +1,18 @@
from typing import Final
import pytest
import respx
import litellm
PERPLEXITY_SEARCH_URL: Final = "https://api.perplexity.ai/search"
@pytest.fixture
def perplexity_search(monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter) -> respx.Route:
monkeypatch.setenv("PERPLEXITYAI_API_KEY", "test-perplexity-key")
monkeypatch.delenv("PERPLEXITY_API_BASE", raising=False)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
return respx_mock.post(PERPLEXITY_SEARCH_URL).respond(
json={"results": [{"title": "AI news", "url": "https://example.com/ai-news", "snippet": "AI news."}]}
)

View file

@ -5,15 +5,19 @@ Tests the end-to-end flow of websearch_interception callback with
litellm.acompletion() for transparent server-side web search execution.
"""
from unittest.mock import MagicMock
import datetime
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import respx
import litellm
from litellm.integrations.websearch_interception.handler import (
WebSearchInterceptionLogger,
)
from litellm.types.utils import LlmProviders, ModelResponse
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.types.utils import CallTypes, LlmProviders, ModelResponse
@pytest.fixture
@ -378,7 +382,6 @@ async def test_execute_chat_completion_agentic_loop_strips_tool_choice():
optional_params into the follow-up params without explicit removal causes the
model to call the search tool again instead of synthesizing an answer.
"""
from unittest.mock import patch
websearch_logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI])
@ -422,3 +425,54 @@ async def test_execute_chat_completion_agentic_loop_strips_tool_choice():
if __name__ == "__main__":
# Run with: pytest test_websearch_chat_completion.py -v -s
pytest.main([__file__, "-v", "-s"])
@pytest.mark.asyncio
@pytest.mark.parametrize(
"requested_model,handler_model,custom_llm_provider",
[
("openrouter/openrouter/free", "openrouter/free", "openrouter"),
("bedrock/converse_like/my-endpoint-model", "my-endpoint-model", "bedrock"),
],
)
async def test_chat_followup_reuses_the_model_the_caller_requested(
requested_model: str, handler_model: str, custom_llm_provider: str, perplexity_search: respx.Route
) -> None:
"""The provider handler sees the model after its routing prefixes are stripped, which
re-qualifying with the provider alone cannot restore."""
logging_obj: Final = Logging(
model=requested_model,
messages=[],
stream=False,
call_type=CallTypes.completion.value,
start_time=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
litellm_call_id="call-chat-followup",
function_id="fn-chat-followup",
)
litellm.completion(
model=requested_model,
messages=[{"role": "user", "content": "What's the latest AI news?"}],
mock_response="searching",
litellm_logging_obj=logging_obj,
)
logger: Final = WebSearchInterceptionLogger()
plan: Final = await logger.async_build_chat_completion_agentic_loop_plan(
tools={
"tool_calls": [{"id": "call_abc", "name": "litellm_web_search", "input": {"query": "latest ai news"}}],
"tool_type": "websearch",
"provider": custom_llm_provider,
"response_format": "openai",
},
model=handler_model,
messages=[{"role": "user", "content": "What's the latest AI news?"}],
response=MagicMock(),
optional_params={},
logging_obj=logging_obj,
stream=False,
kwargs={"custom_llm_provider": custom_llm_provider},
)
assert plan.request_patch is not None
assert plan.request_patch.model == requested_model
assert perplexity_search.call_count == 1

View file

@ -6,17 +6,22 @@ tool calls returned by /v1/responses, executes the search server-side, and
builds a Responses-format follow-up request.
"""
import datetime
from types import SimpleNamespace
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import litellm
from litellm.integrations.websearch_interception.handler import (
WebSearchInterceptionLogger,
)
from litellm.types.integrations.custom_logger import (
RESPONSES_AGENTIC_SURFACE,
)
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.types.utils import CallTypes, LlmProviders
@ -252,6 +257,112 @@ async def test_build_responses_plan_produces_responses_input():
assert patch_obj.model == "openai/gpt-4o"
_WEB_SEARCH_FUNCTION_CALL: Final = {
"tool_calls": [
{
"id": "fc_1",
"call_id": "fc_1",
"type": "function_call",
"name": "litellm_web_search",
"arguments": '{"query": "latest ai news"}',
"input": {"query": "latest ai news"},
}
],
"tool_type": "websearch",
"response_format": "responses",
}
async def _responses_followup_model(model: str, custom_llm_provider: str, logging_obj: Logging | None) -> str | None:
logger: Final = WebSearchInterceptionLogger()
plan: Final = await logger.async_build_responses_agentic_loop_plan(
tools={**_WEB_SEARCH_FUNCTION_CALL, "provider": custom_llm_provider},
model=model,
messages=[{"role": "user", "content": "What's the latest AI news?"}],
response=_responses_output_with_web_search(),
optional_params={"tools": [{"type": "function", "name": "litellm_web_search"}]},
logging_obj=logging_obj,
stream=False,
kwargs={
"custom_llm_provider": custom_llm_provider,
"_agentic_loop_api_surface": RESPONSES_AGENTIC_SURFACE,
},
)
assert plan.request_patch is not None
return plan.request_patch.model
@pytest.mark.asyncio
@pytest.mark.usefixtures("perplexity_search")
async def test_build_responses_plan_keeps_the_provider_on_a_sub_path_model() -> None:
"""#38829 on the Responses surface: a provider-stripped model whose remainder still holds a
slash was left unqualified, so the follow-up could not resolve a provider."""
followup_model: Final = await _responses_followup_model("mantle/anthropic.claude-sonnet-5", "bedrock", None)
assert followup_model == "bedrock/mantle/anthropic.claude-sonnet-5"
@pytest.mark.asyncio
@pytest.mark.usefixtures("perplexity_search")
async def test_responses_followup_reuses_the_model_the_caller_requested() -> None:
"""An OpenRouter model whose own id starts with "openrouter/" reaches the handler as
"openrouter/free", which re-qualifying alone cannot tell apart from a request for model "free"."""
requested_model: Final = "openrouter/openrouter/free"
logging_obj: Final = Logging(
model=requested_model,
messages=[],
stream=False,
call_type=CallTypes.responses.value,
start_time=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
litellm_call_id="call-responses-followup",
function_id="fn-responses-followup",
)
litellm.responses(
model=requested_model,
input="What's the latest AI news?",
mock_response="searching",
litellm_logging_obj=logging_obj,
)
followup_model: Final = await _responses_followup_model("openrouter/free", "openrouter", logging_obj)
assert followup_model == requested_model
@pytest.mark.asyncio
@pytest.mark.usefixtures("perplexity_search")
async def test_responses_followup_keeps_the_caller_model_across_the_chat_completions_bridge() -> None:
"""A Responses request bridged to chat completions re-enters completion() with the stripped model."""
requested_model: Final = "openrouter/openrouter/free"
logging_obj: Final = Logging(
model=requested_model,
messages=[],
stream=False,
call_type=CallTypes.responses.value,
start_time=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
litellm_call_id="call-bridged-followup",
function_id="fn-bridged-followup",
)
litellm.responses(
model=requested_model,
input="What's the latest AI news?",
mock_response="searching",
litellm_logging_obj=logging_obj,
)
litellm.completion(
model="openrouter/free",
custom_llm_provider="openrouter",
messages=[{"role": "user", "content": "What's the latest AI news?"}],
mock_response="searching",
litellm_logging_obj=logging_obj,
_skip_responses_api_bridge=True,
)
followup_model: Final = await _responses_followup_model("openrouter/free", "openrouter", logging_obj)
assert followup_model == requested_model
@pytest.mark.asyncio
async def test_deployment_hook_converts_native_responses_web_search_tool():
"""async_pre_call_deployment_hook converts a native Responses web_search tool

View file

@ -239,6 +239,7 @@ class TestCallSiteWiring:
tool_calls=tool_calls,
optional_params={},
kwargs={},
logging_obj=None,
)
call_kwargs = mock_asearch.await_args.kwargs

View file

@ -364,6 +364,49 @@ async def test_dispatcher_runs_followup_with_incremented_depth_and_patched_messa
assert logger.cleanup_calls == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
"model,custom_llm_provider,routed_model",
[
("cohere/command-a-03-2025", "cohere_chat", "command-a-03-2025"),
("anthropic/claude-2", "anthropic_text", "claude-2"),
],
)
async def test_sdk_followup_keeps_a_model_whose_prefix_already_routes_to_its_provider(
model: str, custom_llm_provider: str, routed_model: str
) -> None:
plan: Final = AgenticLoopPlan(
run_agentic_loop=True,
request_patch=AgenticLoopRequestPatch(messages=_patched_messages()),
)
gate: Final = _GateOnlyLogger(plan=plan, tool_calls={"tool_calls": [{"id": "call_abc"}]})
logging_obj: Final = Logging(
model=model,
messages=[{"role": "user", "content": "what is 6*7?"}],
stream=False,
call_type="acompletion",
start_time=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
litellm_call_id="call-prefix",
function_id="fn-prefix",
dynamic_success_callbacks=[gate],
)
followup: Final = await maybe_run_chat_completion_agentic_loop(
response=_tool_call_model_response(),
model=model,
messages=[{"role": "user", "content": "what is 6*7?"}],
optional_params={},
kwargs={"mock_response": "done"},
logging_obj=logging_obj,
custom_llm_provider=custom_llm_provider,
stream=False,
)
assert isinstance(followup, ModelResponse)
assert followup.model == routed_model
assert followup._hidden_params["custom_llm_provider"] == custom_llm_provider
@pytest.mark.asyncio
async def test_dispatcher_followup_does_not_repeat_a_request_param_found_in_request_kwargs(
restore_callbacks,

View file

@ -26,6 +26,8 @@ from litellm.litellm_core_utils.core_helpers import (
normalize_drop_params,
preserve_upstream_non_openai_attributes,
process_response_headers,
qualify_agentic_followup_model,
qualify_provider_stripped_model,
reconstruct_model_name,
redact_nested_match_and_regex_keys,
set_provider_response_headers_in_hidden_params,
@ -637,6 +639,87 @@ class TestProviderResponseHeadersInHiddenParams:
assert "additional_headers" not in sibling._hidden_params
@pytest.mark.parametrize(
"model,provider,expected",
[
("mantle/anthropic.claude-sonnet-5", "bedrock", "bedrock/mantle/anthropic.claude-sonnet-5"),
("invoke/anthropic.claude-v2", "bedrock", "bedrock/invoke/anthropic.claude-v2"),
("openai/gpt-4o", "openrouter", "openrouter/openai/gpt-4o"),
("gpt-4o", "openai", "openai/gpt-4o"),
("claude-sonnet-4-5", "anthropic", "anthropic/claude-sonnet-4-5"),
],
)
def test_the_provider_prefix_is_restored(model: str, provider: str, expected: str) -> None:
assert qualify_provider_stripped_model(model, provider) == expected
@pytest.mark.parametrize(
"model,provider",
[
("bedrock/mantle/anthropic.claude-sonnet-5", "bedrock"),
("openai/gpt-4o", "openai"),
("cohere/command-a-03-2025", "cohere_chat"),
("ai21/jamba-1.5-large", "ai21_chat"),
("anthropic/claude-2", "anthropic_text"),
],
)
def test_an_already_qualified_model_is_left_alone(model: str, provider: str) -> None:
assert qualify_provider_stripped_model(model, provider) == model
def test_a_provider_that_only_shares_a_prefix_is_still_qualified() -> None:
assert qualify_provider_stripped_model("openai_like/foo", "openai") == "openai/openai_like/foo"
def test_no_provider_leaves_the_model_untouched() -> None:
assert qualify_provider_stripped_model("gpt-4o", "") == "gpt-4o"
def test_a_cross_provider_patched_model_is_dispatched_as_the_hook_asked() -> None:
assert qualify_agentic_followup_model("anthropic/claude-sonnet-4-5", "gpt-4o", "openai") == (
"anthropic/claude-sonnet-4-5"
)
def test_a_bare_patched_model_takes_the_request_provider() -> None:
assert qualify_agentic_followup_model("gpt-4o-mini", "gpt-4o", "openai") == "openai/gpt-4o-mini"
def test_a_sub_path_request_model_keeps_its_provider() -> None:
assert qualify_agentic_followup_model(None, "mantle/anthropic.claude-sonnet-5", "bedrock") == (
"bedrock/mantle/anthropic.claude-sonnet-5"
)
def test_an_unpatched_ordinary_model_takes_the_request_provider() -> None:
assert qualify_agentic_followup_model(None, "gpt-4o", "openai") == "openai/gpt-4o"
def test_a_patched_model_already_holding_the_request_provider_is_left_alone() -> None:
assert qualify_agentic_followup_model("openai/gpt-4o", "gpt-4o", "openai") == "openai/gpt-4o"
@pytest.mark.parametrize("patched", ["", None])
def test_a_patch_without_a_model_falls_back_to_the_request_model(patched: str | None) -> None:
assert qualify_agentic_followup_model(patched, "gpt-4o", "openai") == "openai/gpt-4o"
def test_a_patched_sub_path_model_still_takes_the_request_provider() -> None:
assert qualify_agentic_followup_model("mantle/anthropic.claude-sonnet-5", "gpt-4o", "bedrock") == (
"bedrock/mantle/anthropic.claude-sonnet-5"
)
def test_a_patched_model_on_a_json_configured_provider_reaches_that_provider() -> None:
assert qualify_agentic_followup_model("aihubmix/gpt-4o", "gpt-4o", "openai") == "aihubmix/gpt-4o"
def test_a_patch_echoing_the_request_model_keeps_the_request_provider() -> None:
assert qualify_agentic_followup_model("openai/gpt-4o", "openai/gpt-4o", "openrouter") == (
"openrouter/openai/gpt-4o"
)
def test_spend_metadata_merge_survives_the_logging_thread_inserting_into_the_request_metadata() -> None:
metadata: Final[dict[str, object]] = {}

View file

@ -5634,6 +5634,63 @@ async def test_anthropic_messages_dropped_safeguards_send_neither_the_field_nor_
assert "dangerous-tool-use-2026-09-03" not in sent.headers.get("anthropic-beta", ""), dict(sent.headers)
class TestAgenticFollowUpKeepsTheProviderPrefix:
@staticmethod
async def _run_followup(
model: str, custom_llm_provider: str, patched_model: str | None = None
) -> ModelResponse:
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
from litellm.types.integrations.custom_logger import (
AgenticLoopPlan,
AgenticLoopRequestPatch,
)
response: Final = await BaseLLMHTTPHandler()._execute_chat_completion_agentic_plan(
plan=AgenticLoopPlan(
run_agentic_loop=True,
request_patch=AgenticLoopRequestPatch(
messages=[{"role": "user", "content": "hi"}], model=patched_model
),
),
model=model,
messages=[{"role": "user", "content": "hi"}],
optional_params={"mock_response": "ok from followup"},
kwargs={},
custom_llm_provider=custom_llm_provider,
depth=0,
max_loops=2,
fingerprints=[],
fingerprint="fp",
)
assert isinstance(response, ModelResponse)
return response
@pytest.mark.asyncio
async def test_a_sub_path_model_still_resolves_a_provider(self) -> None:
response: Final = await self._run_followup("mantle/anthropic.claude-sonnet-5", "bedrock")
assert response.choices[0].message.content == "ok from followup"
@pytest.mark.asyncio
async def test_an_ordinary_model_still_resolves_a_provider(self) -> None:
response: Final = await self._run_followup("gpt-4o-mini", "openai")
assert response.choices[0].message.content == "ok from followup"
@pytest.mark.asyncio
async def test_a_hook_that_patches_another_providers_model_reaches_that_provider(self) -> None:
response: Final = await self._run_followup("gpt-4o", "openai", patched_model="anthropic/claude-sonnet-4-5")
assert response.model == "claude-sonnet-4-5"
assert response._hidden_params["custom_llm_provider"] == "anthropic"
@pytest.mark.asyncio
async def test_a_hook_echoing_an_org_style_model_stays_on_the_request_provider(self) -> None:
response: Final = await self._run_followup("openai/gpt-4o", "openrouter", patched_model="openai/gpt-4o")
assert response._hidden_params["custom_llm_provider"] == "openrouter"
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [False, True])
@pytest.mark.parametrize("environment_base", [None, "ANTHROPIC_API_BASE", "ANTHROPIC_BASE_URL"])

View file

@ -15,16 +15,20 @@ Tests cover:
- fail_closed raises HTTPException; fail_open forwards uncompressed
"""
import datetime
import hashlib
import json
from types import SimpleNamespace
from typing import Final
from unittest.mock import AsyncMock, MagicMock, create_autospec, patch
import httpx
import pytest
from fastapi import HTTPException
import litellm
from litellm.constants import PRE_CALL_EXECUTED_GUARDRAILS_KEY
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.proxy.guardrails.guardrail_hooks.compresr.compresr import (
COMPRESR_RETRIEVE_TOOL_NAME,
CompresrGuardrail,
@ -33,7 +37,7 @@ from litellm.proxy.guardrails.guardrail_hooks.compresr.compresr import (
_scoped_store_key,
has_compresr_retrieve_tool,
)
from litellm.types.utils import GenericGuardrailAPIInputs
from litellm.types.utils import CallTypes, GenericGuardrailAPIInputs
FAKE_API_BASE = "https://compresr.example.com"
FAKE_API_KEY = "cmp_test-key"
@ -1435,6 +1439,57 @@ async def test_agentic_plan_builds_responses_followup_shape(
assert output_item["output"] == TOOL_OUTPUT
@pytest.mark.asyncio
async def test_responses_retrieval_followup_reuses_the_model_the_caller_requested(
guardrail: CompresrGuardrail,
) -> None:
requested_model: Final = "bedrock/converse/us.anthropic.claude-sonnet-5-5"
hash_value: Final = "f" * 24
logging_obj: Final = Logging(
model=requested_model,
messages=[],
stream=False,
call_type=CallTypes.responses.value,
start_time=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
litellm_call_id="call-1",
function_id="fn-compresr-followup",
)
litellm.responses(
model=requested_model,
input=USER_QUESTION,
mock_response="retrieving",
litellm_logging_obj=logging_obj,
)
guardrail._store_originals(_scoped_store_key(logging_obj), {hash_value: TOOL_OUTPUT})
response: Final = MagicMock()
response.output = [{"type": "function_call", "call_id": "fc_1"}]
response.content = None
plan: Final = await guardrail.async_build_agentic_loop_plan(
tools={
"tool_calls": [
{
"id": "fc_1",
"type": "function",
"name": COMPRESR_RETRIEVE_TOOL_NAME,
"arguments": {"hash": hash_value},
}
]
},
model="converse/us.anthropic.claude-sonnet-5-5",
messages=[{"role": "user", "content": USER_QUESTION}],
response=response,
anthropic_messages_provider_config=None,
anthropic_messages_optional_request_params={},
logging_obj=logging_obj,
stream=False,
kwargs={},
)
assert plan.request_patch is not None
assert plan.request_patch.model == requested_model
@pytest.mark.asyncio
async def test_agentic_plan_chat_parallel_tool_calls_echoes_only_retrieve(
guardrail: CompresrGuardrail,

View file

@ -21,6 +21,7 @@ Tests cover:
loop resolves the retrieve tool call, then fake-streamed back to the client
"""
import datetime
import json
import time
from typing import Final, Optional
@ -33,6 +34,7 @@ from fastapi import HTTPException
import litellm
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.proxy.guardrails.guardrail_hooks.headroom.headroom import (
HeadroomGuardrail,
has_headroom_retrieve_tool,
@ -771,6 +773,72 @@ async def test_async_build_agentic_loop_plan_builds_responses_api_function_call_
assert output_item["output"] == original_content
@pytest.mark.asyncio
async def test_responses_retrieval_followup_reuses_the_model_the_caller_requested(
guardrail: HeadroomGuardrail,
) -> None:
requested_model: Final = "bedrock/converse/us.anthropic.claude-sonnet-5-5"
retrieve_hash: Final = "b573993006976af767214fac"
logging_obj: Final = Logging(
model=requested_model,
messages=[],
stream=False,
call_type=CallTypes.responses.value,
start_time=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
litellm_call_id="call-1",
function_id="fn-headroom-followup",
)
litellm.responses(
model=requested_model,
input=f"What does it say? hash={retrieve_hash}",
mock_response="retrieving",
litellm_logging_obj=logging_obj,
)
response: Final = MagicMock()
response.choices = None
response.content = None
response.output = [
{
"type": "function_call",
"id": "fc_abc123",
"call_id": "call_abc123",
"name": HEADROOM_RETRIEVE_TOOL_NAME,
"arguments": json.dumps({"hash": retrieve_hash}),
}
]
guardrail._issued_hashes_by_call_id["call-1"] = (frozenset({retrieve_hash}), time.monotonic() + 999)
with patch.object(
guardrail.async_handler,
"get",
new_callable=AsyncMock,
return_value=_make_retrieve_response("The full compressed content."),
):
plan: Final = await guardrail.async_build_agentic_loop_plan(
tools={
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"name": HEADROOM_RETRIEVE_TOOL_NAME,
"arguments": {"hash": retrieve_hash},
}
]
},
model="converse/us.anthropic.claude-sonnet-5-5",
messages=[{"role": "user", "content": f"What does it say? hash={retrieve_hash}"}],
response=response,
anthropic_messages_provider_config=None,
anthropic_messages_optional_request_params={},
logging_obj=logging_obj,
stream=False,
kwargs={"litellm_call_id": "call-1"},
)
assert plan.request_patch is not None
assert plan.request_patch.model == requested_model
@pytest.mark.asyncio
async def test_async_build_agentic_loop_plan_builds_anthropic_tool_result_messages(
guardrail: HeadroomGuardrail,