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:
@@ -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
|
||||
Reference in New Issue
Block a user