API / kiro-gateway /tests /unit /test_streaming_openai.py
sshinmen's picture
Clean deploy to HF Space
bf9e111
Raw
History Blame Contribute Delete
57.2 kB
# -*- coding: utf-8 -*-
"""
Unit tests for streaming_openai module.
Tests for:
- stream_kiro_to_openai() generator
- stream_kiro_to_openai_internal() generator
- stream_with_first_token_retry() function
- collect_stream_response() function
"""
import pytest
import json
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
from kiro.streaming_openai import (
stream_kiro_to_openai,
stream_kiro_to_openai_internal,
stream_with_first_token_retry,
collect_stream_response,
FirstTokenTimeoutError,
)
from kiro.streaming_core import KiroEvent
# ==================================================================================================
# Fixtures
# ==================================================================================================
@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_auth_manager():
"""Mock for KiroAuthManager."""
manager = MagicMock()
return manager
@pytest.fixture
def mock_http_client():
"""Mock for httpx.AsyncClient."""
client = AsyncMock()
return client
@pytest.fixture
def mock_response():
"""Mock for httpx.Response."""
response = AsyncMock()
response.status_code = 200
response.aclose = AsyncMock()
return response
# ==================================================================================================
# Tests for stream_kiro_to_openai()
# ==================================================================================================
class TestStreamKiroToOpenai:
"""Tests for stream_kiro_to_openai() generator."""
@pytest.mark.asyncio
async def test_yields_content_chunks(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Yields content chunks in OpenAI format.
Goal: Verify content streaming.
"""
print("Setup: Mock stream with content events...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="Hello")
yield KiroEvent(type="content", content=" World")
print("Action: Streaming to OpenAI format...")
chunks = []
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
chunks.append(chunk)
print(f"Received {len(chunks)} chunks")
# Should have content chunks
content_chunks = [c for c in chunks if "content" in c and '"Hello"' in c or '" World"' in c]
assert len(content_chunks) >= 2
print("✓ Content chunks yielded correctly")
@pytest.mark.asyncio
async def test_first_chunk_has_role(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: First chunk includes role: assistant.
Goal: Verify OpenAI streaming protocol.
"""
print("Setup: Mock stream with content...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="Hello")
print("Action: Streaming to OpenAI format...")
chunks = []
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
chunks.append(chunk)
print(f"Received {len(chunks)} chunks")
# First content chunk should have role
first_content_chunk = [c for c in chunks if '"content"' in c and '"Hello"' in c][0]
assert '"role": "assistant"' in first_content_chunk
print("✓ First chunk has role: assistant")
@pytest.mark.asyncio
async def test_yields_done_at_end(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Yields [DONE] at end of stream.
Goal: Verify stream termination.
"""
print("Setup: Mock stream with content...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="Hello")
print("Action: Streaming to OpenAI format...")
chunks = []
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
chunks.append(chunk)
print(f"Received {len(chunks)} chunks")
# Last chunk should be [DONE]
assert chunks[-1] == "data: [DONE]\n\n"
print("✓ [DONE] yielded at end")
@pytest.mark.asyncio
async def test_yields_final_chunk_with_usage(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Yields final chunk with usage info.
Goal: Verify usage is included.
"""
print("Setup: Mock stream with content...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="Hello")
yield KiroEvent(type="context_usage", context_usage_percentage=5.0)
print("Action: Streaming to OpenAI format...")
chunks = []
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
chunks.append(chunk)
print(f"Received {len(chunks)} chunks")
# Should have chunk with usage before [DONE]
usage_chunks = [c for c in chunks if '"usage"' in c]
assert len(usage_chunks) >= 1
print("✓ Final chunk with usage yielded")
@pytest.mark.asyncio
async def test_yields_tool_calls_chunk(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Yields tool_calls chunk when tools present.
Goal: Verify tool call streaming.
"""
print("Setup: Mock stream with tool call...")
tool_use_data = {
"id": "call_123",
"type": "function",
"function": {"name": "get_weather", "arguments": '{"city": "Moscow"}'}
}
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="Let me check")
yield KiroEvent(type="tool_use", tool_use=tool_use_data)
print("Action: Streaming to OpenAI format...")
chunks = []
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
chunks.append(chunk)
print(f"Received {len(chunks)} chunks")
# Should have tool_calls chunk
tool_chunks = [c for c in chunks if '"tool_calls"' in c]
assert len(tool_chunks) >= 1
assert "get_weather" in tool_chunks[0]
print("✓ Tool calls chunk yielded")
@pytest.mark.asyncio
async def test_tool_calls_have_index(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Tool calls have index field.
Goal: Verify OpenAI streaming spec compliance.
"""
print("Setup: Mock stream with multiple tool calls...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="tool_use", tool_use={
"id": "call_1", "type": "function",
"function": {"name": "func1", "arguments": "{}"}
})
yield KiroEvent(type="tool_use", tool_use={
"id": "call_2", "type": "function",
"function": {"name": "func2", "arguments": "{}"}
})
print("Action: Streaming to OpenAI format...")
chunks = []
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
chunks.append(chunk)
print(f"Received {len(chunks)} chunks")
# Find tool_calls chunk and verify indices
tool_chunks = [c for c in chunks if '"tool_calls"' in c]
assert len(tool_chunks) >= 1
# Parse and check indices
for chunk in tool_chunks:
if chunk.startswith("data: "):
json_str = chunk[6:].strip()
if json_str != "[DONE]":
data = json.loads(json_str)
if "choices" in data and data["choices"]:
delta = data["choices"][0].get("delta", {})
if "tool_calls" in delta:
for tc in delta["tool_calls"]:
assert "index" in tc
print("✓ Tool calls have index field")
@pytest.mark.asyncio
async def test_finish_reason_is_tool_calls_when_tools_present(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Sets finish_reason to tool_calls when tools present.
Goal: Verify correct finish reason.
"""
print("Setup: Mock stream with tool call...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="tool_use", tool_use={
"id": "call_1", "type": "function",
"function": {"name": "func1", "arguments": "{}"}
})
print("Action: Streaming to OpenAI format...")
chunks = []
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
chunks.append(chunk)
print(f"Received {len(chunks)} chunks")
# Final chunk before [DONE] should have finish_reason: tool_calls
final_chunk = chunks[-2] # Before [DONE]
assert '"finish_reason": "tool_calls"' in final_chunk
print("✓ finish_reason is tool_calls")
@pytest.mark.asyncio
async def test_finish_reason_is_stop_without_tools(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Sets finish_reason to stop without tools.
Goal: Verify correct finish reason.
"""
print("Setup: Mock stream without tool calls...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="Hello")
print("Action: Streaming to OpenAI format...")
chunks = []
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
chunks.append(chunk)
print(f"Received {len(chunks)} chunks")
# Final chunk before [DONE] should have finish_reason: stop
final_chunk = chunks[-2] # Before [DONE]
assert '"finish_reason": "stop"' in final_chunk
print("✓ finish_reason is stop")
@pytest.mark.asyncio
async def test_closes_response_on_completion(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Closes response on completion.
Goal: Verify resource cleanup.
"""
print("Setup: Mock stream...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="Hello")
print("Action: Streaming to OpenAI format...")
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
pass
print("Check: response.aclose() should be called...")
mock_response.aclose.assert_called()
print("✓ Response closed on completion")
@pytest.mark.asyncio
async def test_closes_response_on_error(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Closes response on error.
Goal: Verify resource cleanup on error.
"""
print("Setup: Mock stream that raises error...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="Hello")
raise RuntimeError("Test error")
print("Action: Streaming to OpenAI format with error...")
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
try:
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
pass
except RuntimeError:
pass
print("Check: response.aclose() should be called...")
mock_response.aclose.assert_called()
print("✓ Response closed on error")
# ==================================================================================================
# Tests for thinking content handling
# ==================================================================================================
class TestStreamingOpenaiThinkingContent:
"""Tests for thinking content handling in OpenAI streaming."""
@pytest.mark.asyncio
async def test_yields_thinking_as_reasoning_content(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Yields thinking as reasoning_content when configured.
Goal: Verify thinking content handling.
"""
print("Setup: Mock stream with thinking content...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="thinking", thinking_content="Let me think...")
yield KiroEvent(type="content", content="Here is my answer")
print("Action: Streaming to OpenAI format with reasoning mode...")
chunks = []
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
with patch('kiro.streaming_openai.FAKE_REASONING_HANDLING', 'as_reasoning_content'):
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
chunks.append(chunk)
print(f"Received {len(chunks)} chunks")
# Should have reasoning_content
reasoning_chunks = [c for c in chunks if '"reasoning_content"' in c]
assert len(reasoning_chunks) >= 1
assert "Let me think" in reasoning_chunks[0]
print("✓ Thinking yielded as reasoning_content")
@pytest.mark.asyncio
async def test_yields_thinking_as_content_when_configured(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Yields thinking as content when configured.
Goal: Verify thinking content handling.
"""
print("Setup: Mock stream with thinking content...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="thinking", thinking_content="Let me think...")
yield KiroEvent(type="content", content="Here is my answer")
print("Action: Streaming to OpenAI format with content mode...")
chunks = []
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
with patch('kiro.streaming_openai.FAKE_REASONING_HANDLING', 'include_as_text'):
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
chunks.append(chunk)
print(f"Received {len(chunks)} chunks")
# Should have thinking as content
content_chunks = [c for c in chunks if '"content"' in c and "Let me think" in c]
assert len(content_chunks) >= 1
print("✓ Thinking yielded as content")
# ==================================================================================================
# Tests for None protection in tool calls
# ==================================================================================================
class TestStreamingOpenaiNoneProtection:
"""Tests for None protection in tool calls."""
@pytest.mark.asyncio
async def test_handles_none_function_name(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Handles None in function.name.
Goal: Verify None is replaced with empty string.
"""
print("Setup: Mock stream with None function name...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="tool_use", tool_use={
"id": "call_1", "type": "function",
"function": {"name": None, "arguments": "{}"}
})
print("Action: Streaming to OpenAI format...")
chunks = []
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
chunks.append(chunk)
print(f"Received {len(chunks)} chunks")
# Should handle None gracefully
tool_chunks = [c for c in chunks if '"tool_calls"' in c]
assert len(tool_chunks) >= 1
# Parse and verify name is empty string
for chunk in tool_chunks:
if chunk.startswith("data: "):
json_str = chunk[6:].strip()
if json_str != "[DONE]":
data = json.loads(json_str)
if "choices" in data and data["choices"]:
delta = data["choices"][0].get("delta", {})
if "tool_calls" in delta:
for tc in delta["tool_calls"]:
assert tc["function"]["name"] == ""
print("✓ None function name handled")
@pytest.mark.asyncio
async def test_handles_none_function_arguments(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Handles None in function.arguments.
Goal: Verify None is replaced with "{}".
"""
print("Setup: Mock stream with None arguments...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="tool_use", tool_use={
"id": "call_1", "type": "function",
"function": {"name": "func1", "arguments": None}
})
print("Action: Streaming to OpenAI format...")
chunks = []
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
chunks.append(chunk)
print(f"Received {len(chunks)} chunks")
# Should handle None gracefully
tool_chunks = [c for c in chunks if '"tool_calls"' in c]
assert len(tool_chunks) >= 1
# Parse and verify arguments is "{}"
for chunk in tool_chunks:
if chunk.startswith("data: "):
json_str = chunk[6:].strip()
if json_str != "[DONE]":
data = json.loads(json_str)
if "choices" in data and data["choices"]:
delta = data["choices"][0].get("delta", {})
if "tool_calls" in delta:
for tc in delta["tool_calls"]:
assert tc["function"]["arguments"] == "{}"
print("✓ None function arguments handled")
@pytest.mark.asyncio
async def test_handles_none_function_object(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Handles None function object.
Goal: Verify None function is handled.
"""
print("Setup: Mock stream with None function...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="tool_use", tool_use={
"id": "call_1", "type": "function",
"function": None
})
print("Action: Streaming to OpenAI format...")
chunks = []
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
chunks.append(chunk)
print(f"Received {len(chunks)} chunks")
# Should handle None gracefully without error
assert len(chunks) > 0
print("✓ None function object handled")
# ==================================================================================================
# Tests for stream_with_first_token_retry()
# ==================================================================================================
class TestStreamWithFirstTokenRetry:
"""Tests for stream_with_first_token_retry() function."""
@pytest.mark.asyncio
async def test_retries_on_first_token_timeout(self, mock_http_client, mock_model_cache, mock_auth_manager):
"""
What it does: Retries on first token timeout.
Goal: Verify retry logic.
"""
print("Setup: Mock make_request that succeeds on second attempt...")
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.aclose = AsyncMock()
call_count = 0
async def mock_make_request():
nonlocal call_count
call_count += 1
print(f"make_request called (attempt {call_count})")
return mock_response
# First call raises timeout, second succeeds
timeout_raised = False
async def mock_parse_kiro_stream_with_retry(*args, **kwargs):
nonlocal timeout_raised
if not timeout_raised:
timeout_raised = True
raise FirstTokenTimeoutError("Timeout!")
yield KiroEvent(type="content", content="Success")
print("Action: Running stream_with_first_token_retry...")
chunks = []
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream_with_retry):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
async for chunk in stream_with_first_token_retry(
mock_make_request,
mock_http_client,
"claude-sonnet-4",
mock_model_cache,
mock_auth_manager,
max_retries=3,
first_token_timeout=15
):
chunks.append(chunk)
print(f"Received {len(chunks)} chunks")
print(f"make_request was called {call_count} times")
assert call_count == 2
assert len(chunks) > 0
print("✓ Retry logic worked correctly")
@pytest.mark.asyncio
async def test_raises_504_after_all_retries_exhausted(self, mock_http_client, mock_model_cache, mock_auth_manager):
"""
What it does: Raises 504 after all retries exhausted.
Goal: Verify error handling.
"""
from fastapi import HTTPException
print("Setup: Mock make_request that always times out...")
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.aclose = AsyncMock()
call_count = 0
async def mock_make_request():
nonlocal call_count
call_count += 1
return mock_response
async def mock_parse_kiro_stream_always_timeout(*args, **kwargs):
raise FirstTokenTimeoutError("Timeout!")
yield # Make it a generator
max_retries = 3
print(f"Action: Running stream_with_first_token_retry with max_retries={max_retries}...")
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream_always_timeout):
with pytest.raises(HTTPException) as exc_info:
async for chunk in stream_with_first_token_retry(
mock_make_request,
mock_http_client,
"claude-sonnet-4",
mock_model_cache,
mock_auth_manager,
max_retries=max_retries,
first_token_timeout=15
):
pass
print(f"Caught HTTPException: {exc_info.value.status_code}")
print(f"make_request was called {call_count} times")
assert exc_info.value.status_code == 504
assert call_count == max_retries
print("✓ 504 raised after all retries exhausted")
@pytest.mark.asyncio
async def test_handles_api_error_response(self, mock_http_client, mock_model_cache, mock_auth_manager):
"""
What it does: Handles API error response.
Goal: Verify error response handling.
"""
from fastapi import HTTPException
print("Setup: Mock make_request that returns error...")
mock_response = AsyncMock()
mock_response.status_code = 500
# Use simple error text without curly braces to avoid loguru format issues
mock_response.aread = AsyncMock(return_value=b'Internal server error')
mock_response.aclose = AsyncMock()
async def mock_make_request():
return mock_response
print("Action: Running stream_with_first_token_retry with error response...")
with pytest.raises(HTTPException) as exc_info:
async for chunk in stream_with_first_token_retry(
mock_make_request,
mock_http_client,
"claude-sonnet-4",
mock_model_cache,
mock_auth_manager,
max_retries=3,
first_token_timeout=15
):
pass
print(f"Caught HTTPException: {exc_info.value.status_code}")
assert exc_info.value.status_code == 500
print("✓ API error response handled")
@pytest.mark.asyncio
async def test_propagates_non_timeout_errors(self, mock_http_client, mock_model_cache, mock_auth_manager):
"""
What it does: Propagates non-timeout errors without retry.
Goal: Verify only timeout errors trigger retry.
"""
print("Setup: Mock make_request that raises RuntimeError...")
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.aclose = AsyncMock()
call_count = 0
async def mock_make_request():
nonlocal call_count
call_count += 1
return mock_response
async def mock_parse_kiro_stream_error(*args, **kwargs):
raise RuntimeError("Test error")
yield # Make it a generator
print("Action: Running stream_with_first_token_retry with RuntimeError...")
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream_error):
with pytest.raises(RuntimeError) as exc_info:
async for chunk in stream_with_first_token_retry(
mock_make_request,
mock_http_client,
"claude-sonnet-4",
mock_model_cache,
mock_auth_manager,
max_retries=3,
first_token_timeout=15
):
pass
print(f"Caught RuntimeError: {exc_info.value}")
print(f"make_request was called {call_count} times")
# Should only be called once - no retry for non-timeout errors
assert call_count == 1
assert "Test error" in str(exc_info.value)
print("✓ Non-timeout errors propagated without retry")
@pytest.mark.asyncio
async def test_closes_response_on_retry(self, mock_http_client, mock_model_cache, mock_auth_manager):
"""
What it does: Closes response when retrying.
Goal: Verify resource cleanup on retry.
"""
print("Setup: Mock responses for retry...")
mock_response1 = AsyncMock()
mock_response1.status_code = 200
mock_response1.aclose = AsyncMock()
mock_response2 = AsyncMock()
mock_response2.status_code = 200
mock_response2.aclose = AsyncMock()
responses = [mock_response1, mock_response2]
call_count = 0
async def mock_make_request():
nonlocal call_count
response = responses[call_count]
call_count += 1
return response
# First call raises timeout, second succeeds
timeout_raised = False
async def mock_parse_kiro_stream_with_retry(*args, **kwargs):
nonlocal timeout_raised
if not timeout_raised:
timeout_raised = True
raise FirstTokenTimeoutError("Timeout!")
yield KiroEvent(type="content", content="Success")
print("Action: Running stream_with_first_token_retry...")
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream_with_retry):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
async for chunk in stream_with_first_token_retry(
mock_make_request,
mock_http_client,
"claude-sonnet-4",
mock_model_cache,
mock_auth_manager,
max_retries=3,
first_token_timeout=15
):
pass
print("Check: First response should be closed...")
mock_response1.aclose.assert_called()
print("✓ Response closed on retry")
# ==================================================================================================
# Tests for collect_stream_response()
# ==================================================================================================
class TestCollectStreamResponse:
"""Tests for collect_stream_response() function."""
@pytest.mark.asyncio
async def test_collects_content(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Collects content from stream.
Goal: Verify content accumulation.
"""
print("Setup: Mock stream with content...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="Hello")
yield KiroEvent(type="content", content=" World")
print("Action: Collecting stream response...")
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
result = await collect_stream_response(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
)
print(f"Result: {result}")
assert result["choices"][0]["message"]["content"] == "Hello World"
print("✓ Content collected correctly")
@pytest.mark.asyncio
async def test_collects_reasoning_content(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Collects reasoning content from stream.
Goal: Verify reasoning content accumulation.
"""
print("Setup: Mock stream with thinking content...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="thinking", thinking_content="Let me think...")
yield KiroEvent(type="content", content="Answer")
print("Action: Collecting stream response with reasoning mode...")
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
with patch('kiro.streaming_openai.FAKE_REASONING_HANDLING', 'as_reasoning_content'):
result = await collect_stream_response(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
)
print(f"Result: {result}")
message = result["choices"][0]["message"]
assert "reasoning_content" in message
assert message["reasoning_content"] == "Let me think..."
print("✓ Reasoning content collected correctly")
@pytest.mark.asyncio
async def test_collects_tool_calls(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Collects tool calls from stream.
Goal: Verify tool call accumulation.
"""
print("Setup: Mock stream with tool calls...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="tool_use", tool_use={
"id": "call_1", "type": "function",
"function": {"name": "func1", "arguments": '{"a": 1}'}
})
print("Action: Collecting stream response...")
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
result = await collect_stream_response(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
)
print(f"Result: {result}")
message = result["choices"][0]["message"]
assert "tool_calls" in message
assert len(message["tool_calls"]) == 1
assert message["tool_calls"][0]["function"]["name"] == "func1"
print("✓ Tool calls collected correctly")
@pytest.mark.asyncio
async def test_tool_calls_have_no_index(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Collected tool calls don't have index field.
Goal: Verify index is removed for non-streaming.
"""
print("Setup: Mock stream with tool calls...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="tool_use", tool_use={
"id": "call_1", "type": "function",
"function": {"name": "func1", "arguments": "{}"}
})
print("Action: Collecting stream response...")
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
result = await collect_stream_response(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
)
print(f"Result: {result}")
message = result["choices"][0]["message"]
for tc in message.get("tool_calls", []):
assert "index" not in tc
print("✓ Tool calls have no index field")
@pytest.mark.asyncio
async def test_includes_usage(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Includes usage in response.
Goal: Verify usage is included.
"""
print("Setup: Mock stream with content...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="Hello")
yield KiroEvent(type="context_usage", context_usage_percentage=5.0)
print("Action: Collecting stream response...")
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
result = await collect_stream_response(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
)
print(f"Result: {result}")
assert "usage" in result
assert "prompt_tokens" in result["usage"]
assert "completion_tokens" in result["usage"]
assert "total_tokens" in result["usage"]
print("✓ Usage included in response")
@pytest.mark.asyncio
async def test_sets_finish_reason_tool_calls(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Sets finish_reason to tool_calls when tools present.
Goal: Verify correct finish reason.
"""
print("Setup: Mock stream with tool calls...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="tool_use", tool_use={
"id": "call_1", "type": "function",
"function": {"name": "func1", "arguments": "{}"}
})
print("Action: Collecting stream response...")
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
result = await collect_stream_response(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
)
print(f"Result: {result}")
assert result["choices"][0]["finish_reason"] == "tool_calls"
print("✓ finish_reason is tool_calls")
@pytest.mark.asyncio
async def test_sets_finish_reason_stop(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Sets finish_reason to stop without tools.
Goal: Verify correct finish reason.
"""
print("Setup: Mock stream without tool calls...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="Hello")
print("Action: Collecting stream response...")
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
result = await collect_stream_response(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
)
print(f"Result: {result}")
assert result["choices"][0]["finish_reason"] == "stop"
print("✓ finish_reason is stop")
@pytest.mark.asyncio
async def test_generates_completion_id(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Generates completion ID.
Goal: Verify ID is present.
"""
print("Setup: Mock stream...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="Hello")
print("Action: Collecting stream response...")
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
result = await collect_stream_response(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
)
print(f"ID: {result['id']}")
assert result["id"].startswith("chatcmpl-")
print("✓ Completion ID generated")
@pytest.mark.asyncio
async def test_includes_model_name(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Includes model name in response.
Goal: Verify model is included.
"""
print("Setup: Mock stream...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="Hello")
print("Action: Collecting stream response...")
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
result = await collect_stream_response(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
)
print(f"Model: {result['model']}")
assert result["model"] == "claude-sonnet-4"
print("✓ Model name included")
@pytest.mark.asyncio
async def test_object_is_chat_completion(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Sets object to chat.completion.
Goal: Verify OpenAI format.
"""
print("Setup: Mock stream...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="Hello")
print("Action: Collecting stream response...")
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
result = await collect_stream_response(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
)
print(f"Object: {result['object']}")
assert result["object"] == "chat.completion"
print("✓ Object is chat.completion")
# ==================================================================================================
# Tests for error handling
# ==================================================================================================
class TestStreamingOpenaiErrorHandling:
"""Tests for error handling in streaming_openai."""
@pytest.mark.asyncio
async def test_propagates_first_token_timeout_error(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Propagates FirstTokenTimeoutError.
Goal: Verify timeout error is propagated for retry.
"""
print("Setup: Mock stream that raises timeout...")
async def mock_parse_kiro_stream(*args, **kwargs):
raise FirstTokenTimeoutError("Timeout!")
yield # Make it a generator
print("Action: Streaming to OpenAI format with timeout...")
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with pytest.raises(FirstTokenTimeoutError):
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
pass
print("✓ FirstTokenTimeoutError propagated correctly")
@pytest.mark.asyncio
async def test_handles_generator_exit_gracefully(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Handles GeneratorExit gracefully without re-raising.
Goal: Verify client disconnect is handled without error.
"""
print("Setup: Mock stream that raises GeneratorExit...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="Hello")
raise GeneratorExit()
print("Action: Streaming to OpenAI format with GeneratorExit...")
chunks = []
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
# GeneratorExit is caught internally and not re-raised
# This is correct behavior - client disconnect should be handled gracefully
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
chunks.append(chunk)
print(f"Received {len(chunks)} chunks before disconnect")
# Response should be closed
mock_response.aclose.assert_called()
print("✓ GeneratorExit handled gracefully")
@pytest.mark.asyncio
async def test_propagates_other_exceptions(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Propagates other exceptions.
Goal: Verify errors are not swallowed.
"""
print("Setup: Mock stream that raises RuntimeError...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="Hello")
raise RuntimeError("Test error")
print("Action: Streaming to OpenAI format with RuntimeError...")
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
with pytest.raises(RuntimeError) as exc_info:
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
pass
print(f"Caught exception: {exc_info.value}")
assert "Test error" in str(exc_info.value)
print("✓ RuntimeError propagated correctly")
@pytest.mark.asyncio
async def test_aclose_error_does_not_mask_original(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: aclose() error doesn't mask original error.
Goal: Verify original exception is propagated.
"""
print("Setup: Mock response with error in aclose()...")
mock_response.aclose = AsyncMock(side_effect=ConnectionError("Connection lost"))
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="Hello")
raise RuntimeError("Original error")
print("Action: Streaming to OpenAI format with error and aclose error...")
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
with pytest.raises(RuntimeError) as exc_info:
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
pass
print(f"Caught exception: {exc_info.value}")
assert "Original error" in str(exc_info.value)
print("✓ Original error not masked by aclose error")
# ==================================================================================================
# Tests for bracket tool calls
# ==================================================================================================
class TestStreamingOpenaiBracketToolCalls:
"""Tests for bracket-style tool call handling."""
@pytest.mark.asyncio
async def test_detects_bracket_tool_calls(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Detects bracket-style tool calls in content.
Goal: Verify bracket tool call detection.
"""
print("Setup: Mock stream with bracket tool calls...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="[tool_call: func1]")
bracket_tool_calls = [
{"id": "call_1", "type": "function", "function": {"name": "func1", "arguments": "{}"}}
]
print("Action: Streaming to OpenAI format...")
chunks = []
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=bracket_tool_calls):
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
chunks.append(chunk)
print(f"Received {len(chunks)} chunks")
# Should have tool_calls chunk
tool_chunks = [c for c in chunks if '"tool_calls"' in c]
assert len(tool_chunks) >= 1
print("✓ Bracket tool calls detected")
@pytest.mark.asyncio
async def test_deduplicates_tool_calls(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Deduplicates tool calls from stream and bracket.
Goal: Verify deduplication.
"""
print("Setup: Mock stream with duplicate tool calls...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="text")
yield KiroEvent(type="tool_use", tool_use={
"id": "call_1", "type": "function",
"function": {"name": "func1", "arguments": "{}"}
})
# Same tool call from bracket detection
bracket_tool_calls = [
{"id": "call_1", "type": "function", "function": {"name": "func1", "arguments": "{}"}}
]
print("Action: Streaming to OpenAI format...")
chunks = []
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=bracket_tool_calls):
with patch('kiro.streaming_openai.deduplicate_tool_calls') as mock_dedup:
mock_dedup.return_value = [
{"id": "call_1", "type": "function", "function": {"name": "func1", "arguments": "{}"}}
]
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
chunks.append(chunk)
# Verify deduplicate was called
mock_dedup.assert_called()
print("✓ Tool calls deduplicated")
# ==================================================================================================
# Tests for metering data
# ==================================================================================================
class TestStreamingOpenaiMeteringData:
"""Tests for metering data handling."""
@pytest.mark.asyncio
async def test_includes_credits_used_in_usage(self, mock_http_client, mock_response, mock_model_cache, mock_auth_manager):
"""
What it does: Includes credits_used in usage when metering data present.
Goal: Verify metering data is included.
"""
print("Setup: Mock stream with metering data...")
async def mock_parse_kiro_stream(*args, **kwargs):
yield KiroEvent(type="content", content="Hello")
yield KiroEvent(type="usage", usage={"credits": 0.001})
print("Action: Streaming to OpenAI format...")
chunks = []
with patch('kiro.streaming_openai.parse_kiro_stream', mock_parse_kiro_stream):
with patch('kiro.streaming_openai.parse_bracket_tool_calls', return_value=[]):
async for chunk in stream_kiro_to_openai(
mock_http_client, mock_response, "claude-sonnet-4",
mock_model_cache, mock_auth_manager
):
chunks.append(chunk)
print(f"Received {len(chunks)} chunks")
# Final chunk should have credits_used
final_chunk = chunks[-2] # Before [DONE]
assert '"credits_used"' in final_chunk
print("✓ credits_used included in usage")