Merge pull request #50462 from BerriAI/litellm_backport_messages_reject_background_lts_1_108_x

fix(anthropic): backport background rejection on the /v1/messages Responses bridge to lts/1.108.x
This commit is contained in:
yuneng-jiang 2026-11-09 12:05:49 -08:00 • committed by GitHub
commit 984bc6878f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 95 additions and 14 deletions

View file

@ -16,7 +16,7 @@ from litellm.integrations.anthropic_cache_control_hook import (
)
from litellm.litellm_core_utils.core_helpers import as_str_mapping, iter_items, normalize_drop_params
from litellm.litellm_core_utils.prompt_templates.common_utils import cache_control_as_prompt_cache_breakpoint
from litellm.litellm_core_utils.tool_fields import drops_mcp_servers, tools_without_mcp_servers
from litellm.litellm_core_utils.tool_fields import drop_policy, drops_mcp_servers, tools_without_mcp_servers
from litellm.responses.streaming_iterator import BaseResponsesAPIStreamingIterator
from litellm.types.integrations.custom_logger import CONVERTED_STREAM_KEYS
from litellm.types.llms.anthropic import (
@ -60,6 +60,33 @@ def _forwarded_kwargs(extra_kwargs: Mapping[str, object] | None) -> Mapping[str,
return extra_kwargs or {}
def _requests_background(forwarded_kwargs: Mapping[str, object]) -> bool:
extra_body: Final = as_str_mapping(forwarded_kwargs.get("extra_body")) or {}
return any(
value is not None and value is not False
for value in (forwarded_kwargs.get("background"), extra_body.get("background"))
)
def _without_background(extra_body: Mapping[str, object]) -> Mapping[str, object]:
return {key: value for key, value in extra_body.items() if key != "background"}
def _reject_background_mode(model: str, forwarded_kwargs: Mapping[str, object]) -> None:
if not _requests_background(forwarded_kwargs) or drop_policy(forwarded_kwargs).drops("background"):
return
provider: Final = forwarded_kwargs.get("custom_llm_provider")
raise litellm.UnsupportedParamsError(
model=model,
llm_provider=provider if isinstance(provider, str) else None,
message=(
f"/v1/messages does not support parameters: ['background'], for model={model}. "
"A background run answers with a response id the Anthropic Messages API has no way to retrieve. "
"To drop it, set `litellm.drop_params=True` or for proxy:\n\n`litellm_settings:\n drop_params: true`\n"
),
)
def _provider_returns_encrypted_reasoning(model: str, custom_llm_provider: object) -> bool:
provider: Final = (
custom_llm_provider if isinstance(custom_llm_provider, str) else litellm.get_llm_provider(model=model)[1]
@ -147,6 +174,7 @@ def _build_responses_kwargs(
Build the kwargs dict to pass directly to litellm.responses() / litellm.aresponses().
"""
forwarded_kwargs: Final = _forwarded_kwargs(extra_kwargs)
_reject_background_mode(model, forwarded_kwargs)
target_provider: Final = forwarded_kwargs.get("custom_llm_provider")
request_tools: Final = tools_without_mcp_servers(
forwarded_kwargs.get("mcp_servers"),
@ -227,7 +255,7 @@ def _build_responses_kwargs(
responses_kwargs["stream"] = True
# Forward litellm-specific kwargs (api_key, api_base, logging obj, etc.)
excluded: Final = CONVERTED_STREAM_KEYS | {"anthropic_messages", "mcp_servers"}
excluded: Final = CONVERTED_STREAM_KEYS | {"anthropic_messages", "background", "mcp_servers"}
for key, value in forwarded_kwargs.items():
if key == "litellm_logging_obj" and value is not None:
from litellm.litellm_core_utils.litellm_logging import (
@ -241,6 +269,8 @@ def _build_responses_kwargs(
# Responses API result back to a ModelResponse for the row.
setattr(value, "call_type", CallTypes.anthropic_messages.value)
responses_kwargs[key] = value
elif key == "extra_body" and (extra_body := as_str_mapping(value)) is not None:
responses_kwargs[key] = _without_background(extra_body)
elif key not in excluded and key not in responses_kwargs and value is not None:
responses_kwargs[key] = value

View file

@ -562,6 +562,69 @@ def test_client_cache_control_changes_nothing_for_a_deployment_that_does_not_rea
assert marked == unmarked
@pytest.mark.parametrize(
"background_request",
[
pytest.param({"background": True}, id="background"),
pytest.param({"extra_body": {"background": True}}, id="extra-body-background"),
pytest.param({"extra_body": {"background": "true"}}, id="extra-body-background-string"),
pytest.param({"background": 0}, id="background-zero"),
],
)
def test_background_answers_400_without_drop_params(background_request: Mapping[str, object]) -> None:
with pytest.raises(litellm.UnsupportedParamsError) as rejected:
_build_responses_kwargs(
model="openai/gpt-5.6",
extra_kwargs={**OPENAI_API, **background_request},
max_tokens=1024,
messages=MESSAGES,
)
assert rejected.value.status_code == 400
assert "['background']" in rejected.value.message
@pytest.mark.parametrize(
("drop_setting", "global_drop_params"),
[
pytest.param({"drop_params": True}, False, id="drop_params"),
pytest.param({"additional_drop_params": ["background"]}, False, id="additional_drop_params"),
pytest.param({}, True, id="litellm.drop_params"),
],
)
def test_dropped_background_never_reaches_the_responses_request(
drop_setting: Mapping[str, object], global_drop_params: bool, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "drop_params", global_drop_params)
responses_kwargs: Final = _build_responses_kwargs(
model="openai/gpt-5.6",
extra_kwargs={
**OPENAI_API,
**drop_setting,
"background": True,
"extra_body": {"background": True, "service_tier": "priority"},
},
max_tokens=1024,
messages=MESSAGES,
)
assert "background" not in responses_kwargs
assert responses_kwargs["extra_body"] == {"service_tier": "priority"}
assert responses_kwargs["max_output_tokens"] == 1024
def test_background_false_still_succeeds_without_reaching_the_responses_request() -> None:
responses_kwargs: Final = _build_responses_kwargs(
model="openai/gpt-5.6",
extra_kwargs={**OPENAI_API, "background": False, "extra_body": {"background": False}},
max_tokens=1024,
messages=MESSAGES,
)
assert "background" not in responses_kwargs
assert responses_kwargs["extra_body"] == {}
REASONING_MODEL: Final = "gpt-bridge-reasoning-cost"
REASONING_RATES: Final = {
"input_cost_per_token": 2e-06,

View file

@ -4171,17 +4171,6 @@ _THINKING_HISTORY: Final = (
)
@dataclass(slots=True)
class ScriptedBedrockInvoke:
replies: tuple[tuple[int, Mapping[str, object]], ...]
bodies: tuple[dict[str, object], ...] = ()
def __call__(self, request: httpx.Request) -> httpx.Response:
self.bodies = (*self.bodies, REASONING_BODY_ADAPTER.validate_json(request.content))
status_code, body = self.replies[len(self.bodies) - 1]
return httpx.Response(status_code, json=body)
async def _send_bedrock_messages(
model: str,
model_info: Mapping[str, object] | None,

View file

@ -1288,7 +1288,6 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
"supports_native_streaming": {"type": "boolean"},
"supports_image_size": {"type": "boolean"},
"supports_native_structured_output": {"type": "boolean"},
"supports_bedrock_native_structured_output": {"type": "boolean"},
"use_openai_responses_path": {"type": "boolean"},
"off_peak_pricing": {
"type": "object",