mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
fix(anthropic_messages): give agentic follow-ups the client's max_tokens through the agentic loop params
Hooks get the same params and kwargs as before, so a custom hook that spreads its kwargs keeps working and a plan's own kwargs max_tokens still wins.
This commit is contained in:
parent
a4a140a122
commit
d8913d8127
7 changed files with 66 additions and 79 deletions
|
|
@ -703,7 +703,7 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
anthropic_messages_optional_request_params: Request parameters (tools, etc.)
|
||||
logging_obj: LiteLLM logging object
|
||||
stream: Whether response is streaming
|
||||
kwargs: Additional request parameters, including the client's ``max_tokens``
|
||||
kwargs: Additional request parameters
|
||||
|
||||
Returns:
|
||||
Final response after executing agentic loop
|
||||
|
|
@ -727,8 +727,8 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
final_response = await messages.acreate(
|
||||
model=model,
|
||||
messages=messages + [assistant_msg, user_msg],
|
||||
max_tokens=kwargs["max_tokens"],
|
||||
**{k: v for k, v in anthropic_messages_optional_request_params.items() if k != "max_tokens"}
|
||||
max_tokens=anthropic_messages_optional_request_params.get("max_tokens"),
|
||||
**anthropic_messages_optional_request_params
|
||||
)
|
||||
|
||||
return final_response
|
||||
|
|
|
|||
|
|
@ -1296,6 +1296,7 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
def _resolve_max_tokens(
|
||||
optional_params: dict,
|
||||
kwargs: dict,
|
||||
client_max_tokens: int | None = None,
|
||||
) -> int:
|
||||
"""Extract max_tokens and validate against thinking.budget_tokens.
|
||||
|
||||
|
|
@ -1304,7 +1305,7 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
"""
|
||||
max_tokens: int = optional_params.get(
|
||||
"max_tokens",
|
||||
kwargs.get("max_tokens", 1024),
|
||||
kwargs.get("max_tokens", 1024 if client_max_tokens is None else client_max_tokens),
|
||||
)
|
||||
thinking_param: Final = optional_params.get("thinking")
|
||||
if thinking_param and isinstance(thinking_param, dict):
|
||||
|
|
@ -1377,9 +1378,7 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
if max_tokens is None:
|
||||
max_tokens = cast(int, kwargs.get("max_tokens", 1024))
|
||||
|
||||
patch_kwargs: Final = {
|
||||
key: value for key, value in dict[str, object](request_patch.kwargs).items() if key != "max_tokens"
|
||||
}
|
||||
patch_kwargs: Final = dict[str, object](request_patch.kwargs)
|
||||
response: AnthropicMessagesResponse | AsyncIterator[object] = await anthropic_messages.acreate(
|
||||
max_tokens=max_tokens,
|
||||
messages=request_patch.messages,
|
||||
|
|
@ -1449,9 +1448,16 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
# Correlation context for structured logging
|
||||
_call_id: Final = getattr(logging_obj, "litellm_call_id", None) or kwargs.get("litellm_call_id", "unknown")
|
||||
|
||||
full_model_name = model # safe default before try block
|
||||
agentic_view: Final[_AgenticLoopParamsView] = {
|
||||
"agentic_loop_params": {}
|
||||
if logging_obj is None
|
||||
else logging_obj.model_call_details.get("agentic_loop_params", {})
|
||||
}
|
||||
full_model_name: Final = agentic_view["agentic_loop_params"].get("model", model)
|
||||
|
||||
max_tokens: Final = self._resolve_max_tokens(anthropic_messages_optional_request_params, kwargs)
|
||||
max_tokens: Final = self._resolve_max_tokens(
|
||||
anthropic_messages_optional_request_params, kwargs, agentic_view["agentic_loop_params"].get("max_tokens")
|
||||
)
|
||||
|
||||
verbose_logger.debug("WebSearchInterception: Using max_tokens=%s for follow-up request", max_tokens)
|
||||
|
||||
|
|
@ -1460,11 +1466,6 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
}
|
||||
kwargs_for_followup: Final = self._prepare_followup_kwargs(kwargs)
|
||||
|
||||
if logging_obj is not None:
|
||||
agentic_view: Final[_AgenticLoopParamsView] = {
|
||||
"agentic_loop_params": logging_obj.model_call_details.get("agentic_loop_params", {})
|
||||
}
|
||||
full_model_name = agentic_view["agentic_loop_params"].get("model", model)
|
||||
verbose_logger.debug(
|
||||
"WebSearchInterception: Built anthropic request patch [call_id=%s model=%s messages=%d searches=%d]",
|
||||
_call_id,
|
||||
|
|
|
|||
|
|
@ -636,6 +636,7 @@ def anthropic_messages_handler(
|
|||
litellm_logging_obj.model_call_details["agentic_loop_params"] = {
|
||||
"model": original_model,
|
||||
"custom_llm_provider": custom_llm_provider,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
litellm_logging_obj.model_call_details.setdefault("litellm_params", {})[CallTypes.aanthropic_messages.value] = (
|
||||
is_async
|
||||
|
|
|
|||
|
|
@ -2486,12 +2486,7 @@ class BaseLLMHTTPHandler:
|
|||
|
||||
# Prepare headers
|
||||
kwargs = kwargs or {}
|
||||
kwargs_for_agentic: Final = self._agentic_hook_kwargs(
|
||||
kwargs=kwargs,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
max_tokens=_OPTIONAL_PARAMS.validate_python(anthropic_messages_optional_request_params).get("max_tokens"),
|
||||
)
|
||||
kwargs_for_agentic: Final = self._agentic_hook_kwargs(kwargs=kwargs, api_key=api_key, api_base=api_base)
|
||||
provider_specific_header: Final = cast(
|
||||
litellm.types.utils.ProviderSpecificHeader | Sequence[litellm.types.utils.ProviderSpecificHeader] | None,
|
||||
kwargs.get("provider_specific_header", None),
|
||||
|
|
@ -5786,12 +5781,12 @@ class BaseLLMHTTPHandler:
|
|||
|
||||
@staticmethod
|
||||
def _agentic_hook_kwargs(
|
||||
kwargs: Mapping[str, object], api_key: str | None, api_base: str | None, max_tokens: object
|
||||
kwargs: Mapping[str, object], api_key: str | None, api_base: str | None
|
||||
) -> dict[str, object]:
|
||||
"""The follow-up call an agentic hook makes needs ``api_key``, ``api_base`` and the client's ``max_tokens``.
|
||||
None of them are in ``kwargs``, and the request transform pops ``max_tokens`` from the hooks' params dict."""
|
||||
named_params: Final = {"api_key": api_key, "api_base": api_base, "max_tokens": max_tokens}
|
||||
return {**kwargs, **{key: value for key, value in named_params.items() if value}}
|
||||
"""``api_key`` and ``api_base`` are named parameters of ``anthropic_messages`` rather than kwargs, so the
|
||||
follow-up call an agentic hook makes only reaches the same deployment if they are re-added here."""
|
||||
deployment_params: Final = {"api_key": api_key, "api_base": api_base}
|
||||
return {**kwargs, **{key: value for key, value in deployment_params.items() if value}}
|
||||
|
||||
@staticmethod
|
||||
def _has_agentic_completion_hook(logging_obj: LiteLLMLoggingObj) -> bool:
|
||||
|
|
@ -5981,10 +5976,10 @@ class BaseLLMHTTPHandler:
|
|||
if patch.messages is None:
|
||||
raise ValueError("Agentic loop plan missing patched messages")
|
||||
|
||||
full_model_name = model
|
||||
if logging_obj is not None:
|
||||
agentic_params: Final[Mapping[str, object]] = logging_obj.model_call_details.get("agentic_loop_params", {})
|
||||
full_model_name = cast(str, agentic_params.get("model", model))
|
||||
agentic_params: Final[Mapping[str, object]] = (
|
||||
{} if logging_obj is None else logging_obj.model_call_details.get("agentic_loop_params", {})
|
||||
)
|
||||
full_model_name: Final = cast(str, agentic_params.get("model", model))
|
||||
|
||||
optional_params: Final = dict(anthropic_messages_optional_request_params)
|
||||
optional_params.update(patch.optional_params)
|
||||
|
|
@ -5997,7 +5992,7 @@ class BaseLLMHTTPHandler:
|
|||
else:
|
||||
optional_params.pop("max_tokens", None)
|
||||
if max_tokens is None:
|
||||
max_tokens = cast(int, kwargs.get("max_tokens", 1024))
|
||||
max_tokens = cast(int, kwargs.get("max_tokens", agentic_params.get("max_tokens", 1024)))
|
||||
|
||||
internal_keys: Final = {"litellm_logging_obj"}
|
||||
kwargs_for_followup: Final = {
|
||||
|
|
@ -6015,12 +6010,12 @@ class BaseLLMHTTPHandler:
|
|||
|
||||
response: AnthropicMessagesResponse | AsyncIterator[object] = await anthropic_messages.acreate(
|
||||
**{
|
||||
"max_tokens": max_tokens,
|
||||
"messages": patch.messages,
|
||||
"model": patch.model or full_model_name,
|
||||
"stream": stream,
|
||||
**optional_params,
|
||||
**kwargs_for_followup,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -268,6 +268,9 @@ class AgenticLoopParams(TypedDict, total=False):
|
|||
custom_llm_provider: str
|
||||
"""The LLM provider name (e.g., 'bedrock', 'anthropic')"""
|
||||
|
||||
max_tokens: ReadOnly[int]
|
||||
"""The client's max_tokens, which the request transform removes from the params the hooks receive"""
|
||||
|
||||
|
||||
class OffPeakWindow(TypedDict, total=False):
|
||||
"""One off-peak rule: UTC time-of-day windows, optionally restricted to weekdays.
|
||||
|
|
|
|||
|
|
@ -125,35 +125,6 @@ class TestThinkingBudgetTokensConstraint:
|
|||
|
||||
assert captured_kwargs["max_tokens"] == 5000 + 1024
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_max_tokens_passed_in_kwargs_reaches_the_follow_up(self) -> None:
|
||||
logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"])
|
||||
captured_kwargs: Dict[str, Any] = {}
|
||||
|
||||
async def _fake_acreate(**kw):
|
||||
captured_kwargs.update(kw)
|
||||
return MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.integrations.websearch_interception.handler.anthropic_messages.acreate",
|
||||
side_effect=_fake_acreate,
|
||||
),
|
||||
patch.object(logger, "_execute_search", return_value=("search result", None)),
|
||||
):
|
||||
await logger._execute_agentic_loop(
|
||||
model="us.anthropic.claude-opus-4-6-v1",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tool_calls=_make_tool_calls(),
|
||||
thinking_blocks=[],
|
||||
anthropic_messages_optional_request_params={},
|
||||
logging_obj=_make_logging_obj(),
|
||||
stream=False,
|
||||
kwargs={"max_tokens": 4000},
|
||||
)
|
||||
|
||||
assert captured_kwargs["max_tokens"] == 4000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_tokens_unchanged_when_greater_than_budget(self):
|
||||
"""max_tokens > thinking.budget_tokens → no adjustment needed."""
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import threading
|
|||
import time
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
from typing import Final, Literal
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import httpx
|
||||
|
|
@ -2692,7 +2692,7 @@ def _answer_after_the_lookup(request: httpx.Request) -> httpx.Response:
|
|||
)
|
||||
|
||||
|
||||
def _messages_with_the_lookup_result(messages: list[dict]) -> list[dict]:
|
||||
def _messages_with_the_lookup_result(messages: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
return [
|
||||
*messages,
|
||||
{"role": "assistant", "content": [_LOOKUP_CALL]},
|
||||
|
|
@ -2700,66 +2700,80 @@ def _messages_with_the_lookup_result(messages: list[dict]) -> list[dict]:
|
|||
]
|
||||
|
||||
|
||||
def _without_the_logging_obj(kwargs: Mapping[str, object]) -> dict[str, object]:
|
||||
return {key: value for key, value in kwargs.items() if key != "litellm_logging_obj"}
|
||||
|
||||
|
||||
class _LookupLoop(CustomLogger):
|
||||
async def async_should_run_agentic_loop(
|
||||
self,
|
||||
response: object,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None,
|
||||
messages: list[dict[str, object]],
|
||||
tools: list[dict[str, object]] | None,
|
||||
stream: bool,
|
||||
custom_llm_provider: str,
|
||||
kwargs: dict,
|
||||
) -> tuple[bool, dict]:
|
||||
kwargs: dict[str, object],
|
||||
) -> tuple[bool, dict[str, object]]:
|
||||
return isinstance(response, dict) and response.get("stop_reason") == "tool_use", {}
|
||||
|
||||
|
||||
class _OwnMaxTokensLoop(_LookupLoop):
|
||||
def __init__(self, spreads: Literal["params", "kwargs"]) -> None:
|
||||
super().__init__()
|
||||
self.spreads: Final = spreads
|
||||
|
||||
async def async_run_agentic_loop(
|
||||
self,
|
||||
tools: dict,
|
||||
tools: dict[str, object],
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
messages: list[dict[str, object]],
|
||||
response: object,
|
||||
anthropic_messages_provider_config: object,
|
||||
anthropic_messages_optional_request_params: dict,
|
||||
anthropic_messages_optional_request_params: dict[str, object],
|
||||
logging_obj: object,
|
||||
stream: bool,
|
||||
kwargs: dict,
|
||||
kwargs: dict[str, object],
|
||||
) -> object:
|
||||
spread: Final = (
|
||||
{"api_key": "sk-ant-test", **anthropic_messages_optional_request_params}
|
||||
if self.spreads == "params"
|
||||
else _without_the_logging_obj(kwargs)
|
||||
)
|
||||
return await litellm.anthropic.messages.acreate(
|
||||
model="anthropic/claude-haiku-5-5",
|
||||
messages=_messages_with_the_lookup_result(messages),
|
||||
max_tokens=300,
|
||||
api_key="sk-ant-test",
|
||||
**anthropic_messages_optional_request_params,
|
||||
**spread, # pyright: ignore[reportArgumentType] # a hook forwards the request's params as they arrive
|
||||
)
|
||||
|
||||
|
||||
class _PlannedLoop(_LookupLoop):
|
||||
def __init__(self, max_tokens: int | None) -> None:
|
||||
def __init__(self, max_tokens: int | None, kwargs_max_tokens: int | None = None) -> None:
|
||||
super().__init__()
|
||||
self.max_tokens: Final = max_tokens
|
||||
self.kwargs_max_tokens: Final = kwargs_max_tokens
|
||||
|
||||
async def async_build_agentic_loop_plan(
|
||||
self,
|
||||
tools: dict,
|
||||
tools: dict[str, object],
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
messages: list[dict[str, object]],
|
||||
response: object,
|
||||
anthropic_messages_provider_config: object,
|
||||
anthropic_messages_optional_request_params: dict,
|
||||
anthropic_messages_optional_request_params: dict[str, object],
|
||||
logging_obj: object,
|
||||
stream: bool,
|
||||
kwargs: dict,
|
||||
kwargs: dict[str, object],
|
||||
) -> AgenticLoopPlan:
|
||||
own_kwargs: Final = {} if self.kwargs_max_tokens is None else {"max_tokens": self.kwargs_max_tokens}
|
||||
return AgenticLoopPlan(
|
||||
run_agentic_loop=True,
|
||||
request_patch=AgenticLoopRequestPatch(
|
||||
model="anthropic/claude-haiku-5-5",
|
||||
messages=_messages_with_the_lookup_result(messages),
|
||||
max_tokens=self.max_tokens,
|
||||
kwargs={key: value for key, value in kwargs.items() if key != "litellm_logging_obj"},
|
||||
kwargs={**_without_the_logging_obj(kwargs), **own_kwargs},
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -2768,9 +2782,11 @@ class _PlannedLoop(_LookupLoop):
|
|||
@pytest.mark.parametrize(
|
||||
("hook", "follow_up_max_tokens"),
|
||||
[
|
||||
pytest.param(_OwnMaxTokensLoop(), 300, id="hook-passes-its-own-max-tokens"),
|
||||
pytest.param(_OwnMaxTokensLoop(spreads="params"), 300, id="hook-spreads-the-params-with-its-own-max-tokens"),
|
||||
pytest.param(_OwnMaxTokensLoop(spreads="kwargs"), 300, id="hook-spreads-the-kwargs-with-its-own-max-tokens"),
|
||||
pytest.param(_PlannedLoop(max_tokens=None), 4000, id="plan-leaves-max-tokens-to-the-client"),
|
||||
pytest.param(_PlannedLoop(max_tokens=300), 300, id="plan-sets-its-own-max-tokens"),
|
||||
pytest.param(_PlannedLoop(max_tokens=None, kwargs_max_tokens=300), 300, id="plan-sets-max-tokens-in-kwargs"),
|
||||
],
|
||||
)
|
||||
async def test_agentic_follow_up_uses_the_client_max_tokens_unless_the_hook_sets_its_own(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue