Files
hermes-agent/tests/agent/test_anthropic_stream_fallbacks.py
kshitijk4poor 44f6d3bbee fix(streaming): switch main turn to non-streaming on out-of-order Anthropic SSE (#72833)
_maybe_disable_streaming now reuses anthropic_adapter._is_stream_unavailable_error
(its stream-unsupported + Bedrock IAM checks were a verbatim duplicate), so a custom
anthropic_messages provider raising 'Unexpected event order' retries without
streaming instead of repeating the same broken stream. Bedrock keeps
turn_recovery's sticky Converse switch.

Also drive the #60683 usage:null test through _call_anthropic so dropping the
normalize_stream_usage wiring in _open_anthropic_stream turns it red.
2026-09-24 22:29:32 +05:30

127 lines
5.7 KiB
Python

"""Invariants: Anthropic stream failures fall back or stub in a shape the loop accepts."""
from types import SimpleNamespace
import pytest
from agent.anthropic_adapter import create_anthropic_message
from agent.chat_completion_helpers import _build_partial_stream_stub
from agent.transports.anthropic import AnthropicTransport
class _BrokenStream:
def __init__(self, exc):
self._exc = exc
def __enter__(self):
raise self._exc
def __exit__(self, *a):
return False
def _client(exc):
created = []
messages = SimpleNamespace(
stream=lambda **kw: _BrokenStream(exc),
create=lambda **kw: created.append(kw) or SimpleNamespace(content=[], stop_reason="end_turn"),
)
return SimpleNamespace(messages=messages), created
def test_known_stream_breakage_falls_back_to_create():
# #72833: custom provider SSE emits deltas before message_start.
client, created = _client(RuntimeError('Unexpected event order, got content_block_delta before "message_start"'))
create_anthropic_message(client, {"model": "m", "messages": [], "max_tokens": 8, "stream": True})
assert len(created) == 1 and "stream" not in created[0]
def test_unrelated_stream_error_still_raises():
client, created = _client(AttributeError("'Foo' object has no attribute 'bar'"))
with pytest.raises(AttributeError):
create_anthropic_message(client, {"model": "m", "messages": [], "max_tokens": 8})
assert created == []
@pytest.mark.parametrize("content,overflow", [("partial answer", False), (None, True)])
def test_anthropic_partial_stub_passes_transport_validation(content, overflow):
# #45908: the stub must survive AnthropicTransport.validate_response in anthropic_messages mode.
stub = _build_partial_stream_stub("assistant", content, None, "m", None,
overflow_terminal=overflow, api_mode="anthropic_messages")
transport = AnthropicTransport()
assert transport.validate_response(stub)
assert transport.response_finish_reason(stub) == "length"
assert stub._overflow_terminal is overflow
def _minimax_sdk_stream():
"""Real SDK MessageStream over MiniMax-style usage:null message_start/message_delta events."""
from anthropic import NOT_GIVEN
from anthropic._models import construct_type_unchecked
from anthropic.lib.streaming import MessageStream
from anthropic.types import RawMessageStreamEvent
raw = [construct_type_unchecked(type_=RawMessageStreamEvent, value=v) for v in (
{"type": "message_start", "message": {"id": "m", "type": "message", "role": "assistant",
"model": "MiniMax-M2", "content": [], "usage": None}},
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hi"}},
{"type": "content_block_stop", "index": 0},
{"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": None},
{"type": "message_stop"},
)]
return MessageStream(iter(raw), output_format=NOT_GIVEN)
class _Ctx:
def __init__(self, inner):
self._inner = inner
def __enter__(self):
return self._inner
def __exit__(self, *a):
return False
def test_null_usage_aux_stream_is_normalized_without_create_retry():
# #60683 aux path: usage:null must be normalized in-stream, not crash and re-bill via create().
created = []
messages = SimpleNamespace(stream=lambda **kw: _Ctx(_minimax_sdk_stream()),
create=lambda **kw: created.append(kw))
final = create_anthropic_message(SimpleNamespace(messages=messages),
{"model": "m", "messages": [], "max_tokens": 8})
assert final.content[0].text == "hi" and final.stop_reason == "end_turn"
assert created == []
def _anthropic_agent(stream_factory, provider="custom"):
from run_agent import AIAgent
agent = AIAgent(api_key="k", base_url="https://api.minimax.io/anthropic", model="MiniMax-M2",
quiet_mode=True, skip_context_files=True, skip_memory=True)
agent.api_mode, agent.provider, agent._interrupt_requested = "anthropic_messages", provider, False
client = SimpleNamespace(messages=SimpleNamespace(stream=lambda **kw: stream_factory()))
agent._anthropic_client = client
agent._create_request_anthropic_client = lambda *a, **k: client
return agent
def test_null_usage_stream_is_normalized_before_sdk_accumulation():
# #60683 main turn: MiniMax sends usage:null on message_start/message_delta; the SDK's
# accumulate_event() crashes mid-iteration unless _call_anthropic normalizes the raw events.
agent = _anthropic_agent(lambda: _Ctx(_minimax_sdk_stream()))
final = agent._interruptible_streaming_api_call({"model": "MiniMax-M2", "messages": [], "max_tokens": 8})
assert final.content[0].text == "hi" and final.stop_reason == "end_turn"
@pytest.mark.parametrize("provider,disabled", [("custom", True), ("bedrock", False)])
def test_main_turn_event_order_error_disables_streaming(provider, disabled):
# #72833 main turn: a custom anthropic_messages provider's out-of-order SSE must switch the
# retry to non-streaming; Bedrock keeps turn_recovery's Converse fallback instead.
err = RuntimeError('Unexpected event order, got content_block_delta before "message_start"')
agent = _anthropic_agent(lambda: _BrokenStream(err), provider=provider)
with pytest.raises(RuntimeError, match="Unexpected event order"):
agent._interruptible_streaming_api_call({"model": "MiniMax-M2", "messages": [], "max_tokens": 8})
assert bool(getattr(agent, "_disable_streaming", False)) is disabled