diff --git a/litellm/main.py b/litellm/main.py index 80a2f74c5713..5db85594cd9d 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -147,6 +147,7 @@ token_counter, validate_and_fix_openai_messages, validate_and_fix_openai_tools, + validate_and_fix_thinking_param, validate_chat_completion_tool_choice, validate_openai_optional_params, ) @@ -1102,6 +1103,8 @@ def completion( # type: ignore # noqa: PLR0915 tool_choice = validate_chat_completion_tool_choice(tool_choice=tool_choice) # validate optional params stop = validate_openai_optional_params(stop=stop) + # normalize camelCase thinking keys (e.g. budgetTokens -> budget_tokens) + thinking = validate_and_fix_thinking_param(thinking=thinking) ######### unpacking kwargs ##################### args = locals() diff --git a/litellm/utils.py b/litellm/utils.py index 241b9d217b7a..3c02a17d15ec 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7666,6 +7666,23 @@ def validate_and_fix_openai_tools(tools: Optional[List]) -> Optional[List[dict]] return new_tools +def validate_and_fix_thinking_param( + thinking: Optional["AnthropicThinkingParam"], +) -> Optional["AnthropicThinkingParam"]: + """ + Normalizes camelCase keys in the thinking param to snake_case. + Handles clients that send budgetTokens instead of budget_tokens. + """ + if thinking is None or not isinstance(thinking, dict): + return thinking + normalized = dict(thinking) + if "budgetTokens" in normalized and "budget_tokens" not in normalized: + normalized["budget_tokens"] = normalized.pop("budgetTokens") + elif "budgetTokens" in normalized and "budget_tokens" in normalized: + normalized.pop("budgetTokens") + return cast("AnthropicThinkingParam", normalized) + + def cleanup_none_field_in_message(message: AllMessageValues): """ Cleans up the message by removing the none field. diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 6170f6b6f55d..9cb43a8dee71 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -3425,3 +3425,43 @@ def test_litellm_params_metadata_none(self): litellm_params = {"metadata": None} metadata = litellm_params.get("metadata") or {} assert metadata == {} + + +class TestValidateAndFixThinkingParam: + """Tests for validate_and_fix_thinking_param.""" + + def test_none_returns_none(self): + from litellm.utils import validate_and_fix_thinking_param + + assert validate_and_fix_thinking_param(thinking=None) is None + + def test_already_snake_case(self): + from litellm.utils import validate_and_fix_thinking_param + + thinking = {"type": "enabled", "budget_tokens": 32000} + result = validate_and_fix_thinking_param(thinking=thinking) + assert result == {"type": "enabled", "budget_tokens": 32000} + + def test_camel_case_normalized(self): + from litellm.utils import validate_and_fix_thinking_param + + thinking = {"type": "enabled", "budgetTokens": 32000} + result = validate_and_fix_thinking_param(thinking=thinking) + assert result == {"type": "enabled", "budget_tokens": 32000} + assert "budgetTokens" not in result + + def test_both_keys_snake_case_wins(self): + from litellm.utils import validate_and_fix_thinking_param + + thinking = {"type": "enabled", "budget_tokens": 10000, "budgetTokens": 50000} + result = validate_and_fix_thinking_param(thinking=thinking) + assert result == {"type": "enabled", "budget_tokens": 10000} + assert "budgetTokens" not in result + + def test_original_dict_not_mutated(self): + from litellm.utils import validate_and_fix_thinking_param + + thinking = {"type": "enabled", "budgetTokens": 32000} + validate_and_fix_thinking_param(thinking=thinking) + assert "budgetTokens" in thinking + assert "budget_tokens" not in thinking