diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py index 517ee984006..241f74d9c5e 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -73,6 +73,55 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): # deque and corrupt each other's SSE event order. self.chunk_queue: deque = deque() + def _merge_usage_into_held_stop_reason_chunk(self, chunk: Any) -> Dict[str, Any]: + """Merge usage data from ``chunk`` into the held ``message_delta`` chunk. + + Shared by both the sync ``__next__`` and async ``__anext__`` paths so + the subtle hold-and-merge logic (cache tokens, ``context_management`` + attachment, ``UsageDelta`` shape) lives in exactly one place. + + Caller is responsible for managing ``self.holding_stop_reason_chunk`` + and ``self.queued_usage_chunk`` state and for queuing the returned + merged chunk. + """ + assert self.holding_stop_reason_chunk is not None + merged_chunk = self.holding_stop_reason_chunk.copy() + if "delta" not in merged_chunk: + merged_chunk["delta"] = {} + + uncached_input_tokens = chunk.usage.prompt_tokens or 0 + if ( + hasattr(chunk.usage, "prompt_tokens_details") + and chunk.usage.prompt_tokens_details + ): + cached_tokens = ( + getattr(chunk.usage.prompt_tokens_details, "cached_tokens", 0) or 0 + ) + uncached_input_tokens -= cached_tokens + + usage_dict: UsageDelta = { + "input_tokens": uncached_input_tokens, + "output_tokens": chunk.usage.completion_tokens or 0, + } + if ( + hasattr(chunk.usage, "_cache_creation_input_tokens") + and chunk.usage._cache_creation_input_tokens > 0 + ): + usage_dict["cache_creation_input_tokens"] = ( + chunk.usage._cache_creation_input_tokens + ) + if ( + hasattr(chunk.usage, "_cache_read_input_tokens") + and chunk.usage._cache_read_input_tokens > 0 + ): + usage_dict["cache_read_input_tokens"] = chunk.usage._cache_read_input_tokens + merged_chunk["usage"] = usage_dict + if self.applied_edits and "context_management" not in merged_chunk: + merged_chunk["context_management"] = ContextManagementResponse( + applied_edits=list(self.applied_edits) + ) + return merged_chunk + def _create_initial_usage_delta(self) -> UsageDelta: """ Create the initial UsageDelta for the message_start event. @@ -157,51 +206,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): self.holding_stop_reason_chunk is not None and getattr(chunk, "usage", None) is not None ): - # Merge usage into the held stop_reason chunk - merged_chunk = self.holding_stop_reason_chunk.copy() - if "delta" not in merged_chunk: - merged_chunk["delta"] = {} - - # Add usage to the held chunk - uncached_input_tokens = chunk.usage.prompt_tokens or 0 - if ( - hasattr(chunk.usage, "prompt_tokens_details") - and chunk.usage.prompt_tokens_details - ): - cached_tokens = ( - getattr( - chunk.usage.prompt_tokens_details, "cached_tokens", 0 - ) - or 0 - ) - uncached_input_tokens -= cached_tokens - - usage_dict: UsageDelta = { - "input_tokens": uncached_input_tokens, - "output_tokens": chunk.usage.completion_tokens or 0, - } - # Add cache tokens if available (for prompt caching support) - if ( - hasattr(chunk.usage, "_cache_creation_input_tokens") - and chunk.usage._cache_creation_input_tokens > 0 - ): - usage_dict["cache_creation_input_tokens"] = ( - chunk.usage._cache_creation_input_tokens - ) - if ( - hasattr(chunk.usage, "_cache_read_input_tokens") - and chunk.usage._cache_read_input_tokens > 0 - ): - usage_dict["cache_read_input_tokens"] = ( - chunk.usage._cache_read_input_tokens - ) - merged_chunk["usage"] = usage_dict - if self.applied_edits and "context_management" not in merged_chunk: - merged_chunk["context_management"] = ContextManagementResponse( - applied_edits=list(self.applied_edits) - ) - - # Queue the merged chunk and reset + merged_chunk = self._merge_usage_into_held_stop_reason_chunk(chunk) self.chunk_queue.append(merged_chunk) self.queued_usage_chunk = True self.holding_stop_reason_chunk = None @@ -381,51 +386,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): self.holding_stop_reason_chunk is not None and getattr(chunk, "usage", None) is not None ): - # Merge usage into the held stop_reason chunk - merged_chunk = self.holding_stop_reason_chunk.copy() - if "delta" not in merged_chunk: - merged_chunk["delta"] = {} - - # Add usage to the held chunk - uncached_input_tokens = chunk.usage.prompt_tokens or 0 - if ( - hasattr(chunk.usage, "prompt_tokens_details") - and chunk.usage.prompt_tokens_details - ): - cached_tokens = ( - getattr( - chunk.usage.prompt_tokens_details, "cached_tokens", 0 - ) - or 0 - ) - uncached_input_tokens -= cached_tokens - - usage_dict: UsageDelta = { - "input_tokens": uncached_input_tokens, - "output_tokens": chunk.usage.completion_tokens or 0, - } - # Add cache tokens if available (for prompt caching support) - if ( - hasattr(chunk.usage, "_cache_creation_input_tokens") - and chunk.usage._cache_creation_input_tokens > 0 - ): - usage_dict["cache_creation_input_tokens"] = ( - chunk.usage._cache_creation_input_tokens - ) - if ( - hasattr(chunk.usage, "_cache_read_input_tokens") - and chunk.usage._cache_read_input_tokens > 0 - ): - usage_dict["cache_read_input_tokens"] = ( - chunk.usage._cache_read_input_tokens - ) - merged_chunk["usage"] = usage_dict - if self.applied_edits and "context_management" not in merged_chunk: - merged_chunk["context_management"] = ContextManagementResponse( - applied_edits=list(self.applied_edits) - ) - - # Queue the merged chunk and reset + merged_chunk = self._merge_usage_into_held_stop_reason_chunk(chunk) self.chunk_queue.append(merged_chunk) self.queued_usage_chunk = True self.holding_stop_reason_chunk = None diff --git a/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py b/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py index 17708805513..95eeefe0036 100644 --- a/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py +++ b/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py @@ -465,6 +465,14 @@ async def apply_compact_20260112( if current_tokens <= trigger_tokens: # Slice-only path. If Phase A fired we still slice + system-prefix. + # Guard against an empty downstream message list: this happens when the + # sliced result was a single assistant turn whose only block was the + # compaction block, which ``_strip_compaction_blocks`` then drops. The + # downstream API rejects an empty messages array, so fall back to + # picking a real user question (or a synthetic continuation prompt) — + # mirroring the full-summary path's behavior below. + if not downstream_messages: + downstream_messages = _select_last_user_question(effective_messages) return PolyfillResult( messages=downstream_messages, system=augmented_system,