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
+270
View File
@@ -0,0 +1,270 @@
"""
Response service for creating responses.
Handles both streaming and non-streaming response generation.
Tracks conversation history for analytics and future vector memory.
"""
import time
import secrets
from typing import AsyncGenerator
from src.agents.registry import ModelRegistry
from src.responses.schemas import (
Response,
ResponseRequest,
ResponseUsage,
MessageOutputItem,
ReasoningOutputItem,
FunctionCallOutputItem,
OutputTextContent,
)
from src.responses.streaming import StreamingCoordinator
from src.responses.history import ConversationHistory
from src.responses.context import ContextWindow
# Global conversation history tracker
# In production, this would be backed by a database or Redis
_conversation_history = ConversationHistory(max_turns=20)
# Global context window manager (4096 token default)
_context_window = ContextWindow(max_tokens=4096)
def generate_id() -> str:
"""Generate unique ID for responses."""
return secrets.token_hex(16)
def _calculate_usage(input_messages: list[dict], output_items: list) -> ResponseUsage:
"""
Calculate token usage for the response.
For now: approximate token counting.
Future: Use tiktoken or similar for accurate counting.
Args:
input_messages: Input messages
output_items: Output items generated
Returns:
ResponseUsage: Token usage statistics
"""
# Approximate input tokens (chars / 4)
input_text = " ".join(str(msg) for msg in input_messages)
input_tokens = len(input_text) // 4
# Approximate output tokens
output_tokens = 0
reasoning_tokens = 0
for item in output_items:
if hasattr(item, 'type'):
# Agent OutputItem objects
if item.type == "reasoning":
reasoning_text = " ".join(item.data.get("summary", []))
reasoning_tokens += len(reasoning_text) // 4
elif item.type == "message":
message_text = item.data["content"][0]["text"]
output_tokens += len(message_text) // 4
elif item.type == "function_call":
func_text = item.data["arguments"]
output_tokens += len(func_text) // 4
else:
# Schema OutputItem objects
if isinstance(item, ReasoningOutputItem):
reasoning_text = " ".join(item.summary)
reasoning_tokens += len(reasoning_text) // 4
elif isinstance(item, MessageOutputItem):
message_text = item.content[0].text
output_tokens += len(message_text) // 4
elif isinstance(item, FunctionCallOutputItem):
func_text = item.arguments
output_tokens += len(func_text) // 4
total_tokens = input_tokens + output_tokens + reasoning_tokens
return ResponseUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
reasoning_tokens=reasoning_tokens,
total_tokens=total_tokens
)
async def create_response(request: ResponseRequest) -> Response:
"""
Create non-streaming response.
Tracks conversation history if conversation_id is provided in metadata.
Args:
request: Response request
Returns:
Response: Complete response object
Example:
request = ResponseRequest(
model="lorem-tester",
input=[{"role": "user", "content": "Hello"}],
reasoning={"effort": "medium", "summary": "auto"},
metadata={"conversation_id": "conv_abc123"} # Optional
)
response = await create_response(request)
"""
# Get or generate conversation ID
conversation_id = await _conversation_history.get_conversation_id(request)
# Strip pipeline prefix if present (e.g., "pipeline.model" -> "model")
model_id = request.model
if "." in model_id:
model_id = model_id.split(".", 1)[1]
# Get agent for model
agent = ModelRegistry.get_agent(model_id)
# Collect all output items from agent
output_items = []
async for item in agent.generate_response(
messages=request.input,
reasoning=request.reasoning,
tools=request.tools,
temperature=request.temperature,
max_tokens=request.max_output_tokens,
stop=request.stop,
):
output_items.append(item)
# Convert agent OutputItems to schema OutputItems
converted_items = _convert_output_items(output_items)
# Calculate token usage
usage = _calculate_usage(request.input, output_items)
response = Response(
id=f"resp_{generate_id()}",
created_at=int(time.time()),
model=request.model,
status="completed",
output=converted_items,
usage=usage
)
# Track conversation history (for analytics and future vector memory)
await _conversation_history.add_response(conversation_id, response)
return response
async def create_response_stream(
request: ResponseRequest
) -> AsyncGenerator[dict, None]:
"""
Create streaming response.
Yields SSE-formatted events for streaming to client.
Args:
request: Response request
Yields:
dict: SSE event dict with 'event' and 'data' keys
Example:
async for event in create_response_stream(request):
# event = {"event": "response.output_text.delta", "data": "..."}
yield event
"""
coordinator = StreamingCoordinator()
async for event in coordinator.stream_response(request):
yield {
"event": event.event,
"data": event.model_dump_json()
}
async def get_conversation_history(conversation_id: str) -> list[Response]:
"""
Get conversation history for a given conversation ID.
Args:
conversation_id: Conversation identifier
Returns:
list: List of Response objects
"""
return await _conversation_history.get_history(conversation_id)
async def get_conversation_stats() -> dict:
"""
Get conversation history statistics.
Returns:
dict: Statistics including total conversations tracked
"""
return {
"total_conversations": await _conversation_history.get_conversation_count(),
"max_turns_per_conversation": _conversation_history._max_turns
}
async def clear_conversation(conversation_id: str) -> bool:
"""
Clear a specific conversation history.
Args:
conversation_id: Conversation identifier
Returns:
bool: True if conversation was cleared
"""
return await _conversation_history.clear_conversation(conversation_id)
def get_context_window() -> ContextWindow:
"""
Get the global context window manager.
Returns:
ContextWindow: Context window manager instance
"""
return _context_window
def _convert_output_items(items: list) -> list:
"""
Convert agent OutputItem objects to schema OutputItem objects.
Args:
items: List of agent OutputItem objects
Returns:
list: List of schema OutputItem objects
"""
converted = []
for item in items:
if item.type == "message":
converted.append(MessageOutputItem(
id=item.id,
content=[OutputTextContent(**c) for c in item.data["content"]],
status=item.data.get("status", "completed")
))
elif item.type == "reasoning":
converted.append(ReasoningOutputItem(
id=item.id,
summary=item.data["summary"],
status=item.data.get("status", "completed")
))
elif item.type == "function_call":
converted.append(FunctionCallOutputItem(
id=item.id,
name=item.data["name"],
arguments=item.data["arguments"],
status=item.data.get("status", "completed")
))
return converted