fix(router): support mid-stream fallback for anthropic_messages route type (#38153)

anthropic_messages goes through _ageneric_api_call_with_fallbacks rather
than _acompletion, so its returned streaming iterator was never wrapped
by the chat-completions fallback handler. A retriable SSE event: error
frame (overloaded_error, internal_server_error) from a native
Anthropic/Bedrock passthrough passed through to the client unchanged,
and a MidStreamFallbackError raised by the completion-bridge path's
CustomStreamWrapper propagated unhandled.

Add _aanthropic_messages_streaming_iterator, mirroring
_acompletion_streaming_iterator: it detects a retriable SSE error event
via the new parse_anthropic_error_event helper, raises
MidStreamFallbackError once real generated content (a content_block_delta
frame) has not yet reached the caller, and re-enters the Router's
fallback chain. A MidStreamFallbackError raised directly by the source
iterator (the completion-bridge path) is gated the same way via its own
is_pre_first_chunk flag. The raised MidStreamFallbackError carries a
status-coded original_exception built from the parsed error type, so
status_code/cooldown logic sees the real 429/500/503/etc. instead of a
hardcoded 503.

Lifecycle/bookkeeping frames (message_start, content_block_start, ping,
...) never disqualify a fallback attempt by themselves, since Anthropic
routinely sends message_start before an overload error - but they are
buffered rather than forwarded immediately, since forwarding one and
then appending a fallback attempt's own message_start would produce two
overlapping message lifecycles on one SSE stream. Buffered frames flush,
in order, once real content arrives or the stream ends without error.
Once real content has streamed, or the error is a non-retriable 4xx, the
chunk (or exception) is forwarded as-is rather than starting a second
lifecycle. Content and error coalesced into a single physical read are
handled the same way: once the client has genuinely received the content
(bundled in that same forwarded chunk), no fallback is attempted. A
`ping` keepalive is dropped outright before any real content arrives
(it recurs indefinitely on a slow-starting connection and carries
nothing worth buffering), and the pre-content lifecycle buffer is capped
at MAX_BUFFERED_PRE_CONTENT_ANTHROPIC_CHUNKS, forcing an early commit to
the primary stream so a hostile or pathological upstream can't grow it
without bound. is_anthropic_ping_chunk only matches a chunk whose every
event: line is event: ping, so a ping coalesced with real content or a
retriable error into one physical transport chunk is never dropped.

The fallback request kwargs also deep-copy nested litellm_metadata/metadata
(matching the Responses API path) so the primary attempt's
deployment-specific fields never leak into the fallback request, and the
fallback deployment's own provider headers are merged onto the wrapper's
_hidden_params so they still reach the client/logging pipeline. A
fallback that resolves to a non-streaming response (e.g. an agentic
tool-use interception loop) is synthesized into a real Anthropic SSE
event sequence via the new anthropic_messages_response_as_sse_events
helper, instead of yielding a raw dict into the byte stream - including
a trailing signature_delta for a thinking block, and a message_start
whose stop_reason/stop_sequence/output_tokens stay null/zero the way a
real stream's does instead of leaking the completed response's final
state.

Resolves #24004
This commit is contained in:
Yassin Kortam 2026-08-25 13:40:46 -07:00 • committed by GitHub
parent c96245d7bf
commit c70b911122
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1911 additions and 25 deletions

View file

@ -38,6 +38,21 @@ DROP_DISABLED_THINKING_WARNING: Final = (
"thinking blocks, and those thinking tokens are billed as output tokens."
)
# Anthropic error `type` (both the JSON error body and SSE `event: error`
# payloads use this field) mapped to the HTTP status code it corresponds to.
ANTHROPIC_ERROR_STATUS_CODE_MAP: Final = MappingProxyType(
{
"invalid_request_error": 400,
"authentication_error": 401,
"permission_error": 403,
"not_found_error": 404,
"rate_limit_error": 429,
"api_error": 500,
"overloaded_error": 503,
"timeout_error": 504,
}
)
_BEDROCK_VERSION_SUFFIX_RE: Final = re.compile(r"-v\d+(?::\d+)?$")
_INFERENCE_PROFILE_MINOR_RE: Final = re.compile(r":\d+$")
_DATED_RELEASE_SUFFIX_RE: Final = re.compile(r"-\d{8}$")

View file

@ -1,6 +1,6 @@
import asyncio
import json
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Mapping
from datetime import datetime
from typing import Any, Final, Protocol, runtime_checkable
@ -11,9 +11,11 @@ from typing_extensions import TypedDict
from litellm.litellm_core_utils.core_helpers import process_response_headers
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.llms.anthropic.common_utils import ANTHROPIC_ERROR_STATUS_CODE_MAP
from litellm.proxy.pass_through_endpoints.success_handler import (
PassThroughEndpointLogging,
)
from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicMessagesResponse
from litellm.types.passthrough_endpoints.pass_through_endpoints import EndpointType
from litellm.types.utils import GenericStreamingChunk, ModelResponseStream
@ -33,26 +35,239 @@ def _is_message_stop_chunk(chunk: object) -> bool:
return False
def _is_provider_error_chunk(chunk: object) -> bool:
def is_anthropic_ping_chunk(chunk: object) -> bool:
"""
Whether a chunk is a pure ``ping`` keepalive frame. It carries no content
and can recur indefinitely on a slow-starting or idle connection, so a
mid-stream fallback wrapper drops it outright while still deciding
whether to commit to the primary stream, rather than buffering it.
A physical transport chunk that coalesces a ping with any other SSE
event (``message_start``, ``content_block_delta``, ``event: error``, ...)
is NOT a pure ping - dropping it whole would discard those events - so
only a chunk whose every ``event:`` line is ``event: ping`` qualifies.
"""
if isinstance(chunk, dict):
return chunk.get("type") == "error"
return chunk.get("type") == "ping"
if isinstance(chunk, (bytes, bytearray)):
return any(line == b"event: error" for line in chunk.splitlines())
event_lines: Final = tuple(line for line in chunk.splitlines() if line.startswith(b"event:"))
return bool(event_lines) and all(line == b"event: ping" for line in event_lines)
return False
def is_anthropic_content_delta_chunk(chunk: object) -> bool:
"""
Whether a chunk carries actual assistant-generated output (a
``content_block_delta`` frame), as opposed to a lifecycle/bookkeeping
frame (``message_start``, ``content_block_start``/``stop``,
``message_delta``, ``message_stop``, ``ping``) that carries nothing
worth preserving before an invisible mid-stream fallback retry.
"""
if isinstance(chunk, dict):
return chunk.get("type") == "content_block_delta"
if isinstance(chunk, (bytes, bytearray)):
return any(line == b"event: content_block_delta" for line in chunk.splitlines())
return False
def _decoded_sse_data_line(line: bytes) -> object | None:
if not line.startswith(b"data:"):
return None
try:
return json.loads(line[len(b"data:") :].strip())
except (ValueError, TypeError):
return None
def _anthropic_error_event_payload(chunk: object) -> Mapping[str, object] | None:
if isinstance(chunk, dict):
return chunk if chunk.get("type") == "error" else None
if isinstance(chunk, (bytes, bytearray)):
decoded_lines: Final = (_decoded_sse_data_line(line) for line in chunk.splitlines())
return next(
(
candidate
for candidate in decoded_lines
if isinstance(candidate, dict) and candidate.get("type") == "error"
),
None,
)
return None
def _anthropic_error_body(chunk: object) -> Mapping[str, object] | None:
"""Return the ``error`` object of an Anthropic SSE ``event: error`` chunk, or None."""
payload: Final = _anthropic_error_event_payload(chunk)
error_body: Final = payload.get("error") if payload is not None else None
return error_body if isinstance(error_body, dict) else None
def _is_provider_error_chunk(chunk: object) -> bool:
return _anthropic_error_body(chunk) is not None
def parse_anthropic_error_event(chunk: object) -> tuple[str, str, int] | None:
"""
Extract ``(error_type, message, http_status_code)`` from an Anthropic SSE
``event: error`` chunk (raw bytes or an already-decoded dict), or None if
``chunk`` is not an error event.
The status code is looked up via ANTHROPIC_ERROR_STATUS_CODE_MAP,
defaulting to 500 for an error ``type`` Anthropic hasn't documented yet.
"""
error_body: Final = _anthropic_error_body(chunk)
if error_body is None:
return None
error_type: Final = error_body.get("type")
if not isinstance(error_type, str):
return None
message: Final = error_body.get("message")
return (
error_type,
message if isinstance(message, str) else error_type,
ANTHROPIC_ERROR_STATUS_CODE_MAP.get(error_type, 500),
)
def _is_terminal_stream_chunk(chunk: object) -> bool:
return _is_message_stop_chunk(chunk) or _is_provider_error_chunk(chunk)
def _sse_event(event_type: str, payload: Mapping[str, object]) -> bytes:
return f"event: {event_type}\ndata: {json.dumps(payload)}\n\n".encode()
def _incomplete_stream_error_sse_event() -> bytes:
payload: Final = json.dumps(
{
"type": "error",
"error": {"type": "api_error", "message": INCOMPLETE_STREAM_ERROR_MESSAGE},
}
return _sse_event( # mutable-ok: one-shot JSON payload, never mutated after construction
"error",
{"type": "error", "error": {"type": "api_error", "message": INCOMPLETE_STREAM_ERROR_MESSAGE}},
)
def _anthropic_content_block_start_and_deltas(
block: Mapping[str, object],
) -> tuple[Mapping[str, object], tuple[Mapping[str, object], ...]]:
"""
``(content_block_start.content_block, content_block_delta.delta events)``
for one Anthropic response content block. A thinking block emits both a
thinking_delta and a trailing signature_delta - a real Anthropic stream
does the same, and dropping the signature makes any replay of that
assistant message (a follow-up turn, a tool-use continuation) fail
Anthropic's thinking-signature verification. redacted_thinking has no
delta at all - it is sent complete in content_block_start.
"""
match block.get("type"):
case "tool_use":
return (
{ # mutable-ok: one-shot payload
"id": block.get("id"),
"name": block.get("name"),
"input": {}, # mutable-ok: one-shot payload
"type": "tool_use",
},
(
{ # mutable-ok: one-shot payload
"partial_json": json.dumps(block.get("input") or {}), # mutable-ok: one-shot payload
"type": "input_json_delta",
},
),
)
case "thinking":
signature: Final = block.get("signature")
signature_deltas: Final = (
({"signature": signature, "type": "signature_delta"},) # mutable-ok: one-shot payload
if isinstance(signature, str) and signature
else ()
)
return (
{"thinking": "", "signature": "", "type": "thinking"}, # mutable-ok: one-shot payload
(
{"thinking": block.get("thinking") or "", "type": "thinking_delta"}, # mutable-ok: one-shot payload
*signature_deltas,
),
)
case "redacted_thinking":
return ({"type": "redacted_thinking", "data": block.get("data")}, ()) # mutable-ok: one-shot JSON payload
case _:
return (
{"type": "text", "text": ""}, # mutable-ok: one-shot JSON payload
({"type": "text_delta", "text": block.get("text") or ""},), # mutable-ok: one-shot JSON payload
)
def anthropic_messages_response_as_sse_events(response: AnthropicMessagesResponse) -> tuple[bytes, ...]:
"""
Render a complete (non-streaming) AnthropicMessagesResponse as the SSE
event sequence a real streaming request would have produced.
A mid-stream fallback can resolve to a non-streaming response even
though the client asked to stream (e.g. an agentic tool-use loop that
intercepts and returns a complete message) - yielding that dict directly
into a `/v1/messages` SSE byte stream would produce a malformed
response, so it's synthesized into the message_start/content_block_*/
message_delta/message_stop lifecycle a real stream would have sent.
"""
content_blocks: Final = response.get("content") or ()
content_events: Final = (
event for index, block in enumerate(content_blocks) for event in _anthropic_content_block_events(index, block)
)
# A real message_start always carries a null stop_reason/stop_sequence and
# a zero output_tokens - those are only known once generation finishes, so
# copying the completed response's final values here would let a client
# treat the message as already finished, or double-count output tokens.
message_start_usage: Final = { # mutable-ok: one-shot JSON payload
**(response.get("usage") or {}),
"output_tokens": 0,
}
message_start_payload: Final = { # mutable-ok: one-shot JSON payload, never mutated after construction
"type": "message_start",
"message": { # mutable-ok: one-shot JSON payload
**response,
"content": [], # mutable-ok: one-shot JSON payload
"stop_reason": None,
"stop_sequence": None,
"usage": message_start_usage,
},
}
message_delta_payload: Final = { # mutable-ok: one-shot JSON payload, never mutated after construction
"type": "message_delta",
"delta": { # mutable-ok: one-shot JSON payload
"stop_reason": response.get("stop_reason"),
"stop_sequence": response.get("stop_sequence"),
},
"usage": response.get("usage") or {}, # mutable-ok: one-shot JSON payload
}
return (
_sse_event("message_start", message_start_payload),
*content_events,
_sse_event("message_delta", message_delta_payload),
_sse_event("message_stop", {"type": "message_stop"}), # mutable-ok: one-shot JSON payload
)
def _anthropic_content_block_events(index: int, block: Mapping[str, object]) -> tuple[bytes, ...]:
start_block, deltas = _anthropic_content_block_start_and_deltas(block)
start_payload: Final = { # mutable-ok: one-shot payload
"type": "content_block_start",
"index": index,
"content_block": start_block,
}
stop_payload: Final = { # mutable-ok: one-shot payload
"type": "content_block_stop",
"index": index,
}
delta_events: Final = tuple(
_sse_event(
"content_block_delta",
{"type": "content_block_delta", "index": index, "delta": delta}, # mutable-ok: one-shot payload
)
for delta in deltas
)
return (
_sse_event("content_block_start", start_payload),
*delta_events,
_sse_event("content_block_stop", stop_payload),
)
return f"event: error\ndata: {payload}\n\n".encode()
class AnthropicMessagesStreamHiddenParams(TypedDict):

View file

@ -22,19 +22,7 @@ from litellm.types.llms.openai import (
from litellm.types.utils import CallTypes, LlmProviders, ModelResponse
from ..chat.transformation import AnthropicConfig
from ..common_utils import AnthropicModelInfo
# Map Anthropic error types to HTTP status codes
ANTHROPIC_ERROR_STATUS_CODE_MAP: Final = {
"invalid_request_error": 400,
"authentication_error": 401,
"permission_error": 403,
"not_found_error": 404,
"rate_limit_error": 429,
"api_error": 500,
"overloaded_error": 503,
"timeout_error": 504,
}
from ..common_utils import ANTHROPIC_ERROR_STATUS_CODE_MAP, AnthropicModelInfo
class AnthropicFilesHandler:

View file

@ -8,6 +8,7 @@
# Thank you ! We ❤️ you! - Krrish & Ishaan
import asyncio
import contextlib
import copy
import enum
import hashlib
@ -20,7 +21,7 @@ import time
import traceback
import weakref
from collections import defaultdict
from collections.abc import AsyncGenerator, Callable, Generator, Mapping, Sequence
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Generator, Mapping, Sequence
from functools import lru_cache
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypeAlias, TypeVar, Union, cast
@ -248,6 +249,7 @@ from .router_utils.pattern_match_deployments import PatternMatchRouter
if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
from litellm.exceptions import MidStreamFallbackError
from litellm.responses.streaming_iterator import (
BaseResponsesAPIStreamingIterator,
)
@ -264,6 +266,9 @@ if TYPE_CHECKING:
from litellm.router_strategy.quality_router.quality_router import (
QualityRouter,
)
from litellm.types.llms.anthropic_messages.anthropic_response import (
AnthropicMessagesResponse,
)
from litellm.types.llms.base import BaseLiteLLMOpenAIResponseObject
from litellm.types.llms.openai import (
ResponseAPIUsage,
@ -361,6 +366,101 @@ def _stream_chunks_have_generated_content(chunks: Sequence[ModelResponseStream])
return False
# Router._aanthropic_messages_streaming_iterator buffers lifecycle chunks
# until real content commits the primary stream; a hostile or slow-starting
# upstream that never emits content or an error could otherwise grow that
# buffer without bound, so hitting this cap forces an early commit instead.
MAX_BUFFERED_PRE_CONTENT_ANTHROPIC_CHUNKS: Final = 200
def _anthropic_stream_should_drop_pre_content_ping(chunk: object, has_generated_content: bool) -> bool:
"""A `ping` keepalive seen before any real content is dropped outright - it recurs indefinitely on a
slow-starting connection and carries nothing worth buffering toward a possible fallback."""
from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import is_anthropic_ping_chunk
if has_generated_content:
return False
return is_anthropic_ping_chunk(chunk)
def _is_retriable_anthropic_status(status_code: int) -> bool:
return status_code == 429 or status_code >= 500
def _anthropic_stream_should_decline_fallback(has_generated_content: bool, error: "MidStreamFallbackError") -> bool:
"""
A MidStreamFallbackError raised directly by the source iterator (the
completion-bridge path's CustomStreamWrapper, e.g. on a transport drop)
carries its own pre_first_chunk bookkeeping - gated the same way a
detected SSE error event is, so a fallback is never appended after real
content already reached the client on either path.
"""
return has_generated_content or not error.is_pre_first_chunk
def _anthropic_stream_commits_now(chunk: object, has_generated_content: bool, buffered_chunk_count: int) -> bool:
"""
Whether `chunk` should make Router._aanthropic_messages_streaming_iterator
commit to the primary Anthropic stream (real content arrived, or the
pre-content buffer cap was hit) rather than keep buffering lifecycle
frames toward a possible fallback.
"""
from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import (
is_anthropic_content_delta_chunk,
)
if has_generated_content:
return False
return is_anthropic_content_delta_chunk(chunk) or buffered_chunk_count >= MAX_BUFFERED_PRE_CONTENT_ANTHROPIC_CHUNKS
class FallbackAwareAnthropicMessagesStream:
"""
Bare async generators can't carry the `_hidden_params` attribute the
proxy reads response headers off of (see
router_utils.add_retry_fallback_headers.get_hidden_params_dict), so this
thin wrapper carries it through from the source iterator - mirrors
AnthropicMessagesStreamingResponse. Used by
Router._aanthropic_messages_streaming_iterator.
"""
def __init__(self, async_generator: AsyncGenerator[bytes, None], source_iterator: object) -> None:
self._async_generator = async_generator
self._hidden_params = dict( # mutable-ok: mutated in place by merge_fallback_hidden_params
getattr(source_iterator, "_hidden_params", None) or {}
)
def __aiter__(self) -> "FallbackAwareAnthropicMessagesStream":
return self
async def __anext__(self) -> bytes:
return await self._async_generator.__anext__()
async def aclose(self) -> None:
await self._async_generator.aclose()
def merge_fallback_hidden_params(
self,
fallback_hidden_params: Mapping[str, object],
fallback_headers: Mapping[str, object],
) -> None:
"""
Raw bytes can't carry their own _hidden_params the way a
ModelResponseStream/ResponsesAPI event can, so a mid-stream
fallback's provider headers (e.g. Bedrock's x-amzn-requestid) are
merged onto the wrapper itself instead - mirrors
Router._apply_fallback_hidden_params_to_item's merge shape.
"""
existing_headers: Final = cast( # cast-ok: additional_headers is always a dict[str, object] when present
"dict[str, object]", self._hidden_params.get("additional_headers") or {}
)
self._hidden_params = { # mutable-ok: matches _hidden_params' existing dict[str, object] shape
**self._hidden_params,
**fallback_hidden_params,
"additional_headers": {**existing_headers, **fallback_headers}, # mutable-ok: same shape
}
class RoutingArgs(enum.Enum):
ttl = 60 # 1min (RPM/TPM expire key)
@ -4806,6 +4906,264 @@ class Router:
)
return response
async def _aanthropic_messages_streaming_iterator(
self,
response: AsyncIterator[bytes],
initial_kwargs: dict[str, Any], # mutable-ok: mutated in-place before re-entering the fallback chain
) -> AsyncIterator[bytes]:
"""
Wrap an anthropic_messages (/v1/messages) streaming response so a
mid-stream provider error triggers the Router's fallback chain
(parity with _acompletion_streaming_iterator for the
chat-completions path). See #24004.
anthropic_messages goes through _ageneric_api_call_with_fallbacks
rather than _acompletion, so the returned byte iterator is never
wrapped by the chat-completions fallback handler. Two failure
shapes land here:
- the completion-bridge path (deployments with no native
/v1/messages endpoint, via
LiteLLMMessagesToCompletionTransformationHandler) already
raises MidStreamFallbackError out of its underlying
CustomStreamWrapper; this wrapper only needs to catch it.
- a native Anthropic/Bedrock passthrough never raises anything
for a provider SSE `event: error` frame (e.g. `overloaded_error`,
`internal_server_error`) - it is forwarded to the client as-is -
so this wrapper detects it via parse_anthropic_error_event and
raises MidStreamFallbackError itself.
Only an error before any real content (a content_block_delta frame)
has reached the caller triggers a fallback attempt, mirroring the
restriction _acompletion_streaming_iterator applies: once generated
output has already reached the caller, retrying would start a
second, overlapping Anthropic message lifecycle on the same SSE
stream, so the error is left to propagate instead of being retried
invisibly. A non-retriable client error (4xx other than 429) is
never worth a fallback attempt either, so it is also left to
propagate.
Lifecycle/bookkeeping frames (message_start, content_block_start,
ping, ...) do not by themselves disqualify a fallback attempt -
Anthropic routinely sends message_start before an overload error -
but they are BUFFERED rather than forwarded immediately, since
forwarding one and then appending a fallback attempt's own
message_start would produce two overlapping message lifecycles on
one SSE stream. Buffered frames are flushed, in order, the moment
real content arrives (the primary attempt has committed by then
anyway) or once the stream ends without ever producing content or
an error.
"""
from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import (
aclose_if_supported,
parse_anthropic_error_event,
)
source_iterator: Final = response
async def stream_with_fallbacks() -> AsyncGenerator[bytes, None]:
from litellm.exceptions import MidStreamFallbackError
# Lifecycle/bookkeeping frames (message_start, content_block_start,
# ping, ...) are held back rather than forwarded immediately:
# Anthropic routinely sends message_start before an overload
# error, and once a byte reaches the client a fallback attempt
# can only append its OWN message_start, producing two
# overlapping message lifecycles on one SSE stream. Buffered
# frames are flushed the moment real content (content_block_delta)
# arrives - at that point the primary attempt has committed and a
# clean retry is no longer possible anyway - or once the primary
# stream ends without ever producing content. A `ping` keepalive
# is dropped outright rather than buffered, since it can recur
# indefinitely on a slow-starting connection and carries nothing
# worth preserving; hitting MAX_BUFFERED_PRE_CONTENT_ANTHROPIC_CHUNKS
# forces the same early commit as real content arriving, so a
# hostile or pathological upstream can't grow the buffer forever.
has_generated_content = False # rebind-ok: set once real content is seen, or the buffer cap is hit
buffered_lifecycle_chunks: tuple[bytes, ...] = () # rebind-ok: flushed once committed or on decline
model: Final = cast(str, initial_kwargs.get("model")) # cast-ok: kwargs always carries the model group
try:
async for chunk in source_iterator:
if _anthropic_stream_should_drop_pre_content_ping(chunk, has_generated_content):
continue
if _anthropic_stream_commits_now(chunk, has_generated_content, len(buffered_lifecycle_chunks)):
has_generated_content = True # rebind-ok: real content seen, or the buffer cap was hit
error_event = parse_anthropic_error_event(chunk)
retriable_pending_error = ( # rebind-ok: freshly computed each iteration, never carried over
not has_generated_content
and error_event is not None
and _is_retriable_anthropic_status(error_event[2])
)
if not has_generated_content and not retriable_pending_error and error_event is None:
buffered_lifecycle_chunks = (*buffered_lifecycle_chunks, chunk)
continue
if retriable_pending_error:
assert error_event is not None # guard-ok: retriable_pending_error implies this
_error_type, message, status_code = error_event
raise MidStreamFallbackError(
message=message,
model=model,
llm_provider="anthropic",
original_exception=litellm.exceptions.APIError(
status_code=status_code,
message=message,
llm_provider="anthropic",
model=model,
),
is_pre_first_chunk=True,
)
for buffered_chunk in buffered_lifecycle_chunks:
yield buffered_chunk
buffered_lifecycle_chunks = ()
yield chunk
for buffered_chunk in buffered_lifecycle_chunks:
yield buffered_chunk
except MidStreamFallbackError as e:
if _anthropic_stream_should_decline_fallback(has_generated_content, e):
for buffered_chunk in buffered_lifecycle_chunks:
yield buffered_chunk
if e.original_exception is not None:
raise e.original_exception from e
raise
async for item in self._aanthropic_messages_fallback_attempt(e, initial_kwargs, wrapper):
yield item
finally:
with anyio.CancelScope(shield=True), contextlib.suppress(BaseException):
await aclose_if_supported(source_iterator)
# Referenced by stream_with_fallbacks via closure - assigned here, before
# the generator body ever runs, so the reference resolves fine despite
# being defined textually after the function that captures it.
wrapper: Final = FallbackAwareAnthropicMessagesStream(stream_with_fallbacks(), source_iterator)
return wrapper
async def _aanthropic_messages_fallback_attempt(
self,
e: "MidStreamFallbackError",
initial_kwargs: dict[str, Any], # mutable-ok: mutated in-place before re-entering the fallback chain
wrapper: "FallbackAwareAnthropicMessagesStream",
) -> AsyncGenerator[bytes, None]:
"""
Re-enters the Router's fallback chain for a mid-stream
anthropic_messages error and yields whatever the fallback attempt
produces. Split out of _aanthropic_messages_streaming_iterator to
keep each function's cyclomatic complexity within the repo's C901
budget.
"""
from litellm.exceptions import MidStreamFallbackError
from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import (
aclose_if_supported,
anthropic_messages_response_as_sse_events,
)
fallback_response = None # rebind-ok: pre-init so finally can close it if a fallback was actually attempted
try:
model_group: Final = cast(str, initial_kwargs.get("model")) # cast-ok: model group
fallbacks: Final[list | None] = initial_kwargs.get( # mutable-ok: matches the common_utils list|None param
"fallbacks", self.fallbacks
)
context_window_fallbacks: Final[list | None] = initial_kwargs.get( # mutable-ok: matches the param below
"context_window_fallbacks", self.context_window_fallbacks
)
content_policy_fallbacks: Final[list | None] = initial_kwargs.get( # mutable-ok: matches the param below
"content_policy_fallbacks", self.content_policy_fallbacks
)
initial_kwargs["original_function"] = self._ageneric_api_call_with_fallbacks_helper
self._update_kwargs_before_fallbacks(
model=model_group,
kwargs=initial_kwargs,
metadata_variable_name="litellm_metadata",
)
fallback_response = await self.async_function_with_fallbacks_common_utils( # rebind-ok: set on success
e=e,
disable_fallbacks=False,
fallbacks=fallbacks,
context_window_fallbacks=context_window_fallbacks,
content_policy_fallbacks=content_policy_fallbacks,
model_group=model_group,
args=(),
kwargs=initial_kwargs,
include_fallback_errors=initial_kwargs.get("include_fallback_errors", False) is True,
)
fallback_hidden_params, fallback_headers = Router._prepare_fallback_hidden_params(fallback_response)
wrapper.merge_fallback_hidden_params(fallback_hidden_params, fallback_headers)
if hasattr(fallback_response, "__aiter__"):
async for fallback_item in fallback_response:
yield fallback_item
else:
# A fallback can resolve to a complete AnthropicMessagesResponse
# dict even for a streaming request (e.g. an agentic tool-use
# interception loop) - yielding it as-is would put a raw dict
# into a byte stream, so it's synthesized into the SSE
# lifecycle a real stream would have sent instead.
for event in anthropic_messages_response_as_sse_events(
cast("AnthropicMessagesResponse", fallback_response) # cast-ok: non-streaming shape by elimination
):
yield event
except Exception as fallback_error:
verbose_router_logger.error("Anthropic messages streaming fallback also failed: %s", fallback_error)
if isinstance(fallback_error, MidStreamFallbackError) and fallback_error.original_exception is not None:
raise fallback_error.original_exception from fallback_error
raise
finally:
if fallback_response is not None:
with anyio.CancelScope(shield=True), contextlib.suppress(BaseException):
await aclose_if_supported(fallback_response)
async def _aanthropic_messages_with_streaming_fallbacks(
self,
original_function: Callable,
**kwargs: object, # kwargs-ok: forwarded verbatim to original_function, shape varies per call site
) -> Union["AnthropicMessagesResponse", AsyncIterator[bytes]]:
"""
_ageneric_api_call_with_fallbacks for anthropic_messages, with the
addition of mid-stream fallback handling (see
_aanthropic_messages_streaming_iterator). Parity with
_aresponses_with_streaming_fallbacks for the Responses API.
"""
from litellm.litellm_core_utils.core_helpers import safe_deep_copy
# Snapshot the request kwargs before the primary attempt mutates them
# in place: _update_kwargs_with_deployment writes deployment-specific
# fields (deployment, model_info, api_base, tags, ...) into the
# SAME litellm_metadata/metadata dicts a shallow .copy() would still
# share, leaking primary-deployment metadata into the mid-stream
# fallback request. safe_deep_copy avoids deep-copying the full
# kwargs (which can hold non-deepcopyable logging handles/clients).
fallback_kwargs: Final[dict[str, object]] = kwargs.copy() # mutable-ok: mutated below before re-entry
if isinstance(fallback_kwargs.get("litellm_metadata"), dict):
fallback_kwargs["litellm_metadata"] = safe_deep_copy(fallback_kwargs["litellm_metadata"])
if isinstance(fallback_kwargs.get("metadata"), dict):
fallback_kwargs["metadata"] = safe_deep_copy(fallback_kwargs["metadata"])
fallback_kwargs["original_generic_function"] = original_function
response: Final = await self._ageneric_api_call_with_fallbacks(original_function=original_function, **kwargs)
if kwargs.get("stream") and hasattr(response, "__aiter__"):
return await self._aanthropic_messages_streaming_iterator(
response=cast("AsyncIterator[bytes]", response), # cast-ok: stream=True always returns a byte iterator
initial_kwargs=fallback_kwargs,
)
return response
async def _dispatch_generic_call_type(
self,
call_type: str,
original_function: Callable,
**kwargs: object, # kwargs-ok: forwarded verbatim to the per-call-type helper, shape varies per call site
):
"""
factory_function's shared dispatch for call types with no
call-specific handling, except anthropic_messages: kept out of
factory_function's own async_wrapper (already at the repo's C901
complexity ceiling) so routing its mid-stream fallback handling
(#24004) doesn't add another branch there.
"""
if call_type == "anthropic_messages":
return await self._aanthropic_messages_with_streaming_fallbacks(
original_function=original_function, **kwargs
)
return await self._ageneric_api_call_with_fallbacks(original_function=original_function, **kwargs)
def _generic_api_call_with_fallbacks(self, model: str, original_function: Callable, **kwargs):
"""
Make a generic LLM API call through the router, this allows you to use retries/fallbacks with litellm router
@ -5992,7 +6350,8 @@ class Router:
"aget_skill",
"adelete_skill",
):
return await self._ageneric_api_call_with_fallbacks(
return await self._dispatch_generic_call_type(
call_type=call_type,
original_function=original_function,
**kwargs,
)

View file

@ -11,6 +11,10 @@ from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterato
BaseAnthropicMessagesStreamingIterator,
_incomplete_stream_error_sse_event,
_is_message_stop_chunk,
_is_provider_error_chunk,
anthropic_messages_response_as_sse_events,
is_anthropic_content_delta_chunk,
parse_anthropic_error_event,
)
@ -157,6 +161,96 @@ def test_is_message_stop_chunk_ignores_substring_in_payload():
assert _is_message_stop_chunk(delta_frame_with_substring) is False
def test_parse_anthropic_error_event_from_dict_chunk():
"""Regression for #24004: dict-shaped error chunks parse to
(type, message, status) so the Router can decide whether to fall back."""
chunk = {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}
assert parse_anthropic_error_event(chunk) == ("overloaded_error", "Overloaded", 503)
assert _is_provider_error_chunk(chunk) is True
def test_parse_anthropic_error_event_from_sse_bytes():
"""Regression for #24004: a raw `event: error` SSE frame (what a native
Anthropic/Bedrock passthrough forwards verbatim today) must parse
identically to the dict shape so the Router can raise a fallback."""
sse_chunk = (
b"event: error\n"
b'data: {"type": "error", "error": {"type": "internal_server_error", "message": "boom"}}\n\n'
)
assert parse_anthropic_error_event(sse_chunk) == ("internal_server_error", "boom", 500)
assert _is_provider_error_chunk(sse_chunk) is True
def test_parse_anthropic_error_event_defaults_status_for_unknown_type():
chunk = {"type": "error", "error": {"type": "some_future_error_type", "message": "?"}}
assert parse_anthropic_error_event(chunk) == ("some_future_error_type", "?", 500)
def test_parse_anthropic_error_event_missing_message_falls_back_to_type():
chunk = {"type": "error", "error": {"type": "overloaded_error"}}
assert parse_anthropic_error_event(chunk) == ("overloaded_error", "overloaded_error", 503)
def test_parse_anthropic_error_event_non_string_error_type_returns_none():
"""A malformed error body whose `type` field isn't a string (e.g. an
upstream bug sends null or a number) must not be treated as an error
event rather than crashing or forwarding a garbage error_type."""
chunk = {"type": "error", "error": {"type": None, "message": "boom"}}
assert parse_anthropic_error_event(chunk) is None
def test_decoded_sse_data_line_swallows_invalid_json():
"""A `data:` line that isn't valid JSON (a malformed/truncated frame)
must not be treated as an error event or raise, just be ignored."""
malformed_frame = b"event: error\ndata: {not valid json\n\n"
assert parse_anthropic_error_event(malformed_frame) is None
assert _is_provider_error_chunk(malformed_frame) is False
class TestIsAnthropicContentDeltaChunk:
def test_dict_content_block_delta(self):
assert is_anthropic_content_delta_chunk({"type": "content_block_delta"}) is True
def test_dict_other_type(self):
assert is_anthropic_content_delta_chunk({"type": "message_start"}) is False
def test_bytes_content_block_delta(self):
assert is_anthropic_content_delta_chunk(b"event: content_block_delta\ndata: {}\n\n") is True
def test_bytes_other_event(self):
assert is_anthropic_content_delta_chunk(b"event: message_start\ndata: {}\n\n") is False
def test_neither_dict_nor_bytes(self):
assert is_anthropic_content_delta_chunk("content_block_delta") is False
assert is_anthropic_content_delta_chunk(None) is False
@pytest.mark.parametrize(
"chunk",
[
{"type": "content_block_delta", "delta": {"type": "text_delta", "text": "hi"}},
b'event: content_block_delta\ndata: {"type": "content_block_delta"}\n\n',
b"raw-bytes",
"error",
None,
],
)
def test_parse_anthropic_error_event_non_error_chunks_return_none(chunk):
assert parse_anthropic_error_event(chunk) is None
assert _is_provider_error_chunk(chunk) is False
def test_parse_anthropic_error_event_ignores_substring_in_payload():
"""A content_block_delta whose partial_json happens to contain the
literal string `"type": "error"` must not be misread as an error event."""
delta_frame_with_substring = (
b"event: content_block_delta\n"
b'data: {"type": "content_block_delta", "delta": '
b'{"type": "input_json_delta", "partial_json": "\\"type\\": \\"error\\""}}\n\n'
)
assert parse_anthropic_error_event(delta_frame_with_substring) is None
@pytest.mark.asyncio
async def test_async_sse_wrapper_emits_error_when_bytes_stream_only_mentions_message_stop_in_payload():
"""
@ -307,3 +401,117 @@ def test_incomplete_stream_error_sse_event_is_valid_anthropic_error():
"error": {"type": "api_error", "message": INCOMPLETE_STREAM_ERROR_MESSAGE},
}
assert event.endswith("\n\n")
def _decode_sse_events(events: tuple[bytes, ...]) -> list[tuple[str, dict]]:
decoded = []
for event in events:
assert isinstance(event, bytes)
lines = event.decode().split("\n")
assert lines[0].startswith("event: ")
decoded.append((lines[0].removeprefix("event: "), json.loads(lines[1].removeprefix("data: "))))
return decoded
def test_anthropic_messages_response_as_sse_events_text_block():
response = {
"id": "msg_1",
"model": "claude-haiku",
"role": "assistant",
"type": "message",
"stop_reason": "end_turn",
"stop_sequence": None,
"content": [{"type": "text", "text": "hello"}],
"usage": {"input_tokens": 3, "output_tokens": 2},
}
decoded = _decode_sse_events(anthropic_messages_response_as_sse_events(response))
types = [event_type for event_type, _ in decoded]
assert types == [
"message_start",
"content_block_start",
"content_block_delta",
"content_block_stop",
"message_delta",
"message_stop",
]
# message_start must not carry generated content itself, matching a real
# streaming response - it arrives via the content_block_delta that follows.
assert decoded[0][1]["message"]["content"] == []
assert decoded[0][1]["message"]["id"] == "msg_1"
# Bugbot regression: message_start must not carry the completed response's
# final stop_reason/stop_sequence/output_tokens - a real stream keeps those
# null/zero until message_delta, so a client could otherwise treat the
# message as already finished, or double-count output tokens.
assert decoded[0][1]["message"]["stop_reason"] is None
assert decoded[0][1]["message"]["stop_sequence"] is None
assert decoded[0][1]["message"]["usage"] == {"input_tokens": 3, "output_tokens": 0}
assert decoded[1][1]["content_block"] == {"type": "text", "text": ""}
assert decoded[2][1]["delta"] == {"type": "text_delta", "text": "hello"}
assert decoded[4][1]["delta"]["stop_reason"] == "end_turn"
assert decoded[4][1]["usage"] == {"input_tokens": 3, "output_tokens": 2}
def test_anthropic_messages_response_as_sse_events_tool_use_block():
response = {
"id": "msg_2",
"content": [{"type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {"city": "NYC"}}],
"stop_reason": "tool_use",
}
decoded = _decode_sse_events(anthropic_messages_response_as_sse_events(response))
content_block_start = dict(decoded)["content_block_start"]
assert content_block_start["content_block"] == {
"type": "tool_use",
"id": "toolu_1",
"name": "get_weather",
"input": {},
}
content_block_delta = dict(decoded)["content_block_delta"]
assert json.loads(content_block_delta["delta"]["partial_json"]) == {"city": "NYC"}
assert content_block_delta["delta"]["type"] == "input_json_delta"
def test_anthropic_messages_response_as_sse_events_thinking_block_emits_signature_delta():
"""Bugbot regression: a thinking block's real `signature` must reach the
client via a trailing signature_delta, not be silently dropped - Anthropic
rejects a replayed assistant message (a follow-up turn, a tool-use
continuation) whose thinking block lacks its original signature."""
response = {
"id": "msg_5",
"content": [{"type": "thinking", "thinking": "let me think", "signature": "sig-abc123"}],
"stop_reason": "end_turn",
}
decoded = _decode_sse_events(anthropic_messages_response_as_sse_events(response))
deltas = [payload["delta"] for event_type, payload in decoded if event_type == "content_block_delta"]
assert deltas == [
{"type": "thinking_delta", "thinking": "let me think"},
{"type": "signature_delta", "signature": "sig-abc123"},
]
def test_anthropic_messages_response_as_sse_events_thinking_block_without_signature_omits_delta():
response = {
"id": "msg_6",
"content": [{"type": "thinking", "thinking": "let me think", "signature": None}],
"stop_reason": "end_turn",
}
decoded = _decode_sse_events(anthropic_messages_response_as_sse_events(response))
deltas = [payload["delta"] for event_type, payload in decoded if event_type == "content_block_delta"]
assert deltas == [{"type": "thinking_delta", "thinking": "let me think"}]
def test_anthropic_messages_response_as_sse_events_multiple_blocks_are_indexed():
response = {
"id": "msg_3",
"content": [{"type": "text", "text": "a"}, {"type": "text", "text": "b"}],
}
decoded = _decode_sse_events(anthropic_messages_response_as_sse_events(response))
starts = [payload for event_type, payload in decoded if event_type == "content_block_start"]
assert [s["index"] for s in starts] == [0, 1]
deltas = [payload for event_type, payload in decoded if event_type == "content_block_delta"]
assert [d["delta"]["text"] for d in deltas] == ["a", "b"]
def test_anthropic_messages_response_as_sse_events_no_content_blocks():
response = {"id": "msg_4", "content": [], "stop_reason": "end_turn"}
decoded = _decode_sse_events(anthropic_messages_response_as_sse_events(response))
assert [event_type for event_type, _ in decoded] == ["message_start", "message_delta", "message_stop"]

File diff suppressed because it is too large Load diff