refactor(anthropic-adapter): exhaustively match delta payload lookup

Extracts the adapter stream's closed delta-type set into
StreamingContentBlockDeltaType, shared by the translate layer's return
type and an exhaustive match in _delta_payload_field, so adding a new
delta type without handling it in the emission gate fails
type-checking instead of being silently dropped.
This commit is contained in:
mateo-berri 2026-07-14 18:41:29 -07:00
parent 0f9d593d29
commit bf3a1f47dd
3 changed files with 35 additions and 10 deletions

View file

@ -13,14 +13,18 @@ from typing import (
List,
Literal,
Optional,
get_args,
)
from typing_extensions import assert_never
from litellm._logging import verbose_logger
from litellm._uuid import uuid
from litellm.types.llms.anthropic import (
AppliedEdit,
CompactionBlock,
ContextManagementResponse,
StreamingContentBlockDeltaType,
UsageDelta,
UsageIteration,
)
@ -30,6 +34,23 @@ if TYPE_CHECKING:
from litellm.types.utils import ModelResponseStream
_STREAMING_DELTA_TYPES = frozenset(get_args(StreamingContentBlockDeltaType))
def _delta_payload_field(delta_type: StreamingContentBlockDeltaType) -> str:
match delta_type:
case "text_delta":
return "text"
case "input_json_delta":
return "partial_json"
case "thinking_delta":
return "thinking"
case "signature_delta":
return "signature"
case _:
assert_never(delta_type)
class _CombinedChunkSplitter:
"""
Splits a streaming chunk that carries BOTH response content and a
@ -837,6 +858,12 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
so the trigger chunk's delta must be re-queued or the first token of
the new block (the first non-empty text/thinking delta, or bundled
tool arguments) is silently dropped.
Delta types outside ``StreamingContentBlockDeltaType`` — the closed
set the translate layer can produce — are treated as empty. The
per-type payload lookup is exhaustively matched against that set in
``_delta_payload_field``, so extending the translate layer with a new
delta type fails type-checking here until it is handled.
"""
if processed_chunk.get("type") != "content_block_delta":
return False
@ -844,15 +871,9 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
if not isinstance(delta, dict):
return False
delta_type = delta.get("type")
if delta_type == "text_delta":
return bool(delta.get("text"))
if delta_type == "input_json_delta":
return bool(delta.get("partial_json"))
if delta_type == "thinking_delta":
return bool(delta.get("thinking"))
if delta_type == "signature_delta":
return bool(delta.get("signature"))
return False
if delta_type not in _STREAMING_DELTA_TYPES:
return False
return bool(delta.get(_delta_payload_field(delta_type)))
def _should_start_new_content_block(self, chunk: "ModelResponseStream") -> bool:
"""

View file

@ -104,6 +104,7 @@ from litellm.types.llms.anthropic import (
ContextManagementResponse,
MessageBlockDelta,
MessageDelta,
StreamingContentBlockDeltaType,
UsageDelta,
UsageIteration,
)
@ -1423,7 +1424,7 @@ class LiteLLMAnthropicMessagesAdapter:
def _translate_streaming_openai_chunk_to_anthropic(
self, choices: List[Union[OpenAIStreamingChoice, StreamingChoices]]
) -> Tuple[
Literal["text_delta", "input_json_delta", "thinking_delta", "signature_delta"],
StreamingContentBlockDeltaType,
Union[
ContentTextBlockDelta,
ContentJsonBlockDelta,

View file

@ -439,6 +439,9 @@ class ContentThinkingSignatureBlockDelta(TypedDict):
signature: str
StreamingContentBlockDeltaType = Literal["text_delta", "input_json_delta", "thinking_delta", "signature_delta"]
class ContentBlockDelta(TypedDict):
type: Literal["content_block_delta"]
index: int