Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,6 @@ def transform_anthropic_messages_request(
"model", None
) # do not pass model in request body to vertex ai

sanitize_vertex_anthropic_output_params(anthropic_messages_request)
sanitize_vertex_anthropic_output_params(anthropic_messages_request, model)

return anthropic_messages_request
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,38 @@
keeps the parent module's import surface narrow.
"""

# Keys inside ``output_config`` that Vertex AI Claude does not accept.
# Add an entry only when a 400 "Extra inputs are not permitted" is
# reproducible against the live Vertex endpoint.
# Keys inside ``output_config`` that Vertex AI Claude rejects regardless of
# the target model. Add an entry only when a 400 "Extra inputs are not
# permitted" is reproducible against the live Vertex endpoint for every model.
VERTEX_UNSUPPORTED_OUTPUT_CONFIG_KEYS: frozenset = frozenset()


def sanitize_vertex_anthropic_output_params(data: dict) -> None:
def _model_accepts_output_config_effort(model: str) -> bool:
"""Whether ``model`` accepts ``output_config.effort`` on Vertex.

Opus/Sonnet 4.6+ advertise ``supports_output_config`` (or a reasoning
effort level) and accept it; Haiku 4.5 advertises neither and 400s on
``output_config.effort: Extra inputs are not permitted``. Imported lazily
so this stays a leaf module (see module docstring).
"""
from litellm.llms.anthropic.chat.transformation import AnthropicConfig

return AnthropicConfig._model_supports_effort_param(model)


def sanitize_vertex_anthropic_output_params(data: dict, model: str) -> None:
"""
Strip Vertex-unsupported keys from ``output_config`` /
``output_format`` in-place; forward whatever remains.

Behavior:
* ``output_config`` containing only unsupported keys (e.g. ``effort``
alone) is removed entirely so the request body has no empty dict.
* ``output_config`` containing a mix of supported + unsupported keys
has the unsupported subset filtered out and the rest forwarded.
* ``output_config`` that is supported in full passes through unchanged.
* ``output_config.effort`` is dropped for models that don't accept it
(e.g. Haiku 4.5) and forwarded for those that do (Opus/Sonnet 4.6+).
Clients like Claude Code inject it into every Messages payload, so the
gate has to live here rather than rely on the caller.
* Keys in ``VERTEX_UNSUPPORTED_OUTPUT_CONFIG_KEYS`` are always filtered.
* ``output_config`` left empty after filtering is removed so the request
body has no empty dict.
* ``output_format`` is forwarded as-is (Vertex AI Claude accepts it).
* Non-dict values for ``output_config`` are dropped to avoid sending
malformed payloads downstream.
Expand All @@ -37,11 +52,19 @@ def sanitize_vertex_anthropic_output_params(data: dict) -> None:
if not isinstance(output_config, dict):
data.pop("output_config", None)
return
sanitized = {
k: v
for k, v in output_config.items()
if k not in VERTEX_UNSUPPORTED_OUTPUT_CONFIG_KEYS
}

drop_keys = set(VERTEX_UNSUPPORTED_OUTPUT_CONFIG_KEYS)
if "effort" in output_config and not _model_accepts_output_config_effort(model):
from litellm._logging import verbose_logger

verbose_logger.debug(
"Dropping unsupported output_config.effort for vertex_ai model=%s "
"(no supports_output_config in the model map)",
model,
)
drop_keys.add("effort")

sanitized = {k: v for k, v in output_config.items() if k not in drop_keys}
if sanitized:
data["output_config"] = sanitized
else:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def transform_request(

data.pop("model", None) # vertex anthropic doesn't accept 'model' parameter

sanitize_vertex_anthropic_output_params(data)
sanitize_vertex_anthropic_output_params(data, model)

tools = optional_params.get("tools")
tool_search_used = self.is_tool_search_used(tools)
Expand Down
1 change: 1 addition & 0 deletions litellm/proxy/auth/auth_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,7 @@ async def common_checks( # noqa: PLR0915
route=route,
request_headers=_safe_get_request_headers(request=request),
request_query_params=_safe_get_request_query_params(request=request),
llm_router=llm_router,
)

# 1. If team is blocked
Expand Down
35 changes: 29 additions & 6 deletions litellm/proxy/auth/auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1244,7 +1244,9 @@ def _route_uses_model_routing_sources(route: str) -> bool:


def _extract_models_from_managed_resource_id(
resource_id: Any, resource_id_field: Optional[str] = None
resource_id: Any,
resource_id_field: Optional[str] = None,
llm_router: Optional[Router] = None,
) -> List[str]:
if not isinstance(resource_id, str) or not resource_id:
return []
Expand Down Expand Up @@ -1301,16 +1303,18 @@ def _extract_models_from_managed_resource_id(
)

if resource_id_field == "video_id":
model_id = decode_video_id_with_provider(resource_id).get("model_id")
_append_model_candidates(
candidates=candidates,
value=decode_video_id_with_provider(resource_id).get("model_id"),
value=_resolve_model_id_with_router(model_id, llm_router),
)
else:
model_id = decode_character_id_with_provider(resource_id).get(
"model_id"
)
_append_model_candidates(
candidates=candidates,
value=decode_character_id_with_provider(resource_id).get(
"model_id"
),
value=_resolve_model_id_with_router(model_id, llm_router),
)
except Exception as e:
verbose_proxy_logger.debug(
Expand All @@ -1320,11 +1324,26 @@ def _extract_models_from_managed_resource_id(
return _dedupe_model_candidates(candidates)


def _resolve_model_id_with_router(
model_id: Optional[str], llm_router: Optional[Router]
) -> Optional[str]:
if model_id is None or llm_router is None:
return model_id
try:
return llm_router.resolve_model_name_from_model_id(model_id) or model_id
except Exception as e:
verbose_proxy_logger.debug(
"Unable to resolve model_id from managed resource ID: %s", str(e)
)
return model_id


def _extract_model_candidates_from_request(
request_data: dict,
route: str,
request_headers: Optional[Mapping[str, Any]] = None,
request_query_params: Optional[Mapping[str, Any]] = None,
llm_router: Optional[Router] = None,
) -> List[str]:
candidates: List[str] = []
uses_model_routing_sources = _route_uses_model_routing_sources(route=route)
Expand Down Expand Up @@ -1374,7 +1393,9 @@ def _extract_model_candidates_from_request(
_append_model_candidates(
candidates,
_extract_models_from_managed_resource_id(
request_data.get(field), resource_id_field=field
request_data.get(field),
resource_id_field=field,
llm_router=llm_router,
),
)

Expand All @@ -1396,12 +1417,14 @@ def get_model_from_request(
route: str,
request_headers: Optional[Mapping[str, Any]] = None,
request_query_params: Optional[Mapping[str, Any]] = None,
llm_router: Optional[Router] = None,
) -> Optional[Union[str, List[str]]]:
candidates = _extract_model_candidates_from_request(
request_data=request_data,
route=route,
request_headers=request_headers,
request_query_params=request_query_params,
llm_router=llm_router,
)
model = _format_model_candidates(candidates)

Expand Down
8 changes: 8 additions & 0 deletions litellm/proxy/auth/user_api_key_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,14 @@ def _get_model_from_request_context(
request_data: dict,
route: str,
request: Optional[Request],
llm_router: Optional[Any] = None,
) -> Optional[Union[str, List[str]]]:
return get_model_from_request(
request_data=request_data,
route=route,
request_headers=_safe_get_request_headers(request=request),
request_query_params=_safe_get_request_query_params(request=request),
llm_router=llm_router,
)


Expand Down Expand Up @@ -1034,6 +1036,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
request_data=request_data,
route=route,
request=request,
llm_router=llm_router,
)
skip_budget_checks = False
if model is not None and llm_router is not None:
Expand Down Expand Up @@ -1451,6 +1454,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
request_data=request_data,
route=route,
request=request,
llm_router=llm_router,
)
skip_budget_checks = False
if model is not None and llm_router is not None:
Expand Down Expand Up @@ -1579,6 +1583,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
request_data=request_data,
route=route,
request=request,
llm_router=llm_router,
)
current_models = _get_model_names_for_budget_checks(
model=current_model
Expand Down Expand Up @@ -2159,6 +2164,7 @@ def _should_skip_budget_checks(
request_data=request_data,
route=route,
request=request,
llm_router=llm_router,
)
if model is not None and llm_router is not None:
return _is_model_cost_zero(model=model, llm_router=llm_router)
Expand Down Expand Up @@ -2475,6 +2481,7 @@ async def _enforce_key_and_fallback_model_access(
request_data=request_data,
route=route,
request=request,
llm_router=llm_router,
)

if model is not None:
Expand Down Expand Up @@ -2616,6 +2623,7 @@ async def _run_post_custom_auth_checks(
request_data=request_data,
route=route,
request=request,
llm_router=llm_router,
)
current_models = _get_model_names_for_budget_checks(model=current_model)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -894,7 +894,12 @@ async def _common_key_generation_helper( # noqa: PLR0915
user_api_key_dict.user_role is not None
and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
)
if not _is_proxy_admin:
_org_inherited_from_team = (
team_table is not None
and team_table.organization_id is not None
and data.organization_id == team_table.organization_id
)
if not _is_proxy_admin and not _org_inherited_from_team:
await _validate_caller_can_assign_key_org(
user_api_key_dict=user_api_key_dict,
organization_id=data.organization_id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ def _create_anthropic_response_logging_payload(

handles streaming and non-streaming responses
"""
# Only record complete_streaming_response for actual streaming responses.
# perform_redaction scrubs this field only when stream is True, so setting
# it on a non-streaming response would bypass message redaction.
if logging_obj.model_call_details.get("stream") is True:
logging_obj.model_call_details["complete_streaming_response"] = (
litellm_model_response
)
try:
# Get custom_llm_provider from logging object if available (e.g., azure_ai for Azure Anthropic)
custom_llm_provider = logging_obj.model_call_details.get(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,9 @@ async def pass_through_request( # noqa: PLR0915
)

if stream:
logging_obj.stream = True
logging_obj.model_call_details["stream"] = True

if is_multipart:
response = (
await HttpPassThroughEndpointHelpers.make_multipart_http_request(
Expand Down Expand Up @@ -1108,6 +1111,9 @@ async def pass_through_request( # noqa: PLR0915
verbose_proxy_logger.debug("response.headers= %s", response.headers)

if _is_streaming_response(response) is True:
logging_obj.stream = True
logging_obj.model_call_details["stream"] = True

try:
response.raise_for_status()
except httpx.HTTPStatusError as e:
Expand Down
4 changes: 2 additions & 2 deletions litellm/proxy/spend_tracking/budget_reservation.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ async def reserve_budget_for_request(
return None
if route in {"/models", "/v1/models", "/utils/token_counter"}:
return None
if get_model_from_request(request_body, route) is None:
if get_model_from_request(request_body, route, llm_router=llm_router) is None:
return None

counters = await _get_budget_counters(
Expand Down Expand Up @@ -797,7 +797,7 @@ def estimate_request_max_cost(
route: str,
llm_router: Optional[Router],
) -> Optional[float]:
model = get_model_from_request(request_body, route)
model = get_model_from_request(request_body, route, llm_router=llm_router)
if model is None:
return None

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ def test_handle_logging_anthropic_collected_chunks(all_chunks):
from litellm.types.utils import ModelResponse

litellm_logging_obj = Mock()
litellm_logging_obj.model_call_details = {}
pass_through_logging_obj = Mock()

sent_args = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,40 @@ def test_transform_anthropic_messages_request_removes_scope_from_cache_control()
assert result["messages"][0]["content"][0]["cache_control"]["type"] == "ephemeral"


def test_messages_request_strips_effort_for_haiku_45():
"""Regression: Claude Code (``claude --model claude-haiku-4.5``) sends
``output_config.effort`` in its default Messages payload. Haiku 4.5 on
Vertex rejects it with 400 ``output_config.effort: Extra inputs are not
permitted``, so the pass-through must strip it for Haiku while keeping it
for Opus/Sonnet 4.6+."""
config = VertexAIPartnerModelsAnthropicMessagesConfig()
messages = [{"role": "user", "content": "Hello"}]

haiku_result = config.transform_anthropic_messages_request(
model="claude-haiku-4-5@20251001",
messages=messages,
anthropic_messages_optional_request_params={
"max_tokens": 1024,
"output_config": {"effort": "high"},
},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert "output_config" not in haiku_result

opus_result = config.transform_anthropic_messages_request(
model="claude-opus-4-6",
messages=messages,
anthropic_messages_optional_request_params={
"max_tokens": 1024,
"output_config": {"effort": "high"},
},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert opus_result["output_config"] == {"effort": "high"}


def test_provider_config_manager_reuses_vertex_anthropic_messages_config_instance():
"""
Regression test: repeated provider config lookups for the same Vertex Claude model
Expand Down
Loading
Loading