Merge branch 'main' into litellm_fix_claude_code_invoke_usage

This commit is contained in:
Ishaan Jaffer 2026-01-14 14:21:58 -08:00
commit 0a0b0b21bb
13 changed files with 953 additions and 74 deletions

View file

@ -4456,7 +4456,7 @@ class StandardLoggingPayloadSetup:
@staticmethod
def get_usage_from_response_obj(
response_obj: Optional[dict], combined_usage_object: Optional[Usage] = None
response_obj: Optional[Union[dict, BaseModel]], combined_usage_object: Optional[Usage] = None
) -> Usage:
## BASE CASE ##
if combined_usage_object is not None:
@ -4468,27 +4468,32 @@ class StandardLoggingPayloadSetup:
total_tokens=0,
)
usage = response_obj.get("usage", None) or {}
if usage is None or (
not isinstance(usage, dict) and not isinstance(usage, Usage)
):
usage = _safe_extract_usage_from_obj(response_obj)
if usage is None:
return Usage(
prompt_tokens=0,
completion_tokens=0,
total_tokens=0,
)
elif isinstance(usage, Usage):
if isinstance(usage, Usage):
return usage
elif isinstance(usage, dict):
if ResponseAPILoggingUtils._is_response_api_usage(usage):
return (
ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(
usage
)
)
return Usage(**usage)
raise ValueError(f"usage is required, got={usage} of type {type(usage)}")
transformed_usage = _try_transform_response_api_usage(usage)
if transformed_usage is not None:
return transformed_usage
if isinstance(usage, dict):
created_usage = _try_create_usage_from_dict(usage)
if created_usage is not None:
return created_usage
return Usage(
prompt_tokens=0,
completion_tokens=0,
total_tokens=0,
)
@staticmethod
def get_model_cost_information(
@ -4529,13 +4534,18 @@ class StandardLoggingPayloadSetup:
@staticmethod
def get_final_response_obj(
response_obj: dict, init_response_obj: Union[Any, BaseModel, dict], kwargs: dict
response_obj: Union[dict, BaseModel], init_response_obj: Union[Any, BaseModel, dict], kwargs: dict
) -> Optional[Union[dict, str, list]]:
"""
Get final response object after redacting the message input/output from logging
"""
if response_obj:
final_response_obj: Optional[Union[dict, str, list]] = response_obj
if isinstance(response_obj, BaseModel):
final_response_obj: Optional[Union[dict, str, list]] = _safe_model_dump(
response_obj, default={}
)
else:
final_response_obj = response_obj
elif isinstance(init_response_obj, list) or isinstance(init_response_obj, str):
final_response_obj = init_response_obj
else:
@ -4549,7 +4559,7 @@ class StandardLoggingPayloadSetup:
if modified_final_response_obj is not None and isinstance(
modified_final_response_obj, BaseModel
):
final_response_obj = modified_final_response_obj.model_dump()
final_response_obj = _safe_model_dump(modified_final_response_obj, default={})
else:
final_response_obj = modified_final_response_obj
@ -4820,6 +4830,125 @@ class StandardLoggingPayloadSetup:
return request_tags
def _safe_model_dump(
obj: BaseModel, default: Optional[Union[dict, str, list]] = None
) -> Union[dict, str, list]:
"""
Safely call model_dump() on a BaseModel with fallback strategies.
Args:
obj: BaseModel instance to dump
default: Default value to return if all strategies fail
Returns:
Dict representation of the BaseModel, or fallback value
"""
if default is None:
default = {}
try:
return obj.model_dump()
except (AttributeError, TypeError) as e:
verbose_logger.debug(
f"Error calling model_dump() on BaseModel: {e}, type: {type(obj)}"
)
try:
if hasattr(obj, "__dict__"):
return obj.__dict__
else:
return str(obj)
except Exception:
return default
def _safe_get_attribute(
obj: Union[dict, BaseModel, Any], attr_name: str, default: Any = None
) -> Any:
"""
Safely get an attribute from a dict or BaseModel object.
Args:
obj: Object to get attribute from (dict, BaseModel, or any object)
attr_name: Name of the attribute to get
default: Default value to return if attribute doesn't exist
Returns:
Attribute value or default
"""
try:
if isinstance(obj, dict):
return obj.get(attr_name, default)
else:
return getattr(obj, attr_name, default)
except (AttributeError, TypeError) as e:
verbose_logger.debug(
f"Error getting attribute '{attr_name}' from object: {e}, type: {type(obj)}"
)
return default
def _safe_extract_usage_from_obj(
response_obj: Union[dict, BaseModel, Any]
) -> Optional[Union[dict, Usage, Any]]:
"""
Safely extract usage from response_obj (dict or BaseModel).
Args:
response_obj: Response object (dict, BaseModel, or any object)
Returns:
Usage object, dict, or None
"""
return _safe_get_attribute(response_obj, "usage", None)
def _try_transform_response_api_usage(usage: Any) -> Optional[Usage]:
"""
Try to transform ResponseAPIUsage to Usage object.
Args:
usage: Usage object (dict, ResponseAPIUsage, or other)
Returns:
Transformed Usage object, or None if transformation fails
"""
try:
if ResponseAPILoggingUtils._is_response_api_usage(usage):
return ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
except (AttributeError, TypeError, KeyError) as e:
verbose_logger.debug(
f"Error checking/transforming ResponseAPIUsage: {e}, type: {type(usage)}"
)
return None
def _try_create_usage_from_dict(usage: dict) -> Optional[Usage]:
"""
Try to create Usage object from dict.
Args:
usage: Dict containing usage information
Returns:
Usage object, or None if creation fails
"""
try:
return Usage(**usage)
except (TypeError, ValueError) as e:
# Avoid logging full dict contents, which may include sensitive data
try:
usage_keys = list(usage.keys())
except Exception:
usage_keys = None
verbose_logger.debug(
"Error creating Usage from dict: %s, usage keys: %s, usage type: %s",
e,
usage_keys,
type(usage),
)
return None
def _get_status_fields(
status: StandardLoggingPayloadStatus,
guardrail_information: Optional[List[dict]],
@ -4869,17 +4998,21 @@ def _get_status_fields(
def _extract_response_obj_and_hidden_params(
init_response_obj: Union[Any, BaseModel, dict],
original_exception: Optional[Exception],
) -> Tuple[dict, Optional[dict]]:
) -> Tuple[Union[dict, BaseModel], Optional[dict]]:
"""Extract response_obj and hidden_params from init_response_obj."""
hidden_params: Optional[dict] = None
if init_response_obj is None:
response_obj = {}
response_obj: Union[dict, BaseModel] = {}
elif isinstance(init_response_obj, BaseModel):
response_obj = init_response_obj.model_dump()
hidden_params = getattr(init_response_obj, "_hidden_params", None)
response_obj = init_response_obj
hidden_params = _safe_get_attribute(init_response_obj, "_hidden_params", None)
elif isinstance(init_response_obj, dict):
response_obj = init_response_obj
else:
verbose_logger.debug(
f"Unknown init_response_obj type: {type(init_response_obj)}, defaulting to empty dict"
)
response_obj = {}
if original_exception is not None and hidden_params is None:
@ -4942,7 +5075,10 @@ def get_standard_logging_object_payload(
),
)
id = response_obj.get("id", kwargs.get("litellm_call_id"))
# Preserve falsy values (0, "", False) if they exist in response_obj
id = _safe_get_attribute(response_obj, "id", None)
if id is None:
id = kwargs.get("litellm_call_id")
_model_id = metadata.get("model_info", {}).get("id", "")
_model_group = metadata.get("model_group", "")

View file

@ -17,7 +17,6 @@ from openai.types.chat.chat_completion_chunk import Choice as OpenAIStreamingCho
from litellm.litellm_core_utils.prompt_templates.common_utils import (
parse_tool_call_arguments,
)
from litellm.types.llms.anthropic import (
AllAnthropicToolsValues,
AnthopicMessagesAssistantMessageParam,
@ -210,7 +209,7 @@ class LiteLLMAnthropicMessagesAdapter:
# Convert Anthropic image format to OpenAI format
source = content.get("source", {})
openai_image_url = (
self._translate_anthropic_image_to_openai(source)
self._translate_anthropic_image_to_openai(cast(dict, source))
)
if openai_image_url:
@ -240,7 +239,7 @@ class LiteLLMAnthropicMessagesAdapter:
# Combine all content items into a single tool message
# to avoid creating multiple tool_result blocks with the same ID
# (each tool_use must have exactly one tool_result)
content_items = content.get("content", [])
content_items = list(content.get("content", []))
# For single-item content, maintain backward compatibility with string/url format
if len(content_items) == 1:
@ -266,7 +265,7 @@ class LiteLLMAnthropicMessagesAdapter:
source = c.get("source", {})
openai_image_url = (
self._translate_anthropic_image_to_openai(
source
cast(dict, source)
)
or ""
)
@ -306,7 +305,7 @@ class LiteLLMAnthropicMessagesAdapter:
source = c.get("source", {})
openai_image_url = (
self._translate_anthropic_image_to_openai(
source
cast(dict, source)
)
or ""
)
@ -363,7 +362,7 @@ class LiteLLMAnthropicMessagesAdapter:
}
signature = (
self._extract_signature_from_tool_use_content(
content
cast(Dict[str, Any], content)
)
)
@ -424,14 +423,21 @@ class LiteLLMAnthropicMessagesAdapter:
return new_messages
def translate_anthropic_thinking_to_openai(
self, thinking: Dict[str, Any]
@staticmethod
def translate_anthropic_thinking_to_reasoning_effort(
thinking: Dict[str, Any]
) -> Optional[str]:
"""
Translate Anthropic's thinking parameter to OpenAI's reasoning_effort.
Anthropic thinking format: {'type': 'enabled'|'disabled', 'budget_tokens': int}
OpenAI reasoning_effort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'default'
Mapping:
- budget_tokens >= 10000 -> 'high'
- budget_tokens >= 5000 -> 'medium'
- budget_tokens >= 2000 -> 'low'
- budget_tokens < 2000 -> 'minimal'
"""
if not isinstance(thinking, dict):
return None
@ -453,6 +459,53 @@ class LiteLLMAnthropicMessagesAdapter:
return None
@staticmethod
def is_anthropic_claude_model(model: str) -> bool:
"""
Check if the model is an Anthropic Claude model that supports the thinking parameter.
Returns True for:
- anthropic/* models
- bedrock/*anthropic* models (including converse)
- vertex_ai/*claude* models
"""
model_lower = model.lower()
return (
"anthropic" in model_lower
or "claude" in model_lower
)
@staticmethod
def translate_thinking_for_model(
thinking: Dict[str, Any],
model: str,
) -> Dict[str, Any]:
"""
Translate Anthropic thinking parameter based on the target model.
For Claude/Anthropic models: returns {'thinking': <original_thinking>}
- Preserves exact budget_tokens value
For non-Claude models: returns {'reasoning_effort': <mapped_value>}
- Converts thinking to reasoning_effort to avoid UnsupportedParamsError
Args:
thinking: Anthropic thinking dict with 'type' and 'budget_tokens'
model: The target model name
Returns:
Dict with either 'thinking' or 'reasoning_effort' key
"""
if LiteLLMAnthropicMessagesAdapter.is_anthropic_claude_model(model):
return {"thinking": thinking}
else:
reasoning_effort = LiteLLMAnthropicMessagesAdapter.translate_anthropic_thinking_to_reasoning_effort(
thinking
)
if reasoning_effort:
return {"reasoning_effort": reasoning_effort}
return {}
def translate_anthropic_tool_choice_to_openai(
self, tool_choice: AnthropicMessagesToolChoice
) -> ChatCompletionToolChoiceValues:
@ -566,11 +619,15 @@ class LiteLLMAnthropicMessagesAdapter:
if "thinking" in anthropic_message_request:
thinking = anthropic_message_request["thinking"]
if thinking:
reasoning_effort = self.translate_anthropic_thinking_to_openai(
thinking=cast(Dict[str, Any], thinking)
)
if reasoning_effort:
new_kwargs["reasoning_effort"] = reasoning_effort
model = new_kwargs.get("model", "")
if self.is_anthropic_claude_model(model):
new_kwargs["thinking"] = thinking # type: ignore
else:
reasoning_effort = self.translate_anthropic_thinking_to_reasoning_effort(
cast(Dict[str, Any], thinking)
)
if reasoning_effort:
new_kwargs["reasoning_effort"] = reasoning_effort
translatable_params = self.translatable_anthropic_params()
for k, v in anthropic_message_request.items():

View file

@ -18099,6 +18099,39 @@
"supports_tool_choice": true,
"supports_vision": true
},
"gpt-5.2-codex": {
"cache_read_input_token_cost": 1.75e-07,
"cache_read_input_token_cost_priority": 3.5e-07,
"input_cost_per_token": 1.75e-06,
"input_cost_per_token_priority": 3.5e-06,
"litellm_provider": "openai",
"max_input_tokens": 400000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "responses",
"output_cost_per_token": 1.4e-05,
"output_cost_per_token_priority": 2.8e-05,
"supported_endpoints": [
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": false,
"supports_tool_choice": true,
"supports_vision": true
},
"gpt-5-mini": {
"cache_read_input_token_cost": 2.5e-08,
"cache_read_input_token_cost_flex": 1.25e-08,
@ -23266,6 +23299,25 @@
"supports_reasoning": true,
"supports_tool_choice": true
},
"openrouter/openai/gpt-5.2-codex": {
"cache_read_input_token_cost": 1.75e-07,
"input_cost_per_token": 1.75e-06,
"litellm_provider": "openrouter",
"max_input_tokens": 400000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1.4e-05,
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_reasoning": true,
"supports_tool_choice": true
},
"openrouter/openai/gpt-5": {
"cache_read_input_token_cost": 1.25e-07,
"input_cost_per_token": 1.25e-06,

View file

@ -343,7 +343,7 @@ def _build_where_conditions(
start_date: str,
end_date: str,
model: Optional[str],
api_key: Optional[str],
api_key: Optional[Union[str, List[str]]],
exclude_entity_ids: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Build prisma where clause for daily activity queries."""
@ -357,7 +357,10 @@ def _build_where_conditions(
if model:
where_conditions["model"] = model
if api_key:
where_conditions["api_key"] = api_key
if isinstance(api_key, list):
where_conditions["api_key"] = {"in": api_key}
else:
where_conditions["api_key"] = api_key
if entity_id is not None:
if isinstance(entity_id, list):
@ -445,7 +448,7 @@ async def get_daily_activity(
start_date: Optional[str],
end_date: Optional[str],
model: Optional[str],
api_key: Optional[str],
api_key: Optional[Union[str, List[str]]],
page: int,
page_size: int,
exclude_entity_ids: Optional[List[str]] = None,

View file

@ -3601,7 +3601,7 @@ async def get_team_daily_activity(
},
)
## Fetch team aliases
## Fetch team aliases and check team admin status
where_condition = {}
if team_ids_list:
where_condition["team_id"] = {"in": list(team_ids_list)}
@ -3612,6 +3612,36 @@ async def get_team_daily_activity(
t.team_id: {"team_alias": t.team_alias} for t in team_aliases
}
# Check if user is team admin for any requested teams
# If not, filter by user's API keys
user_api_keys: Optional[List[str]] = None
if not _user_has_admin_view(user_api_key_dict) and team_ids_list and team_aliases:
# Check if user is team admin for any of the teams
is_team_admin_for_any = False
for team_alias in team_aliases:
team_obj = LiteLLM_TeamTable(**team_alias.model_dump())
if _is_user_team_admin(
user_api_key_dict=user_api_key_dict, team_obj=team_obj
):
is_team_admin_for_any = True
break
# If user is not a team admin for any team, filter by their API keys
if not is_team_admin_for_any:
# Get all API keys for this user
user_keys = await prisma_client.db.litellm_verificationtoken.find_many(
where={"user_id": user_api_key_dict.user_id}
)
user_api_keys = [key.token for key in user_keys if key.token]
# If user has no API keys, return empty result
if not user_api_keys:
user_api_keys = [""] # Use empty string to ensure no matches
# If api_key parameter is provided, use it; otherwise use user_api_keys if set
final_api_key_filter: Optional[Union[str, List[str]]] = api_key
if final_api_key_filter is None and user_api_keys is not None:
final_api_key_filter = user_api_keys
return await get_daily_activity(
prisma_client=prisma_client,
table_name="litellm_dailyteamspend",
@ -3622,7 +3652,7 @@ async def get_team_daily_activity(
start_date=start_date,
end_date=end_date,
model=model,
api_key=api_key,
api_key=final_api_key_filter,
page=page,
page_size=page_size,
)

View file

@ -1,10 +1,10 @@
model_list:
- model_name: anthropic/*
- model_name: us.anthropic.claude-sonnet-4-20250514-v1:0
litellm_params:
model: anthropic/*
- model_name: openai/*
litellm_params:
model: openai/*
model: bedrock/converse/us.anthropic.claude-sonnet-4-20250514-v1:0
model_info:
litellm_provider: bedrock_converse
mode: chat
general_settings:
store_prompts_in_spend_logs: true

View file

@ -18099,6 +18099,39 @@
"supports_tool_choice": true,
"supports_vision": true
},
"gpt-5.2-codex": {
"cache_read_input_token_cost": 1.75e-07,
"cache_read_input_token_cost_priority": 3.5e-07,
"input_cost_per_token": 1.75e-06,
"input_cost_per_token_priority": 3.5e-06,
"litellm_provider": "openai",
"max_input_tokens": 400000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "responses",
"output_cost_per_token": 1.4e-05,
"output_cost_per_token_priority": 2.8e-05,
"supported_endpoints": [
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": false,
"supports_tool_choice": true,
"supports_vision": true
},
"gpt-5-mini": {
"cache_read_input_token_cost": 2.5e-08,
"cache_read_input_token_cost_flex": 1.25e-08,
@ -23266,6 +23299,25 @@
"supports_reasoning": true,
"supports_tool_choice": true
},
"openrouter/openai/gpt-5.2-codex": {
"cache_read_input_token_cost": 1.75e-07,
"input_cost_per_token": 1.75e-06,
"litellm_provider": "openrouter",
"max_input_tokens": 400000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1.4e-05,
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_reasoning": true,
"supports_tool_choice": true
},
"openrouter/openai/gpt-5": {
"cache_read_input_token_cost": 1.25e-07,
"input_cost_per_token": 1.25e-06,

View file

@ -67,43 +67,35 @@ async def test_anthropic_messages_litellm_router_bedrock():
@pytest.mark.asyncio
async def test_should_not_fail_with_forwarded_headers_bedrock_invoke_messages():
async def test_anthropic_messages_bedrock_converse_with_thinking():
"""
E2E test for Bedrock invoke messages with header forwarding enabled.
This calls the real Bedrock endpoint (no mocks) and should not raise
SigV4 signature mismatch errors when forwarded headers are present.
Test that bedrock/converse model works with thinking parameter.
Validates the request body from issue where budget_tokens was being lost.
"""
router = Router(
model_list=[
{
"model_name": "claude-sonnet-4-5-20250929",
"model_name": "bedrock/converse/us.anthropic.claude-sonnet-4-20250514-v1:0",
"litellm_params": {
"model": "bedrock/invoke/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
"aws_region_name": os.getenv("AWS_REGION_NAME"),
"model": "bedrock/converse/us.anthropic.claude-sonnet-4-20250514-v1:0",
},
}
},
]
)
forwarded_headers = {
"x-forwarded-for": "10.11.232.194",
"x-forwarded-port": "443",
"x-forwarded-proto": "https",
"x-app": "cli",
}
messages = [{"role": "user", "content": "What is 2+2?"}]
response = await router.aanthropic_messages(
messages=[{"role": "user", "content": "hi"}],
model="claude-sonnet-4-5-20250929",
max_tokens=5,
stream=False,
headers=forwarded_headers, # simulates forward_client_headers_to_llm_api
aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
aws_region_name=os.getenv("AWS_REGION_NAME"),
messages=messages,
model="bedrock/converse/us.anthropic.claude-sonnet-4-20250514-v1:0",
max_tokens=1026,
thinking={
"type": "enabled",
"budget_tokens": 1025
},
)
print("INVOKE API RESPONSE: ", response)
print("bedrock response: ", response)
# Verify response
INSTANCE_BASE_ANTHROPIC_MESSAGES_TEST._validate_response(response)

View file

@ -1,3 +1,4 @@
import json
import os
import sys
@ -6,8 +7,10 @@ from fastapi.testclient import TestClient
sys.path.insert(0, os.path.abspath("../../../../.."))
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from litellm.anthropic_interface import messages
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.types.utils import Delta, ModelResponse, StreamingChoices
@ -87,3 +90,136 @@ def test_anthropic_experimental_pass_through_messages_handler_custom_llm_provide
assert call_kwargs["custom_llm_provider"] == "my-custom-llm"
assert call_kwargs["model"] == "my-custom-llm/my-custom-model"
assert call_kwargs["api_key"] == "test-api-key"
@pytest.mark.asyncio
async def test_bedrock_converse_budget_tokens_preserved():
"""
Test that budget_tokens value in thinking parameter is correctly passed to Bedrock Converse API
when using messages.acreate with bedrock/converse model.
The bug was that the messages -> completion adapter was converting thinking to reasoning_effort
and losing the original budget_tokens value, causing it to use the default (128) instead.
"""
client = AsyncHTTPHandler()
with patch.object(client, "post") as mock_post:
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {}
mock_response.text = "mock response"
mock_response.json.return_value = {
"output": {
"message": {
"role": "assistant",
"content": [{"text": "4"}]
}
},
"stopReason": "end_turn",
"usage": {
"inputTokens": 10,
"outputTokens": 5,
"totalTokens": 15
}
}
mock_post.return_value = mock_response
try:
await messages.acreate(
client=client,
max_tokens=1024,
messages=[{"role": "user", "content": "What is 2+2?"}],
model="bedrock/converse/us.anthropic.claude-sonnet-4-20250514-v1:0",
thinking={
"budget_tokens": 1024,
"type": "enabled"
},
)
except Exception:
pass # Expected due to mock response format
mock_post.assert_called_once()
call_kwargs = mock_post.call_args.kwargs
json_data = call_kwargs.get("json") or json.loads(call_kwargs.get("data", "{}"))
print("Request json: ", json.dumps(json_data, indent=4, default=str))
additional_fields = json_data.get("additionalModelRequestFields", {})
thinking_config = additional_fields.get("thinking", {})
assert "thinking" in additional_fields, "thinking parameter should be in additionalModelRequestFields"
assert thinking_config.get("type") == "enabled", "thinking.type should be 'enabled'"
assert thinking_config.get("budget_tokens") == 1024, f"thinking.budget_tokens should be 1024, but got {thinking_config.get('budget_tokens')}"
def test_openai_model_with_thinking_converts_to_reasoning_effort():
"""
Test that when using a non-Anthropic model (like OpenAI gpt-5.2) with thinking parameter,
the thinking is converted to reasoning_effort and NOT passed as thinking.
This ensures we don't regress on issue #16052 where non-Anthropic models would fail
with UnsupportedParamsError when thinking was passed directly.
"""
from litellm.llms.anthropic.experimental_pass_through.messages.handler import (
anthropic_messages_handler,
)
with patch("litellm.completion", return_value="test-response") as mock_completion:
try:
anthropic_messages_handler(
max_tokens=1024,
messages=[{"role": "user", "content": "What is 2+2?"}],
model="openai/gpt-5.2",
api_key="test-api-key",
thinking={
"type": "enabled",
"budget_tokens": 1024
},
)
except Exception as e:
print(f"Error: {e}")
mock_completion.assert_called_once()
call_kwargs = mock_completion.call_args.kwargs
# Verify reasoning_effort is set (converted from thinking)
assert "reasoning_effort" in call_kwargs, "reasoning_effort should be passed to completion"
assert call_kwargs["reasoning_effort"] == "minimal", f"reasoning_effort should be 'minimal' for budget_tokens=1024, got {call_kwargs.get('reasoning_effort')}"
# Verify thinking is NOT passed (non-Claude model)
assert "thinking" not in call_kwargs, "thinking should NOT be passed for non-Claude models"
class TestThinkingParameterTransformation:
"""Core tests for thinking parameter transformation logic."""
def test_claude_model_preserves_thinking_with_budget_tokens(self):
"""Test that Claude models get thinking parameter passed through with exact budget_tokens."""
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import (
LiteLLMAnthropicMessagesAdapter,
)
thinking = {"type": "enabled", "budget_tokens": 5000}
result = LiteLLMAnthropicMessagesAdapter.translate_thinking_for_model(
thinking=thinking,
model="bedrock/converse/us.anthropic.claude-sonnet-4-20250514-v1:0",
)
assert result == {"thinking": thinking}
assert result["thinking"]["budget_tokens"] == 5000
def test_non_claude_model_converts_thinking_to_reasoning_effort(self):
"""Test that non-Claude models convert thinking to reasoning_effort."""
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import (
LiteLLMAnthropicMessagesAdapter,
)
thinking = {"type": "enabled", "budget_tokens": 1024}
result = LiteLLMAnthropicMessagesAdapter.translate_thinking_for_model(
thinking=thinking,
model="openai/gpt-5.2",
)
assert result == {"reasoning_effort": "minimal"}
assert "thinking" not in result

View file

@ -20,6 +20,7 @@ from litellm.proxy._types import (
LiteLLM_OrganizationTable,
LiteLLM_OrganizationTableWithMembers,
LiteLLM_TeamTable,
LiteLLM_UserTable,
LitellmUserRoles,
Member,
ProxyErrorTypes,
@ -4476,6 +4477,187 @@ async def test_new_team_with_router_settings(mock_db_client, mock_admin_auth):
assert deserialized_settings == router_settings_data
@pytest.mark.asyncio
async def test_get_team_daily_activity_non_admin_filters_by_user_api_keys(
mock_db_client,
):
"""
Test that non-team-admin users only see their own spend (filtered by their API keys)
when calling /team/daily/activity endpoint.
"""
from litellm.proxy.management_endpoints.team_endpoints import (
get_team_daily_activity,
)
# Create a non-admin user
user_id = "test_user_123"
team_id = "test_team_456"
user_api_key_dict = UserAPIKeyAuth(
user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER
)
# Mock user info
mock_user_info = LiteLLM_UserTable(
user_id=user_id,
teams=[team_id],
max_budget=1000.0,
spend=0.0,
user_email="test@example.com",
user_role="internal_user",
)
# Mock team with user as non-admin member
mock_team_member = Member(user_id=user_id, role="user")
mock_team = MagicMock(spec=LiteLLM_TeamTable)
mock_team.team_id = team_id
mock_team.team_alias = "Test Team"
mock_team.members_with_roles = [mock_team_member]
mock_team.model_dump.return_value = {
"team_id": team_id,
"team_alias": "Test Team",
"members_with_roles": [{"user_id": user_id, "role": "user"}],
}
# Mock user's API keys
user_api_key_1 = MagicMock()
user_api_key_1.token = "user_key_1"
user_api_key_2 = MagicMock()
user_api_key_2.token = "user_key_2"
# Setup mocks
mock_db_client.db.litellm_teamtable.find_many = AsyncMock(
return_value=[mock_team]
)
mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(
return_value=[user_api_key_1, user_api_key_2]
)
# Mock get_user_object
with patch(
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
new_callable=AsyncMock,
) as mock_get_user_object:
mock_get_user_object.return_value = mock_user_info
# Mock get_daily_activity to capture the api_key parameter
with patch(
"litellm.proxy.management_endpoints.team_endpoints.get_daily_activity",
new_callable=AsyncMock,
) as mock_get_daily_activity:
mock_get_daily_activity.return_value = MagicMock()
# Call the endpoint
await get_team_daily_activity(
team_ids=team_id,
start_date="2024-01-01",
end_date="2024-01-02",
model=None,
api_key=None,
page=1,
page_size=10,
exclude_team_ids=None,
user_api_key_dict=user_api_key_dict,
)
# Verify get_daily_activity was called with user's API keys as filter
mock_get_daily_activity.assert_called_once()
call_kwargs = mock_get_daily_activity.call_args[1]
assert call_kwargs["api_key"] == ["user_key_1", "user_key_2"]
assert call_kwargs["entity_id"] == [team_id]
# Verify user's API keys were fetched
mock_db_client.db.litellm_verificationtoken.find_many.assert_called_once()
api_key_call_kwargs = (
mock_db_client.db.litellm_verificationtoken.find_many.call_args[1]
)
assert api_key_call_kwargs["where"] == {"user_id": user_id}
@pytest.mark.asyncio
async def test_get_team_daily_activity_team_admin_sees_all_spend(mock_db_client):
"""
Test that team admin users see all team spend (no API key filtering)
when calling /team/daily/activity endpoint.
"""
from litellm.proxy.management_endpoints.team_endpoints import (
get_team_daily_activity,
)
# Create a team admin user
user_id = "test_admin_123"
team_id = "test_team_456"
user_api_key_dict = UserAPIKeyAuth(
user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER
)
# Mock user info
mock_user_info = LiteLLM_UserTable(
user_id=user_id,
teams=[team_id],
max_budget=1000.0,
spend=0.0,
user_email="admin@example.com",
user_role="internal_user",
)
# Mock team with user as admin member
mock_team_member = Member(user_id=user_id, role="admin")
mock_team = MagicMock(spec=LiteLLM_TeamTable)
mock_team.team_id = team_id
mock_team.team_alias = "Test Team"
mock_team.members_with_roles = [mock_team_member]
mock_team.model_dump.return_value = {
"team_id": team_id,
"team_alias": "Test Team",
"members_with_roles": [{"user_id": user_id, "role": "admin"}],
}
# Setup mocks
mock_db_client.db.litellm_teamtable.find_many = AsyncMock(
return_value=[mock_team]
)
# Mock get_user_object
with patch(
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
new_callable=AsyncMock,
) as mock_get_user_object:
mock_get_user_object.return_value = mock_user_info
# Mock get_daily_activity to capture the api_key parameter
with patch(
"litellm.proxy.management_endpoints.team_endpoints.get_daily_activity",
new_callable=AsyncMock,
) as mock_get_daily_activity:
mock_get_daily_activity.return_value = MagicMock()
# Call the endpoint
await get_team_daily_activity(
team_ids=team_id,
start_date="2024-01-01",
end_date="2024-01-02",
model=None,
api_key=None,
page=1,
page_size=10,
exclude_team_ids=None,
user_api_key_dict=user_api_key_dict,
)
# Verify get_daily_activity was called WITHOUT API key filtering
mock_get_daily_activity.assert_called_once()
call_kwargs = mock_get_daily_activity.call_args[1]
assert call_kwargs["api_key"] is None
assert call_kwargs["entity_id"] == [team_id]
# Verify user's API keys were NOT fetched (since they're admin)
if hasattr(
mock_db_client.db.litellm_verificationtoken, "find_many"
) and mock_db_client.db.litellm_verificationtoken.find_many.called:
# If it was called, that's unexpected for admin users
assert False, "API keys should not be fetched for team admin users"
@pytest.mark.asyncio
async def test_update_team_with_router_settings(mock_db_client, mock_admin_auth):
"""
@ -4552,3 +4734,184 @@ async def test_update_team_with_router_settings(mock_db_client, mock_admin_auth)
# Verify router_settings can be deserialized and matches input
deserialized_settings = json.loads(team_data["router_settings"])
assert deserialized_settings == router_settings_data
@pytest.mark.asyncio
async def test_get_team_daily_activity_non_admin_filters_by_user_api_keys(
mock_db_client,
):
"""
Test that non-team-admin users only see their own spend (filtered by their API keys)
when calling /team/daily/activity endpoint.
"""
from litellm.proxy.management_endpoints.team_endpoints import (
get_team_daily_activity,
)
# Create a non-admin user
user_id = "test_user_123"
team_id = "test_team_456"
user_api_key_dict = UserAPIKeyAuth(
user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER
)
# Mock user info
mock_user_info = LiteLLM_UserTable(
user_id=user_id,
teams=[team_id],
max_budget=1000.0,
spend=0.0,
user_email="test@example.com",
user_role="internal_user",
)
# Mock team with user as non-admin member
mock_team_member = Member(user_id=user_id, role="user")
mock_team = MagicMock(spec=LiteLLM_TeamTable)
mock_team.team_id = team_id
mock_team.team_alias = "Test Team"
mock_team.members_with_roles = [mock_team_member]
mock_team.model_dump.return_value = {
"team_id": team_id,
"team_alias": "Test Team",
"members_with_roles": [{"user_id": user_id, "role": "user"}],
}
# Mock user's API keys
user_api_key_1 = MagicMock()
user_api_key_1.token = "user_key_1"
user_api_key_2 = MagicMock()
user_api_key_2.token = "user_key_2"
# Setup mocks
mock_db_client.db.litellm_teamtable.find_many = AsyncMock(
return_value=[mock_team]
)
mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(
return_value=[user_api_key_1, user_api_key_2]
)
# Mock get_user_object
with patch(
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
new_callable=AsyncMock,
) as mock_get_user_object:
mock_get_user_object.return_value = mock_user_info
# Mock get_daily_activity to capture the api_key parameter
with patch(
"litellm.proxy.management_endpoints.team_endpoints.get_daily_activity",
new_callable=AsyncMock,
) as mock_get_daily_activity:
mock_get_daily_activity.return_value = MagicMock()
# Call the endpoint
await get_team_daily_activity(
team_ids=team_id,
start_date="2024-01-01",
end_date="2024-01-02",
model=None,
api_key=None,
page=1,
page_size=10,
exclude_team_ids=None,
user_api_key_dict=user_api_key_dict,
)
# Verify get_daily_activity was called with user's API keys as filter
mock_get_daily_activity.assert_called_once()
call_kwargs = mock_get_daily_activity.call_args[1]
assert call_kwargs["api_key"] == ["user_key_1", "user_key_2"]
assert call_kwargs["entity_id"] == [team_id]
# Verify user's API keys were fetched
mock_db_client.db.litellm_verificationtoken.find_many.assert_called_once()
api_key_call_kwargs = (
mock_db_client.db.litellm_verificationtoken.find_many.call_args[1]
)
assert api_key_call_kwargs["where"] == {"user_id": user_id}
@pytest.mark.asyncio
async def test_get_team_daily_activity_team_admin_sees_all_spend(mock_db_client):
"""
Test that team admin users see all team spend (no API key filtering)
when calling /team/daily/activity endpoint.
"""
from litellm.proxy.management_endpoints.team_endpoints import (
get_team_daily_activity,
)
# Create a team admin user
user_id = "test_admin_123"
team_id = "test_team_456"
user_api_key_dict = UserAPIKeyAuth(
user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER
)
# Mock user info
mock_user_info = LiteLLM_UserTable(
user_id=user_id,
teams=[team_id],
max_budget=1000.0,
spend=0.0,
user_email="admin@example.com",
user_role="internal_user",
)
# Mock team with user as admin member
mock_team_member = Member(user_id=user_id, role="admin")
mock_team = MagicMock(spec=LiteLLM_TeamTable)
mock_team.team_id = team_id
mock_team.team_alias = "Test Team"
mock_team.members_with_roles = [mock_team_member]
mock_team.model_dump.return_value = {
"team_id": team_id,
"team_alias": "Test Team",
"members_with_roles": [{"user_id": user_id, "role": "admin"}],
}
# Setup mocks
mock_db_client.db.litellm_teamtable.find_many = AsyncMock(
return_value=[mock_team]
)
# Mock get_user_object
with patch(
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
new_callable=AsyncMock,
) as mock_get_user_object:
mock_get_user_object.return_value = mock_user_info
# Mock get_daily_activity to capture the api_key parameter
with patch(
"litellm.proxy.management_endpoints.team_endpoints.get_daily_activity",
new_callable=AsyncMock,
) as mock_get_daily_activity:
mock_get_daily_activity.return_value = MagicMock()
# Call the endpoint
await get_team_daily_activity(
team_ids=team_id,
start_date="2024-01-01",
end_date="2024-01-02",
model=None,
api_key=None,
page=1,
page_size=10,
exclude_team_ids=None,
user_api_key_dict=user_api_key_dict,
)
# Verify get_daily_activity was called WITHOUT API key filtering
mock_get_daily_activity.assert_called_once()
call_kwargs = mock_get_daily_activity.call_args[1]
assert call_kwargs["api_key"] is None
assert call_kwargs["entity_id"] == [team_id]
# Verify user's API keys were NOT fetched (since they're admin)
if hasattr(
mock_db_client.db.litellm_verificationtoken, "find_many"
) and mock_db_client.db.litellm_verificationtoken.find_many.called:
# If it was called, that's unexpected for admin users
assert False, "API keys should not be fetched for team admin users"

View file

@ -2,6 +2,9 @@
import React, { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import ModelHubTable from "@/components/AIHub/ModelHubTable";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
export default function PublicModelHubTable() {
const searchParams = useSearchParams()!;
@ -19,5 +22,9 @@ export default function PublicModelHubTable() {
* populate navbar
*
*/
return <ModelHubTable accessToken={accessToken} publicPage={true} premiumUser={false} userRole={null} />;
return (
<QueryClientProvider client={queryClient}>
<ModelHubTable accessToken={accessToken} publicPage={true} premiumUser={false} userRole={null} />
</QueryClientProvider>
);
}

View file

@ -0,0 +1,28 @@
import { render, screen } from "@testing-library/react";
import { Form } from "antd";
import { describe, expect, it } from "vitest";
import ConditionalPublicModelName from "./conditional_public_model_name";
describe("ConditionalPublicModelName", () => {
it("should render", () => {
render(
<Form
initialValues={{
model: ["gpt-4"],
model_mappings: [
{
public_name: "gpt-4",
litellm_model: "gpt-4",
},
],
}}
>
<ConditionalPublicModelName />
</Form>,
);
expect(screen.getByText("Model Mappings")).toBeInTheDocument();
expect(screen.getByText("Public Model Name")).toBeInTheDocument();
expect(screen.getByText("LiteLLM Model Name")).toBeInTheDocument();
});
});

View file

@ -129,8 +129,31 @@ const ConditionalPublicModelName: React.FC = () => {
<TextInput
value={text}
onChange={(e) => {
const newValue = e.target.value;
const newMappings = [...form.getFieldValue("model_mappings")];
newMappings[index].public_name = e.target.value;
// Check conditions for Anthropic -1m suffix handling
const isAnthropic = selectedProvider === Providers.Anthropic;
const endsWith1m = newValue.endsWith("-1m");
const litellmParams = form.getFieldValue("litellm_extra_params");
const isLitellmParamsEmpty = !litellmParams || litellmParams.trim() === "";
let finalPublicName = newValue;
if (isAnthropic && endsWith1m && isLitellmParamsEmpty) {
// Set litellm params with extra_headers
const litellmParamsValue = JSON.stringify(
{ extra_headers: { "anthropic-beta": "context-1m-2025-08-07" } },
null,
2,
);
form.setFieldValue("litellm_extra_params", litellmParamsValue);
// Remove -1m suffix from public_name
finalPublicName = newValue.slice(0, -3); // Remove "-1m" (3 characters)
}
newMappings[index].public_name = finalPublicName;
form.setFieldValue("model_mappings", newMappings);
}}
/>