fix(anthropic): treat stream idle timeout as inactivity only, not total time

This commit is contained in:
shen0122
2026-08-14 23:18:13 +08:00
committed by chengyongru
parent 1437d1a75a
commit 4de728a555
2 changed files with 161 additions and 58 deletions
+6 -5
View File
@@ -782,11 +782,15 @@ class AnthropicProvider(LLMProvider):
idle_timeout_s = resolve_stream_idle_timeout_s() idle_timeout_s = resolve_stream_idle_timeout_s()
try: try:
async with self._client.messages.stream(**kwargs) as stream: async with self._client.messages.stream(**kwargs) as stream:
if on_content_delta or on_thinking_delta or on_tool_call_delta:
# Idle timeout must track *any* SSE chunk (thinking_delta, # Idle timeout must track *any* SSE chunk (thinking_delta,
# tool JSON deltas, etc.), not only text_stream tokens. # tool JSON deltas, etc.), not only text_stream tokens.
# Otherwise extended thinking can stall text_stream for minutes # Otherwise extended thinking can stall text_stream for minutes
# while the connection is healthy (e.g. MiniMax Anthropic). # while the connection is healthy (e.g. MiniMax Anthropic).
# Drain the whole stream with per-chunk idle waits so the
# timeout measures inactivity, not total generation time: a
# long but continuously-active stream must never be killed.
# The SDK accumulates the final message snapshot during
# iteration, so get_final_message() below returns instantly.
tool_blocks: dict[int, dict[str, str]] = {} tool_blocks: dict[int, dict[str, str]] = {}
while True: while True:
try: try:
@@ -839,10 +843,7 @@ class AnthropicProvider(LLMProvider):
"name": state.get("name", ""), "name": state.get("name", ""),
"arguments_delta": partial, "arguments_delta": partial,
}) })
response = await asyncio.wait_for( response = await stream.get_final_message()
stream.get_final_message(),
timeout=idle_timeout_s,
)
return self._parse_response(response) return self._parse_response(response)
except asyncio.TimeoutError: except asyncio.TimeoutError:
return LLMResponse( return LLMResponse(
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@@ -48,6 +49,107 @@ class _FakeAsyncStream:
pass pass
class _ConsumingFakeAsyncStream:
"""Mimics the real AsyncMessageStream: ``__anext__`` yields chunks after a
per-chunk network delay, and ``get_final_message()`` consumes the remaining
chunks (like the SDK's ``until_done()``) before returning."""
def __init__(
self,
chunks: list[SimpleNamespace],
per_chunk_delay: float,
) -> None:
self._chunks = chunks
self._idx = 0
self._delay = per_chunk_delay
async def __anext__(self) -> SimpleNamespace:
if self._idx >= len(self._chunks):
raise StopAsyncIteration
c = self._chunks[self._idx]
self._idx += 1
await asyncio.sleep(self._delay)
return c
def __aiter__(self) -> _ConsumingFakeAsyncStream:
return self
async def get_final_message(self) -> SimpleNamespace:
async for _ in self:
pass
return _final_message_stub("ok")
async def __aenter__(self) -> _ConsumingFakeAsyncStream:
return self
async def __aexit__(self, *_exc: object) -> None:
pass
@pytest.mark.asyncio
async def test_chat_stream_without_callback_survives_long_active_stream(monkeypatch) -> None:
"""Regression: the idle timeout must not double as a total timeout.
A stream that keeps producing chunks (5 x 0.06s = 0.30s) well past the
idle timeout (0.15s) must complete. Currently the no-callback path wraps
``stream.get_final_message()`` in ``wait_for(timeout=idle_timeout_s)``,
which measures total wall-clock time and kills the stream even though it
is continuously active.
"""
monkeypatch.setenv("NANOBOT_STREAM_IDLE_TIMEOUT_S", "0.15")
provider = AnthropicProvider(api_key="sk-test")
provider._client = MagicMock()
chunks = [
SimpleNamespace(
type="content_block_delta",
delta=SimpleNamespace(type="text_delta", text="a"),
)
for _ in range(5)
]
fake = _ConsumingFakeAsyncStream(chunks, per_chunk_delay=0.06)
stream_cm = MagicMock()
stream_cm.__aenter__ = AsyncMock(return_value=fake)
stream_cm.__aexit__ = AsyncMock(return_value=None)
provider._client.messages.stream = MagicMock(return_value=stream_cm)
res = await provider.chat_stream(
messages=[{"role": "user", "content": "hello"}],
)
assert res.finish_reason != "error", (
f"active stream was killed by total-timeout misuse: {res.content}"
)
assert res.content == "ok"
@pytest.mark.asyncio
async def test_chat_stream_without_callback_still_enforces_idle_timeout(monkeypatch) -> None:
"""A genuinely stalled stream must still be cut off by the idle timeout."""
monkeypatch.setenv("NANOBOT_STREAM_IDLE_TIMEOUT_S", "0.05")
provider = AnthropicProvider(api_key="sk-test")
provider._client = MagicMock()
class _StalledStream(_FakeAsyncStream):
async def __anext__(self) -> SimpleNamespace:
await asyncio.sleep(3600)
raise StopAsyncIteration
fake = _StalledStream([])
stream_cm = MagicMock()
stream_cm.__aenter__ = AsyncMock(return_value=fake)
stream_cm.__aexit__ = AsyncMock(return_value=None)
provider._client.messages.stream = MagicMock(return_value=stream_cm)
res = await provider.chat_stream(
messages=[{"role": "user", "content": "hello"}],
)
assert res.finish_reason == "error"
assert res.error_kind == "timeout"
assert "stalled" in (res.content or "")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chat_stream_calls_on_content_delta_only_for_text_delta() -> None: async def test_chat_stream_calls_on_content_delta_only_for_text_delta() -> None:
"""Thinking deltas must be consumed without invoking on_content_delta.""" """Thinking deltas must be consumed without invoking on_content_delta."""