diff --git a/litellm/llms/moonshot/chat/transformation.py b/litellm/llms/moonshot/chat/transformation.py index 24f852c28b..c97bd6c4e1 100644 --- a/litellm/llms/moonshot/chat/transformation.py +++ b/litellm/llms/moonshot/chat/transformation.py @@ -155,9 +155,11 @@ class MoonshotChatConfig(OpenAIGPTConfig): message that contains tool_calls (multi-turn tool-calling flows). For each such message that is missing the field: - 1. Promote provider_specific_fields["reasoning_content"] if present and non-empty + 1. Check if reasoning_content exists at the top level (for Pydantic models + that have the attribute but don't support 'in' operator) + 2. Promote provider_specific_fields["reasoning_content"] if present and non-empty (this is where LiteLLM stores it from a previous response) - 2. Otherwise inject a single space — the minimum value the API accepts + 3. Otherwise inject a single space — the minimum value the API accepts Messages that already carry the field, or are not assistant/tool-call messages, are appended as-is (no copy made). """ @@ -166,7 +168,7 @@ class MoonshotChatConfig(OpenAIGPTConfig): if ( msg.get("role") == "assistant" and msg.get("tool_calls") - and "reasoning_content" not in msg + and not msg.get("reasoning_content") # Check using .get() which works for both dicts and Pydantic models ): patched = dict(cast(dict, msg)) provider_fields = patched.get("provider_specific_fields") or {} diff --git a/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py b/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py index c557fb395f..f7e07ce8d9 100644 --- a/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py +++ b/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py @@ -550,4 +550,72 @@ class TestMoonshotConfig: # reasoning_content must not have been injected for msg in result["messages"]: - assert "reasoning_content" not in msg \ No newline at end of file + assert "reasoning_content" not in msg + + def test_reasoning_content_preserved_on_pydantic_message_object(self): + """reasoning_content on Pydantic Message objects is preserved (not overwritten with placeholder). + + Regression test for: https://github.com/BerriAI/litellm/issues/23765 + The issue was that 'reasoning_content' in msg doesn't work for Pydantic models + because they don't support the 'in' operator the same way as dicts. + """ + from litellm.types.utils import Message + + config = MoonshotChatConfig() + + # Create a Pydantic Message object with reasoning_content (as would come from API response) + message_with_reasoning = Message( + role="assistant", + content=None, + reasoning_content="User wants weather", + tool_calls=[ + {"id": "call_1", "type": "function", "function": {"name": "fn", "arguments": "{}"}} + ], + ) + + messages = [message_with_reasoning] + + result = config.fill_reasoning_content(messages) + + # reasoning_content should be preserved, not replaced with placeholder + assert result[0].get("reasoning_content") == "User wants weather" + + def test_reasoning_content_preserved_in_multi_turn_flow(self): + """reasoning_content is preserved through multi-turn conversation flow. + + This tests the complete flow: API response -> Message object -> dict -> fill_reasoning_content + """ + from litellm.types.utils import Message + from litellm.utils import convert_to_dict + + config = MoonshotChatConfig() + + # Simulate API response with reasoning_content + api_response = { + "role": "assistant", + "content": None, + "reasoning_content": "Planning to call weather tool", + "tool_calls": [ + {"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": '{}'}} + ], + } + + # Convert to Message object (as LiteLLM does) + message_obj = Message(**api_response) + + # Convert back to dict (when building next request) + message_dict = convert_to_dict(message_obj) + + # Build multi-turn conversation + messages = [ + {"role": "user", "content": "What's the weather?"}, + message_dict, + {"role": "tool", "tool_call_id": "call_1", "content": '{"temp": 72}'}, + {"role": "user", "content": "Thanks!"}, + ] + + # Apply fill_reasoning_content + result = config.fill_reasoning_content(messages) + + # reasoning_content should be preserved in the assistant message + assert result[1].get("reasoning_content") == "Planning to call weather tool"