mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-22 00:31:44 +00:00
Merge d1ce467d04 into 252c71c0b2
This commit is contained in:
commit
09eeb59af9
6 changed files with 728 additions and 112 deletions
|
|
@ -318,6 +318,20 @@ ANTHROPIC_ADAPTER: Final = AnthropicAdapter()
|
|||
|
||||
|
||||
class LiteLLMMessagesToCompletionTransformationHandler:
|
||||
@staticmethod
|
||||
def _is_thinking_disabled(thinking: Mapping | None) -> bool:
|
||||
"""Return True (suppressed) unless the client explicitly opted in.
|
||||
|
||||
Only ``{"type": "enabled"|"adaptive"}`` enables the reasoning
|
||||
translation. Absent, disabled, or malformed objects (missing
|
||||
``type``) fail closed: the request side
|
||||
(``translate_anthropic_thinking_to_reasoning_effort``) already
|
||||
defaults a missing ``type`` to ``disabled``, and a malformed
|
||||
object must not surface provider ``reasoning_content`` through
|
||||
a thinking block (review finding).
|
||||
"""
|
||||
return not (isinstance(thinking, dict) and thinking.get("type") in ("enabled", "adaptive"))
|
||||
|
||||
@staticmethod
|
||||
def _route_openai_thinking_to_responses_api_if_needed(
|
||||
completion_kwargs: _CompletionKwargs,
|
||||
|
|
@ -615,6 +629,8 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
|
||||
completion_response: Final = await litellm.acompletion(**completion_kwargs)
|
||||
|
||||
thinking_disabled = LiteLLMMessagesToCompletionTransformationHandler._is_thinking_disabled(thinking)
|
||||
|
||||
if stream:
|
||||
transformed_stream: Final = ANTHROPIC_ADAPTER.translate_completion_output_params_streaming(
|
||||
completion_response,
|
||||
|
|
@ -622,6 +638,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
tool_name_mapping=tool_name_mapping,
|
||||
polyfill_result=polyfill_result,
|
||||
is_async=True,
|
||||
thinking_disabled=thinking_disabled,
|
||||
litellm_logging_obj=litellm_logging_obj_from_kwargs(kwargs),
|
||||
)
|
||||
if transformed_stream is not None:
|
||||
|
|
@ -632,6 +649,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
cast(ModelResponse, completion_response),
|
||||
tool_name_mapping=tool_name_mapping,
|
||||
polyfill_result=polyfill_result,
|
||||
thinking_disabled=thinking_disabled,
|
||||
)
|
||||
if anthropic_response is not None:
|
||||
return anthropic_response
|
||||
|
|
@ -750,6 +768,8 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
|
||||
completion_response: Final = litellm.completion(**completion_kwargs)
|
||||
|
||||
thinking_disabled = LiteLLMMessagesToCompletionTransformationHandler._is_thinking_disabled(thinking)
|
||||
|
||||
if stream:
|
||||
transformed_stream: Final = ANTHROPIC_ADAPTER.translate_completion_output_params_streaming(
|
||||
completion_response,
|
||||
|
|
@ -757,6 +777,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
tool_name_mapping=tool_name_mapping,
|
||||
polyfill_result=polyfill_result,
|
||||
is_async=False,
|
||||
thinking_disabled=thinking_disabled,
|
||||
litellm_logging_obj=litellm_logging_obj_from_kwargs(kwargs),
|
||||
)
|
||||
if transformed_stream is not None:
|
||||
|
|
@ -767,6 +788,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
cast(ModelResponse, completion_response),
|
||||
tool_name_mapping=tool_name_mapping,
|
||||
polyfill_result=polyfill_result,
|
||||
thinking_disabled=thinking_disabled,
|
||||
)
|
||||
if anthropic_response is not None:
|
||||
return anthropic_response
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ from litellm.types.llms.anthropic import (
|
|||
UsageDelta,
|
||||
UsageIteration,
|
||||
)
|
||||
from litellm.types.utils import AdapterCompletionStreamWrapper, Delta
|
||||
from litellm.types.utils import AdapterCompletionStreamWrapper, Delta, StreamingChoices
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject
|
||||
|
|
@ -300,7 +300,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
sent_first_chunk: bool = False
|
||||
sent_content_block_start: bool = False
|
||||
sent_content_block_finish: bool = False
|
||||
current_content_block_type: Literal["text", "tool_use", "thinking"] = "text"
|
||||
current_content_block_type: Literal["text", "tool_use", "thinking", "redacted_thinking"] = "text"
|
||||
sent_last_message: bool = False
|
||||
holding_chunk: ContentBlockDelta | None = None
|
||||
holding_stop_reason_chunk: MessageBlockDelta | None = None
|
||||
|
|
@ -315,6 +315,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
applied_edits: list[AppliedEdit] | None = None,
|
||||
compaction_block: CompactionBlock | None = None,
|
||||
iterations_usage: list[UsageIteration] | None = None,
|
||||
thinking_disabled: bool = False,
|
||||
litellm_logging_obj: "LiteLLMLoggingObject | None" = None,
|
||||
):
|
||||
# Wrap the upstream stream so chunks that carry both content and a
|
||||
|
|
@ -332,6 +333,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
# Synthesized compaction block from compact_20260112 polyfill (streaming).
|
||||
self.compaction_block = compaction_block
|
||||
self.iterations_usage = iterations_usage
|
||||
self.thinking_disabled = thinking_disabled
|
||||
self._refusal_text: str = ""
|
||||
self.sent_compaction_block: bool = False
|
||||
# Per-phase flags so the compaction block's start/delta/stop events
|
||||
|
|
@ -584,6 +586,29 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
elif should_start_new_block:
|
||||
self._increment_content_block_index()
|
||||
|
||||
is_final_chunk = chunk.choices[0].finish_reason is not None
|
||||
|
||||
# Guard fired in _should_start_new_content_block: a
|
||||
# non-substantial (empty/role-only) chunk arrived while a
|
||||
# thinking block is open, and the guard suppressed the block
|
||||
# TRANSITION (correctly, no spurious text block opens) but the
|
||||
# chunk would still be translated below into an empty
|
||||
# text_delta and emitted INSIDE the open thinking block — a
|
||||
# block-type/delta-type mismatch, the exact class of bug this
|
||||
# patch series exists to prevent. Suppress the chunk entirely.
|
||||
# Exclude the finish chunk (it ALSO has should_start_new_block
|
||||
# == False, per _should_start_new_content_block's own early
|
||||
# `if chunk.choices[0].finish_reason is not None: return False`
|
||||
# guard) — it must still flow through to close the block and
|
||||
# emit message_delta/message_stop, not be silently dropped.
|
||||
if (
|
||||
not should_start_new_block
|
||||
and not is_final_chunk
|
||||
and self.current_content_block_type in ("thinking", "redacted_thinking")
|
||||
and not self._chunk_has_substantial_content(chunk, thinking_disabled=self.thinking_disabled)
|
||||
):
|
||||
continue
|
||||
|
||||
# applied_edits only needs to flow to the final message_delta
|
||||
# (when finish_reason is set); skip threading it through every
|
||||
# intermediate chunk. For the hold-and-merge path below,
|
||||
|
|
@ -594,11 +619,11 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
will_merge_into_held = (
|
||||
self.holding_stop_reason_chunk is not None and getattr(chunk, "usage", None) is not None
|
||||
)
|
||||
is_final_chunk = chunk.choices[0].finish_reason is not None
|
||||
processed_chunk = LiteLLMAnthropicMessagesAdapter().translate_streaming_openai_response_to_anthropic(
|
||||
response=chunk,
|
||||
current_content_block_index=self.current_content_block_index,
|
||||
applied_edits=(self.applied_edits if is_final_chunk and not will_merge_into_held else None),
|
||||
thinking_disabled=self.thinking_disabled,
|
||||
)
|
||||
processed_chunk = self._with_refusal_stop_details(processed_chunk)
|
||||
|
||||
|
|
@ -667,20 +692,24 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
continue
|
||||
|
||||
if processed_chunk["type"] == "message_delta" and self.sent_content_block_finish is False:
|
||||
# Queue both the content_block_stop and the message_delta
|
||||
self.chunk_queue.append(
|
||||
{
|
||||
"type": "content_block_stop",
|
||||
"index": self.current_content_block_index,
|
||||
}
|
||||
)
|
||||
# Empty responses legitimately have no content block. Only
|
||||
# close a block if one was actually opened.
|
||||
if self.sent_content_block_start:
|
||||
self.chunk_queue.append(
|
||||
{
|
||||
"type": "content_block_stop",
|
||||
"index": self.current_content_block_index,
|
||||
}
|
||||
)
|
||||
self.sent_content_block_finish = True
|
||||
if processed_chunk.get("delta", {}).get("stop_reason") is not None:
|
||||
self.holding_stop_reason_chunk = processed_chunk
|
||||
else:
|
||||
processed_chunk = self._augment_message_delta_usage(processed_chunk)
|
||||
self.chunk_queue.append(processed_chunk)
|
||||
return self.chunk_queue.popleft()
|
||||
if self.chunk_queue:
|
||||
return self.chunk_queue.popleft()
|
||||
continue
|
||||
elif self.holding_chunk is not None:
|
||||
self.chunk_queue.append(self.holding_chunk)
|
||||
if processed_chunk.get("type") == "message_delta":
|
||||
|
|
@ -712,7 +741,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
# valid Anthropic order (... -> content_block_stop ->
|
||||
# message_delta). Emit ``content_block_stop`` here if
|
||||
# the active content block was not already closed.
|
||||
if not self.sent_content_block_finish:
|
||||
if self.sent_content_block_start and not self.sent_content_block_finish:
|
||||
self.chunk_queue.append(
|
||||
{
|
||||
"type": "content_block_stop",
|
||||
|
|
@ -741,7 +770,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
# Anthropic SSE ordering is preserved (content_block_stop ->
|
||||
# message_delta).
|
||||
if self.holding_stop_reason_chunk is not None:
|
||||
if not self.sent_content_block_finish:
|
||||
if self.sent_content_block_start and not self.sent_content_block_finish:
|
||||
self.sent_content_block_finish = True
|
||||
self.chunk_queue.append(self._augment_message_delta_usage(self.holding_stop_reason_chunk))
|
||||
self.holding_stop_reason_chunk = None
|
||||
|
|
@ -819,6 +848,29 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
elif should_start_new_block:
|
||||
self._increment_content_block_index()
|
||||
|
||||
is_final_chunk = chunk.choices[0].finish_reason is not None
|
||||
|
||||
# Guard fired in _should_start_new_content_block: a
|
||||
# non-substantial (empty/role-only) chunk arrived while a
|
||||
# thinking block is open, and the guard suppressed the block
|
||||
# TRANSITION (correctly, no spurious text block opens) but the
|
||||
# chunk would still be translated below into an empty
|
||||
# text_delta and emitted INSIDE the open thinking block — a
|
||||
# block-type/delta-type mismatch, the exact class of bug this
|
||||
# patch series exists to prevent. Suppress the chunk entirely.
|
||||
# Exclude the finish chunk (it ALSO has should_start_new_block
|
||||
# == False, per _should_start_new_content_block's own early
|
||||
# `if chunk.choices[0].finish_reason is not None: return False`
|
||||
# guard) — it must still flow through to close the block and
|
||||
# emit message_delta/message_stop, not be silently dropped.
|
||||
if (
|
||||
not should_start_new_block
|
||||
and not is_final_chunk
|
||||
and self.current_content_block_type in ("thinking", "redacted_thinking")
|
||||
and not self._chunk_has_substantial_content(chunk, thinking_disabled=self.thinking_disabled)
|
||||
):
|
||||
continue
|
||||
|
||||
# applied_edits only needs to flow to the final message_delta
|
||||
# (when finish_reason is set); skip threading it through every
|
||||
# intermediate chunk. For the hold-and-merge path below,
|
||||
|
|
@ -829,11 +881,11 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
will_merge_into_held = (
|
||||
self.holding_stop_reason_chunk is not None and getattr(chunk, "usage", None) is not None
|
||||
)
|
||||
is_final_chunk = chunk.choices[0].finish_reason is not None
|
||||
processed_chunk = LiteLLMAnthropicMessagesAdapter().translate_streaming_openai_response_to_anthropic(
|
||||
response=chunk,
|
||||
current_content_block_index=self.current_content_block_index,
|
||||
applied_edits=(self.applied_edits if is_final_chunk and not will_merge_into_held else None),
|
||||
thinking_disabled=self.thinking_disabled,
|
||||
)
|
||||
processed_chunk = self._with_refusal_stop_details(processed_chunk)
|
||||
|
||||
|
|
@ -894,20 +946,24 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
continue
|
||||
|
||||
if processed_chunk["type"] == "message_delta" and self.sent_content_block_finish is False:
|
||||
# Queue both the content_block_stop and the holding chunk
|
||||
self.chunk_queue.append(
|
||||
{
|
||||
"type": "content_block_stop",
|
||||
"index": self.current_content_block_index,
|
||||
}
|
||||
)
|
||||
# Empty responses legitimately have no content block. Only
|
||||
# close a block if one was actually opened.
|
||||
if self.sent_content_block_start:
|
||||
self.chunk_queue.append(
|
||||
{
|
||||
"type": "content_block_stop",
|
||||
"index": self.current_content_block_index,
|
||||
}
|
||||
)
|
||||
self.sent_content_block_finish = True
|
||||
if processed_chunk.get("delta", {}).get("stop_reason") is not None:
|
||||
self.holding_stop_reason_chunk = processed_chunk
|
||||
else:
|
||||
processed_chunk = self._augment_message_delta_usage(processed_chunk)
|
||||
self.chunk_queue.append(processed_chunk)
|
||||
return self.chunk_queue.popleft()
|
||||
if self.chunk_queue:
|
||||
return self.chunk_queue.popleft()
|
||||
continue
|
||||
elif self.holding_chunk is not None:
|
||||
# Queue both chunks
|
||||
self.chunk_queue.append(self.holding_chunk)
|
||||
|
|
@ -940,7 +996,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
# valid Anthropic order (... -> content_block_stop ->
|
||||
# message_delta). Emit ``content_block_stop`` here if
|
||||
# the active content block was not already closed.
|
||||
if not self.sent_content_block_finish:
|
||||
if self.sent_content_block_start and not self.sent_content_block_finish:
|
||||
self.chunk_queue.append(
|
||||
{
|
||||
"type": "content_block_stop",
|
||||
|
|
@ -974,7 +1030,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
# Anthropic SSE ordering is preserved (content_block_stop ->
|
||||
# message_delta).
|
||||
if self.holding_stop_reason_chunk is not None:
|
||||
if not self.sent_content_block_finish:
|
||||
if self.sent_content_block_start and not self.sent_content_block_finish:
|
||||
self.sent_content_block_finish = True
|
||||
self.chunk_queue.append(self._augment_message_delta_usage(self.holding_stop_reason_chunk))
|
||||
self.holding_stop_reason_chunk = None
|
||||
|
|
@ -1088,7 +1144,65 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
delta_type: Final = delta.get("type")
|
||||
if delta_type not in _STREAMING_DELTA_TYPES:
|
||||
return False
|
||||
return bool(delta.get(_delta_payload_field(delta_type)))
|
||||
# The membership test above is the runtime guard; the cast tells the type
|
||||
# checker what it already proved, so the exhaustive match in
|
||||
# _delta_payload_field keeps its compile-time value.
|
||||
return bool(delta.get(_delta_payload_field(cast(StreamingContentBlockDeltaType, delta_type))))
|
||||
|
||||
@staticmethod
|
||||
def _chunk_has_substantial_content(chunk: "ModelResponseStream", thinking_disabled: bool = False) -> bool:
|
||||
"""Return True when the chunk carries content that should determine or
|
||||
continue a content block. Delegates to the shared classifier
|
||||
(ADR-0022) so this check can never diverge from the block-type
|
||||
classifier or delta emitter's own notion of substantiality — the
|
||||
root cause of the CTG-85 corrected bug was exactly this kind of
|
||||
divergence (this function previously used a truthy check while the
|
||||
classifier used .strip()).
|
||||
|
||||
Remains a @staticmethod with an explicit thinking_disabled parameter
|
||||
(default False) rather than becoming an instance method, because two
|
||||
pre-existing tests (test_empty_chunk_is_not_substantial,
|
||||
test_reasoning_chunk_is_substantial) call it unbound as
|
||||
AnthropicStreamWrapper._chunk_has_substantial_content(chunk) — converting
|
||||
to an instance method would break those calls.
|
||||
|
||||
The two sites' conditions are kept identical in code so they cannot
|
||||
drift into two different notions of substantiality (the CTG-85 failure
|
||||
mode). Rules mirrored from the classifier, per choice, with the same
|
||||
getattr-with-default guards (Delta deletes reasoning_content /
|
||||
thinking_blocks entirely when unset):
|
||||
|
||||
- a reasoning-only chunk is not substantial when thinking is disabled;
|
||||
- a structured thinking / redacted block is always substantial (even
|
||||
with an empty payload) when thinking is enabled;
|
||||
- a flat reasoning_content string is substantial only when it carries
|
||||
non-whitespace, when thinking is enabled;
|
||||
- a tool call with a function, and a truthy (NOT .strip()-based) text
|
||||
content, are substantial regardless of thinking state."""
|
||||
for choice in chunk.choices:
|
||||
reasoning_text = ""
|
||||
has_structured_thinking_block = False
|
||||
if isinstance(choice, StreamingChoices):
|
||||
thinking_blocks = getattr(choice.delta, "thinking_blocks", None) or []
|
||||
if len(thinking_blocks) > 0:
|
||||
first_block = thinking_blocks[0]
|
||||
if first_block.get("type") in ("thinking", "redacted_thinking"):
|
||||
has_structured_thinking_block = True
|
||||
reasoning_text = str(first_block.get("thinking") or "")
|
||||
if not has_structured_thinking_block:
|
||||
reasoning_text = str(getattr(choice.delta, "reasoning_content", "") or "")
|
||||
has_substantial_reasoning = bool(reasoning_text.strip()) or has_structured_thinking_block
|
||||
has_tool_calls = (
|
||||
choice.delta.tool_calls is not None
|
||||
and len(choice.delta.tool_calls) > 0
|
||||
and choice.delta.tool_calls[0].function is not None
|
||||
)
|
||||
text_content = str(choice.delta.content or "")
|
||||
if has_tool_calls or bool(text_content):
|
||||
return True
|
||||
if not thinking_disabled and has_substantial_reasoning:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_blank_delta(chunk: "ModelResponseStream") -> bool:
|
||||
|
|
@ -1147,7 +1261,8 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
block_type,
|
||||
content_block_start,
|
||||
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic_content_block(
|
||||
choices=chunk.choices
|
||||
choices=chunk.choices,
|
||||
thinking_disabled=self.thinking_disabled,
|
||||
)
|
||||
|
||||
# Restore original tool name if it was truncated for OpenAI's 64-char limit
|
||||
|
|
@ -1165,6 +1280,12 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
tool_block["name"] = original_name
|
||||
|
||||
if block_type != self.current_content_block_type:
|
||||
if (
|
||||
block_type == "text"
|
||||
and self.current_content_block_type in ("thinking", "redacted_thinking")
|
||||
and not self._chunk_has_substantial_content(chunk, thinking_disabled=self.thinking_disabled)
|
||||
):
|
||||
return False
|
||||
self.current_content_block_type = block_type
|
||||
self.current_content_block_start = content_block_start
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -247,6 +247,7 @@ class AnthropicAdapter:
|
|||
response: ModelResponse,
|
||||
tool_name_mapping: dict[str, str] | None = None,
|
||||
polyfill_result: PolyfillResult | None = None,
|
||||
thinking_disabled: bool = False,
|
||||
) -> AnthropicMessagesResponse | None:
|
||||
"""
|
||||
Translate OpenAI response to Anthropic format.
|
||||
|
|
@ -257,11 +258,13 @@ class AnthropicAdapter:
|
|||
Used to restore original names for tools that exceeded
|
||||
OpenAI's 64-char limit.
|
||||
polyfill_result: PolyfillResult from context_management polyfill.
|
||||
thinking_disabled: When True, suppress reasoning_content → thinking block.
|
||||
"""
|
||||
return LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(
|
||||
response=response,
|
||||
tool_name_mapping=tool_name_mapping,
|
||||
polyfill_result=polyfill_result,
|
||||
thinking_disabled=thinking_disabled,
|
||||
)
|
||||
|
||||
def translate_completion_output_params_streaming(
|
||||
|
|
@ -271,6 +274,7 @@ class AnthropicAdapter:
|
|||
tool_name_mapping: dict[str, str] | None = None,
|
||||
polyfill_result: PolyfillResult | None = None,
|
||||
is_async: bool = True,
|
||||
thinking_disabled: bool = False,
|
||||
litellm_logging_obj: "LiteLLMLoggingObject | None" = None,
|
||||
) -> AsyncIterator[bytes] | Iterator[bytes] | None:
|
||||
"""
|
||||
|
|
@ -298,6 +302,7 @@ class AnthropicAdapter:
|
|||
applied_edits=applied_edits,
|
||||
compaction_block=compaction_block,
|
||||
iterations_usage=iterations_usage,
|
||||
thinking_disabled=thinking_disabled,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
)
|
||||
# Return the SSE-wrapped version for proper event formatting.
|
||||
|
|
@ -490,6 +495,7 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
has_cache_control_in_text = False
|
||||
tool_calls: list[ChatCompletionAssistantToolCall] = []
|
||||
thinking_blocks: list[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock] = []
|
||||
unsigned_thinking_texts: list[str] = []
|
||||
if m["role"] == "assistant":
|
||||
if isinstance(m.get("content"), str):
|
||||
assistant_message_str = str(m.get("content", ""))
|
||||
|
|
@ -533,16 +539,32 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
self._add_cache_control_if_applicable(content, tool_call, model)
|
||||
tool_calls.append(tool_call)
|
||||
elif content.get("type") == "thinking":
|
||||
# Only include thinking blocks that have a real
|
||||
# signature. Blocks synthesized from flat
|
||||
# reasoning_content have no signature — passing
|
||||
# them to Claude causes:
|
||||
# "signature.str: Input should be a valid string"
|
||||
# Strip them so multi-turn history stays clean.
|
||||
# Anthropic's schema has no cache_control on thinking or
|
||||
# redacted_thinking blocks, and anthropic_messages_pt replays
|
||||
# these verbatim at content[0], so carrying one here (or
|
||||
# inventing an empty one) is a guaranteed 400 on the way back.
|
||||
thinking_block = ChatCompletionThinkingBlock(
|
||||
type="thinking",
|
||||
thinking=content.get("thinking") or "",
|
||||
signature=content.get("signature") or "",
|
||||
)
|
||||
thinking_blocks.append(thinking_block)
|
||||
if content.get("signature"):
|
||||
thinking_block = ChatCompletionThinkingBlock(
|
||||
type="thinking",
|
||||
thinking=content.get("thinking") or "",
|
||||
signature=content.get("signature") or "",
|
||||
)
|
||||
thinking_blocks.append(thinking_block)
|
||||
else:
|
||||
# Unsigned text is NOT dropped: it is
|
||||
# replayed as the flat reasoning_content field
|
||||
# below (the provider-visible form), while
|
||||
# staying out of thinking_blocks, so the
|
||||
# signature-400 stays avoided.
|
||||
unsigned_text = str(content.get("thinking") or "")
|
||||
if unsigned_text:
|
||||
unsigned_thinking_texts.append(unsigned_text)
|
||||
elif content.get("type") == "redacted_thinking":
|
||||
redacted_thinking_block = ChatCompletionRedactedThinkingBlock(
|
||||
type="redacted_thinking",
|
||||
|
|
@ -575,6 +597,9 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
if len(thinking_blocks) > 0:
|
||||
assistant_message["thinking_blocks"] = thinking_blocks
|
||||
reasoning_content = reasoning_content_from_thinking_blocks(thinking_blocks)
|
||||
if unsigned_thinking_texts:
|
||||
unsigned = "\n".join(unsigned_thinking_texts)
|
||||
reasoning_content = f"{reasoning_content}\n{unsigned}" if reasoning_content else unsigned
|
||||
if reasoning_content:
|
||||
assistant_message["reasoning_content"] = reasoning_content
|
||||
new_messages.append(assistant_message)
|
||||
|
|
@ -1281,6 +1306,7 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
self,
|
||||
choices: list[Choices],
|
||||
tool_name_mapping: dict[str, str] | None = None,
|
||||
thinking_disabled: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
new_content: Final[list[dict[str, Any]]] = []
|
||||
for choice in choices:
|
||||
|
|
@ -1307,15 +1333,28 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
data=str(data_value) if data_value is not None else "",
|
||||
).model_dump()
|
||||
)
|
||||
# Handle reasoning_content when thinking_blocks is not present
|
||||
elif hasattr(choice.message, "reasoning_content") and choice.message.reasoning_content:
|
||||
new_content.append(
|
||||
AnthropicResponseContentBlockThinking(
|
||||
type="thinking",
|
||||
thinking=str(choice.message.reasoning_content),
|
||||
signature=None,
|
||||
).model_dump()
|
||||
)
|
||||
# Handle reasoning_content when thinking_blocks is not present.
|
||||
# Skip if the original request had thinking disabled — a provider
|
||||
# may still return reasoning_content, but emitting
|
||||
# a thinking block when the client said thinking=disabled causes
|
||||
# "Content block is not a thinking block" on the client side.
|
||||
# Also skip empty or whitespace-only reasoning_content — Anthropic
|
||||
# rejects thinking blocks with no content ("each thinking block
|
||||
# must contain thinking") when they are replayed as history.
|
||||
elif (
|
||||
not thinking_disabled
|
||||
and hasattr(choice.message, "reasoning_content")
|
||||
and choice.message.reasoning_content
|
||||
):
|
||||
reasoning = str(choice.message.reasoning_content).strip()
|
||||
if reasoning:
|
||||
new_content.append(
|
||||
AnthropicResponseContentBlockThinking(
|
||||
type="thinking",
|
||||
thinking=reasoning,
|
||||
signature=None,
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
# Handle text content
|
||||
if choice.message.content is not None:
|
||||
|
|
@ -1466,6 +1505,7 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
response: ModelResponse,
|
||||
tool_name_mapping: dict[str, str] | None = None,
|
||||
polyfill_result: PolyfillResult | None = None,
|
||||
thinking_disabled: bool = False,
|
||||
) -> AnthropicMessagesResponse:
|
||||
"""
|
||||
Translate OpenAI response to Anthropic format.
|
||||
|
|
@ -1476,11 +1516,14 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
Used to restore original names for tools that exceeded
|
||||
OpenAI's 64-char limit.
|
||||
polyfill_result: PolyfillResult from context_management polyfill.
|
||||
thinking_disabled: When True, suppress reasoning_content translation
|
||||
into Anthropic thinking blocks.
|
||||
"""
|
||||
## translate content block
|
||||
anthropic_content: Final = self._translate_openai_content_to_anthropic(
|
||||
choices=response.choices,
|
||||
tool_name_mapping=tool_name_mapping,
|
||||
thinking_disabled=thinking_disabled,
|
||||
)
|
||||
refusal_text: Final = next(
|
||||
(text for choice in response.choices if (text := openai_chat_refusal_text(choice.message)) is not None),
|
||||
|
|
@ -1530,23 +1573,165 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
|
||||
return translated_obj
|
||||
|
||||
@staticmethod
|
||||
def _classify_streaming_chunk(
|
||||
choices: Sequence["OpenAIStreamingChoice | StreamingChoices"],
|
||||
thinking_disabled: bool = False,
|
||||
) -> Literal["thinking", "redacted_thinking", "tool_use", "text", "skip"]:
|
||||
"""
|
||||
Single source of truth for what an OpenAI-format streaming chunk
|
||||
represents in Anthropic terms. Both the block-type classifier
|
||||
(_translate_streaming_openai_chunk_to_anthropic_content_block) and the
|
||||
delta emitter (_translate_streaming_openai_chunk_to_anthropic) MUST
|
||||
derive their decision from this function's result for the same chunk,
|
||||
so they can never disagree on the open block's type (ADR-0022,
|
||||
CTG-85 corrected fix).
|
||||
|
||||
Precedence when multiple signals are present in one chunk:
|
||||
thinking > tool_use > text > skip.
|
||||
|
||||
"Substantial" reasoning uses .strip() — a whitespace-only
|
||||
reasoning_content chunk is NOT substantial and returns "skip".
|
||||
Text content, by contrast, uses a plain truthy check — whitespace IS
|
||||
meaningful in visible answer text (e.g. a lone " " token between two
|
||||
words in a streamed response), so a whitespace-only content chunk
|
||||
still returns "text", not "skip". Applying .strip() to text would
|
||||
silently drop those tokens. See the inline comments near
|
||||
`has_substantial_text` below for the full rationale (and the test
|
||||
`test_classify_whitespace_text_is_still_text_not_skip`).
|
||||
|
||||
Returns "skip" when the chunk carries nothing that should determine or
|
||||
continue any content block (role-only chunk, whitespace-only reasoning
|
||||
with no other signal, or a disabled-thinking chunk whose only content is
|
||||
empty/whitespace reasoning).
|
||||
"""
|
||||
for choice in choices:
|
||||
has_tool_calls = (
|
||||
choice.delta.tool_calls is not None
|
||||
and len(choice.delta.tool_calls) > 0
|
||||
and choice.delta.tool_calls[0].function is not None
|
||||
)
|
||||
|
||||
# Reasoning signal: thinking_blocks (structured) OR reasoning_content
|
||||
# (flat string from an OpenAI-compatible provider). Use getattr with a
|
||||
# default throughout — Delta deletes reasoning_content/thinking_blocks
|
||||
# entirely when unset, so a direct attribute access can raise
|
||||
# AttributeError.
|
||||
reasoning_text = ""
|
||||
has_structured_thinking_block = False
|
||||
structured_thinking_block_type: str | None = None
|
||||
if isinstance(choice, StreamingChoices):
|
||||
thinking_blocks = getattr(choice.delta, "thinking_blocks", None) or []
|
||||
if len(thinking_blocks) > 0:
|
||||
first_block = thinking_blocks[0]
|
||||
if first_block.get("type") in ("thinking", "redacted_thinking"):
|
||||
has_structured_thinking_block = True
|
||||
structured_thinking_block_type = first_block.get("type")
|
||||
reasoning_text = str(first_block.get("thinking") or "")
|
||||
if not has_structured_thinking_block:
|
||||
reasoning_text = str(getattr(choice.delta, "reasoning_content", "") or "")
|
||||
|
||||
# A structured thinking_block is ALWAYS substantial, regardless of
|
||||
# whether its thinking/signature text happens to be empty — it
|
||||
# represents an explicit, structured signal from the provider (e.g.
|
||||
# a redacted_thinking block, or a signature-only closing chunk for an
|
||||
# already-open thinking block), which is categorically different
|
||||
# from a flat, un-structured reasoning_content string that can
|
||||
# legitimately be pure incidental whitespace. Flat reasoning_content,
|
||||
# by contrast, is only substantial when it has non-whitespace
|
||||
# content — this is the actual bug fix (a whitespace-only flat
|
||||
# reasoning_content chunk must classify as 'skip', not 'thinking' or
|
||||
# 'text').
|
||||
#
|
||||
# IMPORTANT: do not require a non-empty data/signature field here.
|
||||
# A redacted_thinking block remains a structured provider signal
|
||||
# even when its encrypted payload is empty.
|
||||
has_substantial_reasoning = bool(reasoning_text.strip()) or has_structured_thinking_block
|
||||
|
||||
# IMPORTANT: text content substantiality uses a plain truthy check,
|
||||
# NOT .strip() — unlike reasoning, whitespace IS meaningful in
|
||||
# visible answer text (e.g. the space between two words arriving as
|
||||
# separate streaming tokens, "foo", " ", "bar"). Only reasoning_content
|
||||
# gets the .strip()-based "is this incidental formatting whitespace"
|
||||
# treatment; applying the same rule to text would silently drop
|
||||
# legitimate whitespace tokens from the visible answer.
|
||||
text_content = str(choice.delta.content or "")
|
||||
has_substantial_text = bool(text_content)
|
||||
|
||||
if (
|
||||
not thinking_disabled
|
||||
and has_substantial_reasoning
|
||||
and structured_thinking_block_type == "redacted_thinking"
|
||||
):
|
||||
return "redacted_thinking"
|
||||
if not thinking_disabled and has_substantial_reasoning:
|
||||
return "thinking"
|
||||
if has_tool_calls:
|
||||
return "tool_use"
|
||||
if has_substantial_text:
|
||||
return "text"
|
||||
# Nothing substantial on this choice — try the next choice (multiple
|
||||
# choices is rare but the existing functions loop over all of them).
|
||||
if thinking_disabled and (reasoning_text.strip() or has_structured_thinking_block):
|
||||
# Thinking disabled but the backend still sent reasoning — this
|
||||
# chunk carries no client-visible content once suppressed.
|
||||
continue
|
||||
return "skip"
|
||||
|
||||
def _translate_streaming_openai_chunk_to_anthropic_content_block(
|
||||
self, choices: list[OpenAIStreamingChoice | StreamingChoices]
|
||||
self,
|
||||
choices: Sequence["OpenAIStreamingChoice | StreamingChoices"],
|
||||
thinking_disabled: bool = False,
|
||||
) -> tuple[
|
||||
Literal["text", "tool_use", "thinking"],
|
||||
Literal["text", "tool_use", "thinking", "redacted_thinking"],
|
||||
"ContentBlockContentBlockDict",
|
||||
]:
|
||||
from litellm._uuid import uuid
|
||||
from litellm.types.llms.anthropic import TextBlock
|
||||
|
||||
for choice in choices:
|
||||
if (
|
||||
choice.delta.tool_calls is not None
|
||||
and len(choice.delta.tool_calls) > 0
|
||||
and choice.delta.tool_calls[0].function is not None
|
||||
):
|
||||
raw_id = choice.delta.tool_calls[0].id or str(uuid.uuid4())
|
||||
tool_name = choice.delta.tool_calls[0].function.name or ""
|
||||
block_type = self._classify_streaming_chunk(choices=[choice], thinking_disabled=thinking_disabled)
|
||||
if block_type == "skip":
|
||||
continue
|
||||
|
||||
if block_type == "thinking":
|
||||
if (
|
||||
isinstance(choice, StreamingChoices)
|
||||
and hasattr(choice.delta, "thinking_blocks")
|
||||
and choice.delta.thinking_blocks
|
||||
and len(choice.delta.thinking_blocks) > 0
|
||||
and choice.delta.thinking_blocks[0].get("type") in ("thinking", "redacted_thinking")
|
||||
):
|
||||
thinking_block = choice.delta.thinking_blocks[0]
|
||||
thinking = thinking_block.get("thinking") or ""
|
||||
signature = thinking_block.get("signature") or ""
|
||||
assert isinstance(thinking, str)
|
||||
assert isinstance(signature, str)
|
||||
return "thinking", ChatCompletionThinkingBlock(
|
||||
type="thinking", thinking=thinking, signature=signature
|
||||
)
|
||||
return "thinking", ChatCompletionThinkingBlock(type="thinking", thinking="", signature="")
|
||||
|
||||
if block_type == "redacted_thinking":
|
||||
thinking_blocks = getattr(choice.delta, "thinking_blocks", None) or []
|
||||
data = str(thinking_blocks[0].get("data") or "")
|
||||
redacted_block = AnthropicResponseContentBlockRedactedThinking(
|
||||
type="redacted_thinking",
|
||||
data=data,
|
||||
).model_dump()
|
||||
return "redacted_thinking", cast("ContentBlockContentBlockDict", redacted_block)
|
||||
|
||||
if block_type == "tool_use":
|
||||
# Explicit narrowing (base pattern): the classifier only emits
|
||||
# "tool_use" when the first tool call carries a function, so
|
||||
# these asserts hold and keep the member accesses below
|
||||
# optional-free without changing behaviour.
|
||||
tool_calls = choice.delta.tool_calls
|
||||
assert tool_calls is not None and len(tool_calls) > 0
|
||||
first_tool_call = tool_calls[0]
|
||||
assert first_tool_call.function is not None
|
||||
raw_id = first_tool_call.id or str(uuid.uuid4())
|
||||
tool_name = first_tool_call.function.name or ""
|
||||
thought_sig: str | None = None
|
||||
if THOUGHT_SIGNATURE_SEPARATOR in raw_id:
|
||||
parts = raw_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)
|
||||
|
|
@ -1558,75 +1743,25 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
"input": {},
|
||||
}
|
||||
if thought_sig:
|
||||
tool_block["provider_specific_fields"] = {
|
||||
"signature": thought_sig,
|
||||
}
|
||||
tool_block["provider_specific_fields"] = {"signature": thought_sig}
|
||||
return "tool_use", cast("ContentBlockContentBlockDict", tool_block)
|
||||
elif (choice.delta.content is not None and len(choice.delta.content) > 0) or openai_chat_refusal_text(
|
||||
choice.delta
|
||||
) is not None:
|
||||
|
||||
if block_type == "text":
|
||||
return "text", TextBlock(type="text", text="")
|
||||
elif isinstance(choice, StreamingChoices) and hasattr(choice.delta, "thinking_blocks"):
|
||||
thinking_blocks = choice.delta.thinking_blocks or []
|
||||
if len(thinking_blocks) > 0:
|
||||
thinking_block = thinking_blocks[0]
|
||||
if thinking_block["type"] == "thinking":
|
||||
thinking = thinking_block.get("thinking") or ""
|
||||
signature = thinking_block.get("signature") or ""
|
||||
|
||||
assert isinstance(thinking, str)
|
||||
assert isinstance(signature, str)
|
||||
|
||||
return "thinking", ChatCompletionThinkingBlock(
|
||||
type="thinking", thinking=thinking, signature=signature
|
||||
)
|
||||
# OpenAI-compatible reasoning backends (e.g. vLLM/SGLang reasoning
|
||||
# parsers) populate ``reasoning_content`` without ``thinking_blocks``.
|
||||
# ``Delta`` deletes the ``thinking_blocks`` attribute when unset, so the
|
||||
# branch above is skipped entirely; open a ``thinking`` block here so the
|
||||
# matching ``thinking_delta`` stream is not emitted into a text block.
|
||||
elif isinstance(choice, StreamingChoices) and getattr(choice.delta, "reasoning_content", None):
|
||||
return "thinking", ChatCompletionThinkingBlock(type="thinking", thinking="", signature="")
|
||||
|
||||
return "text", TextBlock(type="text", text="")
|
||||
|
||||
def _translate_streaming_openai_chunk_to_anthropic(
|
||||
self, choices: list[OpenAIStreamingChoice | StreamingChoices]
|
||||
self,
|
||||
choices: Sequence["OpenAIStreamingChoice | StreamingChoices"],
|
||||
thinking_disabled: bool = False,
|
||||
) -> tuple[
|
||||
StreamingContentBlockDeltaType,
|
||||
ContentTextBlockDelta | ContentJsonBlockDelta | ContentThinkingBlockDelta | ContentThinkingSignatureBlockDelta,
|
||||
]:
|
||||
text: str = ""
|
||||
reasoning_content: str = ""
|
||||
reasoning_signature: str = ""
|
||||
partial_json: str | None = None
|
||||
for choice in choices:
|
||||
if choice.delta.content is not None and len(choice.delta.content) > 0:
|
||||
text += choice.delta.content
|
||||
if choice.delta.tool_calls:
|
||||
partial_json = ""
|
||||
for tool in choice.delta.tool_calls:
|
||||
if tool.function is not None and tool.function.arguments is not None:
|
||||
partial_json = (partial_json or "") + tool.function.arguments
|
||||
elif isinstance(choice, StreamingChoices) and hasattr(choice.delta, "thinking_blocks"):
|
||||
thinking_blocks = choice.delta.thinking_blocks or []
|
||||
if len(thinking_blocks) > 0:
|
||||
for thinking_block in thinking_blocks:
|
||||
if thinking_block["type"] == "thinking":
|
||||
thinking = thinking_block.get("thinking") or ""
|
||||
signature = thinking_block.get("signature") or ""
|
||||
|
||||
assert isinstance(thinking, str)
|
||||
assert isinstance(signature, str)
|
||||
|
||||
reasoning_content += thinking
|
||||
reasoning_signature += signature
|
||||
# Handle reasoning_content when thinking_blocks is not present
|
||||
# This handles providers like OpenRouter that return reasoning_content
|
||||
elif isinstance(choice, StreamingChoices) and hasattr(choice.delta, "reasoning_content"):
|
||||
if choice.delta.reasoning_content is not None:
|
||||
reasoning_content += choice.delta.reasoning_content
|
||||
|
||||
text, reasoning_content, reasoning_signature, partial_json = self._accumulate_streaming_chunk_payloads(
|
||||
choices, thinking_disabled=thinking_disabled
|
||||
)
|
||||
if partial_json is not None:
|
||||
return "input_json_delta", ContentJsonBlockDelta(type="input_json_delta", partial_json=partial_json)
|
||||
elif reasoning_signature:
|
||||
|
|
@ -1641,11 +1776,61 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
)
|
||||
return "text_delta", ContentTextBlockDelta(type="text_delta", text=text + refusal_text)
|
||||
|
||||
def _accumulate_streaming_chunk_payloads(
|
||||
self,
|
||||
choices: Sequence["OpenAIStreamingChoice | StreamingChoices"],
|
||||
thinking_disabled: bool = False,
|
||||
) -> tuple[str, str, str, str | None]:
|
||||
"""Fold a chunk's choices into (text, reasoning_content, reasoning_signature, partial_json).
|
||||
|
||||
``partial_json`` is ``None`` when the chunk carries no tool calls — the
|
||||
caller uses that to decide the delta type's precedence (tool JSON beats
|
||||
thinking/thinking-signature text).
|
||||
"""
|
||||
text: str = ""
|
||||
reasoning_content: str = ""
|
||||
reasoning_signature: str = ""
|
||||
partial_json: str | None = None
|
||||
|
||||
for choice in choices:
|
||||
block_type = self._classify_streaming_chunk(choices=[choice], thinking_disabled=thinking_disabled)
|
||||
if block_type == "skip":
|
||||
continue
|
||||
|
||||
if block_type == "thinking":
|
||||
thinking_blocks = getattr(choice.delta, "thinking_blocks", None)
|
||||
if isinstance(choice, StreamingChoices) and thinking_blocks:
|
||||
for thinking_block in thinking_blocks:
|
||||
if thinking_block.get("type") in ("thinking", "redacted_thinking"):
|
||||
reasoning_content += str(thinking_block.get("thinking") or "")
|
||||
reasoning_signature += str(thinking_block.get("signature") or "")
|
||||
elif getattr(choice.delta, "reasoning_content", None):
|
||||
reasoning_content += str(choice.delta.reasoning_content)
|
||||
|
||||
elif block_type == "redacted_thinking":
|
||||
# Redacted thinking is carried wholly in content_block_start;
|
||||
# Anthropic defines no redacted-thinking delta type.
|
||||
continue
|
||||
|
||||
elif block_type == "tool_use":
|
||||
if choice.delta.tool_calls:
|
||||
partial_json = partial_json or ""
|
||||
for tool in choice.delta.tool_calls:
|
||||
if tool.function is not None and tool.function.arguments is not None:
|
||||
partial_json += tool.function.arguments
|
||||
|
||||
elif block_type == "text":
|
||||
if choice.delta.content is not None and len(choice.delta.content) > 0:
|
||||
text += choice.delta.content
|
||||
|
||||
return text, reasoning_content, reasoning_signature, partial_json
|
||||
|
||||
def translate_streaming_openai_response_to_anthropic(
|
||||
self,
|
||||
response: ModelResponse,
|
||||
current_content_block_index: int,
|
||||
applied_edits: list[AppliedEdit] | None = None,
|
||||
thinking_disabled: bool = False,
|
||||
) -> ContentBlockDelta | MessageBlockDelta:
|
||||
## base case - final chunk w/ finish reason
|
||||
if response.choices[0].finish_reason is not None:
|
||||
|
|
@ -1673,7 +1858,10 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
(
|
||||
type_of_content,
|
||||
content_block_delta,
|
||||
) = self._translate_streaming_openai_chunk_to_anthropic(choices=response.choices)
|
||||
) = self._translate_streaming_openai_chunk_to_anthropic(
|
||||
choices=response.choices,
|
||||
thinking_disabled=thinking_disabled,
|
||||
)
|
||||
return ContentBlockDelta(
|
||||
type="content_block_delta",
|
||||
index=current_content_block_index,
|
||||
|
|
|
|||
|
|
@ -3971,6 +3971,76 @@ def test_translate_anthropic_tools_to_openai_preserves_parameters_type():
|
|||
assert new_tools[0]["type"] == "function"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# thinking_disabled gating tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_streaming_reasoning_content_suppressed_when_thinking_disabled():
|
||||
"""A streaming chunk with reasoning_content must NOT open/emit a thinking
|
||||
block when thinking_disabled=True — the client didn't ask for thinking."""
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
choice = StreamingChoices(
|
||||
index=0,
|
||||
delta=Delta(content=None, role="assistant", reasoning_content="internal reasoning"),
|
||||
finish_reason=None,
|
||||
)
|
||||
block_type, _ = adapter._translate_streaming_openai_chunk_to_anthropic_content_block(
|
||||
choices=[choice], thinking_disabled=True
|
||||
)
|
||||
assert block_type == "text", f"expected 'text' when thinking_disabled=True, got {block_type!r}"
|
||||
|
||||
|
||||
def test_streaming_reasoning_content_preserved_by_default():
|
||||
"""Default behavior (thinking_disabled omitted) is unchanged: reasoning_content
|
||||
still opens a thinking block."""
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
choice = StreamingChoices(
|
||||
index=0,
|
||||
delta=Delta(content=None, role="assistant", reasoning_content="internal reasoning"),
|
||||
finish_reason=None,
|
||||
)
|
||||
block_type, _ = adapter._translate_streaming_openai_chunk_to_anthropic_content_block(choices=[choice])
|
||||
assert block_type == "thinking", f"expected 'thinking' by default, got {block_type!r}"
|
||||
|
||||
|
||||
def test_streaming_emitter_reasoning_content_suppressed_when_thinking_disabled():
|
||||
"""The delta emitter must not accumulate reasoning_content into a
|
||||
thinking_delta when thinking_disabled=True."""
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
choice = StreamingChoices(
|
||||
index=0,
|
||||
delta=Delta(content=None, role="assistant", reasoning_content="internal reasoning"),
|
||||
finish_reason=None,
|
||||
)
|
||||
delta_type, _ = adapter._translate_streaming_openai_chunk_to_anthropic(choices=[choice], thinking_disabled=True)
|
||||
assert delta_type == "text_delta", f"expected 'text_delta' when thinking_disabled=True, got {delta_type!r}"
|
||||
|
||||
|
||||
def test_non_streaming_reasoning_content_suppressed_when_thinking_disabled():
|
||||
"""A non-streaming completion with reasoning_content must not produce a
|
||||
thinking content block when thinking_disabled=True."""
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
message = Message(role="assistant", content=None, reasoning_content="internal reasoning")
|
||||
choice = Choices(index=0, message=message, finish_reason="stop")
|
||||
content = adapter._translate_openai_content_to_anthropic(choices=[choice], thinking_disabled=True)
|
||||
assert not any(block.get("type") == "thinking" for block in content), (
|
||||
f"expected no thinking block when thinking_disabled=True, got {content!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_non_streaming_reasoning_content_preserved_by_default():
|
||||
"""Default behavior (thinking_disabled omitted) is unchanged for the
|
||||
non-streaming path: reasoning_content still becomes a thinking block."""
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
message = Message(role="assistant", content=None, reasoning_content="internal reasoning")
|
||||
choice = Choices(index=0, message=message, finish_reason="stop")
|
||||
content = adapter._translate_openai_content_to_anthropic(choices=[choice])
|
||||
assert any(block.get("type") == "thinking" for block in content), (
|
||||
f"expected a thinking block by default, got {content!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_translate_anthropic_tools_to_openai_maps_strict_onto_function_not_parameters():
|
||||
"""A tool-level `strict` lands on the OpenAI function, leaving the caller's `input_schema` untouched."""
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
"""Handler-level tests for ``thinking_disabled`` computation and threading.
|
||||
|
||||
Covers the boolean logic that decides whether thinking is disabled
|
||||
(fail-closed: only an explicit ``{\"type\": \"enabled"|\"adaptive\"}``
|
||||
enables it; absent, disabled, or malformed objects disable it) and
|
||||
verifies it is threaded correctly to ``ANTHROPIC_ADAPTER`` output-
|
||||
translation calls for both the async and sync handler entry points, in
|
||||
streaming and non-streaming modes.
|
||||
|
||||
Mocks ``litellm.acompletion`` / ``litellm.completion`` and
|
||||
``ANTHROPIC_ADAPTER`` directly, alongside the preparation helpers that run
|
||||
before the ``thinking_disabled`` computation, so the tests are focused on
|
||||
the computation and threading rather than the full request pipeline.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.llms.anthropic.experimental_pass_through.adapters.handler import (
|
||||
LiteLLMMessagesToCompletionTransformationHandler,
|
||||
)
|
||||
|
||||
THINKING_PARAMS = [
|
||||
(None, True),
|
||||
({}, True),
|
||||
({"type": "disabled"}, True),
|
||||
({"budget_tokens": 1024}, True),
|
||||
({"type": "weird"}, True),
|
||||
({"type": "enabled", "budget_tokens": 1024}, False),
|
||||
({"type": "adaptive"}, False),
|
||||
]
|
||||
|
||||
MESSAGES = [{"role": "user", "content": "hello"}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async handler — streaming
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"thinking_param,expected_thinking_disabled",
|
||||
THINKING_PARAMS,
|
||||
)
|
||||
async def test_async_handler_streaming_threads_thinking_disabled(thinking_param, expected_thinking_disabled):
|
||||
"""Async handler, stream=True: ``thinking_disabled`` reaches the streaming
|
||||
adapter call."""
|
||||
with (
|
||||
patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
"litellm.llms.anthropic.experimental_pass_through.adapters.handler._prepare_context_managed_request",
|
||||
return_value=None,
|
||||
),
|
||||
patch.object( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
LiteLLMMessagesToCompletionTransformationHandler,
|
||||
"_prepare_completion_kwargs",
|
||||
return_value=({}, {}),
|
||||
),
|
||||
patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
"litellm.acompletion", return_value=MagicMock()
|
||||
), # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
"litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER"
|
||||
) as mock_adapter, # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
):
|
||||
mock_adapter.translate_completion_output_params_streaming.return_value = iter([])
|
||||
await LiteLLMMessagesToCompletionTransformationHandler.async_anthropic_messages_handler(
|
||||
max_tokens=100,
|
||||
messages=MESSAGES,
|
||||
model="gpt-4o",
|
||||
stream=True,
|
||||
thinking=thinking_param,
|
||||
)
|
||||
call_kwargs = mock_adapter.translate_completion_output_params_streaming.call_args.kwargs
|
||||
assert call_kwargs.get("thinking_disabled") is expected_thinking_disabled, (
|
||||
f"thinking={thinking_param!r}: expected thinking_disabled={expected_thinking_disabled}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async handler — non-streaming
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"thinking_param,expected_thinking_disabled",
|
||||
THINKING_PARAMS,
|
||||
)
|
||||
async def test_async_handler_non_streaming_threads_thinking_disabled(thinking_param, expected_thinking_disabled):
|
||||
"""Async handler, stream=False: ``thinking_disabled`` reaches the
|
||||
non-streaming adapter call."""
|
||||
with (
|
||||
patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
"litellm.llms.anthropic.experimental_pass_through.adapters.handler._prepare_context_managed_request",
|
||||
return_value=None,
|
||||
),
|
||||
patch.object( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
LiteLLMMessagesToCompletionTransformationHandler,
|
||||
"_prepare_completion_kwargs",
|
||||
return_value=({}, {}),
|
||||
),
|
||||
patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
"litellm.acompletion", return_value=MagicMock()
|
||||
), # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
"litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER"
|
||||
) as mock_adapter, # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
):
|
||||
mock_adapter.translate_completion_output_params.return_value = MagicMock()
|
||||
await LiteLLMMessagesToCompletionTransformationHandler.async_anthropic_messages_handler(
|
||||
max_tokens=100,
|
||||
messages=MESSAGES,
|
||||
model="gpt-4o",
|
||||
stream=False,
|
||||
thinking=thinking_param,
|
||||
)
|
||||
call_kwargs = mock_adapter.translate_completion_output_params.call_args.kwargs
|
||||
assert call_kwargs.get("thinking_disabled") is expected_thinking_disabled, (
|
||||
f"thinking={thinking_param!r}: expected thinking_disabled={expected_thinking_disabled}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync handler — streaming
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"thinking_param,expected_thinking_disabled",
|
||||
THINKING_PARAMS,
|
||||
)
|
||||
def test_sync_handler_streaming_threads_thinking_disabled(thinking_param, expected_thinking_disabled):
|
||||
"""Sync handler, stream=True: ``thinking_disabled`` reaches the streaming
|
||||
adapter call.
|
||||
|
||||
Uses the direct synchronous path (no ``context_management``, no compaction
|
||||
blocks) so ``run_async_function`` is never invoked.
|
||||
"""
|
||||
with (
|
||||
patch.object( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
LiteLLMMessagesToCompletionTransformationHandler,
|
||||
"_prepare_completion_kwargs",
|
||||
return_value=({}, {}),
|
||||
),
|
||||
patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
"litellm.completion", return_value=MagicMock()
|
||||
), # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
"litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER"
|
||||
) as mock_adapter, # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
):
|
||||
mock_adapter.translate_completion_output_params_streaming.return_value = iter([])
|
||||
LiteLLMMessagesToCompletionTransformationHandler.anthropic_messages_handler(
|
||||
max_tokens=100,
|
||||
messages=MESSAGES,
|
||||
model="gpt-4o",
|
||||
stream=True,
|
||||
thinking=thinking_param,
|
||||
)
|
||||
call_kwargs = mock_adapter.translate_completion_output_params_streaming.call_args.kwargs
|
||||
assert call_kwargs.get("thinking_disabled") is expected_thinking_disabled, (
|
||||
f"thinking={thinking_param!r}: expected thinking_disabled={expected_thinking_disabled}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync handler — non-streaming
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"thinking_param,expected_thinking_disabled",
|
||||
THINKING_PARAMS,
|
||||
)
|
||||
def test_sync_handler_non_streaming_threads_thinking_disabled(thinking_param, expected_thinking_disabled):
|
||||
"""Sync handler, stream=False: ``thinking_disabled`` reaches the
|
||||
non-streaming adapter call.
|
||||
|
||||
Uses the direct synchronous path (no ``context_management``, no compaction
|
||||
blocks) so ``run_async_function`` is never invoked.
|
||||
"""
|
||||
with (
|
||||
patch.object( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
LiteLLMMessagesToCompletionTransformationHandler,
|
||||
"_prepare_completion_kwargs",
|
||||
return_value=({}, {}),
|
||||
),
|
||||
patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
"litellm.completion", return_value=MagicMock()
|
||||
), # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
patch( # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
"litellm.llms.anthropic.experimental_pass_through.adapters.handler.ANTHROPIC_ADAPTER"
|
||||
) as mock_adapter, # test-quality-ok: handler unit test - fakes completion dispatch + adapter seams; unit under test is the thinking_disabled translation wiring, not the transport
|
||||
):
|
||||
mock_adapter.translate_completion_output_params.return_value = MagicMock()
|
||||
LiteLLMMessagesToCompletionTransformationHandler.anthropic_messages_handler(
|
||||
max_tokens=100,
|
||||
messages=MESSAGES,
|
||||
model="gpt-4o",
|
||||
stream=False,
|
||||
thinking=thinking_param,
|
||||
)
|
||||
call_kwargs = mock_adapter.translate_completion_output_params.call_args.kwargs
|
||||
assert call_kwargs.get("thinking_disabled") is expected_thinking_disabled, (
|
||||
f"thinking={thinking_param!r}: expected thinking_disabled={expected_thinking_disabled}"
|
||||
)
|
||||
|
|
@ -1013,7 +1013,11 @@ def test_anthropic_messages_replays_tool_loop_and_maps_reasoning_to_thinking_blo
|
|||
assert tool_turn["content"] == "Sunny, 18C"
|
||||
|
||||
blocks = {block["type"]: block for block in response["content"]}
|
||||
assert blocks["thinking"]["thinking"] == "Tool said sunny."
|
||||
|
||||
# Contract (thinking param absent): the provider's reasoning is NOT
|
||||
# surfaced as an Anthropic thinking block. The mock response still
|
||||
# carries reasoning, and this asserts it is suppressed.
|
||||
assert "thinking" not in blocks
|
||||
assert blocks["text"]["text"] == "Sunny in SF."
|
||||
assert response["stop_reason"] == "end_turn"
|
||||
|
||||
|
|
@ -1031,6 +1035,9 @@ def test_anthropic_messages_streams_together_tool_call_as_input_json_delta():
|
|||
captured_requests: list[httpx.Request] = []
|
||||
client = _sync_client(captured_requests, _sse_response(*PARALLEL_TOOL_CALL_STREAM))
|
||||
|
||||
# Explicit thinking= would be needed to surface provider reasoning
|
||||
# (the pass-through contract suppresses it when the thinking param is
|
||||
# absent); this test asserts the streaming translation only.
|
||||
events = _anthropic_sse_events(
|
||||
litellm.anthropic.messages.create(
|
||||
model=f"together_ai/{UNMAPPED_MODEL}",
|
||||
|
|
@ -1067,7 +1074,9 @@ def test_anthropic_messages_streams_together_tool_call_as_input_json_delta():
|
|||
for event in events
|
||||
if event["type"] == "content_block_delta" and event["delta"]["type"] == "thinking_delta"
|
||||
)
|
||||
assert thinking_text == "Need weather and time."
|
||||
# thinking param absent in the request: provider reasoning is
|
||||
# suppressed per the contract, so no thinking block may appear.
|
||||
assert thinking_text == ""
|
||||
assert [event["delta"]["stop_reason"] for event in events if event["type"] == "message_delta"] == ["tool_use"]
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue