"""Tests for streaming error handling in providers/nvidia_nim/client.py.""" import json from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import httpx import openai import pytest from free_claude_code.config.nim import NimSettings from free_claude_code.core.anthropic.stream_contracts import ( parse_sse_text, ) from free_claude_code.core.anthropic.streaming import ( AnthropicStreamLedger, make_text_recovery_body, ) from free_claude_code.core.failures import ExecutionFailure from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.nvidia_nim import NvidiaNimProvider from free_claude_code.providers.openai_chat.provider import ( _OpenAIChatStreamRunner, ) from free_claude_code.providers.openai_chat.tool_calls import ( OpenAIToolCallAssembler, has_committed_sse_output, iter_heuristic_tool_use_sse, ) from free_claude_code.providers.stream_recovery import ( MIDSTREAM_RECOVERY_ATTEMPTS, TruncatedProviderStreamError, ) from tests.providers.request_factory import make_messages_request from tests.providers.support import passthrough_rate_limiter class AsyncStreamMock: """Async iterable mock that yields chunks then optionally raises.""" def __init__(self, chunks, error=None): self._chunks = chunks self._error = error def __aiter__(self): return self._aiter() async def _aiter(self): for chunk in self._chunks: yield chunk if self._error: raise self._error class ClosableAsyncStreamMock(AsyncStreamMock): """Async stream mock that records cleanup.""" def __init__(self, chunks, error=None): super().__init__(chunks, error=error) self.closed = False async def aclose(self): self.closed = True def _make_provider(): """Create a provider instance for testing.""" config = ProviderConfig( api_key="test_key", base_url="https://test.api.nvidia.com/v1", rate_limit=10, rate_window=60, ) return NvidiaNimProvider( config, nim_settings=NimSettings(), rate_limiter=passthrough_rate_limiter(), ) def _make_tool_assembler(provider: NvidiaNimProvider) -> OpenAIToolCallAssembler: return OpenAIToolCallAssembler( record_extra_content=provider._record_tool_call_extra_content ) def _make_provider_with_thinking_enabled(enabled: bool): """Create a provider instance with thinking explicitly enabled or disabled.""" config = ProviderConfig( api_key="test_key", base_url="https://test.api.nvidia.com/v1", rate_limit=10, rate_window=60, enable_thinking=enabled, ) return NvidiaNimProvider( config, nim_settings=NimSettings(), rate_limiter=passthrough_rate_limiter(), ) def _make_request(model: str = "test-model", stream: bool = True, **overrides: object): """Create a concrete request matching the original streaming-test defaults.""" request_overrides: dict[str, object] = { "messages": [], "max_tokens": 4096, "temperature": None, "top_p": None, "system": None, "tools": None, "extra_body": None, "thinking": None, "stream": stream, } request_overrides.update(overrides) return make_messages_request(model, **request_overrides) def _make_stream_runner( provider: NvidiaNimProvider, *, request=None, request_id: str | None = None, ) -> _OpenAIChatStreamRunner: return _OpenAIChatStreamRunner( provider, request=request or _make_request(), input_tokens=0, request_id=request_id, thinking_enabled=None, ) def _make_chunk( content=None, finish_reason=None, tool_calls=None, reasoning_content=None ): """Create a mock streaming chunk.""" delta = MagicMock() delta.content = content delta.tool_calls = tool_calls delta.reasoning_content = reasoning_content choice = MagicMock() choice.delta = delta choice.finish_reason = finish_reason chunk = MagicMock() chunk.choices = [choice] chunk.usage = None return chunk def _make_tool_calls_chunk(*, name: str, arguments: str, tool_id: str, index: int = 0): """Single OpenAI-style tool_calls delta (starts a native streamed tool block).""" tc = MagicMock() tc.index = index tc.id = tool_id fn = MagicMock() fn.name = name fn.arguments = arguments tc.function = fn return _make_chunk(tool_calls=[tc]) async def _collect_stream(provider, request): """Collect all SSE events from a stream.""" return [e async for e in provider.stream_response(request)] async def _collect_stream_error(provider, request, **kwargs) -> ExecutionFailure: with pytest.raises(ExecutionFailure) as exc_info: [e async for e in provider.stream_response(request, **kwargs)] return exc_info.value async def _collect_stream_and_error( provider, request, **kwargs ) -> tuple[list[str], ExecutionFailure]: events: list[str] = [] with pytest.raises(ExecutionFailure) as exc_info: async for event in provider.stream_response(request, **kwargs): events.extend((event,)) return events, exc_info.value def _assert_no_content_deltas_after_error_text( events: list[str], error_substr: str ) -> None: """After the error text delta, only block close + message tail events may follow.""" parsed = parse_sse_text("".join(events)) first_error_idx = None for i, ev in enumerate(parsed): if ev.event != "content_block_delta": continue delta = ev.data.get("delta", {}) if delta.get("type") == "text_delta" and error_substr in str( delta.get("text", "") ): first_error_idx = i break assert first_error_idx is not None, (error_substr, "".join(events)) for ev in parsed[first_error_idx + 1 :]: assert ev.event in ("content_block_stop", "message_delta", "message_stop"), ( ev.event, ev.data, ) def _assert_error_not_in_text_deltas_after_tool( events: list[str], error_substr: str ) -> None: """Transport errors after a native tool call must not use assistant text_delta (issue #206).""" blob = "".join(events) for ev in parse_sse_text(blob): if ev.event != "content_block_delta": continue delta = ev.data.get("delta", {}) if delta.get("type") == "text_delta" and error_substr in str( delta.get("text", "") ): raise AssertionError( f"error leaked as text_delta after tool_use: {ev.data!r} full={blob!r}" ) class TestStreamingExceptionHandling: """Tests for error paths during stream_response.""" @pytest.mark.asyncio async def test_pre_start_api_error_raises_provider_error(self): """Before holdback commit, provider failures raise for API-level non-200.""" provider = _make_provider() request = _make_request() with ( patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, side_effect=RuntimeError("API failed"), ), patch.object( provider._rate_limiter, "wait_if_blocked", new_callable=AsyncMock, return_value=False, ), ): error = await _collect_stream_error(provider, request) assert "API failed" in error.message @pytest.mark.asyncio async def test_read_timeout_with_empty_message_raises_fallback(self): """ReadTimeout(TimeoutError()) should raise a non-empty timeout message.""" provider = _make_provider() request = _make_request() with ( patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, side_effect=httpx.ReadTimeout(""), ), patch.object( provider._rate_limiter, "wait_if_blocked", new_callable=AsyncMock, return_value=False, ), patch("asyncio.sleep", new_callable=AsyncMock), ): error = await _collect_stream_error( provider, request, request_id="req_timeout123", ) assert "timed out after" in error.message assert "Request ID: req_timeout123" in error.message @pytest.mark.asyncio async def test_error_after_precommit_partial_content_raises(self): """Precommit partial text is discarded so the API can return non-200.""" provider = _make_provider() request = _make_request() chunk1 = _make_chunk(content="Hello ") stream_mock = AsyncStreamMock([chunk1], error=RuntimeError("Connection lost")) with ( patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ), patch.object( provider._rate_limiter, "wait_if_blocked", new_callable=AsyncMock, return_value=False, ), ): error = await _collect_stream_error(provider, request) assert "Connection lost" in error.message @pytest.mark.asyncio async def test_error_after_native_tool_call_closes_block_then_raises(self): """A provider closes tool state, then leaves terminal serialization to API.""" provider = _make_provider() request = _make_request() tool_chunk = _make_tool_calls_chunk( name="echo_smoke", arguments="{}", tool_id="call_206", index=0 ) stream_mock = AsyncStreamMock( [tool_chunk], error=RuntimeError("Connection lost after tool") ) with ( patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ), patch.object( provider._rate_limiter, "wait_if_blocked", new_callable=AsyncMock, return_value=False, ), ): events, error = await _collect_stream_and_error(provider, request) event_text = "".join(events) parsed = parse_sse_text(event_text) assert "tool_use" in event_text assert parsed[-1].event == "content_block_stop" assert "Connection lost after tool" in error.message assert "Connection lost after tool" not in event_text assert "event: error\n" not in event_text assert "message_stop" not in event_text _assert_error_not_in_text_deltas_after_tool( events, "Connection lost after tool" ) @pytest.mark.asyncio async def test_empty_response_gets_space(self): """Empty response with no text/tools gets a single space text block.""" provider = _make_provider() request = _make_request() empty_chunk = _make_chunk(finish_reason="stop") stream_mock = AsyncStreamMock([empty_chunk]) with ( patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ), patch.object( provider._rate_limiter, "wait_if_blocked", new_callable=AsyncMock, return_value=False, ), ): events = await _collect_stream(provider, request) event_text = "".join(events) assert '"text_delta"' in event_text assert "message_stop" in event_text @pytest.mark.asyncio async def test_upstream_completion_tokens_null_emits_int_usage(self): """NIM/GLM may send usage.completion_tokens=null; final SSE must not use JSON null.""" provider = _make_provider() request = _make_request() delta = SimpleNamespace( content="hello", tool_calls=None, reasoning_content=None, ) choice = SimpleNamespace(delta=delta, finish_reason="stop") usage = SimpleNamespace(completion_tokens=None, prompt_tokens=None) chunk = SimpleNamespace(choices=[choice], usage=usage) stream_mock = AsyncStreamMock([chunk]) with ( patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ), patch.object( provider._rate_limiter, "wait_if_blocked", new_callable=AsyncMock, return_value=False, ), ): events = await _collect_stream(provider, request) parsed = parse_sse_text("".join(events)) delta_events = [e for e in parsed if e.event == "message_delta"] assert len(delta_events) == 1 usage_out = delta_events[0].data.get("usage", {}) assert isinstance(usage_out.get("output_tokens"), int) assert usage_out["output_tokens"] is not None assert '"output_tokens": null' not in "".join(events) @pytest.mark.asyncio async def test_reasoning_only_stream_emits_placeholder_text(self): """When the model streams only ``reasoning_content`` (no ``content``), add text block. NIM / some templates may emit no main ``content``; a minimal text block matches the empty-body placeholder and helps clients that expect a text segment. """ provider = _make_provider_with_thinking_enabled(True) request = _make_request() chunk1 = _make_chunk(reasoning_content="reasoning only from provider") chunk2 = _make_chunk(finish_reason="stop") stream_mock = AsyncStreamMock([chunk1, chunk2]) with ( patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ), patch.object( provider._rate_limiter, "wait_if_blocked", new_callable=AsyncMock, return_value=False, ), ): events = await _collect_stream(provider, request) event_text = "".join(events) assert "thinking_delta" in event_text assert '"text_delta"' in event_text assert "message_stop" in event_text @pytest.mark.asyncio async def test_stream_with_thinking_content(self): """Thinking content via think tags is emitted as thinking blocks.""" provider = _make_provider() request = _make_request() chunk1 = _make_chunk(content="reasoninganswer") chunk2 = _make_chunk(finish_reason="stop") stream_mock = AsyncStreamMock([chunk1, chunk2]) with ( patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ), patch.object( provider._rate_limiter, "wait_if_blocked", new_callable=AsyncMock, return_value=False, ), ): events = await _collect_stream(provider, request) event_text = "".join(events) assert "thinking" in event_text assert "reasoning" in event_text assert "answer" in event_text @pytest.mark.asyncio async def test_stream_with_reasoning_content_field(self): """reasoning_content delta field is emitted as thinking block.""" provider = _make_provider() request = _make_request() chunk1 = _make_chunk(reasoning_content="I think...") chunk2 = _make_chunk(content="The answer") chunk3 = _make_chunk(finish_reason="stop") stream_mock = AsyncStreamMock([chunk1, chunk2, chunk3]) with ( patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ), patch.object( provider._rate_limiter, "wait_if_blocked", new_callable=AsyncMock, return_value=False, ), ): events = await _collect_stream(provider, request) event_text = "".join(events) assert "thinking_delta" in event_text assert "I think..." in event_text assert "The answer" in event_text @pytest.mark.asyncio async def test_stream_with_empty_reasoning_content_starts_thinking_block_only(self): """Empty reasoning_content is stateful but must not emit visible thinking text.""" provider = _make_provider() request = _make_request() chunk1 = _make_chunk(reasoning_content="") chunk2 = _make_chunk(finish_reason="stop") stream_mock = AsyncStreamMock([chunk1, chunk2]) with ( patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ), patch.object( provider._rate_limiter, "wait_if_blocked", new_callable=AsyncMock, return_value=False, ), ): events = await _collect_stream(provider, request) parsed = parse_sse_text("".join(events)) thinking_starts = [ event for event in parsed if event.event == "content_block_start" and event.data["content_block"]["type"] == "thinking" ] thinking_deltas = [ event for event in parsed if event.event == "content_block_delta" and event.data["delta"]["type"] == "thinking_delta" ] assert len(thinking_starts) == 1 assert thinking_deltas == [] assert parsed[-1].event == "message_stop" @pytest.mark.asyncio async def test_stream_with_reasoning_content_suppressed_when_disabled(self): """reasoning deltas are stripped while normal text still streams.""" provider = _make_provider_with_thinking_enabled(False) request = _make_request() chunk1 = _make_chunk(reasoning_content="I think...") chunk2 = _make_chunk(content="secretThe answer") chunk3 = _make_chunk(finish_reason="stop") stream_mock = AsyncStreamMock([chunk1, chunk2, chunk3]) with ( patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ), patch.object( provider._rate_limiter, "wait_if_blocked", new_callable=AsyncMock, return_value=False, ), ): events = await _collect_stream(provider, request) event_text = "".join(events) assert "thinking_delta" not in event_text assert "I think..." not in event_text assert "secret" not in event_text assert "The answer" in event_text @pytest.mark.asyncio async def test_stream_with_upstream_405_mentions_provider_name(self): """HTTP 405s are surfaced as upstream method/endpoint rejections.""" provider = _make_provider() request = _make_request() response = httpx.Response( status_code=405, request=httpx.Request("POST", "https://example.com/v1/chat/completions"), ) error = httpx.HTTPStatusError( "Method Not Allowed", request=response.request, response=response, ) with patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, side_effect=error, ): stream_error = await _collect_stream_error( provider, request, request_id="REQ405", ) assert ( "Upstream provider NIM rejected the request method or endpoint (HTTP 405)." in stream_error.message ) assert "Request ID: REQ405" in stream_error.message @pytest.mark.asyncio async def test_stream_with_openai_bad_request_surfaces_upstream_body(self): """OpenAI SDK bodies should be raised so users can copy exact provider errors.""" provider = _make_provider() request = _make_request() response = httpx.Response( status_code=400, request=httpx.Request("POST", "https://example.com/v1/chat/completions"), ) body = { "error": { "type": "BadRequest", "message": "Thinking mode does not support this tool_choice", } } error = openai.BadRequestError("Bad Request", response=response, body=body) with patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, side_effect=error, ): stream_error = await _collect_stream_error( provider, request, request_id="REQ_BODY", ) assert "Upstream provider NIM returned HTTP 400." in stream_error.message assert "Category: BadRequest" in stream_error.message assert "Thinking mode does not support this tool_choice" in stream_error.message assert ( '{"error":{"type":"BadRequest","message":"Thinking mode does not support this tool_choice"}}' in stream_error.message ) assert "Request ID: REQ_BODY" in stream_error.message @pytest.mark.asyncio async def test_error_after_native_tool_call_failure_includes_body(self): """Detailed failure data survives after the provider closes tool state.""" provider = _make_provider() request = _make_request() tool_chunk = _make_tool_calls_chunk( name="echo_smoke", arguments="{}", tool_id="call_body", index=0 ) response = httpx.Response( status_code=400, request=httpx.Request("POST", "https://example.com/v1/chat/completions"), ) body = {"error": {"message": "bad after tool"}} error = openai.BadRequestError("Bad Request", response=response, body=body) stream_mock = AsyncStreamMock([tool_chunk], error=error) with patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ): events, stream_error = await _collect_stream_and_error( provider, request, request_id="REQ_TOOL_BODY", ) event_text = "".join(events) parsed = parse_sse_text(event_text) assert "tool_use" in event_text assert parsed[-1].event == "content_block_stop" assert "event: error\n" not in event_text assert "bad after tool" not in event_text assert "Request ID: REQ_TOOL_BODY" not in event_text assert "message_stop" not in event_text assert "bad after tool" in stream_error.message assert "Request ID: REQ_TOOL_BODY" in stream_error.message _assert_error_not_in_text_deltas_after_tool(events, "bad after tool") @pytest.mark.asyncio async def test_clean_eof_after_complete_tool_call_salvages_tool_use(self): """A complete tool JSON payload missing finish_reason is committed as tool_use.""" provider = _make_provider() request = _make_request() tool_chunk = _make_tool_calls_chunk( name="echo_smoke", arguments='{"message":"ok"}', tool_id="call_eof" ) stream_mock = AsyncStreamMock([tool_chunk]) with patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ): events = await _collect_stream(provider, request) parsed = parse_sse_text("".join(events)) assert parsed[-1].event == "message_stop" assert any( event.event == "message_delta" and event.data.get("delta", {}).get("stop_reason") == "tool_use" for event in parsed ) assert not any(event.event == "error" for event in parsed) @pytest.mark.asyncio @pytest.mark.parametrize("finish_reason", ["tool_calls", "stop"]) async def test_heuristic_only_tool_stream_does_not_emit_fallback_text( self, finish_reason ): """Text-parsed tool calls count as emitted tool output when finalizing.""" provider = _make_provider() request = _make_request() heuristic_tool = ( "● test.py" "10" ) stream_mock = AsyncStreamMock( [ _make_chunk(content=heuristic_tool), _make_chunk(finish_reason=finish_reason), ] ) with patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ): events = await _collect_stream(provider, request) parsed = parse_sse_text("".join(events)) assert any( event.event == "content_block_start" and event.data.get("content_block", {}).get("type") == "tool_use" for event in parsed ) assert not any( event.event == "content_block_delta" and event.data.get("delta", {}).get("type") == "text_delta" and event.data.get("delta", {}).get("text") == " " for event in parsed ) assert any( event.event == "message_delta" and event.data.get("delta", {}).get("stop_reason") == "tool_use" for event in parsed ) @pytest.mark.asyncio async def test_precommit_openai_holdback_retries_without_leaking_partial(self): """A retryable early cutoff before holdback commit is retried invisibly.""" provider = _make_provider() request = _make_request() first_stream = AsyncStreamMock( [_make_chunk(content="hidden")], error=httpx.ReadError("early cutoff"), ) second_stream = AsyncStreamMock( [ _make_chunk(content="visible"), _make_chunk(finish_reason="stop"), ] ) with patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, side_effect=[first_stream, second_stream], ) as mock_create: events = await _collect_stream(provider, request) event_text = "".join(events) assert mock_create.await_count == 2 assert "hidden" not in event_text assert "visible" in event_text parsed = parse_sse_text(event_text) assert parsed[0].event == "message_start" assert sum(event.event == "message_start" for event in parsed) == 1 assert parsed[-1].event == "message_stop" @pytest.mark.asyncio async def test_clean_eof_after_text_continues_with_overlap_trim(self): """A truncated text stream is continued and duplicate overlap is trimmed.""" provider = _make_provider() request = _make_request() stream_mock = AsyncStreamMock([_make_chunk(content="hello wor")]) with ( patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ), patch.object( _OpenAIChatStreamRunner, "_collect_recovery_text", new_callable=AsyncMock, return_value=("world", ""), ), ): events = await _collect_stream(provider, request) parsed = parse_sse_text("".join(events)) text_deltas = [ event.data.get("delta", {}).get("text", "") for event in parsed if event.event == "content_block_delta" ] assert text_deltas == ["hello wor", "ld"] assert "".join(text_deltas) == "hello world" assert any( event.event == "message_delta" and event.data.get("delta", {}).get("stop_reason") == "end_turn" for event in parsed ) assert not any(event.event == "error" for event in parsed) @pytest.mark.asyncio async def test_recovery_collect_text_requires_finish_reason(self): """Recovery collectors reject truncated OpenAI-chat continuation streams.""" streams = [ ClosableAsyncStreamMock([_make_chunk(content=f"world {index}")]) for index in range(MIDSTREAM_RECOVERY_ATTEMPTS) ] create_stream = AsyncMock(side_effect=[(stream, {}) for stream in streams]) provider = _make_provider() runner = _make_stream_runner(provider) with ( patch.object(provider, "_create_stream", create_stream), pytest.raises(TruncatedProviderStreamError), ): await runner._collect_recovery_text({"messages": []}) assert create_stream.await_count == MIDSTREAM_RECOVERY_ATTEMPTS assert all(stream.closed for stream in streams) @pytest.mark.asyncio async def test_recovery_collect_text_closes_retryable_failed_streams(self): """Recovery collectors close failed stream attempts before retrying.""" streams = [ ClosableAsyncStreamMock( [_make_chunk(content=f"partial {index}")], error=TimeoutError("recovery cutoff"), ) for index in range(MIDSTREAM_RECOVERY_ATTEMPTS) ] create_stream = AsyncMock(side_effect=[(stream, {}) for stream in streams]) provider = _make_provider() runner = _make_stream_runner(provider) with ( patch.object(provider, "_create_stream", create_stream), pytest.raises(TimeoutError), ): await runner._collect_recovery_text({"messages": []}) assert create_stream.await_count == MIDSTREAM_RECOVERY_ATTEMPTS assert all(stream.closed for stream in streams) @pytest.mark.asyncio async def test_recovery_collect_text_accepts_finish_reason(self): """Recovery collectors return text only after the upstream terminal marker.""" stream = ClosableAsyncStreamMock( [ _make_chunk(content="world"), _make_chunk(finish_reason="stop"), ] ) create_stream = AsyncMock( return_value=( stream, {}, ) ) provider = _make_provider() runner = _make_stream_runner(provider) with patch.object(provider, "_create_stream", create_stream): result = await runner._collect_recovery_text({"messages": []}) assert result == ("world", "") assert stream.closed is True def test_text_recovery_body_preserves_thinking_context(self): """Continuation prompts include emitted thinking without provider-specific fields.""" body = { "messages": [{"role": "user", "content": "hello"}], "tools": [{"name": "Read"}], "tool_choice": {"type": "auto"}, } recovery_body = make_text_recovery_body( body, partial_text="visible answer", partial_thinking="hidden reasoning", ) assert "tools" not in recovery_body assert "tool_choice" not in recovery_body assert recovery_body["messages"][-2] == { "role": "assistant", "content": "visible answer", } recovery_prompt = recovery_body["messages"][-1] assert recovery_prompt["role"] == "user" assert "hidden reasoning" in recovery_prompt["content"] assert "reasoning_content" not in recovery_prompt @pytest.mark.asyncio async def test_openai_text_recovery_passes_thinking_context(self): """OpenAI-chat recovery call sites seed emitted thinking in the prompt.""" runner = _make_stream_runner( _make_provider(), request=_make_request(), request_id="req_recovery" ) ledger = AnthropicStreamLedger("msg_recovery", "model") ledger.start_thinking_block() ledger.emit_thinking_delta("hidden reasoning") list(ledger.ensure_text_block()) ledger.emit_text_delta("visible answer") with patch.object( runner, "_collect_recovery_text", new_callable=AsyncMock, return_value=("visible answer done", "hidden reasoning more"), ) as mock_collect: events = await runner._recovery_events( body={"messages": [{"role": "user", "content": "hello"}]}, ledger=ledger, error=TimeoutError("cutoff"), tool_argument_alias_buffers={}, ) assert events is not None assert mock_collect.await_args is not None recovery_body = mock_collect.await_args.args[0] assert "hidden reasoning" in recovery_body["messages"][-1]["content"] @pytest.mark.asyncio async def test_primary_stream_closes_when_iteration_fails(self): """OpenAI-chat main streams close after iterator failures.""" provider = _make_provider() request = _make_request() stream = ClosableAsyncStreamMock( [_make_chunk(content="partial")], error=ValueError("provider stream failed"), ) with patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream, ): error = await _collect_stream_error(provider, request) assert stream.closed is True assert "provider stream failed" in error.message.lower() @pytest.mark.asyncio async def test_truncated_recovery_stream_closes_block_then_raises(self): """Partial recovery bytes never become success or provider-owned wire errors.""" provider = _make_provider() request = _make_request() original_text = "hello wor" + ("x" * 70_000) original_stream = AsyncStreamMock([_make_chunk(content=original_text)]) with ( patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=original_stream, ) as mock_create, patch.object( _OpenAIChatStreamRunner, "_collect_recovery_text", new_callable=AsyncMock, side_effect=TruncatedProviderStreamError( "Recovery stream ended without finish_reason." ), ) as mock_collect, ): events, error = await _collect_stream_and_error(provider, request) event_text = "".join(events) assert mock_create.await_count == 1 assert mock_collect.await_count == 1 assert original_text in event_text assert "world" not in event_text assert "Provider stream ended without finish_reason." in error.message assert "Provider stream ended without finish_reason." not in event_text parsed = parse_sse_text(event_text) assert parsed[-1].event == "content_block_stop" assert not any(event.event == "error" for event in parsed) assert not any(event.event == "message_stop" for event in parsed) assert not any( event.event == "content_block_delta" and event.data.get("delta", {}).get("text") == "ld" for event in parse_sse_text(event_text) ) @pytest.mark.asyncio async def test_incomplete_tool_call_repair_appends_schema_valid_suffix(self): """A truncated tool JSON prefix is repaired append-only before tool_use tail.""" provider = _make_provider() request = _make_request( tools=[ { "name": "echo_smoke", "description": "Echo", "input_schema": { "type": "object", "properties": {"message": {"type": "string"}}, "required": ["message"], "additionalProperties": False, }, } ] ) tool_chunk = _make_tool_calls_chunk( name="echo_smoke", arguments='{"message":', tool_id="call_repair" ) stream_mock = AsyncStreamMock([tool_chunk]) with ( patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ), patch.object( _OpenAIChatStreamRunner, "_collect_recovery_text", new_callable=AsyncMock, return_value=('"ok"}', ""), ), ): events = await _collect_stream(provider, request) event_text = "".join(events) parsed = parse_sse_text(event_text) assert '"partial_json": "\\"ok\\"}"' in event_text assert any( event.event == "message_delta" and event.data.get("delta", {}).get("stop_reason") == "tool_use" for event in parsed ) assert not any(event.event == "error" for event in parsed) @pytest.mark.asyncio async def test_stream_rate_limited_retries_via_execute_with_retry(self): """When rate limited, execute_with_retry handles retries transparently.""" provider = _make_provider() request = _make_request() chunk1 = _make_chunk(content="Response") chunk2 = _make_chunk(finish_reason="stop") stream_mock = AsyncStreamMock([chunk1, chunk2]) with patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ): # Mock execute_with_retry to pass through to the actual function async def _passthrough(fn, *args, **kwargs): return await fn(*args, **kwargs) with patch.object( provider._rate_limiter, "execute_with_retry", new_callable=AsyncMock, side_effect=_passthrough, ): events = await _collect_stream(provider, request) event_text = "".join(events) assert "Response" in event_text class TestProcessToolCall: """Tests for OpenAI tool-call assembly.""" def test_heuristic_tool_use_sse_marks_committed_tool_output(self): """Heuristic tool blocks are emitted content, even without OpenAI tool state.""" from free_claude_code.core.anthropic import AnthropicStreamLedger ledger = AnthropicStreamLedger("msg_test", "test-model") events = list( iter_heuristic_tool_use_sse( ledger, { "id": "toolu_heuristic", "name": "Read", "input": {"path": "test.py"}, }, ) ) event_text = "".join(events) assert "tool_use" in event_text assert ledger.has_emitted_tool_block() assert has_committed_sse_output(ledger) def test_tool_call_with_id(self): """Tool call with id starts a tool block.""" provider = _make_provider() from free_claude_code.core.anthropic import AnthropicStreamLedger sse = AnthropicStreamLedger("msg_test", "test-model") tc = { "index": 0, "id": "call_123", "function": {"name": "search", "arguments": '{"q": "test"}'}, } events = list(_make_tool_assembler(provider).process_tool_call(tc, sse)) event_text = "".join(events) assert "tool_use" in event_text assert "search" in event_text assert "call_123" in event_text def test_tool_call_id_arrives_before_name_still_emits_id_and_name(self): """Split-stream tool: id (no name) then name then args; id preserved on start.""" provider = _make_provider() from free_claude_code.core.anthropic import AnthropicStreamLedger sse = AnthropicStreamLedger("msg_test", "test-model") t1 = { "index": 0, "id": "call_split", "function": {"name": None, "arguments": ""}, } t2 = { "index": 0, "id": "call_split", "function": {"name": "Grep", "arguments": ""}, } t3 = { "index": 0, "id": "call_split", "function": {"name": None, "arguments": "{}"}, } b1 = "".join(_make_tool_assembler(provider).process_tool_call(t1, sse)) b2 = "".join(_make_tool_assembler(provider).process_tool_call(t2, sse)) b3 = "".join(_make_tool_assembler(provider).process_tool_call(t3, sse)) combined = b1 + b2 + b3 assert "call_split" in combined assert "Grep" in combined assert b1 == "" def test_tool_call_arguments_buffered_until_name(self): """Argument deltas before tool name are emitted after the block starts.""" provider = _make_provider() from free_claude_code.core.anthropic import AnthropicStreamLedger sse = AnthropicStreamLedger("msg_test", "test-model") t1 = { "index": 0, "id": "call_buf", "function": {"name": None, "arguments": '{"x":'}, } t2 = { "index": 0, "id": "call_buf", "function": {"name": "Read", "arguments": "1}"}, } b1 = "".join(_make_tool_assembler(provider).process_tool_call(t1, sse)) b2 = "".join(_make_tool_assembler(provider).process_tool_call(t2, sse)) assert b1 == "" combined = b2 assert "Read" in combined assert "call_buf" in combined assert '{"x":' in combined or "partial_json" in combined def test_tool_call_without_id_generates_uuid(self): """Tool call without id generates a uuid-based id.""" provider = _make_provider() from free_claude_code.core.anthropic import AnthropicStreamLedger sse = AnthropicStreamLedger("msg_test", "test-model") tc = { "index": 0, "id": None, "function": {"name": "test", "arguments": "{}"}, } events = list(_make_tool_assembler(provider).process_tool_call(tc, sse)) event_text = "".join(events) assert "tool_" in event_text def test_task_tool_forces_background_false(self): """Task tool with run_in_background=true is forced to false.""" provider = _make_provider() from free_claude_code.core.anthropic import AnthropicStreamLedger sse = AnthropicStreamLedger("msg_test", "test-model") args = json.dumps({"run_in_background": True, "prompt": "test"}) tc = { "index": 0, "id": "call_task", "function": {"name": "Task", "arguments": args}, } events = list(_make_tool_assembler(provider).process_tool_call(tc, sse)) event_text = "".join(events) # The intercepted args should have run_in_background=false assert "false" in event_text.lower() def test_task_tool_chunked_args_forces_background_false(self): """Chunked Task args are buffered until valid JSON, then forced to false.""" provider = _make_provider() from free_claude_code.core.anthropic import AnthropicStreamLedger sse = AnthropicStreamLedger("msg_test", "test-model") tc1 = { "index": 0, "id": "call_task_chunked", "function": {"name": "Task", "arguments": '{"run_in_background": true,'}, } tc2 = { "index": 0, "id": "call_task_chunked", "function": {"name": None, "arguments": ' "prompt": "test"}'}, } events1 = list(_make_tool_assembler(provider).process_tool_call(tc1, sse)) assert len(events1) > 0 assert "false" not in "".join(events1).lower() events2 = list(_make_tool_assembler(provider).process_tool_call(tc2, sse)) event_text = "".join(events1 + events2) assert "false" in event_text.lower() def test_task_tool_invalid_json_logs_warning_on_flush(self, caplog): """Invalid JSON args for Task tool emits {} on flush and logs a warning.""" provider = _make_provider() from free_claude_code.core.anthropic import AnthropicStreamLedger sse = AnthropicStreamLedger("msg_test", "test-model") tc = { "index": 0, "id": "call_task2", "function": {"name": "Task", "arguments": "not json"}, } events = list(_make_tool_assembler(provider).process_tool_call(tc, sse)) assert len(events) > 0 with caplog.at_level("WARNING"): flushed = list(_make_tool_assembler(provider).flush_task_arg_buffers(sse)) assert len(flushed) > 0 assert "{}" in "".join(flushed) assert any("Task args invalid JSON" in r.message for r in caplog.records) def test_negative_tool_index_fallback(self): """tc_index < 0 uses len(tool_indices) as fallback.""" provider = _make_provider() from free_claude_code.core.anthropic import AnthropicStreamLedger sse = AnthropicStreamLedger("msg_test", "test-model") tc = { "index": -1, "id": "call_neg", "function": {"name": "test", "arguments": "{}"}, } events = list(_make_tool_assembler(provider).process_tool_call(tc, sse)) # Should not crash, should still emit events assert len(events) > 0 def test_none_tool_index_defaults_to_zero(self): """Gemini may stream tool_call deltas with a null index.""" provider = _make_provider() from free_claude_code.core.anthropic import AnthropicStreamLedger sse = AnthropicStreamLedger("msg_test", "test-model") tc = { "index": None, "id": "call_none", "function": {"name": "test", "arguments": "{}"}, } events = list(_make_tool_assembler(provider).process_tool_call(tc, sse)) event_text = "".join(events) assert "tool_use" in event_text assert "call_none" in event_text def test_tool_args_emitted_as_delta(self): """Arguments are emitted as input_json_delta events.""" provider = _make_provider() from free_claude_code.core.anthropic import AnthropicStreamLedger sse = AnthropicStreamLedger("msg_test", "test-model") tc = { "index": 0, "id": "call_args", "function": {"name": "grep", "arguments": '{"pattern": "test"}'}, } events = list(_make_tool_assembler(provider).process_tool_call(tc, sse)) event_text = "".join(events) assert "input_json_delta" in event_text class TestStreamChunkEdgeCases: """Tests for edge cases in stream chunk handling.""" @pytest.mark.asyncio async def test_stream_chunk_with_empty_choices_skipped(self): """Chunk with choices=[] is skipped without crashing.""" provider = _make_provider() request = _make_request() empty_choices_chunk = MagicMock() empty_choices_chunk.choices = [] empty_choices_chunk.usage = None finish_chunk = _make_chunk(finish_reason="stop") stream_mock = AsyncStreamMock([empty_choices_chunk, finish_chunk]) with ( patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ), patch.object( provider._rate_limiter, "wait_if_blocked", new_callable=AsyncMock, return_value=False, ), ): events = await _collect_stream(provider, request) event_text = "".join(events) assert "message_start" in event_text assert "message_stop" in event_text @pytest.mark.asyncio async def test_stream_chunk_with_none_delta_handled(self): """Chunk with choice.delta=None is handled defensively.""" provider = _make_provider() request = _make_request() none_delta_chunk = MagicMock() none_delta_chunk.usage = None choice = MagicMock() choice.delta = None choice.finish_reason = None none_delta_chunk.choices = [choice] finish_chunk = _make_chunk(finish_reason="stop") stream_mock = AsyncStreamMock([none_delta_chunk, finish_chunk]) with ( patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ), patch.object( provider._rate_limiter, "wait_if_blocked", new_callable=AsyncMock, return_value=False, ), ): events = await _collect_stream(provider, request) event_text = "".join(events) assert "message_start" in event_text assert "message_stop" in event_text @pytest.mark.asyncio async def test_stream_generator_cleanup_on_exception(self): """When stream raises mid-iteration, message_stop still emitted.""" provider = _make_provider() request = _make_request() chunk1 = _make_chunk(content="Partial") stream_mock = AsyncStreamMock( [chunk1], error=ConnectionResetError("Connection reset") ) with ( patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ), patch.object( provider._rate_limiter, "wait_if_blocked", new_callable=AsyncMock, return_value=False, ), ): error = await _collect_stream_error(provider, request) assert "Connection reset" in error.message def test_stream_malformed_tool_args_chunked(self): """Chunked tool args that never form valid JSON are flushed with {}.""" provider = _make_provider() from free_claude_code.core.anthropic import AnthropicStreamLedger sse = AnthropicStreamLedger("msg_test", "test-model") tc1 = { "index": 0, "id": "call_malformed", "function": {"name": "Task", "arguments": '{"broken":'}, } tc2 = { "index": 0, "id": "call_malformed", "function": {"name": None, "arguments": " never valid }"}, } events1 = list(_make_tool_assembler(provider).process_tool_call(tc1, sse)) events2 = list(_make_tool_assembler(provider).process_tool_call(tc2, sse)) flushed = list(_make_tool_assembler(provider).flush_task_arg_buffers(sse)) event_text = "".join(events1 + events2 + flushed) assert "tool_use" in event_text assert "{}" in event_text @pytest.mark.asyncio async def test_openai_compat_stream_ends_with_contract_when_tool_name_never_arrives() -> ( None ): """Nameless / incomplete tool-call buffer must not break Anthropic stream contract.""" provider = _make_provider() request = _make_request() tc0 = SimpleNamespace( index=0, id="call_inc", function=SimpleNamespace(name=None, arguments="{}"), ) stream_mock = AsyncStreamMock([_make_chunk(tool_calls=[tc0])]) with ( patch.object( provider._client.chat.completions, "create", new_callable=AsyncMock, return_value=stream_mock, ), patch.object( provider._rate_limiter, "wait_if_blocked", new_callable=AsyncMock, return_value=False, ), ): error = await _collect_stream_error(provider, request) assert "Provider stream ended without finish_reason." in error.message