Add Responses API with streaming, history, and advanced features

Implements Phases 2, 3, and 6: Complete Responses API implementation

Core API (Phase 2):
- OpenAI Responses API format with structured output items
- Streaming and non-streaming support via SSE-Starlette
- Reasoning items (thinking summaries)
- Function call items (tool execution)
- Message items (assistant responses)
- Router, schemas, service, and streaming coordinator

Conversation History (Phase 3):
- Hybrid client/server approach
- Auto-generated deterministic conversation IDs
- Configurable max turns with automatic trimming
- Context window management with token counting
- Token usage statistics
- Placeholder for future vector memory integration

Advanced Features (Phase 6):
- Parameter validation with Pydantic field validators:
  - Temperature: 0.0-2.0 range enforcement
  - Reasoning effort: 6 levels (none to xhigh)
  - Max output tokens: positive integer enforcement
  - Stop sequences: up to 4, non-empty strings
- Real-time stop sequence detection during streaming
- Real-time max tokens enforcement with token counting
- Graceful error handling and OpenAI-compatible error format

Testing:
- 9 unit tests for API endpoints and streaming
- 11 unit tests for error handling
- 13 unit tests for conversation history and context
- 12 unit tests for advanced features and validation
- Total: 45 tests with comprehensive coverage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-06 19:38:00 +01:00
co-authored by Claude
parent 4e6ca4466b
commit ff6c3cf1b5
12 changed files with 2680 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Tests for Responses API implementation."""
+378
View File
@@ -0,0 +1,378 @@
"""
Tests for Phase 6 advanced features.
Tests:
- Parameter validation (reasoning effort, max_output_tokens, stop sequences)
- Stop sequence detection and enforcement
- Max tokens enforcement
"""
import json
import pytest
from fastapi.testclient import TestClient
from httpx import AsyncClient
from pydantic import ValidationError
from src.responses.schemas import ResponseRequest
from src.responses.streaming import StreamingCoordinator
# ============================================================================
# Parameter Validation Tests
# ============================================================================
@pytest.mark.unit
def test_temperature_validation():
"""Test temperature parameter validation."""
# Valid temperatures
valid_temps = [0.0, 0.5, 1.0, 1.5, 2.0]
for temp in valid_temps:
request = ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Hello"}],
temperature=temp
)
assert request.temperature == temp
# Invalid temperatures
with pytest.raises(ValidationError) as exc_info:
ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Hello"}],
temperature=-0.1 # Too low
)
assert "temperature" in str(exc_info.value).lower()
with pytest.raises(ValidationError) as exc_info:
ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Hello"}],
temperature=2.1 # Too high
)
assert "temperature" in str(exc_info.value).lower()
@pytest.mark.unit
def test_reasoning_effort_validation():
"""Test reasoning.effort parameter validation."""
# Valid effort levels
valid_efforts = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']
for effort in valid_efforts:
request = ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Hello"}],
reasoning={"effort": effort, "summary": "auto"}
)
assert request.reasoning["effort"] == effort
# Invalid effort level
with pytest.raises(ValidationError) as exc_info:
ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Hello"}],
reasoning={"effort": "invalid", "summary": "auto"}
)
assert "reasoning.effort" in str(exc_info.value)
@pytest.mark.unit
def test_reasoning_summary_validation():
"""Test reasoning.summary parameter validation."""
# Valid summary values
valid_summaries = ['auto', 'off']
for summary in valid_summaries:
request = ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Hello"}],
reasoning={"effort": "medium", "summary": summary}
)
assert request.reasoning["summary"] == summary
# Invalid summary value
with pytest.raises(ValidationError) as exc_info:
ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Hello"}],
reasoning={"effort": "medium", "summary": "invalid"}
)
assert "reasoning.summary" in str(exc_info.value)
@pytest.mark.unit
def test_max_output_tokens_validation():
"""Test max_output_tokens parameter validation."""
# Valid values
request = ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Hello"}],
max_output_tokens=100
)
assert request.max_output_tokens == 100
# None is valid (unlimited)
request = ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Hello"}],
max_output_tokens=None
)
assert request.max_output_tokens is None
# Invalid: zero or negative
with pytest.raises(ValidationError) as exc_info:
ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Hello"}],
max_output_tokens=0
)
assert "max_output_tokens" in str(exc_info.value)
with pytest.raises(ValidationError) as exc_info:
ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Hello"}],
max_output_tokens=-10
)
assert "max_output_tokens" in str(exc_info.value)
@pytest.mark.unit
def test_stop_sequences_validation():
"""Test stop sequences parameter validation."""
# Valid: up to 4 stop sequences
for num_seqs in range(1, 5):
stop_seqs = [f"stop{i}" for i in range(num_seqs)]
request = ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Hello"}],
stop=stop_seqs
)
assert request.stop == stop_seqs
# Invalid: more than 4 stop sequences
with pytest.raises(ValidationError) as exc_info:
ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Hello"}],
stop=["stop1", "stop2", "stop3", "stop4", "stop5"] # 5 sequences
)
assert "4 stop sequences" in str(exc_info.value)
# Invalid: empty string in stop sequences
with pytest.raises(ValidationError) as exc_info:
ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Hello"}],
stop=["stop1", ""] # Empty string
)
assert "non-empty" in str(exc_info.value).lower()
# ============================================================================
# Stop Sequence Enforcement Tests
# ============================================================================
@pytest.mark.unit
def test_stop_sequence_detection_helper():
"""Test stop sequence detection helper method."""
coordinator = StreamingCoordinator()
# No stop sequences
found, text = coordinator._check_stop_sequence("Hello world", None)
assert found is False
assert text == "Hello world"
# Stop sequence not present
found, text = coordinator._check_stop_sequence(
"Hello world",
["STOP", "END"]
)
assert found is False
assert text == "Hello world"
# Stop sequence found
found, text = coordinator._check_stop_sequence(
"Hello STOP this should not appear",
["STOP"]
)
assert found is True
assert text == "Hello "
# Multiple stop sequences, first one wins
found, text = coordinator._check_stop_sequence(
"Hello STOP this END that",
["STOP", "END"]
)
assert found is True
assert text == "Hello "
@pytest.mark.unit
@pytest.mark.asyncio
async def test_stop_sequence_in_streaming(async_client: AsyncClient):
"""Test stop sequence enforcement during streaming."""
# We'll use lorem-tester which generates predictable text
# The agent generates lorem ipsum text, so we use a stop sequence
# that's likely to appear
request_data = {
"model": "lorem-tester",
"input": [{"role": "user", "content": "Generate long text"}],
"stop": ["dolor"], # Common word in lorem ipsum
"stream": True
}
chunks_received = []
async with async_client.stream(
"POST",
"/v1/responses",
json=request_data,
timeout=20.0,
) as response:
assert response.status_code == 200
async for line in response.aiter_lines():
if not line.strip():
continue
if line.startswith("data: "):
data_str = line[6:].strip()
if data_str != "[DONE]":
try:
chunk = json.loads(data_str)
chunks_received.append(chunk)
except json.JSONDecodeError:
pass
# Should have received chunks and stopped early
# (Can't verify exact stop behavior with random lorem ipsum,
# but test ensures no errors occur)
assert len(chunks_received) > 0
# ============================================================================
# Max Tokens Enforcement Tests
# ============================================================================
@pytest.mark.unit
def test_max_tokens_check_helper():
"""Test max tokens check helper method."""
coordinator = StreamingCoordinator()
# No limit
assert coordinator._check_max_tokens(100, None) is False
# Under limit
assert coordinator._check_max_tokens(50, 100) is False
# At limit
assert coordinator._check_max_tokens(100, 100) is True
# Over limit
assert coordinator._check_max_tokens(150, 100) is True
@pytest.mark.unit
def test_token_counting_helper():
"""Test approximate token counting."""
coordinator = StreamingCoordinator()
# Empty string
assert coordinator._count_tokens_approx("") == 0
# ~4 chars per token
text = "Hello world" # 11 chars
tokens = coordinator._count_tokens_approx(text)
assert tokens == 2 # 11 // 4 = 2
text = "A" * 100 # 100 chars
tokens = coordinator._count_tokens_approx(text)
assert tokens == 25 # 100 // 4 = 25
@pytest.mark.unit
@pytest.mark.asyncio
async def test_max_tokens_in_streaming(async_client: AsyncClient):
"""Test max tokens enforcement during streaming."""
# Set very low max_output_tokens to force early stop
request_data = {
"model": "lorem-tester",
"input": [{"role": "user", "content": "Generate text"}],
"max_output_tokens": 5, # Very low limit
"stream": True
}
chunks_received = []
text_chunks = []
async with async_client.stream(
"POST",
"/v1/responses",
json=request_data,
timeout=20.0,
) as response:
assert response.status_code == 200
async for line in response.aiter_lines():
if not line.strip():
continue
if line.startswith("event: "):
event_type = line[7:].strip()
elif line.startswith("data: "):
data_str = line[6:].strip()
if data_str != "[DONE]":
try:
chunk = json.loads(data_str)
chunks_received.append(chunk)
# Collect text deltas
if "delta" in chunk:
text_chunks.append(chunk["delta"])
except json.JSONDecodeError:
pass
# Should have stopped early due to token limit
total_text = "".join(text_chunks)
# With max_output_tokens=5, total text should be small
# (Approximate: 5 tokens * 4 chars ≈ 20 chars)
assert len(total_text) < 100 # Reasonable upper bound
# ============================================================================
# Combined Features Test
# ============================================================================
@pytest.mark.unit
def test_combined_validation(client: TestClient):
"""Test combined parameter validation in actual request."""
# Valid request with all advanced features
request_data = {
"model": "lorem-tester",
"input": [{"role": "user", "content": "Hello"}],
"temperature": 0.7,
"max_output_tokens": 100,
"stop": ["STOP", "END"],
"reasoning": {"effort": "high", "summary": "auto"},
"stream": False
}
response = client.post("/v1/responses", json=request_data)
assert response.status_code == 200
data = response.json()
assert data["status"] == "completed"
@pytest.mark.unit
def test_invalid_combined_parameters(client: TestClient):
"""Test that invalid parameters are rejected."""
# Invalid temperature
request_data = {
"model": "lorem-tester",
"input": [{"role": "user", "content": "Hello"}],
"temperature": 3.0, # Too high
"stream": False
}
response = client.post("/v1/responses", json=request_data)
assert response.status_code == 422
data = response.json()
assert "error" in data
assert data["error"]["type"] == "invalid_request_error"
+281
View File
@@ -0,0 +1,281 @@
"""
Tests for error handling in Responses API.
"""
import json
import pytest
from fastapi.testclient import TestClient
from httpx import AsyncClient
@pytest.mark.unit
def test_model_not_found_error(client: TestClient) -> None:
"""Test response when model doesn't exist."""
request_data = {
"model": "nonexistent-model-12345",
"input": [
{"role": "user", "content": "Hello"}
],
"stream": False
}
response = client.post("/v1/responses", json=request_data)
assert response.status_code == 404
data = response.json()
assert "detail" in data
assert "not found" in data["detail"].lower()
@pytest.mark.unit
def test_validation_error_missing_model(client: TestClient) -> None:
"""Test validation error when model field is missing."""
request_data = {
"input": [
{"role": "user", "content": "Hello"}
]
}
response = client.post("/v1/responses", json=request_data)
assert response.status_code == 422
@pytest.mark.unit
def test_validation_error_invalid_temperature(client: TestClient) -> None:
"""Test validation error for out-of-range temperature."""
request_data = {
"model": "lorem-tester",
"input": [
{"role": "user", "content": "Hello"}
],
"temperature": 3.0 # Max is 2.0
}
response = client.post("/v1/responses", json=request_data)
assert response.status_code == 422
@pytest.mark.unit
def test_rate_limit_error(client: TestClient) -> None:
"""Test rate limit error trigger."""
request_data = {
"model": "lorem-tester",
"input": [
{"role": "user", "content": "trigger_rate_limit"}
],
"stream": False
}
response = client.post("/v1/responses", json=request_data)
# Should get an error response
assert response.status_code in [429, 500] # Rate limit or internal error
@pytest.mark.unit
def test_context_overflow_error(client: TestClient) -> None:
"""Test context length overflow error trigger."""
request_data = {
"model": "lorem-tester",
"input": [
{"role": "user", "content": "trigger_context_overflow"}
],
"stream": False
}
response = client.post("/v1/responses", json=request_data)
# Should get an error response
assert response.status_code in [400, 500] # Bad request or internal error
@pytest.mark.unit
@pytest.mark.asyncio
async def test_streaming_model_not_found(async_client: AsyncClient) -> None:
"""Test streaming response with nonexistent model."""
request_data = {
"model": "nonexistent-streaming-model",
"input": [
{"role": "user", "content": "Hello"}
],
"stream": True
}
async with async_client.stream(
"POST",
"/v1/responses",
json=request_data,
timeout=20.0,
) as response:
# Streaming always returns 200 OK, errors are sent as events
assert response.status_code == 200
# Collect events and look for error event
error_found = False
async for line in response.aiter_lines():
if not line.strip():
continue
if line.startswith("event: "):
event_type = line[7:].strip()
if event_type == "error":
error_found = True
break
# Should have received an error event
assert error_found, "Expected error event in stream"
@pytest.mark.unit
@pytest.mark.asyncio
async def test_streaming_rate_limit_error(async_client: AsyncClient) -> None:
"""Test streaming with rate limit error."""
request_data = {
"model": "lorem-tester",
"input": [
{"role": "user", "content": "trigger_rate_limit"}
],
"stream": True
}
async with async_client.stream(
"POST",
"/v1/responses",
json=request_data,
timeout=20.0,
) as response:
# May get error status or error event
# Collect all events
events = []
async for line in response.aiter_lines():
if not line.strip():
continue
if line.startswith("event: "):
event_type = line[7:].strip()
elif line.startswith("data: "):
data_str = line[6:].strip()
try:
data = json.loads(data_str)
if event_type:
events.append({"event": event_type, "data": data})
except json.JSONDecodeError:
pass
# Should either have error status or error event
if response.status_code == 200:
# Check for error event
error_events = [e for e in events if e["event"] == "error"]
# May or may not have error event depending on where error occurs
# At minimum, should not crash
@pytest.mark.unit
@pytest.mark.asyncio
async def test_streaming_context_overflow_error(async_client: AsyncClient) -> None:
"""Test streaming with context overflow error."""
request_data = {
"model": "lorem-tester",
"input": [
{"role": "user", "content": "trigger_context_overflow"}
],
"stream": True
}
async with async_client.stream(
"POST",
"/v1/responses",
json=request_data,
timeout=20.0,
) as response:
# May get error status or error event
events = []
async for line in response.aiter_lines():
if not line.strip():
continue
if line.startswith("event: "):
event_type = line[7:].strip()
elif line.startswith("data: "):
data_str = line[6:].strip()
try:
data = json.loads(data_str)
if event_type:
events.append({"event": event_type, "data": data})
except json.JSONDecodeError:
pass
# Should not crash
assert response.status_code in [200, 400, 500]
@pytest.mark.unit
def test_function_call_output_item(client: TestClient) -> None:
"""Test response with function call items."""
request_data = {
"model": "lorem-tester",
"input": [
{"role": "user", "content": "Use the search tool"}
],
"tools": [
{
"name": "search",
"description": "Search for information",
"parameters": {}
}
],
"stream": False
}
response = client.post("/v1/responses", json=request_data)
assert response.status_code == 200
data = response.json()
# May or may not have function_call items (random in lorem-tester)
# But should successfully handle them if present
output_items = data["output"]
for item in output_items:
assert item["type"] in ["message", "reasoning", "function_call"]
if item["type"] == "function_call":
assert "name" in item
assert "arguments" in item
@pytest.mark.unit
def test_pipeline_prefix_stripping(client: TestClient) -> None:
"""Test that pipeline prefixes are stripped from model names."""
request_data = {
"model": "some_pipeline.lorem-tester",
"input": [
{"role": "user", "content": "Hello"}
],
"stream": False
}
response = client.post("/v1/responses", json=request_data)
# Should successfully strip prefix and find model
assert response.status_code == 200
data = response.json()
assert data["status"] == "completed"
@pytest.mark.unit
def test_multiple_pipeline_prefixes(client: TestClient) -> None:
"""Test multiple dots in model name (only first is prefix)."""
request_data = {
"model": "pipeline.sub.lorem-tester",
"input": [
{"role": "user", "content": "Hello"}
],
"stream": False
}
response = client.post("/v1/responses", json=request_data)
# Should strip only first part
# "pipeline.sub.lorem-tester" -> "sub.lorem-tester"
# This should fail since "sub.lorem-tester" doesn't exist
assert response.status_code == 404
+383
View File
@@ -0,0 +1,383 @@
"""
Tests for conversation history management.
"""
import pytest
from fastapi.testclient import TestClient
from src.responses.history import ConversationHistory
from src.responses.context import ContextWindow
from src.responses.schemas import ResponseRequest
from src.responses import service
@pytest.mark.unit
@pytest.mark.asyncio
async def test_conversation_id_from_metadata():
"""Test conversation ID extraction from metadata."""
history = ConversationHistory()
request = ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Hello"}],
metadata={"conversation_id": "conv_123"}
)
conv_id = await history.get_conversation_id(request)
assert conv_id == "conv_123"
@pytest.mark.unit
@pytest.mark.asyncio
async def test_conversation_id_generation():
"""Test conversation ID generation from first message."""
history = ConversationHistory()
request = ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Hello"}]
# No metadata provided
)
conv_id = await history.get_conversation_id(request)
assert isinstance(conv_id, str)
assert len(conv_id) == 16 # 16 character hex
# Same first message should generate same ID
request2 = ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Hello"}]
)
conv_id2 = await history.get_conversation_id(request2)
assert conv_id == conv_id2
@pytest.mark.unit
def test_conversation_history_tracking(client: TestClient):
"""Test that conversations are tracked server-side."""
# First request with conversation ID
request_data = {
"model": "lorem-tester",
"input": [{"role": "user", "content": "Hello"}],
"metadata": {"conversation_id": "test_conv_001"},
"stream": False
}
response = client.post("/v1/responses", json=request_data)
assert response.status_code == 200
# Second request with same conversation ID
request_data2 = {
"model": "lorem-tester",
"input": [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
{"role": "user", "content": "How are you?"}
],
"metadata": {"conversation_id": "test_conv_001"},
"stream": False
}
response2 = client.post("/v1/responses", json=request_data2)
assert response2.status_code == 200
# Both responses should be successful
assert response.json()["status"] == "completed"
assert response2.json()["status"] == "completed"
@pytest.mark.unit
@pytest.mark.asyncio
async def test_conversation_history_retrieval():
"""Test retrieving conversation history."""
history = ConversationHistory()
request = ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Test"}],
metadata={"conversation_id": "test_retrieve"}
)
conv_id = await history.get_conversation_id(request)
# Initially empty
retrieved = await history.get_history(conv_id)
assert len(retrieved) == 0
# Add mock response
from src.responses.schemas import Response, ResponseUsage, MessageOutputItem, OutputTextContent
mock_response = Response(
id="resp_123",
created_at=1234567890,
model="lorem-tester",
status="completed",
output=[
MessageOutputItem(
id="msg_1",
content=[OutputTextContent(text="Test response")]
)
],
usage=ResponseUsage(
input_tokens=10,
output_tokens=5,
reasoning_tokens=0,
total_tokens=15
)
)
await history.add_response(conv_id, mock_response)
# Should now have 1 response
retrieved = await history.get_history(conv_id)
assert len(retrieved) == 1
assert retrieved[0].id == "resp_123"
@pytest.mark.unit
@pytest.mark.asyncio
async def test_conversation_history_trimming():
"""Test that history is trimmed to max_turns."""
history = ConversationHistory(max_turns=3)
from src.responses.schemas import Response, ResponseUsage, MessageOutputItem, OutputTextContent
conv_id = "test_trim"
# Add 5 responses (more than max_turns)
for i in range(5):
response = Response(
id=f"resp_{i}",
created_at=1234567890 + i,
model="lorem-tester",
status="completed",
output=[
MessageOutputItem(
id=f"msg_{i}",
content=[OutputTextContent(text=f"Response {i}")]
)
],
usage=ResponseUsage(
input_tokens=10,
output_tokens=5,
reasoning_tokens=0,
total_tokens=15
)
)
await history.add_response(conv_id, response)
# Should only keep last 3
retrieved = await history.get_history(conv_id)
assert len(retrieved) == 3
assert retrieved[0].id == "resp_2" # Oldest kept
assert retrieved[1].id == "resp_3"
assert retrieved[2].id == "resp_4" # Most recent
@pytest.mark.unit
@pytest.mark.asyncio
async def test_clear_conversation():
"""Test clearing conversation history."""
history = ConversationHistory()
from src.responses.schemas import Response, ResponseUsage, MessageOutputItem, OutputTextContent
conv_id = "test_clear"
# Add a response
response = Response(
id="resp_123",
created_at=1234567890,
model="lorem-tester",
status="completed",
output=[
MessageOutputItem(
id="msg_1",
content=[OutputTextContent(text="Test")]
)
],
usage=ResponseUsage(
input_tokens=10,
output_tokens=5,
reasoning_tokens=0,
total_tokens=15
)
)
await history.add_response(conv_id, response)
# Verify it exists
assert len(await history.get_history(conv_id)) == 1
# Clear it
cleared = await history.clear_conversation(conv_id)
assert cleared is True
# Should be empty now
assert len(await history.get_history(conv_id)) == 0
# Clearing non-existent conversation should return False
cleared_again = await history.clear_conversation(conv_id)
assert cleared_again is False
@pytest.mark.unit
@pytest.mark.asyncio
async def test_conversation_count():
"""Test conversation count tracking."""
history = ConversationHistory()
initial_count = await history.get_conversation_count()
# Add responses to 3 different conversations
from src.responses.schemas import Response, ResponseUsage, MessageOutputItem, OutputTextContent
for i in range(3):
response = Response(
id=f"resp_{i}",
created_at=1234567890,
model="lorem-tester",
status="completed",
output=[
MessageOutputItem(
id=f"msg_{i}",
content=[OutputTextContent(text="Test")]
)
],
usage=ResponseUsage(
input_tokens=10,
output_tokens=5,
reasoning_tokens=0,
total_tokens=15
)
)
await history.add_response(f"conv_{i}", response)
new_count = await history.get_conversation_count()
assert new_count == initial_count + 3
@pytest.mark.unit
@pytest.mark.asyncio
async def test_context_window_token_counting():
"""Test token counting in context window."""
context = ContextWindow(max_tokens=4096)
# Test string counting
text = "Hello world! " * 100 # ~1200 characters
tokens = await context.count_tokens([text])
assert tokens > 0
assert tokens == len(text) // 4 # Approximate
# Test dict counting
message = {"role": "user", "content": "Test message"}
tokens = await context.count_tokens([message])
assert tokens > 0
@pytest.mark.unit
@pytest.mark.asyncio
async def test_context_window_trimming():
"""Test context window trimming to fit."""
context = ContextWindow(max_tokens=100)
# Create items that exceed limit
items = [
"This is a long message " * 20, # ~480 chars = ~120 tokens
"Another message " * 10, # ~160 chars = ~40 tokens
"Short message" # ~13 chars = ~3 tokens
]
# Trim with 10 token reserve
trimmed = await context.trim_to_fit(items, reserve_tokens=10)
# Should keep only items that fit (90 tokens available)
# Most recent first: "Short message" (3 tokens) + "Another message..." (40 tokens) = 43 tokens
assert len(trimmed) >= 1 # At least the short message
assert "Short message" in trimmed # Most recent always kept if it fits
@pytest.mark.unit
@pytest.mark.asyncio
async def test_context_window_fits_check():
"""Test checking if items fit in context."""
context = ContextWindow(max_tokens=100)
small_items = ["Hello", "World"]
large_items = ["Very long text " * 200] # ~3200 chars = ~800 tokens
# Small items should fit
assert await context.fits_in_context(small_items, reserve_tokens=10) is True
# Large items should not fit
assert await context.fits_in_context(large_items, reserve_tokens=10) is False
@pytest.mark.unit
@pytest.mark.asyncio
async def test_context_window_usage_stats():
"""Test context window usage statistics."""
context = ContextWindow(max_tokens=1000)
items = ["Test message " * 50] # ~650 chars = ~162 tokens
stats = await context.get_usage_stats(items, reserve_tokens=100)
assert "total_tokens" in stats
assert "max_tokens" in stats
assert "reserved_tokens" in stats
assert "available_tokens" in stats
assert "usage_percent" in stats
assert "fits" in stats
assert stats["max_tokens"] == 1000
assert stats["reserved_tokens"] == 100
assert stats["available_tokens"] == 900
assert isinstance(stats["usage_percent"], (int, float))
@pytest.mark.unit
@pytest.mark.asyncio
async def test_service_conversation_helpers():
"""Test service helper functions for conversation history."""
# Get stats
stats = await service.get_conversation_stats()
assert "total_conversations" in stats
assert "max_turns_per_conversation" in stats
# Get context window
context = service.get_context_window()
assert context.max_tokens == 4096
# Test clearing (should handle non-existent gracefully)
result = await service.clear_conversation("non_existent_conv")
assert result is False
@pytest.mark.unit
@pytest.mark.asyncio
async def test_streaming_tracks_history(async_client):
"""Test that streaming responses also track conversation history."""
from httpx import AsyncClient
import json
request_data = {
"model": "lorem-tester",
"input": [{"role": "user", "content": "Hello"}],
"metadata": {"conversation_id": "stream_test_001"},
"stream": True
}
async with async_client.stream(
"POST",
"/v1/responses",
json=request_data,
timeout=20.0,
) as response:
assert response.status_code == 200
# Consume the stream
async for line in response.aiter_lines():
if line.startswith("data: ") and "[DONE]" not in line:
continue
# History should be tracked
# (We can't easily verify this without exposing a GET endpoint,
# but the integration is tested in the non-streaming tests)
+281
View File
@@ -0,0 +1,281 @@
"""
Tests for Responses API router.
"""
import json
import pytest
from fastapi.testclient import TestClient
from httpx import AsyncClient
@pytest.mark.unit
def test_create_response_non_streaming(client: TestClient) -> None:
"""Test non-streaming response creation."""
request_data = {
"model": "lorem-tester",
"input": [
{"role": "user", "content": "Hello, how are you?"}
],
"stream": False
}
response = client.post("/v1/responses", json=request_data)
assert response.status_code == 200
data = response.json()
# Verify response structure
assert "id" in data
assert data["object"] == "response"
assert "created_at" in data
assert data["model"] == "lorem-tester"
assert data["status"] == "completed"
assert "output" in data
assert "usage" in data
# Verify output array
assert isinstance(data["output"], list)
assert len(data["output"]) >= 1 # At least message item
# Verify usage
assert data["usage"]["input_tokens"] > 0
assert data["usage"]["output_tokens"] > 0
assert data["usage"]["total_tokens"] > 0
@pytest.mark.unit
def test_create_response_with_reasoning(client: TestClient) -> None:
"""Test response with reasoning enabled."""
request_data = {
"model": "lorem-tester",
"input": [
{"role": "user", "content": "Explain something"}
],
"reasoning": {
"effort": "medium",
"summary": "auto"
},
"stream": False
}
response = client.post("/v1/responses", json=request_data)
assert response.status_code == 200
data = response.json()
# Should have reasoning item and message item
assert len(data["output"]) >= 2
# Check for reasoning item
reasoning_items = [item for item in data["output"] if item["type"] == "reasoning"]
assert len(reasoning_items) >= 1
reasoning_item = reasoning_items[0]
assert "summary" in reasoning_item
assert isinstance(reasoning_item["summary"], list)
assert len(reasoning_item["summary"]) > 0
# Check for message item
message_items = [item for item in data["output"] if item["type"] == "message"]
assert len(message_items) >= 1
# Verify reasoning tokens counted
assert data["usage"]["reasoning_tokens"] > 0
@pytest.mark.unit
def test_create_response_with_tools(client: TestClient) -> None:
"""Test response with tools available."""
request_data = {
"model": "lorem-tester",
"input": [
{"role": "user", "content": "Use a tool to help"}
],
"tools": [
{
"name": "search_knowledge",
"description": "Search knowledge base",
"parameters": {}
}
],
"stream": False
}
response = client.post("/v1/responses", json=request_data)
assert response.status_code == 200
data = response.json()
# May or may not have function_call items (randomized in lorem-tester)
# But should always have message item
message_items = [item for item in data["output"] if item["type"] == "message"]
assert len(message_items) >= 1
@pytest.mark.unit
def test_create_response_invalid_model(client: TestClient) -> None:
"""Test response with non-existent model."""
request_data = {
"model": "nonexistent-model",
"input": [
{"role": "user", "content": "Hello"}
],
"stream": False
}
response = client.post("/v1/responses", json=request_data)
assert response.status_code == 404
data = response.json()
assert "detail" in data
@pytest.mark.unit
def test_create_response_validation_error(client: TestClient) -> None:
"""Test response with invalid request data."""
# Missing required 'model' field
request_data = {
"input": [
{"role": "user", "content": "Hello"}
]
}
response = client.post("/v1/responses", json=request_data)
assert response.status_code == 422
@pytest.mark.unit
@pytest.mark.asyncio
async def test_create_response_streaming(async_client: AsyncClient) -> None:
"""Test streaming response creation."""
request_data = {
"model": "lorem-tester",
"input": [
{"role": "user", "content": "Hello"}
],
"stream": True
}
async with async_client.stream(
"POST",
"/v1/responses",
json=request_data,
timeout=20.0,
) as response:
assert response.status_code == 200
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
events = []
async for line in response.aiter_lines():
if not line.strip():
continue
# Parse SSE format: "event: event_type" and "data: {json}"
if line.startswith("event: "):
event_type = line[7:].strip()
elif line.startswith("data: "):
data_str = line[6:].strip()
try:
data = json.loads(data_str)
events.append({"event": event_type, "data": data})
except json.JSONDecodeError:
pass # Skip malformed data
# Should have multiple events
assert len(events) > 0
# Should have response.done event as last event
assert events[-1]["event"] == "response.done"
# response.done should have complete response
done_data = events[-1]["data"]
assert "response" in done_data
assert done_data["response"]["status"] == "completed"
@pytest.mark.unit
@pytest.mark.asyncio
async def test_create_response_streaming_with_reasoning(async_client: AsyncClient) -> None:
"""Test streaming response with reasoning."""
request_data = {
"model": "lorem-tester",
"input": [
{"role": "user", "content": "Explain"}
],
"reasoning": {
"effort": "medium",
"summary": "auto"
},
"stream": True
}
async with async_client.stream(
"POST",
"/v1/responses",
json=request_data,
timeout=20.0,
) as response:
assert response.status_code == 200
events = []
event_type = None
async for line in response.aiter_lines():
if not line.strip():
continue
if line.startswith("event: "):
event_type = line[7:].strip()
elif line.startswith("data: "):
data_str = line[6:].strip()
try:
data = json.loads(data_str)
if event_type:
events.append({"event": event_type, "data": data})
except json.JSONDecodeError:
pass
# Should have reasoning events
reasoning_events = [
e for e in events
if e["event"] == "response.reasoning_summary_text.delta"
]
assert len(reasoning_events) > 0
# Should have output text events
output_events = [
e for e in events
if e["event"] == "response.output_text.delta"
]
assert len(output_events) > 0
@pytest.mark.unit
def test_create_response_temperature_parameter(client: TestClient) -> None:
"""Test temperature parameter handling."""
request_data = {
"model": "lorem-tester",
"input": [{"role": "user", "content": "Test"}],
"temperature": 0.5,
"stream": False
}
response = client.post("/v1/responses", json=request_data)
assert response.status_code == 200
@pytest.mark.unit
def test_create_response_max_tokens_parameter(client: TestClient) -> None:
"""Test max_output_tokens parameter handling."""
request_data = {
"model": "lorem-tester",
"input": [{"role": "user", "content": "Test"}],
"max_output_tokens": 100,
"stream": False
}
response = client.post("/v1/responses", json=request_data)
assert response.status_code == 200