|
|
| |
|
|
| """ |
| Unit tests for streaming_core module. |
| |
| Tests for: |
| - KiroEvent dataclass |
| - StreamResult dataclass |
| - FirstTokenTimeoutError exception |
| - parse_kiro_stream() function |
| - collect_stream_to_result() function |
| - calculate_tokens_from_context_usage() function |
| """ |
|
|
| import pytest |
| import asyncio |
| from unittest.mock import AsyncMock, MagicMock, patch |
| from dataclasses import asdict |
|
|
| from kiro.streaming_core import ( |
| KiroEvent, |
| StreamResult, |
| FirstTokenTimeoutError, |
| parse_kiro_stream, |
| collect_stream_to_result, |
| calculate_tokens_from_context_usage, |
| stream_with_first_token_retry, |
| _process_chunk, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| @pytest.fixture |
| def mock_model_cache(): |
| """Mock for ModelInfoCache.""" |
| cache = MagicMock() |
| cache.get_max_input_tokens.return_value = 200000 |
| return cache |
|
|
|
|
| @pytest.fixture |
| def mock_response(): |
| """Mock for httpx.Response.""" |
| response = AsyncMock() |
| response.status_code = 200 |
| response.aclose = AsyncMock() |
| return response |
|
|
|
|
| @pytest.fixture |
| def mock_parser(): |
| """Mock for AwsEventStreamParser.""" |
| parser = MagicMock() |
| parser.feed.return_value = [] |
| parser.get_tool_calls.return_value = [] |
| return parser |
|
|
|
|
| |
| |
| |
|
|
| class TestKiroEvent: |
| """Tests for KiroEvent dataclass.""" |
| |
| def test_creates_content_event(self): |
| """ |
| What it does: Creates a content event with text. |
| Goal: Verify KiroEvent can represent content events. |
| """ |
| print("Action: Creating content event...") |
| event = KiroEvent(type="content", content="Hello, world!") |
| |
| print(f"Comparing type: Expected 'content', Got '{event.type}'") |
| assert event.type == "content" |
| print(f"Comparing content: Expected 'Hello, world!', Got '{event.content}'") |
| assert event.content == "Hello, world!" |
| assert event.thinking_content is None |
| assert event.tool_use is None |
| print("✓ Content event created correctly") |
| |
| def test_creates_thinking_event(self): |
| """ |
| What it does: Creates a thinking event with reasoning content. |
| Goal: Verify KiroEvent can represent thinking events. |
| """ |
| print("Action: Creating thinking event...") |
| event = KiroEvent( |
| type="thinking", |
| thinking_content="Let me think...", |
| is_first_thinking_chunk=True, |
| is_last_thinking_chunk=False |
| ) |
| |
| print(f"Comparing type: Expected 'thinking', Got '{event.type}'") |
| assert event.type == "thinking" |
| print(f"Comparing thinking_content: Expected 'Let me think...', Got '{event.thinking_content}'") |
| assert event.thinking_content == "Let me think..." |
| assert event.is_first_thinking_chunk is True |
| assert event.is_last_thinking_chunk is False |
| print("✓ Thinking event created correctly") |
| |
| def test_creates_tool_use_event(self): |
| """ |
| What it does: Creates a tool_use event with tool data. |
| Goal: Verify KiroEvent can represent tool use events. |
| """ |
| print("Action: Creating tool_use event...") |
| tool_data = { |
| "id": "call_123", |
| "type": "function", |
| "function": {"name": "get_weather", "arguments": '{"city": "Moscow"}'} |
| } |
| event = KiroEvent(type="tool_use", tool_use=tool_data) |
| |
| print(f"Comparing type: Expected 'tool_use', Got '{event.type}'") |
| assert event.type == "tool_use" |
| print(f"Comparing tool_use: Expected {tool_data}, Got {event.tool_use}") |
| assert event.tool_use == tool_data |
| print("✓ Tool use event created correctly") |
| |
| def test_creates_usage_event(self): |
| """ |
| What it does: Creates a usage event with metering data. |
| Goal: Verify KiroEvent can represent usage events. |
| """ |
| print("Action: Creating usage event...") |
| usage_data = {"credits": 0.001} |
| event = KiroEvent(type="usage", usage=usage_data) |
| |
| print(f"Comparing type: Expected 'usage', Got '{event.type}'") |
| assert event.type == "usage" |
| print(f"Comparing usage: Expected {usage_data}, Got {event.usage}") |
| assert event.usage == usage_data |
| print("✓ Usage event created correctly") |
| |
| def test_creates_context_usage_event(self): |
| """ |
| What it does: Creates a context_usage event with percentage. |
| Goal: Verify KiroEvent can represent context usage events. |
| """ |
| print("Action: Creating context_usage event...") |
| event = KiroEvent(type="context_usage", context_usage_percentage=5.5) |
| |
| print(f"Comparing type: Expected 'context_usage', Got '{event.type}'") |
| assert event.type == "context_usage" |
| print(f"Comparing context_usage_percentage: Expected 5.5, Got {event.context_usage_percentage}") |
| assert event.context_usage_percentage == 5.5 |
| print("✓ Context usage event created correctly") |
| |
| def test_default_values(self): |
| """ |
| What it does: Verifies default values for optional fields. |
| Goal: Ensure all optional fields default to None/False. |
| """ |
| print("Action: Creating minimal event...") |
| event = KiroEvent(type="content") |
| |
| print("Checking default values...") |
| assert event.content is None |
| assert event.thinking_content is None |
| assert event.tool_use is None |
| assert event.usage is None |
| assert event.context_usage_percentage is None |
| assert event.is_first_thinking_chunk is False |
| assert event.is_last_thinking_chunk is False |
| print("✓ All default values are correct") |
|
|
|
|
| |
| |
| |
|
|
| class TestStreamResult: |
| """Tests for StreamResult dataclass.""" |
| |
| def test_creates_empty_result(self): |
| """ |
| What it does: Creates an empty StreamResult. |
| Goal: Verify default values are correct. |
| """ |
| print("Action: Creating empty StreamResult...") |
| result = StreamResult() |
| |
| print("Checking default values...") |
| assert result.content == "" |
| assert result.thinking_content == "" |
| assert result.tool_calls == [] |
| assert result.usage is None |
| assert result.context_usage_percentage is None |
| print("✓ Empty StreamResult created correctly") |
| |
| def test_creates_result_with_content(self): |
| """ |
| What it does: Creates StreamResult with content. |
| Goal: Verify content is stored correctly. |
| """ |
| print("Action: Creating StreamResult with content...") |
| result = StreamResult(content="Hello, world!") |
| |
| print(f"Comparing content: Expected 'Hello, world!', Got '{result.content}'") |
| assert result.content == "Hello, world!" |
| print("✓ StreamResult with content created correctly") |
| |
| def test_creates_result_with_tool_calls(self): |
| """ |
| What it does: Creates StreamResult with tool calls. |
| Goal: Verify tool calls are stored correctly. |
| """ |
| print("Action: Creating StreamResult with tool calls...") |
| tool_calls = [ |
| {"id": "call_1", "function": {"name": "func1"}}, |
| {"id": "call_2", "function": {"name": "func2"}} |
| ] |
| result = StreamResult(tool_calls=tool_calls) |
| |
| print(f"Comparing tool_calls count: Expected 2, Got {len(result.tool_calls)}") |
| assert len(result.tool_calls) == 2 |
| assert result.tool_calls[0]["id"] == "call_1" |
| print("✓ StreamResult with tool calls created correctly") |
| |
| def test_creates_result_with_usage(self): |
| """ |
| What it does: Creates StreamResult with usage data. |
| Goal: Verify usage is stored correctly. |
| """ |
| print("Action: Creating StreamResult with usage...") |
| usage = {"credits": 0.002} |
| result = StreamResult(usage=usage) |
| |
| print(f"Comparing usage: Expected {usage}, Got {result.usage}") |
| assert result.usage == usage |
| print("✓ StreamResult with usage created correctly") |
| |
| def test_creates_full_result(self): |
| """ |
| What it does: Creates StreamResult with all fields. |
| Goal: Verify all fields work together. |
| """ |
| print("Action: Creating full StreamResult...") |
| result = StreamResult( |
| content="Response text", |
| thinking_content="Thinking...", |
| tool_calls=[{"id": "call_1"}], |
| usage={"credits": 0.001}, |
| context_usage_percentage=3.5 |
| ) |
| |
| print("Checking all fields...") |
| assert result.content == "Response text" |
| assert result.thinking_content == "Thinking..." |
| assert len(result.tool_calls) == 1 |
| assert result.usage == {"credits": 0.001} |
| assert result.context_usage_percentage == 3.5 |
| print("✓ Full StreamResult created correctly") |
|
|
|
|
| |
| |
| |
|
|
| class TestFirstTokenTimeoutError: |
| """Tests for FirstTokenTimeoutError exception.""" |
| |
| def test_creates_exception_with_message(self): |
| """ |
| What it does: Creates exception with custom message. |
| Goal: Verify exception message is stored correctly. |
| """ |
| print("Action: Creating FirstTokenTimeoutError...") |
| error = FirstTokenTimeoutError("No response within 30 seconds") |
| |
| print(f"Comparing message: Expected 'No response within 30 seconds', Got '{str(error)}'") |
| assert str(error) == "No response within 30 seconds" |
| print("✓ Exception created correctly") |
| |
| def test_exception_is_catchable(self): |
| """ |
| What it does: Verifies exception can be caught. |
| Goal: Ensure exception inherits from Exception. |
| """ |
| print("Action: Raising and catching FirstTokenTimeoutError...") |
| |
| with pytest.raises(FirstTokenTimeoutError) as exc_info: |
| raise FirstTokenTimeoutError("Timeout!") |
| |
| print(f"Caught exception: {exc_info.value}") |
| assert "Timeout!" in str(exc_info.value) |
| print("✓ Exception is catchable") |
| |
| def test_exception_inherits_from_exception(self): |
| """ |
| What it does: Verifies inheritance chain. |
| Goal: Ensure proper exception hierarchy. |
| """ |
| print("Action: Checking inheritance...") |
| error = FirstTokenTimeoutError("Test") |
| |
| assert isinstance(error, Exception) |
| print("✓ FirstTokenTimeoutError inherits from Exception") |
|
|
|
|
| |
| |
| |
|
|
| class TestParseKiroStream: |
| """Tests for parse_kiro_stream() function.""" |
| |
| @pytest.mark.asyncio |
| async def test_parses_content_events(self, mock_response, mock_parser): |
| """ |
| What it does: Parses content events from Kiro stream. |
| Goal: Verify content events are yielded correctly. |
| """ |
| print("Setup: Mock parser to return content events...") |
| mock_parser.feed.return_value = [ |
| {"type": "content", "data": "Hello"}, |
| {"type": "content", "data": " World"} |
| ] |
| |
| async def mock_aiter_bytes(): |
| yield b'chunk1' |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| |
| print("Action: Parsing stream...") |
| events = [] |
| |
| with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser): |
| with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False): |
| async for event in parse_kiro_stream(mock_response, first_token_timeout=30): |
| events.append(event) |
| |
| print(f"Received {len(events)} events") |
| content_events = [e for e in events if e.type == "content"] |
| print(f"Content events: {len(content_events)}") |
| |
| assert len(content_events) == 2 |
| assert content_events[0].content == "Hello" |
| assert content_events[1].content == " World" |
| print("✓ Content events parsed correctly") |
| |
| @pytest.mark.asyncio |
| async def test_parses_usage_events(self, mock_response, mock_parser): |
| """ |
| What it does: Parses usage events from Kiro stream. |
| Goal: Verify usage events are yielded correctly. |
| """ |
| print("Setup: Mock parser to return usage event...") |
| mock_parser.feed.return_value = [ |
| {"type": "usage", "data": {"credits": 0.001}} |
| ] |
| |
| async def mock_aiter_bytes(): |
| yield b'chunk1' |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| |
| print("Action: Parsing stream...") |
| events = [] |
| |
| with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser): |
| with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False): |
| async for event in parse_kiro_stream(mock_response, first_token_timeout=30): |
| events.append(event) |
| |
| print(f"Received {len(events)} events") |
| usage_events = [e for e in events if e.type == "usage"] |
| |
| assert len(usage_events) == 1 |
| assert usage_events[0].usage == {"credits": 0.001} |
| print("✓ Usage events parsed correctly") |
| |
| @pytest.mark.asyncio |
| async def test_parses_context_usage_events(self, mock_response, mock_parser): |
| """ |
| What it does: Parses context_usage events from Kiro stream. |
| Goal: Verify context usage percentage is yielded correctly. |
| """ |
| print("Setup: Mock parser to return context_usage event...") |
| mock_parser.feed.return_value = [ |
| {"type": "context_usage", "data": 5.5} |
| ] |
| |
| async def mock_aiter_bytes(): |
| yield b'chunk1' |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| |
| print("Action: Parsing stream...") |
| events = [] |
| |
| with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser): |
| with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False): |
| async for event in parse_kiro_stream(mock_response, first_token_timeout=30): |
| events.append(event) |
| |
| print(f"Received {len(events)} events") |
| context_events = [e for e in events if e.type == "context_usage"] |
| |
| assert len(context_events) == 1 |
| assert context_events[0].context_usage_percentage == 5.5 |
| print("✓ Context usage events parsed correctly") |
| |
| @pytest.mark.asyncio |
| async def test_yields_tool_calls_at_end(self, mock_response, mock_parser): |
| """ |
| What it does: Yields tool calls collected during parsing. |
| Goal: Verify tool calls are yielded as events. |
| """ |
| print("Setup: Mock parser with tool calls...") |
| mock_parser.feed.return_value = [{"type": "content", "data": "text"}] |
| mock_parser.get_tool_calls.return_value = [ |
| {"id": "call_1", "function": {"name": "func1", "arguments": "{}"}} |
| ] |
| |
| async def mock_aiter_bytes(): |
| yield b'chunk1' |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| |
| print("Action: Parsing stream...") |
| events = [] |
| |
| with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser): |
| with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False): |
| async for event in parse_kiro_stream(mock_response, first_token_timeout=30): |
| events.append(event) |
| |
| print(f"Received {len(events)} events") |
| tool_events = [e for e in events if e.type == "tool_use"] |
| |
| assert len(tool_events) == 1 |
| assert tool_events[0].tool_use["id"] == "call_1" |
| print("✓ Tool calls yielded correctly") |
| |
| @pytest.mark.asyncio |
| async def test_raises_timeout_on_first_token(self, mock_response): |
| """ |
| What it does: Raises FirstTokenTimeoutError on timeout. |
| Goal: Verify timeout handling for first token. |
| """ |
| print("Setup: Mock response that times out...") |
| |
| async def mock_aiter_bytes(): |
| yield b'chunk' |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| |
| async def mock_wait_for_timeout(*args, **kwargs): |
| raise asyncio.TimeoutError() |
| |
| print("Action: Parsing stream with timeout...") |
| |
| with patch('kiro.streaming_core.asyncio.wait_for', side_effect=mock_wait_for_timeout): |
| with pytest.raises(FirstTokenTimeoutError) as exc_info: |
| async for event in parse_kiro_stream(mock_response, first_token_timeout=30): |
| pass |
| |
| print(f"Caught exception: {exc_info.value}") |
| assert "30" in str(exc_info.value) |
| print("✓ FirstTokenTimeoutError raised on timeout") |
| |
| @pytest.mark.asyncio |
| async def test_handles_empty_response(self, mock_response): |
| """ |
| What it does: Handles empty response gracefully. |
| Goal: Verify no events yielded for empty response. |
| """ |
| print("Setup: Mock empty response...") |
| |
| async def mock_aiter_bytes(): |
| return |
| yield |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| |
| |
| async def mock_wait_for_empty(*args, **kwargs): |
| raise StopAsyncIteration() |
| |
| print("Action: Parsing empty stream...") |
| events = [] |
| |
| with patch('kiro.streaming_core.asyncio.wait_for', side_effect=mock_wait_for_empty): |
| async for event in parse_kiro_stream(mock_response, first_token_timeout=30): |
| events.append(event) |
| |
| print(f"Received {len(events)} events") |
| assert len(events) == 0 |
| print("✓ Empty response handled correctly") |
| |
| @pytest.mark.asyncio |
| async def test_handles_generator_exit(self, mock_response, mock_parser): |
| """ |
| What it does: Handles GeneratorExit gracefully. |
| Goal: Verify client disconnect is handled. |
| """ |
| print("Setup: Mock response that raises GeneratorExit...") |
| |
| async def mock_aiter_bytes(): |
| yield b'chunk1' |
| raise GeneratorExit() |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| mock_parser.feed.return_value = [{"type": "content", "data": "Hello"}] |
| |
| print("Action: Parsing stream with GeneratorExit...") |
| events = [] |
| generator_exit_raised = False |
| |
| with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser): |
| with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False): |
| try: |
| async for event in parse_kiro_stream(mock_response, first_token_timeout=30): |
| events.append(event) |
| except GeneratorExit: |
| generator_exit_raised = True |
| |
| print(f"GeneratorExit raised: {generator_exit_raised}") |
| assert generator_exit_raised |
| print("✓ GeneratorExit handled correctly") |
|
|
|
|
| |
| |
| |
|
|
| class TestProcessChunk: |
| """Tests for _process_chunk() helper function.""" |
| |
| @pytest.mark.asyncio |
| async def test_processes_content_event(self, mock_parser): |
| """ |
| What it does: Processes content event from chunk. |
| Goal: Verify content is converted to KiroEvent. |
| """ |
| print("Setup: Mock parser with content event...") |
| mock_parser.feed.return_value = [{"type": "content", "data": "Hello"}] |
| |
| print("Action: Processing chunk...") |
| events = [] |
| async for event in _process_chunk(mock_parser, b'chunk', None): |
| events.append(event) |
| |
| print(f"Received {len(events)} events") |
| assert len(events) == 1 |
| assert events[0].type == "content" |
| assert events[0].content == "Hello" |
| print("✓ Content event processed correctly") |
| |
| @pytest.mark.asyncio |
| async def test_processes_usage_event(self, mock_parser): |
| """ |
| What it does: Processes usage event from chunk. |
| Goal: Verify usage is converted to KiroEvent. |
| """ |
| print("Setup: Mock parser with usage event...") |
| mock_parser.feed.return_value = [{"type": "usage", "data": {"credits": 0.001}}] |
| |
| print("Action: Processing chunk...") |
| events = [] |
| async for event in _process_chunk(mock_parser, b'chunk', None): |
| events.append(event) |
| |
| print(f"Received {len(events)} events") |
| assert len(events) == 1 |
| assert events[0].type == "usage" |
| assert events[0].usage == {"credits": 0.001} |
| print("✓ Usage event processed correctly") |
| |
| @pytest.mark.asyncio |
| async def test_processes_context_usage_event(self, mock_parser): |
| """ |
| What it does: Processes context_usage event from chunk. |
| Goal: Verify context usage is converted to KiroEvent. |
| """ |
| print("Setup: Mock parser with context_usage event...") |
| mock_parser.feed.return_value = [{"type": "context_usage", "data": 7.5}] |
| |
| print("Action: Processing chunk...") |
| events = [] |
| async for event in _process_chunk(mock_parser, b'chunk', None): |
| events.append(event) |
| |
| print(f"Received {len(events)} events") |
| assert len(events) == 1 |
| assert events[0].type == "context_usage" |
| assert events[0].context_usage_percentage == 7.5 |
| print("✓ Context usage event processed correctly") |
| |
| @pytest.mark.asyncio |
| async def test_processes_multiple_events(self, mock_parser): |
| """ |
| What it does: Processes multiple events from single chunk. |
| Goal: Verify all events are yielded. |
| """ |
| print("Setup: Mock parser with multiple events...") |
| mock_parser.feed.return_value = [ |
| {"type": "content", "data": "Hello"}, |
| {"type": "content", "data": " World"}, |
| {"type": "usage", "data": {"credits": 0.001}} |
| ] |
| |
| print("Action: Processing chunk...") |
| events = [] |
| async for event in _process_chunk(mock_parser, b'chunk', None): |
| events.append(event) |
| |
| print(f"Received {len(events)} events") |
| assert len(events) == 3 |
| assert events[0].type == "content" |
| assert events[1].type == "content" |
| assert events[2].type == "usage" |
| print("✓ Multiple events processed correctly") |
| |
| @pytest.mark.asyncio |
| async def test_processes_with_thinking_parser(self, mock_parser): |
| """ |
| What it does: Processes content through thinking parser. |
| Goal: Verify thinking parser integration. |
| """ |
| print("Setup: Mock parser and thinking parser...") |
| mock_parser.feed.return_value = [{"type": "content", "data": "Hello"}] |
| |
| mock_thinking_parser = MagicMock() |
| mock_thinking_parser.feed.return_value = MagicMock( |
| thinking_content=None, |
| regular_content="Hello", |
| is_first_thinking_chunk=False, |
| is_last_thinking_chunk=False |
| ) |
| |
| print("Action: Processing chunk with thinking parser...") |
| events = [] |
| async for event in _process_chunk(mock_parser, b'chunk', mock_thinking_parser): |
| events.append(event) |
| |
| print(f"Received {len(events)} events") |
| assert len(events) == 1 |
| assert events[0].type == "content" |
| assert events[0].content == "Hello" |
| print("✓ Thinking parser integration works correctly") |
| |
| @pytest.mark.asyncio |
| async def test_yields_thinking_content(self, mock_parser): |
| """ |
| What it does: Yields thinking content from thinking parser. |
| Goal: Verify thinking events are created. |
| """ |
| print("Setup: Mock parser and thinking parser with thinking content...") |
| mock_parser.feed.return_value = [{"type": "content", "data": "<thinking>Let me think</thinking>"}] |
| |
| mock_thinking_parser = MagicMock() |
| mock_thinking_parser.feed.return_value = MagicMock( |
| thinking_content="Let me think", |
| regular_content=None, |
| is_first_thinking_chunk=True, |
| is_last_thinking_chunk=True |
| ) |
| mock_thinking_parser.process_for_output.return_value = "Let me think" |
| |
| print("Action: Processing chunk with thinking content...") |
| events = [] |
| async for event in _process_chunk(mock_parser, b'chunk', mock_thinking_parser): |
| events.append(event) |
| |
| print(f"Received {len(events)} events") |
| thinking_events = [e for e in events if e.type == "thinking"] |
| assert len(thinking_events) == 1 |
| assert thinking_events[0].thinking_content == "Let me think" |
| print("✓ Thinking content yielded correctly") |
|
|
|
|
| |
| |
| |
|
|
| class TestCollectStreamToResult: |
| """Tests for collect_stream_to_result() function.""" |
| |
| @pytest.mark.asyncio |
| async def test_collects_content(self, mock_response, mock_parser): |
| """ |
| What it does: Collects content from stream. |
| Goal: Verify content is accumulated correctly. |
| """ |
| print("Setup: Mock parser with content events...") |
| mock_parser.feed.return_value = [ |
| {"type": "content", "data": "Hello"}, |
| {"type": "content", "data": " World"} |
| ] |
| mock_parser.get_tool_calls.return_value = [] |
| |
| async def mock_aiter_bytes(): |
| yield b'chunk1' |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| |
| print("Action: Collecting stream...") |
| |
| with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser): |
| with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False): |
| with patch('kiro.streaming_core.parse_bracket_tool_calls', return_value=[]): |
| result = await collect_stream_to_result(mock_response, first_token_timeout=30) |
| |
| print(f"Collected content: '{result.content}'") |
| assert result.content == "Hello World" |
| print("✓ Content collected correctly") |
| |
| @pytest.mark.asyncio |
| async def test_collects_tool_calls(self, mock_response, mock_parser): |
| """ |
| What it does: Collects tool calls from stream. |
| Goal: Verify tool calls are accumulated correctly. |
| """ |
| print("Setup: Mock parser with tool calls...") |
| mock_parser.feed.return_value = [{"type": "content", "data": "text"}] |
| mock_parser.get_tool_calls.return_value = [ |
| {"id": "call_1", "function": {"name": "func1", "arguments": "{}"}} |
| ] |
| |
| async def mock_aiter_bytes(): |
| yield b'chunk1' |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| |
| print("Action: Collecting stream...") |
| |
| with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser): |
| with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False): |
| with patch('kiro.streaming_core.parse_bracket_tool_calls', return_value=[]): |
| result = await collect_stream_to_result(mock_response, first_token_timeout=30) |
| |
| print(f"Collected tool calls: {len(result.tool_calls)}") |
| assert len(result.tool_calls) == 1 |
| assert result.tool_calls[0]["id"] == "call_1" |
| print("✓ Tool calls collected correctly") |
| |
| @pytest.mark.asyncio |
| async def test_collects_usage(self, mock_response, mock_parser): |
| """ |
| What it does: Collects usage from stream. |
| Goal: Verify usage is stored correctly. |
| """ |
| print("Setup: Mock parser with usage event...") |
| mock_parser.feed.return_value = [ |
| {"type": "content", "data": "text"}, |
| {"type": "usage", "data": {"credits": 0.002}} |
| ] |
| mock_parser.get_tool_calls.return_value = [] |
| |
| async def mock_aiter_bytes(): |
| yield b'chunk1' |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| |
| print("Action: Collecting stream...") |
| |
| with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser): |
| with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False): |
| with patch('kiro.streaming_core.parse_bracket_tool_calls', return_value=[]): |
| result = await collect_stream_to_result(mock_response, first_token_timeout=30) |
| |
| print(f"Collected usage: {result.usage}") |
| assert result.usage == {"credits": 0.002} |
| print("✓ Usage collected correctly") |
| |
| @pytest.mark.asyncio |
| async def test_collects_context_usage_percentage(self, mock_response, mock_parser): |
| """ |
| What it does: Collects context usage percentage from stream. |
| Goal: Verify context usage is stored correctly. |
| """ |
| print("Setup: Mock parser with context_usage event...") |
| mock_parser.feed.return_value = [ |
| {"type": "content", "data": "text"}, |
| {"type": "context_usage", "data": 8.5} |
| ] |
| mock_parser.get_tool_calls.return_value = [] |
| |
| async def mock_aiter_bytes(): |
| yield b'chunk1' |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| |
| print("Action: Collecting stream...") |
| |
| with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser): |
| with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False): |
| with patch('kiro.streaming_core.parse_bracket_tool_calls', return_value=[]): |
| result = await collect_stream_to_result(mock_response, first_token_timeout=30) |
| |
| print(f"Collected context_usage_percentage: {result.context_usage_percentage}") |
| assert result.context_usage_percentage == 8.5 |
| print("✓ Context usage percentage collected correctly") |
| |
| @pytest.mark.asyncio |
| async def test_collects_thinking_content(self, mock_response, mock_parser): |
| """ |
| What it does: Collects thinking content from stream. |
| Goal: Verify thinking content is accumulated correctly. |
| """ |
| print("Setup: Mock parser with thinking content...") |
| |
| mock_parser.feed.return_value = [{"type": "content", "data": "thinking text"}] |
| mock_parser.get_tool_calls.return_value = [] |
| |
| async def mock_aiter_bytes(): |
| yield b'chunk1' |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| |
| |
| mock_events = [ |
| KiroEvent(type="thinking", thinking_content="Let me think..."), |
| KiroEvent(type="content", content="Here is my answer") |
| ] |
| |
| async def mock_parse_kiro_stream(*args, **kwargs): |
| for event in mock_events: |
| yield event |
| |
| print("Action: Collecting stream with thinking...") |
| |
| with patch('kiro.streaming_core.parse_kiro_stream', mock_parse_kiro_stream): |
| with patch('kiro.streaming_core.parse_bracket_tool_calls', return_value=[]): |
| result = await collect_stream_to_result(mock_response, first_token_timeout=30) |
| |
| print(f"Collected thinking_content: '{result.thinking_content}'") |
| print(f"Collected content: '{result.content}'") |
| assert result.thinking_content == "Let me think..." |
| assert result.content == "Here is my answer" |
| print("✓ Thinking content collected correctly") |
| |
| @pytest.mark.asyncio |
| async def test_deduplicates_bracket_tool_calls(self, mock_response, mock_parser): |
| """ |
| What it does: Deduplicates bracket-style tool calls. |
| Goal: Verify duplicate tool calls are removed. |
| """ |
| print("Setup: Mock parser with tool calls and bracket tool calls...") |
| mock_parser.feed.return_value = [{"type": "content", "data": "text"}] |
| mock_parser.get_tool_calls.return_value = [ |
| {"id": "call_1", "function": {"name": "func1", "arguments": "{}"}} |
| ] |
| |
| bracket_tool_calls = [ |
| {"id": "call_1", "function": {"name": "func1", "arguments": "{}"}}, |
| {"id": "call_2", "function": {"name": "func2", "arguments": "{}"}} |
| ] |
| |
| async def mock_aiter_bytes(): |
| yield b'chunk1' |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| |
| print("Action: Collecting stream with duplicates...") |
| |
| with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser): |
| with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False): |
| with patch('kiro.streaming_core.parse_bracket_tool_calls', return_value=bracket_tool_calls): |
| with patch('kiro.streaming_core.deduplicate_tool_calls') as mock_dedup: |
| mock_dedup.return_value = [ |
| {"id": "call_1", "function": {"name": "func1", "arguments": "{}"}}, |
| {"id": "call_2", "function": {"name": "func2", "arguments": "{}"}} |
| ] |
| result = await collect_stream_to_result(mock_response, first_token_timeout=30) |
| |
| print(f"Collected tool calls: {len(result.tool_calls)}") |
| assert len(result.tool_calls) == 2 |
| print("✓ Tool calls deduplicated correctly") |
|
|
|
|
| |
| |
| |
|
|
| class TestCalculateTokensFromContextUsage: |
| """Tests for calculate_tokens_from_context_usage() function.""" |
| |
| def test_calculates_tokens_from_percentage(self, mock_model_cache): |
| """ |
| What it does: Calculates tokens from context usage percentage. |
| Goal: Verify token calculation is correct. |
| """ |
| print("Setup: Context usage 10% with 200000 max tokens...") |
| context_usage_percentage = 10.0 |
| completion_tokens = 100 |
| |
| print("Action: Calculating tokens...") |
| prompt_tokens, total_tokens, prompt_source, total_source = calculate_tokens_from_context_usage( |
| context_usage_percentage, completion_tokens, mock_model_cache, "claude-sonnet-4" |
| ) |
| |
| |
| |
| print(f"Comparing total_tokens: Expected 20000, Got {total_tokens}") |
| assert total_tokens == 20000 |
| print(f"Comparing prompt_tokens: Expected 19900, Got {prompt_tokens}") |
| assert prompt_tokens == 19900 |
| assert prompt_source == "subtraction" |
| assert total_source == "API Kiro" |
| print("✓ Tokens calculated correctly") |
| |
| def test_handles_zero_percentage(self, mock_model_cache): |
| """ |
| What it does: Handles zero context usage percentage. |
| Goal: Verify fallback behavior for zero percentage. |
| """ |
| print("Setup: Context usage 0%...") |
| context_usage_percentage = 0.0 |
| completion_tokens = 100 |
| |
| print("Action: Calculating tokens...") |
| prompt_tokens, total_tokens, prompt_source, total_source = calculate_tokens_from_context_usage( |
| context_usage_percentage, completion_tokens, mock_model_cache, "claude-sonnet-4" |
| ) |
| |
| print(f"Comparing prompt_tokens: Expected 0, Got {prompt_tokens}") |
| assert prompt_tokens == 0 |
| print(f"Comparing total_tokens: Expected 100, Got {total_tokens}") |
| assert total_tokens == 100 |
| assert prompt_source == "unknown" |
| assert total_source == "tiktoken" |
| print("✓ Zero percentage handled correctly") |
| |
| def test_handles_none_percentage(self, mock_model_cache): |
| """ |
| What it does: Handles None context usage percentage. |
| Goal: Verify fallback behavior for None percentage. |
| """ |
| print("Setup: Context usage None...") |
| context_usage_percentage = None |
| completion_tokens = 100 |
| |
| print("Action: Calculating tokens...") |
| prompt_tokens, total_tokens, prompt_source, total_source = calculate_tokens_from_context_usage( |
| context_usage_percentage, completion_tokens, mock_model_cache, "claude-sonnet-4" |
| ) |
| |
| print(f"Comparing prompt_tokens: Expected 0, Got {prompt_tokens}") |
| assert prompt_tokens == 0 |
| print(f"Comparing total_tokens: Expected 100, Got {total_tokens}") |
| assert total_tokens == 100 |
| assert prompt_source == "unknown" |
| assert total_source == "tiktoken" |
| print("✓ None percentage handled correctly") |
| |
| def test_prevents_negative_prompt_tokens(self, mock_model_cache): |
| """ |
| What it does: Prevents negative prompt tokens. |
| Goal: Verify prompt_tokens is never negative. |
| """ |
| print("Setup: Very small context usage with large completion...") |
| context_usage_percentage = 0.01 |
| completion_tokens = 100 |
| |
| print("Action: Calculating tokens...") |
| prompt_tokens, total_tokens, prompt_source, total_source = calculate_tokens_from_context_usage( |
| context_usage_percentage, completion_tokens, mock_model_cache, "claude-sonnet-4" |
| ) |
| |
| print(f"Comparing prompt_tokens: Expected >= 0, Got {prompt_tokens}") |
| assert prompt_tokens >= 0 |
| print("✓ Negative prompt tokens prevented") |
| |
| def test_uses_model_specific_max_tokens(self, mock_model_cache): |
| """ |
| What it does: Uses model-specific max input tokens. |
| Goal: Verify model cache is queried correctly. |
| """ |
| print("Setup: Different max tokens for model...") |
| mock_model_cache.get_max_input_tokens.return_value = 100000 |
| context_usage_percentage = 10.0 |
| completion_tokens = 100 |
| |
| print("Action: Calculating tokens...") |
| prompt_tokens, total_tokens, prompt_source, total_source = calculate_tokens_from_context_usage( |
| context_usage_percentage, completion_tokens, mock_model_cache, "claude-haiku-3" |
| ) |
| |
| |
| print(f"Comparing total_tokens: Expected 10000, Got {total_tokens}") |
| assert total_tokens == 10000 |
| |
| |
| mock_model_cache.get_max_input_tokens.assert_called_with("claude-haiku-3") |
| print("✓ Model-specific max tokens used correctly") |
| |
| def test_small_percentage_calculation(self, mock_model_cache): |
| """ |
| What it does: Calculates tokens for small percentage. |
| Goal: Verify precision for small percentages. |
| """ |
| print("Setup: Context usage 0.5%...") |
| context_usage_percentage = 0.5 |
| completion_tokens = 50 |
| |
| print("Action: Calculating tokens...") |
| prompt_tokens, total_tokens, prompt_source, total_source = calculate_tokens_from_context_usage( |
| context_usage_percentage, completion_tokens, mock_model_cache, "claude-sonnet-4" |
| ) |
| |
| |
| |
| print(f"Comparing total_tokens: Expected 1000, Got {total_tokens}") |
| assert total_tokens == 1000 |
| print(f"Comparing prompt_tokens: Expected 950, Got {prompt_tokens}") |
| assert prompt_tokens == 950 |
| print("✓ Small percentage calculated correctly") |
| |
| def test_large_percentage_calculation(self, mock_model_cache): |
| """ |
| What it does: Calculates tokens for large percentage. |
| Goal: Verify calculation for high context usage. |
| """ |
| print("Setup: Context usage 95%...") |
| context_usage_percentage = 95.0 |
| completion_tokens = 1000 |
| |
| print("Action: Calculating tokens...") |
| prompt_tokens, total_tokens, prompt_source, total_source = calculate_tokens_from_context_usage( |
| context_usage_percentage, completion_tokens, mock_model_cache, "claude-sonnet-4" |
| ) |
| |
| |
| |
| print(f"Comparing total_tokens: Expected 190000, Got {total_tokens}") |
| assert total_tokens == 190000 |
| print(f"Comparing prompt_tokens: Expected 189000, Got {prompt_tokens}") |
| assert prompt_tokens == 189000 |
| print("✓ Large percentage calculated correctly") |
|
|
|
|
| |
| |
| |
|
|
| class TestThinkingParserIntegration: |
| """Tests for thinking parser integration in streaming.""" |
| |
| @pytest.mark.asyncio |
| async def test_thinking_parser_enabled_when_fake_reasoning_on(self, mock_response, mock_parser): |
| """ |
| What it does: Enables thinking parser when FAKE_REASONING_ENABLED is True. |
| Goal: Verify thinking parser is created. |
| """ |
| print("Setup: Enable fake reasoning...") |
| mock_parser.feed.return_value = [{"type": "content", "data": "Hello"}] |
| mock_parser.get_tool_calls.return_value = [] |
| |
| async def mock_aiter_bytes(): |
| yield b'chunk1' |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| |
| print("Action: Parsing stream with fake reasoning enabled...") |
| events = [] |
| |
| with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser): |
| with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', True): |
| with patch('kiro.streaming_core.ThinkingParser') as mock_thinking_parser_class: |
| mock_thinking_parser = MagicMock() |
| mock_thinking_parser.feed.return_value = MagicMock( |
| thinking_content=None, |
| regular_content="Hello", |
| is_first_thinking_chunk=False, |
| is_last_thinking_chunk=False |
| ) |
| mock_thinking_parser.finalize.return_value = MagicMock( |
| thinking_content=None, |
| regular_content=None, |
| is_first_thinking_chunk=False, |
| is_last_thinking_chunk=False |
| ) |
| mock_thinking_parser.found_thinking_block = False |
| mock_thinking_parser_class.return_value = mock_thinking_parser |
| |
| async for event in parse_kiro_stream(mock_response, first_token_timeout=30): |
| events.append(event) |
| |
| |
| mock_thinking_parser_class.assert_called_once() |
| |
| print("✓ Thinking parser enabled when fake reasoning is on") |
| |
| @pytest.mark.asyncio |
| async def test_thinking_parser_disabled_when_fake_reasoning_off(self, mock_response, mock_parser): |
| """ |
| What it does: Disables thinking parser when FAKE_REASONING_ENABLED is False. |
| Goal: Verify thinking parser is not created. |
| """ |
| print("Setup: Disable fake reasoning...") |
| mock_parser.feed.return_value = [{"type": "content", "data": "Hello"}] |
| mock_parser.get_tool_calls.return_value = [] |
| |
| async def mock_aiter_bytes(): |
| yield b'chunk1' |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| |
| print("Action: Parsing stream with fake reasoning disabled...") |
| events = [] |
| |
| with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser): |
| with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False): |
| with patch('kiro.streaming_core.ThinkingParser') as mock_thinking_parser_class: |
| async for event in parse_kiro_stream(mock_response, first_token_timeout=30): |
| events.append(event) |
| |
| |
| mock_thinking_parser_class.assert_not_called() |
| |
| print("✓ Thinking parser disabled when fake reasoning is off") |
| |
| @pytest.mark.asyncio |
| async def test_thinking_parser_can_be_disabled_via_parameter(self, mock_response, mock_parser): |
| """ |
| What it does: Disables thinking parser via enable_thinking_parser parameter. |
| Goal: Verify parameter overrides config. |
| """ |
| print("Setup: Enable fake reasoning but disable via parameter...") |
| mock_parser.feed.return_value = [{"type": "content", "data": "Hello"}] |
| mock_parser.get_tool_calls.return_value = [] |
| |
| async def mock_aiter_bytes(): |
| yield b'chunk1' |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| |
| print("Action: Parsing stream with thinking parser disabled via parameter...") |
| events = [] |
| |
| with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser): |
| with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', True): |
| with patch('kiro.streaming_core.ThinkingParser') as mock_thinking_parser_class: |
| async for event in parse_kiro_stream( |
| mock_response, |
| first_token_timeout=30, |
| enable_thinking_parser=False |
| ): |
| events.append(event) |
| |
| |
| mock_thinking_parser_class.assert_not_called() |
| |
| print("✓ Thinking parser disabled via parameter") |
|
|
|
|
| |
| |
| |
|
|
| class TestStreamingCoreErrorHandling: |
| """Tests for error handling in streaming_core.""" |
| |
| @pytest.mark.asyncio |
| async def test_propagates_first_token_timeout_error(self, mock_response): |
| """ |
| What it does: Propagates FirstTokenTimeoutError. |
| Goal: Verify timeout error is not caught internally. |
| """ |
| print("Setup: Mock response that times out...") |
| |
| async def mock_aiter_bytes(): |
| yield b'chunk' |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| |
| async def mock_wait_for_timeout(*args, **kwargs): |
| raise asyncio.TimeoutError() |
| |
| print("Action: Parsing stream with timeout...") |
| |
| with patch('kiro.streaming_core.asyncio.wait_for', side_effect=mock_wait_for_timeout): |
| with pytest.raises(FirstTokenTimeoutError): |
| async for event in parse_kiro_stream(mock_response, first_token_timeout=30): |
| pass |
| |
| print("✓ FirstTokenTimeoutError propagated correctly") |
| |
| @pytest.mark.asyncio |
| async def test_propagates_generator_exit(self, mock_response, mock_parser): |
| """ |
| What it does: Propagates GeneratorExit. |
| Goal: Verify client disconnect is handled. |
| """ |
| print("Setup: Mock response that raises GeneratorExit...") |
| |
| async def mock_aiter_bytes(): |
| yield b'chunk1' |
| raise GeneratorExit() |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| mock_parser.feed.return_value = [{"type": "content", "data": "Hello"}] |
| |
| print("Action: Parsing stream with GeneratorExit...") |
| |
| with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser): |
| with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False): |
| with pytest.raises(GeneratorExit): |
| async for event in parse_kiro_stream(mock_response, first_token_timeout=30): |
| pass |
| |
| print("✓ GeneratorExit propagated correctly") |
| |
| @pytest.mark.asyncio |
| async def test_propagates_other_exceptions(self, mock_response, mock_parser): |
| """ |
| What it does: Propagates other exceptions. |
| Goal: Verify errors are not swallowed. |
| """ |
| print("Setup: Mock response that raises RuntimeError...") |
| |
| async def mock_aiter_bytes(): |
| yield b'chunk1' |
| raise RuntimeError("Test error") |
| |
| mock_response.aiter_bytes = mock_aiter_bytes |
| mock_parser.feed.return_value = [{"type": "content", "data": "Hello"}] |
| |
| print("Action: Parsing stream with RuntimeError...") |
| |
| with patch('kiro.streaming_core.AwsEventStreamParser', return_value=mock_parser): |
| with patch('kiro.streaming_core.FAKE_REASONING_ENABLED', False): |
| with pytest.raises(RuntimeError) as exc_info: |
| async for event in parse_kiro_stream(mock_response, first_token_timeout=30): |
| pass |
| |
| print(f"Caught exception: {exc_info.value}") |
| assert "Test error" in str(exc_info.value) |
| print("✓ RuntimeError propagated correctly") |
|
|
|
|
| |
| |
| |
|
|
| class TestStreamWithFirstTokenRetryCore: |
| """ |
| Tests for stream_with_first_token_retry() generic function. |
| |
| This function provides automatic retry logic on first token timeout. |
| It is used by both OpenAI and Anthropic streaming implementations. |
| """ |
| |
| @pytest.mark.asyncio |
| async def test_yields_chunks_on_success(self): |
| """ |
| What it does: Yields chunks on successful streaming. |
| Goal: Verify normal operation without retries. |
| """ |
| print("Setup: Mock successful request...") |
| |
| mock_response = AsyncMock() |
| mock_response.status_code = 200 |
| mock_response.aclose = AsyncMock() |
| |
| async def mock_make_request(): |
| return mock_response |
| |
| async def mock_stream_processor(response): |
| yield "chunk1" |
| yield "chunk2" |
| yield "chunk3" |
| |
| print("Action: Streaming with retry wrapper...") |
| chunks = [] |
| |
| async for chunk in stream_with_first_token_retry( |
| make_request=mock_make_request, |
| stream_processor=mock_stream_processor, |
| max_retries=3, |
| first_token_timeout=30 |
| ): |
| chunks.append(chunk) |
| |
| print(f"Received {len(chunks)} chunks") |
| assert len(chunks) == 3 |
| assert chunks == ["chunk1", "chunk2", "chunk3"] |
| print("✓ Chunks yielded on success") |
| |
| @pytest.mark.asyncio |
| async def test_retries_on_first_token_timeout(self): |
| """ |
| What it does: Retries on first token timeout. |
| Goal: Verify retry logic is triggered. |
| """ |
| print("Setup: Mock request that times out then succeeds...") |
| |
| call_count = 0 |
| |
| async def mock_make_request(): |
| nonlocal call_count |
| call_count += 1 |
| response = AsyncMock() |
| response.status_code = 200 |
| response.aclose = AsyncMock() |
| return response |
| |
| async def mock_stream_processor(response): |
| nonlocal call_count |
| if call_count == 1: |
| raise FirstTokenTimeoutError("Timeout on first attempt") |
| yield "success_chunk" |
| |
| print("Action: Streaming with retry on timeout...") |
| chunks = [] |
| |
| async for chunk in stream_with_first_token_retry( |
| make_request=mock_make_request, |
| stream_processor=mock_stream_processor, |
| max_retries=3, |
| first_token_timeout=30 |
| ): |
| chunks.append(chunk) |
| |
| print(f"Call count: {call_count}") |
| print(f"Received {len(chunks)} chunks") |
| |
| assert call_count == 2 |
| assert len(chunks) == 1 |
| assert chunks[0] == "success_chunk" |
| print("✓ Retry on timeout works correctly") |
| |
| @pytest.mark.asyncio |
| async def test_raises_exception_after_all_retries(self): |
| """ |
| What it does: Raises exception after all retries exhausted. |
| Goal: Verify error handling when all retries fail. |
| """ |
| print("Setup: Mock request that always times out...") |
| |
| call_count = 0 |
| |
| async def mock_make_request(): |
| nonlocal call_count |
| call_count += 1 |
| response = AsyncMock() |
| response.status_code = 200 |
| response.aclose = AsyncMock() |
| return response |
| |
| async def mock_stream_processor(response): |
| raise FirstTokenTimeoutError("Timeout!") |
| yield |
| |
| print("Action: Streaming with all retries failing...") |
| |
| with pytest.raises(Exception) as exc_info: |
| async for chunk in stream_with_first_token_retry( |
| make_request=mock_make_request, |
| stream_processor=mock_stream_processor, |
| max_retries=3, |
| first_token_timeout=30 |
| ): |
| pass |
| |
| print(f"Call count: {call_count}") |
| print(f"Exception: {exc_info.value}") |
| |
| assert call_count == 3 |
| assert "30" in str(exc_info.value) |
| assert "3" in str(exc_info.value) |
| print("✓ Exception raised after all retries") |
| |
| @pytest.mark.asyncio |
| async def test_uses_custom_error_callbacks(self): |
| """ |
| What it does: Uses custom error callbacks. |
| Goal: Verify on_http_error and on_all_retries_failed callbacks. |
| """ |
| print("Setup: Mock request that always times out with custom callbacks...") |
| |
| async def mock_make_request(): |
| response = AsyncMock() |
| response.status_code = 200 |
| response.aclose = AsyncMock() |
| return response |
| |
| async def mock_stream_processor(response): |
| raise FirstTokenTimeoutError("Timeout!") |
| yield |
| |
| def custom_all_retries_failed(max_retries, timeout): |
| return ValueError(f"Custom error: {max_retries} retries, {timeout}s timeout") |
| |
| print("Action: Streaming with custom callback...") |
| |
| with pytest.raises(ValueError) as exc_info: |
| async for chunk in stream_with_first_token_retry( |
| make_request=mock_make_request, |
| stream_processor=mock_stream_processor, |
| max_retries=2, |
| first_token_timeout=15, |
| on_all_retries_failed=custom_all_retries_failed |
| ): |
| pass |
| |
| print(f"Exception: {exc_info.value}") |
| assert "Custom error" in str(exc_info.value) |
| assert "2 retries" in str(exc_info.value) |
| assert "15" in str(exc_info.value) |
| print("✓ Custom callback used correctly") |
| |
| @pytest.mark.asyncio |
| async def test_handles_http_error(self): |
| """ |
| What it does: Handles HTTP error from API. |
| Goal: Verify HTTP errors are handled correctly. |
| """ |
| print("Setup: Mock request that returns HTTP error...") |
| |
| async def mock_make_request(): |
| response = AsyncMock() |
| response.status_code = 500 |
| response.aread = AsyncMock(return_value=b"Internal Server Error") |
| response.aclose = AsyncMock() |
| return response |
| |
| async def mock_stream_processor(response): |
| yield "should not reach" |
| |
| print("Action: Streaming with HTTP error...") |
| |
| with pytest.raises(Exception) as exc_info: |
| async for chunk in stream_with_first_token_retry( |
| make_request=mock_make_request, |
| stream_processor=mock_stream_processor, |
| max_retries=3, |
| first_token_timeout=30 |
| ): |
| pass |
| |
| print(f"Exception: {exc_info.value}") |
| assert "500" in str(exc_info.value) |
| assert "Internal Server Error" in str(exc_info.value) |
| print("✓ HTTP error handled correctly") |
| |
| @pytest.mark.asyncio |
| async def test_uses_custom_http_error_callback(self): |
| """ |
| What it does: Uses custom HTTP error callback. |
| Goal: Verify on_http_error callback is used. |
| """ |
| print("Setup: Mock request with custom HTTP error callback...") |
| |
| async def mock_make_request(): |
| response = AsyncMock() |
| response.status_code = 429 |
| response.aread = AsyncMock(return_value=b"Rate limited") |
| response.aclose = AsyncMock() |
| return response |
| |
| async def mock_stream_processor(response): |
| yield "should not reach" |
| |
| def custom_http_error(status_code, error_text): |
| return RuntimeError(f"Custom HTTP error: {status_code} - {error_text}") |
| |
| print("Action: Streaming with custom HTTP error callback...") |
| |
| with pytest.raises(RuntimeError) as exc_info: |
| async for chunk in stream_with_first_token_retry( |
| make_request=mock_make_request, |
| stream_processor=mock_stream_processor, |
| max_retries=3, |
| first_token_timeout=30, |
| on_http_error=custom_http_error |
| ): |
| pass |
| |
| print(f"Exception: {exc_info.value}") |
| assert "Custom HTTP error" in str(exc_info.value) |
| assert "429" in str(exc_info.value) |
| assert "Rate limited" in str(exc_info.value) |
| print("✓ Custom HTTP error callback used correctly") |
| |
| @pytest.mark.asyncio |
| async def test_closes_response_on_timeout(self): |
| """ |
| What it does: Closes response on timeout. |
| Goal: Verify response is properly closed after timeout. |
| """ |
| print("Setup: Mock request that times out...") |
| |
| responses = [] |
| |
| async def mock_make_request(): |
| response = AsyncMock() |
| response.status_code = 200 |
| response.aclose = AsyncMock() |
| responses.append(response) |
| return response |
| |
| async def mock_stream_processor(response): |
| raise FirstTokenTimeoutError("Timeout!") |
| yield |
| |
| print("Action: Streaming with timeout...") |
| |
| try: |
| async for chunk in stream_with_first_token_retry( |
| make_request=mock_make_request, |
| stream_processor=mock_stream_processor, |
| max_retries=2, |
| first_token_timeout=30 |
| ): |
| pass |
| except Exception: |
| pass |
| |
| print(f"Created {len(responses)} responses") |
| |
| |
| for i, response in enumerate(responses): |
| print(f"Response {i} aclose called: {response.aclose.called}") |
| response.aclose.assert_called() |
| |
| print("✓ Responses closed on timeout") |
| |
| @pytest.mark.asyncio |
| async def test_propagates_non_timeout_exceptions(self): |
| """ |
| What it does: Propagates non-timeout exceptions without retry. |
| Goal: Verify other exceptions are not retried. |
| """ |
| print("Setup: Mock request that raises RuntimeError...") |
| |
| call_count = 0 |
| |
| async def mock_make_request(): |
| nonlocal call_count |
| call_count += 1 |
| response = AsyncMock() |
| response.status_code = 200 |
| response.aclose = AsyncMock() |
| return response |
| |
| async def mock_stream_processor(response): |
| raise RuntimeError("Not a timeout error") |
| yield |
| |
| print("Action: Streaming with non-timeout error...") |
| |
| with pytest.raises(RuntimeError) as exc_info: |
| async for chunk in stream_with_first_token_retry( |
| make_request=mock_make_request, |
| stream_processor=mock_stream_processor, |
| max_retries=3, |
| first_token_timeout=30 |
| ): |
| pass |
| |
| print(f"Call count: {call_count}") |
| print(f"Exception: {exc_info.value}") |
| |
| assert call_count == 1 |
| assert "Not a timeout error" in str(exc_info.value) |
| print("✓ Non-timeout exceptions propagated without retry") |
| |
| @pytest.mark.asyncio |
| async def test_uses_configured_max_retries(self): |
| """ |
| What it does: Uses configured max_retries value. |
| Goal: Verify max_retries parameter is respected. |
| """ |
| print("Setup: Mock request that always times out...") |
| |
| call_count = 0 |
| |
| async def mock_make_request(): |
| nonlocal call_count |
| call_count += 1 |
| response = AsyncMock() |
| response.status_code = 200 |
| response.aclose = AsyncMock() |
| return response |
| |
| async def mock_stream_processor(response): |
| raise FirstTokenTimeoutError("Timeout!") |
| yield |
| |
| print("Action: Streaming with max_retries=5...") |
| |
| try: |
| async for chunk in stream_with_first_token_retry( |
| make_request=mock_make_request, |
| stream_processor=mock_stream_processor, |
| max_retries=5, |
| first_token_timeout=30 |
| ): |
| pass |
| except Exception: |
| pass |
| |
| print(f"Call count: {call_count}") |
| assert call_count == 5 |
| print("✓ max_retries parameter respected") |
| |
| @pytest.mark.asyncio |
| async def test_multiple_retries_then_success(self): |
| """ |
| What it does: Succeeds after multiple retries. |
| Goal: Verify recovery after multiple failures. |
| """ |
| print("Setup: Mock request that fails twice then succeeds...") |
| |
| call_count = 0 |
| |
| async def mock_make_request(): |
| nonlocal call_count |
| call_count += 1 |
| response = AsyncMock() |
| response.status_code = 200 |
| response.aclose = AsyncMock() |
| return response |
| |
| async def mock_stream_processor(response): |
| nonlocal call_count |
| if call_count < 3: |
| raise FirstTokenTimeoutError(f"Timeout on attempt {call_count}") |
| yield "finally_success" |
| |
| print("Action: Streaming with multiple retries...") |
| chunks = [] |
| |
| async for chunk in stream_with_first_token_retry( |
| make_request=mock_make_request, |
| stream_processor=mock_stream_processor, |
| max_retries=5, |
| first_token_timeout=30 |
| ): |
| chunks.append(chunk) |
| |
| print(f"Call count: {call_count}") |
| print(f"Received {len(chunks)} chunks") |
| |
| assert call_count == 3 |
| assert len(chunks) == 1 |
| assert chunks[0] == "finally_success" |
| print("✓ Multiple retries then success works correctly") |
| |
| @pytest.mark.asyncio |
| async def test_closes_response_on_http_error(self): |
| """ |
| What it does: Closes response on HTTP error. |
| Goal: Verify response is properly closed after HTTP error. |
| """ |
| print("Setup: Mock request that returns HTTP error...") |
| |
| response = AsyncMock() |
| response.status_code = 503 |
| response.aread = AsyncMock(return_value=b"Service Unavailable") |
| response.aclose = AsyncMock() |
| |
| async def mock_make_request(): |
| return response |
| |
| async def mock_stream_processor(resp): |
| yield "should not reach" |
| |
| print("Action: Streaming with HTTP error...") |
| |
| try: |
| async for chunk in stream_with_first_token_retry( |
| make_request=mock_make_request, |
| stream_processor=mock_stream_processor, |
| max_retries=3, |
| first_token_timeout=30 |
| ): |
| pass |
| except Exception: |
| pass |
| |
| print(f"Response aclose called: {response.aclose.called}") |
| response.aclose.assert_called() |
| print("✓ Response closed on HTTP error") |