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.
This commit is contained in:
pokepoke81 2026-08-14 14:58:30 -04:00
parent ffb6294b7b
commit 40c09ee27d
2 changed files with 40 additions and 5 deletions

View file

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

View file

@ -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"}