fix(utils): normalize camelCase thinking param keys to snake_case (#21762)

Clients like OpenCode's @ai-sdk/openai-compatible send budgetTokens
(camelCase) instead of budget_tokens in the thinking parameter, causing
validation errors. Add early normalization in completion().
This commit is contained in:
Cesar Garcia 2026-02-21 16:14:39 -03:00 committed by GitHub
parent e0aaedc9d1
commit cc6ef0e3f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 60 additions and 0 deletions

View file

@ -147,6 +147,7 @@ from litellm.utils import (
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()

View file

@ -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.

View file

@ -3478,3 +3478,43 @@ class TestMetadataNoneHandling:
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