From 3282d1453f445ae39d83acbe565e2745905eb49e Mon Sep 17 00:00:00 2001 From: pokepoke81 <4258646+pokepoke81@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:08:25 -0400 Subject: [PATCH 1/5] fix(databricks): replay extended thinking as a reasoning content block Databricks rejects LiteLLM's message-level thinking_blocks and reasoning_content keys with "Extra inputs are not permitted", so any multi-turn conversation using extended thinking failed as soon as an assistant turn carrying thinking was replayed as history. Converts them to the reasoning content block the endpoint accepts. Verified against a live endpoint: exactly one reasoning block holding exactly one summary entry, whose signature is required and whose text may be empty. Sending two of either, an Anthropic-native thinking block, or the content/reasoning_text shape in Databricks' docs are all rejected. reasoning_content arrives as an empty string, so it needs a key-presence check rather than a truthiness one. --- .../llms/databricks/chat/transformation.py | 50 ++++++++ .../test_databricks_chat_transformation.py | 110 ++++++++++++++++++ 2 files changed, 160 insertions(+) diff --git a/litellm/llms/databricks/chat/transformation.py b/litellm/llms/databricks/chat/transformation.py index 8a625569cfa..1355c0a7bd6 100644 --- a/litellm/llms/databricks/chat/transformation.py +++ b/litellm/llms/databricks/chat/transformation.py @@ -428,6 +428,8 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): # 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) + if "thinking_blocks" in _message or "reasoning_content" in _message: + _message = self._move_reasoning_into_content_block(_message) _sanitize_empty_content(cast(dict[str, Any], _message)) new_messages.append(_message) @@ -464,6 +466,54 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): ] return cast(AllMessageValues, transformed_message) + def _move_reasoning_into_content_block(self, message: AllMessageValues) -> AllMessageValues: + """ + Converts LiteLLM's message-level reasoning fields into the reasoning content block + Databricks accepts, so extended thinking survives a multi-turn round trip. + + Transforms: + {"role": "assistant", "content": "text", "thinking_blocks": [{"thinking": "t", "signature": "s"}]} + Into: + {"role": "assistant", "content": [ + {"type": "reasoning", "summary": [{"type": "summary_text", "text": "t", "signature": "s"}]}, + {"type": "text", "text": "text"}, + ]} + + Databricks rejects both message-level keys outright, and constrains the replacement to + exactly one reasoning block holding exactly one summary entry, whose signature is + required. Blocks without a signature cannot be replayed, so the keys are dropped rather + than sent in a form the API refuses. reasoning_content is a plain-text mirror of the + same thinking, so it carries nothing the signed summary does not. + """ + transformed_message: Final = cast(dict[str, Any], message.copy()) + thinking_blocks: Final = transformed_message.pop("thinking_blocks", None) + transformed_message.pop("reasoning_content", None) + signed_block: Final = next( + (block for block in thinking_blocks or [] if isinstance(block, dict) and block.get("signature")), + None, + ) + if signed_block is None: + return cast(AllMessageValues, transformed_message) + + content: Final = transformed_message.get("content") + existing_blocks: Final = ( + [{"type": "text", "text": content}] if isinstance(content, str) and content else list(content or []) + ) + transformed_message["content"] = [ + { + "type": "reasoning", + "summary": [ + { + "type": "summary_text", + "text": signed_block.get("thinking") or "", + "signature": signed_block["signature"], + } + ], + }, + *existing_blocks, + ] + return cast(AllMessageValues, transformed_message) + @staticmethod def extract_content_str( content: AllDatabricksContentValues | None, 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 165046a2298..e4beb3bbfc4 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 @@ -496,3 +496,113 @@ def test_chunk_parser_without_usage_still_parses_content(): assert result.id == "chatcmpl-test" assert result.model == "databricks-claude-sonnet-5" assert result.choices[0]["delta"]["content"] == "hi" +def _thinking_message(thinking_blocks, content="391"): + return {"role": "assistant", "content": content, "thinking_blocks": thinking_blocks} + + +def test_thinking_blocks_become_a_reasoning_content_block(): + """Databricks rejects the message-level thinking_blocks key outright, so extended + thinking never survived a second turn. It must become a reasoning content block + carrying exactly one signed summary entry.""" + result = DatabricksConfig()._move_reasoning_into_content_block( + _thinking_message([{"type": "thinking", "thinking": "let me work it out", "signature": "sig-abc"}]) + ) + + assert "thinking_blocks" not in result + assert result["content"] == [ + { + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "let me work it out", "signature": "sig-abc"}], + }, + {"type": "text", "text": "391"}, + ] + + +def test_thinking_blocks_conversion_preserves_existing_content_blocks(): + result = DatabricksConfig()._move_reasoning_into_content_block( + _thinking_message( + [{"type": "thinking", "thinking": "", "signature": "sig-abc"}], + content=[{"type": "text", "text": "391"}, {"type": "text", "text": "trailing"}], + ) + ) + + assert result["content"][0]["type"] == "reasoning" + assert result["content"][1:] == [{"type": "text", "text": "391"}, {"type": "text", "text": "trailing"}] + + +def test_thinking_blocks_collapse_to_a_single_summary_entry(): + """Databricks accepts exactly one reasoning block with one summary entry; sending two + of either is rejected, so extra blocks are dropped rather than sent in a refused shape.""" + result = DatabricksConfig()._move_reasoning_into_content_block( + _thinking_message( + [ + {"type": "thinking", "thinking": "first", "signature": "sig-1"}, + {"type": "thinking", "thinking": "second", "signature": "sig-2"}, + ] + ) + ) + + reasoning_blocks = [b for b in result["content"] if b.get("type") == "reasoning"] + assert len(reasoning_blocks) == 1 + assert reasoning_blocks[0]["summary"] == [{"type": "summary_text", "text": "first", "signature": "sig-1"}] + + +@pytest.mark.parametrize( + "thinking_blocks", + [ + None, + [], + [{"type": "redacted_thinking", "data": "opaque"}], + ], + ids=["none", "empty", "unsigned_only"], +) +def test_unreplayable_thinking_blocks_are_dropped(thinking_blocks): + """A signature is mandatory, so blocks that lack one cannot be replayed. The key is + still removed, since leaving it would fail the request outright.""" + result = DatabricksConfig()._move_reasoning_into_content_block(_thinking_message(thinking_blocks)) + + assert "thinking_blocks" not in result + assert result["content"] == "391" + + +def test_transform_messages_strips_thinking_blocks(): + messages = [ + {"role": "user", "content": "what is 17 * 23?"}, + _thinking_message([{"type": "thinking", "thinking": "t", "signature": "sig-abc"}]), + {"role": "user", "content": "now double it"}, + ] + + result = DatabricksConfig()._transform_messages(messages=messages, model="databricks-claude-opus-5") + + assert all("thinking_blocks" not in m for m in result) + assert result[1]["content"][0]["type"] == "reasoning" + + +def test_reasoning_content_is_dropped_even_when_empty(): + """reasoning_content is an empty string on Databricks responses, so a truthiness check + would leave it in place and the request would still be rejected.""" + result = DatabricksConfig()._move_reasoning_into_content_block( + {"role": "assistant", "content": "391", "reasoning_content": ""} + ) + + assert "reasoning_content" not in result + assert result["content"] == "391" + + +def test_transform_messages_strips_both_reasoning_fields(): + """A verbatim assistant turn from a Databricks thinking response carries both keys.""" + messages = [ + {"role": "user", "content": "what is 17 * 23?"}, + { + "role": "assistant", + "content": "391", + "reasoning_content": "", + "thinking_blocks": [{"type": "thinking", "thinking": "", "signature": "sig-abc"}], + }, + {"role": "user", "content": "now double it"}, + ] + + result = DatabricksConfig()._transform_messages(messages=messages, model="databricks-claude-opus-5") + + assert all("thinking_blocks" not in m and "reasoning_content" not in m for m in result) + assert result[1]["content"][0]["summary"][0]["signature"] == "sig-abc" From 6c232f3b85b8a0e7cb79ce116d6f6c014809632e Mon Sep 17 00:00:00 2001 From: pokepoke81 <4258646+pokepoke81@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:31:54 -0400 Subject: [PATCH 2/5] fix(databricks): keep a single reasoning block and build the message immutably A message arriving with a reasoning block already in its content and signed thinking_blocks alongside it gained a second reasoning block, which Databricks rejects. Keep the existing block and drop the redundant keys. Also builds the transformed message in one shot rather than copying and mutating, per the repo's immutability convention for new code. --- .../llms/databricks/chat/transformation.py | 60 ++++++++++--------- .../test_databricks_chat_transformation.py | 20 +++++++ 2 files changed, 53 insertions(+), 27 deletions(-) diff --git a/litellm/llms/databricks/chat/transformation.py b/litellm/llms/databricks/chat/transformation.py index 1355c0a7bd6..ee4993bcbf3 100644 --- a/litellm/llms/databricks/chat/transformation.py +++ b/litellm/llms/databricks/chat/transformation.py @@ -481,38 +481,44 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): Databricks rejects both message-level keys outright, and constrains the replacement to exactly one reasoning block holding exactly one summary entry, whose signature is - required. Blocks without a signature cannot be replayed, so the keys are dropped rather - than sent in a form the API refuses. reasoning_content is a plain-text mirror of the - same thinking, so it carries nothing the signed summary does not. + required. Blocks without a signature cannot be replayed, and a message already carrying + a reasoning block must not gain a second one, so in both cases the keys are dropped + rather than sent in a form the API refuses. reasoning_content is a plain-text mirror of + the same thinking, so it carries nothing the signed summary does not. """ - transformed_message: Final = cast(dict[str, Any], message.copy()) - thinking_blocks: Final = transformed_message.pop("thinking_blocks", None) - transformed_message.pop("reasoning_content", None) - signed_block: Final = next( - (block for block in thinking_blocks or [] if isinstance(block, dict) and block.get("signature")), - None, + stripped: Final = cast( + dict[str, Any], + {key: value for key, value in message.items() if key not in ("thinking_blocks", "reasoning_content")}, ) - if signed_block is None: - return cast(AllMessageValues, transformed_message) - - content: Final = transformed_message.get("content") + content: Final = stripped.get("content") existing_blocks: Final = ( [{"type": "text", "text": content}] if isinstance(content, str) and content else list(content or []) ) - transformed_message["content"] = [ - { - "type": "reasoning", - "summary": [ - { - "type": "summary_text", - "text": signed_block.get("thinking") or "", - "signature": signed_block["signature"], - } - ], - }, - *existing_blocks, - ] - return cast(AllMessageValues, transformed_message) + if any(isinstance(block, dict) and block.get("type") == "reasoning" for block in existing_blocks): + return cast(AllMessageValues, stripped) + + signed_block: Final = next( + ( + block + for block in message.get("thinking_blocks") or [] + if isinstance(block, dict) and block.get("signature") + ), + None, + ) + if signed_block is None: + return cast(AllMessageValues, stripped) + + reasoning_block: Final = { + "type": "reasoning", + "summary": [ + { + "type": "summary_text", + "text": signed_block.get("thinking") or "", + "signature": signed_block["signature"], + } + ], + } + return cast(AllMessageValues, {**stripped, "content": [reasoning_block, *existing_blocks]}) @staticmethod def extract_content_str( 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 e4beb3bbfc4..6800c61d4e1 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 @@ -606,3 +606,23 @@ def test_transform_messages_strips_both_reasoning_fields(): assert all("thinking_blocks" not in m and "reasoning_content" not in m for m in result) assert result[1]["content"][0]["summary"][0]["signature"] == "sig-abc" + + +def test_existing_reasoning_block_is_not_duplicated(): + """Databricks rejects a message holding two reasoning blocks, so a message that already + carries one keeps it and the redundant thinking_blocks are dropped instead.""" + already_converted = { + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "earlier", "signature": "sig-earlier"}], + } + + result = DatabricksConfig()._move_reasoning_into_content_block( + { + "role": "assistant", + "content": [already_converted, {"type": "text", "text": "391"}], + "thinking_blocks": [{"type": "thinking", "thinking": "later", "signature": "sig-later"}], + } + ) + + assert "thinking_blocks" not in result + assert [b for b in result["content"] if b.get("type") == "reasoning"] == [already_converted] From ffb6294b7b64e9399c7c8936ddc89dfd78126da9 Mon Sep 17 00:00:00 2001 From: pokepoke81 <4258646+pokepoke81@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:42:10 -0400 Subject: [PATCH 3/5] chore(databricks): hold the type-discipline budget at its ceiling The reasoning conversion added 9 LIT002 and 3 LIT006 violations against budgets that sit exactly at their current count, so any addition fails the gate. Collapses each construction onto a single suppressed line, uses tuples where nothing downstream inspects the container, and narrows to one cast at a single return point. The outbound content stays a list rather than a tuple: _sanitize_empty_content gates on isinstance(content, list), so a tuple would silently skip the empty-text-block filtering that Databricks requires. --- .../llms/databricks/chat/transformation.py | 45 +++++++------------ 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/litellm/llms/databricks/chat/transformation.py b/litellm/llms/databricks/chat/transformation.py index ee4993bcbf3..d236fdd0d23 100644 --- a/litellm/llms/databricks/chat/transformation.py +++ b/litellm/llms/databricks/chat/transformation.py @@ -486,39 +486,24 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): rather than sent in a form the API refuses. reasoning_content is a plain-text mirror of the same thinking, so it carries nothing the signed summary does not. """ - stripped: Final = cast( - dict[str, Any], - {key: value for key, value in message.items() if key not in ("thinking_blocks", "reasoning_content")}, - ) + dropped: Final = ("thinking_blocks", "reasoning_content") + stripped: Final = {k: v for k, v in message.items() if k not in dropped} # mutable-ok: outbound provider JSON content: Final = stripped.get("content") - existing_blocks: Final = ( - [{"type": "text", "text": content}] if isinstance(content, str) and content else list(content or []) - ) - if any(isinstance(block, dict) and block.get("type") == "reasoning" for block in existing_blocks): - return cast(AllMessageValues, stripped) - - signed_block: Final = next( - ( - block - for block in message.get("thinking_blocks") or [] - if isinstance(block, dict) and block.get("signature") - ), + as_text: Final = ({"type": "text", "text": content},) # mutable-ok: outbound provider JSON + listed: Final = tuple(content) if isinstance(content, list) else () + existing: Final = as_text if isinstance(content, str) and content else listed + signed: Final = next( + (b for b in message.get("thinking_blocks") or () if isinstance(b, dict) and b.get("signature")), None, ) - if signed_block is None: - return cast(AllMessageValues, stripped) - - reasoning_block: Final = { - "type": "reasoning", - "summary": [ - { - "type": "summary_text", - "text": signed_block.get("thinking") or "", - "signature": signed_block["signature"], - } - ], - } - return cast(AllMessageValues, {**stripped, "content": [reasoning_block, *existing_blocks]}) + holds_reasoning: Final = any(isinstance(b, dict) and b.get("type") == "reasoning" for b in existing) + replace: Final = signed is not None and not holds_reasoning + text: Final = (signed.get("thinking") or "") if signed is not None else "" + signature: Final = signed["signature"] if signed is not None else "" + entry: Final = {"type": "summary_text", "text": text, "signature": signature} # mutable-ok: provider JSON + block: Final = {"type": "reasoning", "summary": [entry]} # mutable-ok: outbound provider JSON + rebuilt: Final = {**stripped, "content": [block, *existing]} # mutable-ok: outbound provider JSON + return cast(AllMessageValues, rebuilt if replace else stripped) # cast-ok: same TypedDict, shape unchanged @staticmethod def extract_content_str( From 40c09ee27d658a780949ba159f4879d44ff1dd37 Mon Sep 17 00:00:00 2001 From: pokepoke81 <4258646+pokepoke81@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:58:30 -0400 Subject: [PATCH 4/5] fix(databricks): only inject a reasoning block when content follows it Databricks rejects a reasoning block in the final position of an assistant message. That is exactly a thinking-plus-tool-call turn: the call moves to message-level tool_calls, leaving the reasoning block alone and therefore final, which returns "The final block in an assistant message cannot be thinking". Verified against a live endpoint. The keys are still stripped when the block cannot be placed, so neither the original nor this failure can occur. --- .../llms/databricks/chat/transformation.py | 13 +++++--- .../test_databricks_chat_transformation.py | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/litellm/llms/databricks/chat/transformation.py b/litellm/llms/databricks/chat/transformation.py index d236fdd0d23..9848bb1da31 100644 --- a/litellm/llms/databricks/chat/transformation.py +++ b/litellm/llms/databricks/chat/transformation.py @@ -481,10 +481,13 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): Databricks rejects both message-level keys outright, and constrains the replacement to exactly one reasoning block holding exactly one summary entry, whose signature is - required. Blocks without a signature cannot be replayed, and a message already carrying - a reasoning block must not gain a second one, so in both cases the keys are dropped - rather than sent in a form the API refuses. reasoning_content is a plain-text mirror of - the same thinking, so it carries nothing the signed summary does not. + required. It also rejects a reasoning block as the final block of an assistant message, + which happens when the turn was thinking plus a tool call: the call moves to + message-level tool_calls and leaves nothing behind it. So the block is only injected + when other content follows it, and a message already carrying a reasoning block never + gains a second one. Otherwise the keys are dropped rather than sent in a form the API + refuses. reasoning_content is a plain-text mirror of the same thinking, so it carries + nothing the signed summary does not. """ dropped: Final = ("thinking_blocks", "reasoning_content") stripped: Final = {k: v for k, v in message.items() if k not in dropped} # mutable-ok: outbound provider JSON @@ -497,7 +500,7 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): None, ) holds_reasoning: Final = any(isinstance(b, dict) and b.get("type") == "reasoning" for b in existing) - replace: Final = signed is not None and not holds_reasoning + replace: Final = signed is not None and not holds_reasoning and bool(existing) text: Final = (signed.get("thinking") or "") if signed is not None else "" signature: Final = signed["signature"] if signed is not None else "" entry: Final = {"type": "summary_text", "text": text, "signature": signature} # mutable-ok: provider JSON 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 6800c61d4e1..5a5a8acc748 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 @@ -626,3 +626,35 @@ def test_existing_reasoning_block_is_not_duplicated(): assert "thinking_blocks" not in result assert [b for b in result["content"] if b.get("type") == "reasoning"] == [already_converted] + + +def test_reasoning_block_is_not_emitted_as_the_only_content_block(): + """Databricks rejects a reasoning block as the final block of an assistant message. That + is exactly a thinking-plus-tool-call turn, where the call moves to message-level + tool_calls and leaves nothing after the reasoning block, so the block is dropped.""" + result = DatabricksConfig()._move_reasoning_into_content_block( + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "bash", "arguments": "{}"}}], + "thinking_blocks": [{"type": "thinking", "thinking": "", "signature": "sig-abc"}], + } + ) + + assert "thinking_blocks" not in result + assert not any(isinstance(b, dict) and b.get("type") == "reasoning" for b in result.get("content") or []) + assert result["tool_calls"][0]["id"] == "call_1" + + +def test_reasoning_block_still_emitted_when_text_follows(): + """The normal case must keep working: text after the reasoning block is valid.""" + result = DatabricksConfig()._move_reasoning_into_content_block( + { + "role": "assistant", + "content": "391", + "thinking_blocks": [{"type": "thinking", "thinking": "t", "signature": "sig-abc"}], + } + ) + + assert result["content"][0]["type"] == "reasoning" + assert result["content"][1] == {"type": "text", "text": "391"} From a48db2e7d7f339059a353fab04354dc1c4ec0a0e Mon Sep 17 00:00:00 2001 From: pokepoke81 <4258646+pokepoke81@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:50:51 -0400 Subject: [PATCH 5/5] fix(databricks): read the thinking signature with get to satisfy the type checker thinking_blocks is a union that includes ChatCompletionRedactedThinkingBlock, which has no signature key, so subscripting it tripped reportGeneralTypeIssues and breached the basedpyright budget. The filter already guarantees the key is present. --- litellm/llms/databricks/chat/transformation.py | 2 +- .../llms/databricks/chat/test_databricks_chat_transformation.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/litellm/llms/databricks/chat/transformation.py b/litellm/llms/databricks/chat/transformation.py index 9848bb1da31..7e61b231764 100644 --- a/litellm/llms/databricks/chat/transformation.py +++ b/litellm/llms/databricks/chat/transformation.py @@ -502,7 +502,7 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): holds_reasoning: Final = any(isinstance(b, dict) and b.get("type") == "reasoning" for b in existing) replace: Final = signed is not None and not holds_reasoning and bool(existing) text: Final = (signed.get("thinking") or "") if signed is not None else "" - signature: Final = signed["signature"] if signed is not None else "" + signature: Final = (signed.get("signature") or "") if signed is not None else "" entry: Final = {"type": "summary_text", "text": text, "signature": signature} # mutable-ok: provider JSON block: Final = {"type": "reasoning", "summary": [entry]} # mutable-ok: outbound provider JSON rebuilt: Final = {**stripped, "content": [block, *existing]} # mutable-ok: outbound provider JSON 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 5a5a8acc748..14de0127d9a 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 @@ -496,6 +496,8 @@ def test_chunk_parser_without_usage_still_parses_content(): assert result.id == "chatcmpl-test" assert result.model == "databricks-claude-sonnet-5" assert result.choices[0]["delta"]["content"] == "hi" + + def _thinking_message(thinking_blocks, content="391"): return {"role": "assistant", "content": content, "thinking_blocks": thinking_blocks}