From e543ae39808f61bfa416ef0450c535f75fe6b798 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:27:26 +0000 Subject: [PATCH 1/3] fix(databricks): strip thinking_blocks and reasoning_content from outbound messages Databricks Model Serving validates assistant messages with additionalProperties=false, so replaying a thinking turn translated by the Anthropic Messages adapter 400s with 'messages.N.thinking_blocks: Extra inputs are not permitted'. Drop litellm's internal fields in DatabricksConfig._transform_messages via a shared common_utils helper. Resolves LIT-6762 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../prompt_templates/common_utils.py | 14 ++++++++ .../llms/databricks/chat/transformation.py | 3 ++ .../test_databricks_chat_transformation.py | 34 +++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index ff46440ff5c..d5b1cbb7274 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -1554,6 +1554,20 @@ def with_prompt_cache_breakpoint(target: _MarkedT, marker: object) -> _MarkedT: return cast(_MarkedT, marked) # cast-ok: same block shape as the input plus the marker key +LITELLM_INTERNAL_MESSAGE_FIELDS: Final = frozenset({"thinking_blocks", "reasoning_content", "provider_specific_fields"}) + + +def strip_litellm_internal_message_fields(message: AllMessageValues) -> AllMessageValues: + """Drop the fields litellm attaches to assistant messages (e.g. when translating Anthropic thinking + blocks) that OpenAI-compatible endpoints with strict schemas reject as extra inputs.""" + if LITELLM_INTERNAL_MESSAGE_FIELDS.isdisjoint(message): + return message + return cast( # cast-ok: same TypedDict minus internal keys + AllMessageValues, + {key: value for key, value in message.items() if key not in LITELLM_INTERNAL_MESSAGE_FIELDS}, + ) + + def filter_value_from_dict(dictionary: dict, key: str, depth: int = 0) -> Any: """ Filters a value from a dictionary diff --git a/litellm/llms/databricks/chat/transformation.py b/litellm/llms/databricks/chat/transformation.py index c587146005f..e59db2dac19 100644 --- a/litellm/llms/databricks/chat/transformation.py +++ b/litellm/llms/databricks/chat/transformation.py @@ -15,6 +15,7 @@ from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response impo _should_convert_tool_call_to_json_mode, ) from litellm.litellm_core_utils.prompt_templates.common_utils import ( + strip_litellm_internal_message_fields, strip_name_from_message, ) from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator @@ -419,6 +420,7 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): """ Databricks does not support: - 'name' in user message. + - litellm's internal `thinking_blocks` / `reasoning_content` on assistant messages. """ new_messages = [] for idx, message in enumerate(messages): @@ -427,6 +429,7 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): else: _message = message _message = strip_name_from_message(_message, allowed_name_roles=["user"]) + _message = strip_litellm_internal_message_fields(_message) # Move message-level cache_control into a content block when content is a string. if "cache_control" in _message and isinstance(_message.get("content"), str): _message = self._move_cache_control_into_string_content_block(_message) diff --git a/tests/test_litellm/llms/databricks/chat/test_databricks_chat_transformation.py b/tests/test_litellm/llms/databricks/chat/test_databricks_chat_transformation.py index 41fb2589655..0a792a546e4 100644 --- a/tests/test_litellm/llms/databricks/chat/test_databricks_chat_transformation.py +++ b/tests/test_litellm/llms/databricks/chat/test_databricks_chat_transformation.py @@ -255,6 +255,40 @@ def test_transform_messages_sanitizes_empty_content(): assert result[1]["content"] == "Hi" +def test_transform_request_strips_thinking_blocks_and_reasoning_content(): + """Regression for LIT-6762: replaying an assistant turn that litellm decorated with + `thinking_blocks` / `reasoning_content` made Databricks 400 with + 'messages.N.thinking_blocks: Extra inputs are not permitted'.""" + config = DatabricksConfig() + messages = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "Hello! How can I help?", + "thinking_blocks": [ + {"type": "thinking", "thinking": "greet briefly", "signature": "sig_abc", "cache_control": {}} + ], + "reasoning_content": "greet briefly", + "provider_specific_fields": {"foo": "bar"}, + }, + {"role": "user", "content": "thanks"}, + ] + + result = config.transform_request( + model="databricks-claude-opus-5", + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + )["messages"] + + assert result[1] == {"role": "assistant", "content": "Hello! How can I help?"} + assert not any( + key in message for message in result for key in ("thinking_blocks", "reasoning_content", "provider_specific_fields") + ) + assert "thinking_blocks" in messages[1] + + def _parallel_tool_calls(): return [ { From 518506834a3adfe068d07cb567fd27448c9f1bc6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:43:22 +0000 Subject: [PATCH 2/3] fix(databricks): drop assistant turns left empty after stripping thinking_blocks Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../llms/databricks/chat/transformation.py | 10 +++++ .../test_databricks_chat_transformation.py | 44 ++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/litellm/llms/databricks/chat/transformation.py b/litellm/llms/databricks/chat/transformation.py index e59db2dac19..4f61a39f692 100644 --- a/litellm/llms/databricks/chat/transformation.py +++ b/litellm/llms/databricks/chat/transformation.py @@ -56,6 +56,14 @@ from ...openai_like.chat.transformation import OpenAILikeChatConfig from ..common_utils import DatabricksBase, DatabricksException +def _is_bare_assistant_message(message_dict: dict[str, Any]) -> bool: + """Databricks rejects assistant messages with neither content nor tool calls, e.g. a replayed + thinking-only turn once its `thinking_blocks` are stripped.""" + return message_dict.get("role") == "assistant" and not any( + message_dict.get(key) for key in ("content", "tool_calls", "function_call") + ) + + def _sanitize_empty_content(message_dict: dict[str, Any]) -> None: """ Remove or filter content so empty text blocks are not sent. @@ -434,6 +442,8 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): if "cache_control" in _message and isinstance(_message.get("content"), str): _message = self._move_cache_control_into_string_content_block(_message) _sanitize_empty_content(cast(dict[str, Any], _message)) + if _is_bare_assistant_message(cast(dict[str, Any], _message)): + continue new_messages.append(_message) if "claude" not in model: diff --git a/tests/test_litellm/llms/databricks/chat/test_databricks_chat_transformation.py b/tests/test_litellm/llms/databricks/chat/test_databricks_chat_transformation.py index 0a792a546e4..b2c52617e9d 100644 --- a/tests/test_litellm/llms/databricks/chat/test_databricks_chat_transformation.py +++ b/tests/test_litellm/llms/databricks/chat/test_databricks_chat_transformation.py @@ -284,11 +284,53 @@ def test_transform_request_strips_thinking_blocks_and_reasoning_content(): assert result[1] == {"role": "assistant", "content": "Hello! How can I help?"} assert not any( - key in message for message in result for key in ("thinking_blocks", "reasoning_content", "provider_specific_fields") + key in message + for message in result + for key in ("thinking_blocks", "reasoning_content", "provider_specific_fields") ) assert "thinking_blocks" in messages[1] +def test_transform_request_drops_thinking_only_assistant_turn_but_keeps_tool_call_turn(): + """A replayed thinking-only assistant turn has nothing left once `thinking_blocks` are stripped, so it must be + dropped instead of being sent as a bare {"role": "assistant"}. A thinking + tool_use turn keeps its tool_calls.""" + config = DatabricksConfig() + tool_call = {"id": "call_1", "type": "function", "function": {"name": "f", "arguments": "{}"}} + messages = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": None, + "thinking_blocks": [{"type": "thinking", "thinking": "hmm", "signature": "sig_1"}], + "reasoning_content": "hmm", + }, + {"role": "user", "content": "again"}, + { + "role": "assistant", + "content": None, + "thinking_blocks": [{"type": "thinking", "thinking": "call f", "signature": "sig_2"}], + "reasoning_content": "call f", + "tool_calls": [tool_call], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "ok"}, + ] + + result = config.transform_request( + model="databricks-claude-opus-5", + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + )["messages"] + + assert result == [ + {"role": "user", "content": "hi"}, + {"role": "user", "content": "again"}, + {"role": "assistant", "tool_calls": [tool_call]}, + {"role": "tool", "tool_call_id": "call_1", "content": "ok"}, + ] + + def _parallel_tool_calls(): return [ { From 1d40202a4e8863161f9a2339193d367895d7ea1a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:48:25 +0000 Subject: [PATCH 3/3] fix(databricks): keep new helpers within the type discipline budget Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/litellm_core_utils/prompt_templates/common_utils.py | 4 +++- litellm/llms/databricks/chat/transformation.py | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index d5b1cbb7274..17ebde83eee 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -1564,7 +1564,9 @@ def strip_litellm_internal_message_fields(message: AllMessageValues) -> AllMessa return message return cast( # cast-ok: same TypedDict minus internal keys AllMessageValues, - {key: value for key, value in message.items() if key not in LITELLM_INTERNAL_MESSAGE_FIELDS}, + { # mutable-ok: provider transforms mutate message dicts in place downstream + key: value for key, value in message.items() if key not in LITELLM_INTERNAL_MESSAGE_FIELDS + }, ) diff --git a/litellm/llms/databricks/chat/transformation.py b/litellm/llms/databricks/chat/transformation.py index 4f61a39f692..420c357ab87 100644 --- a/litellm/llms/databricks/chat/transformation.py +++ b/litellm/llms/databricks/chat/transformation.py @@ -3,7 +3,7 @@ Translates from OpenAI's `/v1/chat/completions` to Databricks' `/chat/completion """ import os -from collections.abc import AsyncIterator, Coroutine, Iterator +from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping from typing import TYPE_CHECKING, Any, Final, Literal, cast, overload import httpx @@ -56,7 +56,7 @@ from ...openai_like.chat.transformation import OpenAILikeChatConfig from ..common_utils import DatabricksBase, DatabricksException -def _is_bare_assistant_message(message_dict: dict[str, Any]) -> bool: +def _is_bare_assistant_message(message_dict: Mapping[str, object]) -> bool: """Databricks rejects assistant messages with neither content nor tool calls, e.g. a replayed thinking-only turn once its `thinking_blocks` are stripped.""" return message_dict.get("role") == "assistant" and not any( @@ -442,7 +442,7 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): if "cache_control" in _message and isinstance(_message.get("content"), str): _message = self._move_cache_control_into_string_content_block(_message) _sanitize_empty_content(cast(dict[str, Any], _message)) - if _is_bare_assistant_message(cast(dict[str, Any], _message)): + if _is_bare_assistant_message(_message): continue new_messages.append(_message)