diff --git a/litellm/llms/deepseek/chat/transformation.py b/litellm/llms/deepseek/chat/transformation.py index ea19a7c7ddf..c767d568602 100644 --- a/litellm/llms/deepseek/chat/transformation.py +++ b/litellm/llms/deepseek/chat/transformation.py @@ -73,7 +73,8 @@ class DeepSeekChatConfig(OpenAIGPTConfig): (LiteLLM stores provider-specific response fields there). 2. Otherwise inject a single space — the minimum value the API accepts. """ - result: Final[list[AllMessageValues]] = [] + result: list[AllMessageValues] = [] + placeholder_injections = 0 for msg in messages: if msg.get("role") == "assistant" and not msg.get("reasoning_content"): patched = dict(cast(dict, msg)) @@ -85,20 +86,29 @@ class DeepSeekChatConfig(OpenAIGPTConfig): cleaned.pop("reasoning_content", None) patched["provider_specific_fields"] = cleaned else: - litellm.verbose_logger.warning( - "DeepSeek thinking mode: assistant message is missing " - "`reasoning_content` and none was saved in " - "`provider_specific_fields`. A single-space placeholder " - "is being injected to satisfy API validation, but the " - "model will receive a blank reasoning chain for this turn, " - "which may silently degrade multi-turn response quality. " - "Preserve `reasoning_content` from the original assistant " - "response when building multi-turn conversation history." - ) + placeholder_injections += 1 patched["reasoning_content"] = " " result.append(cast(AllMessageValues, patched)) else: result.append(msg) + + # Emit a single aggregated warning per request instead of one per + # affected message. The old per-message warning produced an identical + # line for every historical assistant turn (issue #37629): a 6-message + # conversation logged 6 copies on every request, drowning out real + # errors. Aggregating keeps the diagnostic while reporting the count. + if placeholder_injections: + litellm.verbose_logger.warning( + "DeepSeek thinking mode: %d assistant message(s) were missing " + "`reasoning_content` and none was saved in " + "`provider_specific_fields`. A single-space placeholder was " + "injected for each to satisfy API validation, but the model " + "will receive a blank reasoning chain for those turns, which " + "may silently degrade multi-turn response quality. Preserve " + "`reasoning_content` from the original assistant response when " + "building multi-turn conversation history.", + placeholder_injections, + ) return result @overload diff --git a/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py b/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py index 3f93264d0d0..2537a927743 100644 --- a/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py +++ b/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py @@ -1,7 +1,113 @@ +import logging +from copy import deepcopy + import litellm from litellm.llms.deepseek.chat.transformation import DeepSeekChatConfig +class _ListHandler(logging.Handler): + def __init__(self) -> None: + super().__init__(level=logging.WARNING) + self.records: list[logging.LogRecord] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.records.append(record) + + +def _capture_reasoning_warnings(messages): + handler = _ListHandler() + logger = litellm.verbose_logger + previous_level = logger.level + logger.addHandler(handler) + logger.setLevel(logging.WARNING) + try: + result = DeepSeekChatConfig()._fill_reasoning_content(messages) + finally: + logger.removeHandler(handler) + logger.setLevel(previous_level) + records = [record for record in handler.records if "reasoning_content" in record.getMessage()] + return result, records + + +def test_fill_reasoning_content_warns_once_per_request_with_count(): + messages = [{"role": "system", "content": "You are helpful."}] + for index in range(6): + messages.append({"role": "user", "content": f"q{index}"}) + messages.append({"role": "assistant", "content": f"a{index}"}) + + result, warning_records = _capture_reasoning_warnings(messages) + + placeholders = [ + message for message in result if message.get("role") == "assistant" and message.get("reasoning_content") == " " + ] + assert len(placeholders) == 6 + assert len(warning_records) == 1 + assert "6 assistant message(s)" in warning_records[0].getMessage() + + +def test_fill_reasoning_content_does_not_warn_when_nothing_is_missing(): + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello", "reasoning_content": "thinking"}, + { + "role": "assistant", + "content": "sure", + "provider_specific_fields": {"reasoning_content": "stored"}, + }, + ] + + _result, warning_records = _capture_reasoning_warnings(messages) + + assert warning_records == [] + + +def test_transform_request_replays_warn_once_and_preserve_history(): + messages = [ + {"role": "user", "content": "Use both tools."}, + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "first"}], + "reasoning_content": "", + }, + {"role": "tool", "tool_call_id": "first", "content": "first result"}, + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "second"}], + "reasoning_content": "", + }, + {"role": "tool", "tool_call_id": "second", "content": "second result"}, + {"role": "user", "content": "Continue."}, + ] + original_messages = deepcopy(messages) + handler = _ListHandler() + logger = litellm.verbose_logger + previous_level = logger.level + logger.addHandler(handler) + logger.setLevel(logging.WARNING) + try: + results = [ + DeepSeekChatConfig().transform_request( + model="deepseek-reasoner", + messages=messages, + optional_params={"thinking": {"type": "enabled"}}, + litellm_params={}, + headers={}, + ) + for _ in range(2) + ] + finally: + logger.removeHandler(handler) + logger.setLevel(previous_level) + + warnings = [record for record in handler.records if "reasoning_content" in record.getMessage()] + assert len(warnings) == 2 + assert all("2 assistant message(s)" in record.getMessage() for record in warnings) + assert all([result["messages"][index]["reasoning_content"] for index in (1, 3)] == [" ", " "] for result in results) + assert messages == original_messages + + def _function_tool(name: str) -> dict: return { "type": "function",