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
+27
View File
@@ -0,0 +1,27 @@
"""
Responses API implementation.
This module implements the OpenAI Responses API format:
- /v1/responses endpoint
- Output items (reasoning, function_call, message)
- Streaming events
- Multi-turn conversations
"""
from src.responses.schemas import (
Response,
ResponseRequest,
OutputItem,
MessageOutputItem,
ReasoningOutputItem,
FunctionCallOutputItem,
)
__all__ = [
"Response",
"ResponseRequest",
"OutputItem",
"MessageOutputItem",
"ReasoningOutputItem",
"FunctionCallOutputItem",
]
+178
View File
@@ -0,0 +1,178 @@
"""
Context window management and token counting.
Handles:
- Token counting (approximate for now, exact in future)
- Context trimming to fit model limits
- Reserve tokens for output generation
"""
from typing import Any
class ContextWindow:
"""
Manage token limits and context trimming.
Real production context window management:
- Approximate token counting (character-based)
- Context trimming (keep most recent within limits)
- Reserve tokens for model output
- Future: Integration with tiktoken for exact counts
"""
def __init__(self, max_tokens: int = 4096):
"""
Initialize context window manager.
Args:
max_tokens: Maximum context window size in tokens
"""
self.max_tokens = max_tokens
async def count_tokens(self, items: list[Any]) -> int:
"""
Count tokens in items.
Current implementation: Approximate via character count
Future: Use tiktoken or similar for exact token counts
Approximation: ~4 characters per token (common for English text)
Args:
items: List of items to count (messages, strings, dicts, etc.)
Returns:
int: Approximate token count
"""
total_chars = 0
for item in items:
if isinstance(item, str):
total_chars += len(item)
elif isinstance(item, dict):
# Count all values in dict
total_chars += len(str(item))
else:
# Fallback: convert to string
total_chars += len(str(item))
# Approximate: 4 characters per token
return total_chars // 4
async def trim_to_fit(
self,
items: list[Any],
reserve_tokens: int = 512
) -> list[Any]:
"""
Trim items to fit within context window.
Strategy:
1. Reserve tokens for output generation
2. Keep most recent items that fit
3. Drop oldest items first
Args:
items: List of items to trim
reserve_tokens: Tokens to reserve for output (default 512)
Returns:
list: Trimmed list of items that fit in context
"""
available_tokens = self.max_tokens - reserve_tokens
if available_tokens <= 0:
# Edge case: reserve too large
return []
# Start from most recent, work backwards
kept_items = []
current_tokens = 0
for item in reversed(items):
item_tokens = await self.count_tokens([item])
if current_tokens + item_tokens <= available_tokens:
# Fits - keep this item
kept_items.insert(0, item)
current_tokens += item_tokens
else:
# Doesn't fit - stop here
break
return kept_items
async def fits_in_context(
self,
items: list[Any],
reserve_tokens: int = 512
) -> bool:
"""
Check if items fit within context window.
Args:
items: List of items to check
reserve_tokens: Tokens to reserve for output
Returns:
bool: True if items fit in context
"""
total_tokens = await self.count_tokens(items)
available_tokens = self.max_tokens - reserve_tokens
return total_tokens <= available_tokens
async def get_usage_stats(
self,
items: list[Any],
reserve_tokens: int = 512
) -> dict:
"""
Get context window usage statistics.
Args:
items: List of items to analyze
reserve_tokens: Tokens reserved for output
Returns:
dict: Usage statistics with keys:
- total_tokens: Total tokens in items
- max_tokens: Maximum context window
- reserved_tokens: Tokens reserved for output
- available_tokens: Tokens available for input
- usage_percent: Percentage of context used
- fits: Whether items fit in context
"""
total_tokens = await self.count_tokens(items)
available_tokens = self.max_tokens - reserve_tokens
usage_percent = (total_tokens / available_tokens * 100) if available_tokens > 0 else 100.0
return {
"total_tokens": total_tokens,
"max_tokens": self.max_tokens,
"reserved_tokens": reserve_tokens,
"available_tokens": available_tokens,
"usage_percent": round(usage_percent, 2),
"fits": total_tokens <= available_tokens
}
# ========================================================================
# Future: Exact Token Counting
# ========================================================================
async def count_tokens_exact(self, text: str, model: str = "gpt-3.5-turbo") -> int:
"""
Count exact tokens using tiktoken.
TODO: Integrate tiktoken library for exact token counting
TODO: Support multiple model encodings (GPT-4, Claude, etc.)
Args:
text: Text to count
model: Model name for encoding
Returns:
int: Exact token count (placeholder - returns approximate for now)
"""
# Placeholder - would use tiktoken here
return await self.count_tokens([text])
+158
View File
@@ -0,0 +1,158 @@
"""
Conversation history management.
Supports hybrid approach:
- Client sends full input array (OpenAI compatible)
- Optional conversation_id in metadata for server-side grouping
- Server can augment with vector memories (future)
"""
import hashlib
from typing import Dict, List
from src.responses.schemas import Response, ResponseRequest
class ConversationHistory:
"""
Conversation history management with hybrid approach.
The hybrid approach means:
1. Clients MUST send full conversation history in request.input[]
(OpenAI compatible - client maintains state)
2. Server optionally tracks conversations via metadata.conversation_id
(for analytics, debugging, future vector memory augmentation)
3. Server does NOT modify the input[] - respects client's context choices
Future enhancements:
- Vector memory integration (Qdrant)
- Semantic search across conversation history
- Auto-summarization of long contexts
"""
def __init__(self, max_turns: int = 20):
"""
Initialize conversation history manager.
Args:
max_turns: Maximum number of response turns to keep per conversation
"""
self._conversations: Dict[str, List[Response]] = {}
self._max_turns = max_turns
async def get_conversation_id(self, request: ResponseRequest) -> str:
"""
Get or generate conversation ID.
Priority:
1. metadata.conversation_id if provided by client
2. Generate deterministic ID from first message hash
Args:
request: Response request
Returns:
str: Conversation ID (16 character hex)
"""
if request.metadata and "conversation_id" in request.metadata:
return request.metadata["conversation_id"]
# Generate deterministic ID from first message
# This allows same conversation to be grouped even without explicit ID
first_msg = str(request.input[0]) if request.input else ""
return hashlib.sha256(first_msg.encode()).hexdigest()[:16]
async def add_response(
self,
conversation_id: str,
response: Response
) -> None:
"""
Add response to conversation history.
Args:
conversation_id: Conversation identifier
response: Response object to store
"""
if conversation_id not in self._conversations:
self._conversations[conversation_id] = []
self._conversations[conversation_id].append(response)
# Trim old turns to stay within limit
await self._trim_history(conversation_id)
async def get_history(self, conversation_id: str) -> List[Response]:
"""
Retrieve conversation history.
Args:
conversation_id: Conversation identifier
Returns:
list: List of Response objects (may be empty)
"""
return self._conversations.get(conversation_id, [])
async def get_conversation_count(self) -> int:
"""
Get total number of tracked conversations.
Returns:
int: Number of conversations
"""
return len(self._conversations)
async def clear_conversation(self, conversation_id: str) -> bool:
"""
Clear a specific conversation history.
Args:
conversation_id: Conversation identifier
Returns:
bool: True if conversation existed and was cleared
"""
if conversation_id in self._conversations:
del self._conversations[conversation_id]
return True
return False
async def _trim_history(self, conversation_id: str) -> None:
"""
Keep only recent turns within max_turns limit.
Args:
conversation_id: Conversation identifier
"""
if len(self._conversations[conversation_id]) > self._max_turns:
self._conversations[conversation_id] = (
self._conversations[conversation_id][-self._max_turns:]
)
# ========================================================================
# Future: Vector Memory Integration
# ========================================================================
async def get_relevant_memories(
self,
conversation_id: str,
query: str,
limit: int = 5
) -> List[dict]:
"""
Retrieve relevant memories from vector store.
TODO: Integrate Qdrant for semantic search across conversation history
TODO: Enable RAG (Retrieval Augmented Generation) for long conversations
TODO: Add memory importance scoring
Args:
conversation_id: Conversation identifier
query: Search query
limit: Maximum memories to return
Returns:
list: Relevant memories (empty for now - placeholder)
"""
# Placeholder for future Qdrant integration
return []
+116
View File
@@ -0,0 +1,116 @@
"""
Responses router.
OpenAI-compatible /v1/responses endpoint with streaming support.
"""
import logging
from fastapi import APIRouter, HTTPException
from sse_starlette.sse import EventSourceResponse
from src.responses import service
from src.responses.schemas import ResponseRequest, Response
from src.core.exceptions import ModelNotFoundError, AppException
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/responses", tags=["responses"])
@router.post("", response_model=Response)
async def create_response(
request: ResponseRequest,
) -> Response | EventSourceResponse:
"""
Create a response using Responses API format.
Supports:
- Reasoning summaries (thinking/reasoning display)
- Function calling (tool usage)
- Streaming responses
- Multi-turn conversations
- Error handling
Args:
request: Response request with model, input, optional reasoning/tools
Returns:
Response object or SSE stream
Example non-streaming request:
POST /v1/responses
{
"model": "lorem-tester",
"input": [{"role": "user", "content": "Hello"}],
"reasoning": {"effort": "medium", "summary": "auto"},
"stream": false
}
Example streaming request:
POST /v1/responses
{
"model": "lorem-tester",
"input": [{"role": "user", "content": "Hello"}],
"stream": true
}
Response format (non-streaming):
{
"id": "resp_...",
"object": "response",
"created_at": 1733529600,
"model": "lorem-tester",
"status": "completed",
"output": [
{
"type": "reasoning",
"id": "rs_...",
"summary": ["Analyzing...", "Considering..."]
},
{
"type": "message",
"id": "msg_...",
"role": "assistant",
"content": [{"type": "output_text", "text": "Lorem ipsum..."}]
}
],
"usage": {
"input_tokens": 10,
"output_tokens": 50,
"reasoning_tokens": 20,
"total_tokens": 80
}
}
Streaming format (SSE):
event: response.reasoning_summary_text.delta
data: {"delta": "Analyzing..."}
event: response.output_text.delta
data: {"delta": "Lorem"}
event: response.done
data: {"response": {...}}
"""
logger.info(f"Response request for model: {request.model}")
try:
if request.stream:
logger.info("Streaming response requested")
return EventSourceResponse(
service.create_response_stream(request)
)
return await service.create_response(request)
except ModelNotFoundError as e:
logger.error(f"Model not found: {e}")
raise HTTPException(status_code=404, detail=str(e))
except AppException as e:
logger.error(f"Application error: {e}")
raise HTTPException(status_code=e.status_code, detail=e.message)
except Exception as e:
logger.error(f"Unexpected error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
+232
View File
@@ -0,0 +1,232 @@
"""
Response schemas for Responses API.
OpenAI Responses API format with support for:
- Reasoning items (thinking/reasoning summaries)
- Function call items (tool execution)
- Message items (assistant responses)
- Streaming and non-streaming modes
"""
from typing import Literal, Any
from pydantic import Field, field_validator
from src.core.models import CustomBaseModel
# ============================================================================
# Output Item Schemas (appear in response.output array)
# ============================================================================
class OutputTextContent(CustomBaseModel):
"""Text content in message output."""
type: Literal["output_text"] = "output_text"
text: str
annotations: list[dict] = Field(default_factory=list)
class MessageOutputItem(CustomBaseModel):
"""
Message item in output array.
Represents the assistant's final response message.
"""
type: Literal["message"] = "message"
id: str
role: Literal["assistant"] = "assistant"
content: list[OutputTextContent]
status: Literal["completed", "in_progress", "failed"] = "completed"
class ReasoningOutputItem(CustomBaseModel):
"""
Reasoning item in output array.
Represents the model's thinking/reasoning process.
Displayed separately from the final answer.
"""
type: Literal["reasoning"] = "reasoning"
id: str
summary: list[str] # List of reasoning steps
status: Literal["completed", "in_progress", "failed"] = "completed"
class FunctionCallOutputItem(CustomBaseModel):
"""
Function call item in output array.
Represents a tool/function that the model wants to execute.
"""
type: Literal["function_call"] = "function_call"
id: str
name: str
arguments: str # JSON string of arguments
status: Literal["completed", "in_progress", "failed"] = "completed"
# Union type for all output items
# Type: ignore because Pydantic handles union types specially
OutputItem = MessageOutputItem | ReasoningOutputItem | FunctionCallOutputItem # type: ignore
# ============================================================================
# Usage Tracking
# ============================================================================
class ResponseUsage(CustomBaseModel):
"""Token usage statistics for the response."""
input_tokens: int
output_tokens: int
reasoning_tokens: int = 0
total_tokens: int
# ============================================================================
# Request Schema
# ============================================================================
class Tool(CustomBaseModel):
"""Tool/function definition."""
name: str
description: str
parameters: dict[str, Any]
class ReasoningConfig(CustomBaseModel):
"""Reasoning configuration."""
effort: Literal["none", "minimal", "low", "medium", "high", "xhigh"] = "medium"
summary: Literal["auto", "off"] = "auto"
class ResponseRequest(CustomBaseModel):
"""
Request to create a response.
OpenAI Responses API format with optional extensions.
"""
model: str = Field(description="Model ID to use")
input: list[dict] = Field(
description="Input messages or previous responses"
)
reasoning: dict[str, Any] | None = Field(
default=None,
description="Reasoning configuration: {effort: 'medium', summary: 'auto'}"
)
tools: list[dict] | None = Field(
default=None,
description="Available tools/functions"
)
metadata: dict[str, Any] | None = Field(
default=None,
description="Custom metadata (e.g., conversation_id for server-side tracking)"
)
stream: bool = Field(
default=False,
description="Enable streaming mode"
)
max_output_tokens: int | None = Field(
default=None,
description="Maximum tokens to generate"
)
temperature: float = Field(
default=1.0,
ge=0.0,
le=2.0,
description="Sampling temperature"
)
stop: list[str] | None = Field(
default=None,
description="Stop sequences"
)
@field_validator('reasoning')
@classmethod
def validate_reasoning(cls, v: dict[str, Any] | None) -> dict[str, Any] | None:
"""
Validate reasoning configuration.
Checks:
- effort must be valid level (none, minimal, low, medium, high, xhigh)
- summary must be 'auto' or 'off'
"""
if v is not None:
if 'effort' in v:
allowed_efforts = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']
if v['effort'] not in allowed_efforts:
raise ValueError(
f"reasoning.effort must be one of {allowed_efforts}, got '{v['effort']}'"
)
if 'summary' in v:
allowed_summaries = ['auto', 'off']
if v['summary'] not in allowed_summaries:
raise ValueError(
f"reasoning.summary must be one of {allowed_summaries}, got '{v['summary']}'"
)
return v
@field_validator('max_output_tokens')
@classmethod
def validate_max_output_tokens(cls, v: int | None) -> int | None:
"""
Validate max_output_tokens.
Must be positive if provided.
"""
if v is not None and v <= 0:
raise ValueError(f"max_output_tokens must be positive, got {v}")
return v
@field_validator('stop')
@classmethod
def validate_stop_sequences(cls, v: list[str] | None) -> list[str] | None:
"""
Validate stop sequences.
Checks:
- Maximum 4 stop sequences
- Each must be non-empty string
"""
if v is not None:
if len(v) > 4:
raise ValueError(f"Maximum 4 stop sequences allowed, got {len(v)}")
for seq in v:
if not seq or not isinstance(seq, str):
raise ValueError("Stop sequences must be non-empty strings")
return v
# ============================================================================
# Response Schema
# ============================================================================
class Response(CustomBaseModel):
"""
Complete response object.
Contains output array with reasoning, function calls, and messages.
"""
id: str = Field(description="Unique response ID")
object: Literal["response"] = "response"
created_at: int = Field(description="Unix timestamp")
model: str = Field(description="Model used")
status: Literal["completed", "in_progress", "failed", "cancelled"]
output: list[OutputItem] = Field(
description="Output items (reasoning, function_call, message)"
)
usage: ResponseUsage = Field(description="Token usage statistics")
# ============================================================================
# Error Schema
# ============================================================================
class ErrorDetail(CustomBaseModel):
"""Error detail object."""
type: str
message: str
code: int | None = None
class ErrorResponse(CustomBaseModel):
"""Error response format."""
error: ErrorDetail
+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
+375
View File
@@ -0,0 +1,375 @@
"""
Streaming event schemas and coordinator for Responses API.
Handles Server-Sent Events (SSE) streaming with proper event types:
- response.reasoning_summary_text.delta
- response.output_text.delta
- response.function_call_arguments.delta
- response.done
"""
from enum import Enum
from typing import Literal, AsyncGenerator
import json
import time
from src.core.models import CustomBaseModel
from src.responses.schemas import Response
from src.agents.registry import ModelRegistry
# ============================================================================
# Stream Event Types
# ============================================================================
class StreamEventType(str, Enum):
"""Streaming event types for Responses API."""
REASONING_SUMMARY_DELTA = "response.reasoning_summary_text.delta"
REASONING_SUMMARY_DONE = "response.reasoning_summary_text.done"
OUTPUT_TEXT_DELTA = "response.output_text.delta"
OUTPUT_TEXT_DONE = "response.output_text.done"
FUNCTION_CALL_DELTA = "response.function_call_arguments.delta"
FUNCTION_CALL_DONE = "response.function_call_arguments.done"
RESPONSE_DONE = "response.done"
ERROR = "error"
# ============================================================================
# Stream Event Schemas
# ============================================================================
class ReasoningSummaryDelta(CustomBaseModel):
"""Reasoning summary text delta event."""
event: Literal[StreamEventType.REASONING_SUMMARY_DELTA] = StreamEventType.REASONING_SUMMARY_DELTA
delta: str
class ReasoningSummaryDone(CustomBaseModel):
"""Reasoning summary completion event."""
event: Literal[StreamEventType.REASONING_SUMMARY_DONE] = StreamEventType.REASONING_SUMMARY_DONE
class OutputTextDelta(CustomBaseModel):
"""Output text delta event."""
event: Literal[StreamEventType.OUTPUT_TEXT_DELTA] = StreamEventType.OUTPUT_TEXT_DELTA
delta: str
class OutputTextDone(CustomBaseModel):
"""Output text completion event."""
event: Literal[StreamEventType.OUTPUT_TEXT_DONE] = StreamEventType.OUTPUT_TEXT_DONE
class FunctionCallDelta(CustomBaseModel):
"""Function call arguments delta event."""
event: Literal[StreamEventType.FUNCTION_CALL_DELTA] = StreamEventType.FUNCTION_CALL_DELTA
delta: str
name: str | None = None # Only in first chunk
class FunctionCallDone(CustomBaseModel):
"""Function call completion event."""
event: Literal[StreamEventType.FUNCTION_CALL_DONE] = StreamEventType.FUNCTION_CALL_DONE
class ResponseDone(CustomBaseModel):
"""Response completion event with full response."""
event: Literal[StreamEventType.RESPONSE_DONE] = StreamEventType.RESPONSE_DONE
response: Response
class ErrorEvent(CustomBaseModel):
"""Error event."""
event: Literal[StreamEventType.ERROR] = StreamEventType.ERROR
error: dict
# Union type for all stream events
StreamEvent = (
ReasoningSummaryDelta |
ReasoningSummaryDone |
OutputTextDelta |
OutputTextDone |
FunctionCallDelta |
FunctionCallDone |
ResponseDone |
ErrorEvent
)
# ============================================================================
# Streaming Coordinator
# ============================================================================
class StreamingCoordinator:
"""
Coordinates streaming from agents to SSE format.
Real production streaming logic that handles:
1. Reasoning summary chunks
2. Function call arguments
3. Output text chunks
4. Error handling
5. Final response event
"""
async def stream_response(
self,
request: "ResponseRequest" # type: ignore # Forward reference
) -> AsyncGenerator[StreamEvent, None]:
"""
Coordinate streaming from agent to SSE events.
Args:
request: Response request
Yields:
StreamEvent: Stream of SSE events
Example SSE output:
event: response.reasoning_summary_text.delta
data: {"delta": "Analyzing..."}
event: response.output_text.delta
data: {"delta": "Lorem"}
event: response.done
data: {"response": {...}}
"""
from src.responses.service import _calculate_usage, generate_id
import asyncio
output_items = []
try:
# 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)
# Stream from agent
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)
# Stream based on item type
if item.type == "reasoning":
# Stream reasoning summary
for step in item.data.get("summary", []):
yield ReasoningSummaryDelta(delta=step + " ")
await asyncio.sleep(0.05) # Simulate typing
yield ReasoningSummaryDone()
elif item.type == "function_call":
# Stream function call arguments
# First chunk includes name
yield FunctionCallDelta(
name=item.data["name"],
delta=""
)
# Stream arguments in chunks
args = item.data["arguments"]
chunk_size = 20
for i in range(0, len(args), chunk_size):
yield FunctionCallDelta(
delta=args[i:i+chunk_size]
)
await asyncio.sleep(0.03)
yield FunctionCallDone()
elif item.type == "message":
# Stream output text with stop sequence and max tokens enforcement
text = item.data["content"][0]["text"]
words = text.split()
# Track accumulated text and tokens for enforcement
accumulated_text = ""
output_tokens = 0
for word in words:
# Add word to accumulated text
word_with_space = f"{word} "
accumulated_text += word_with_space
# Check stop sequences
stop_found, text_before_stop = self._check_stop_sequence(
accumulated_text,
request.stop
)
if stop_found:
# Emit final text before stop sequence
remaining_text = text_before_stop[len(accumulated_text) - len(word_with_space):]
if remaining_text:
yield OutputTextDelta(delta=remaining_text)
yield OutputTextDone()
break
# Check max tokens
output_tokens = self._count_tokens_approx(accumulated_text)
if self._check_max_tokens(output_tokens, request.max_output_tokens):
# Max tokens reached - stop streaming
yield OutputTextDone()
break
# Normal streaming
yield OutputTextDelta(delta=word_with_space)
await asyncio.sleep(0.05) # Simulate typing
else:
# Completed normally without stop/limit
yield OutputTextDone()
# Final response.done event with complete response
usage = _calculate_usage(request.input, output_items)
final_response = Response(
id=f"resp_{generate_id()}",
created_at=int(time.time()),
model=request.model,
status="completed",
output=self._convert_output_items(output_items),
usage=usage
)
# Track conversation history (import here to avoid circular dependency)
from src.responses.service import _conversation_history
conversation_id = await _conversation_history.get_conversation_id(request)
await _conversation_history.add_response(conversation_id, final_response)
yield ResponseDone(response=final_response)
except Exception as e:
# Stream error event
yield self._create_error_event(e)
def _convert_output_items(self, items: list) -> list:
"""Convert agent OutputItem objects to schema OutputItem objects."""
from src.responses.schemas import (
MessageOutputItem,
ReasoningOutputItem,
FunctionCallOutputItem,
OutputTextContent,
)
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
def _create_error_event(self, error: Exception) -> ErrorEvent:
"""Create error event from exception."""
from src.core.exceptions import (
RateLimitError,
ContextLengthError,
AppException,
)
if isinstance(error, RateLimitError):
error_type = "rate_limit_exceeded"
code = 429
elif isinstance(error, ContextLengthError):
error_type = "context_length_exceeded"
code = 400
elif isinstance(error, AppException):
error_type = "app_error"
code = error.status_code
else:
error_type = "internal_error"
code = 500
return ErrorEvent(
error={
"type": error_type,
"message": str(error),
"code": code
}
)
def _check_stop_sequence(
self,
accumulated_text: str,
stop_sequences: list[str] | None
) -> tuple[bool, str]:
"""
Check if any stop sequence is encountered.
Args:
accumulated_text: Text accumulated so far
stop_sequences: List of stop sequences to check
Returns:
tuple: (stop_found, text_before_stop)
- stop_found: True if stop sequence found
- text_before_stop: Text before the stop sequence (or full text if not found)
"""
if not stop_sequences:
return False, accumulated_text
for stop_seq in stop_sequences:
if stop_seq in accumulated_text:
# Found stop sequence - return text before it
idx = accumulated_text.index(stop_seq)
return True, accumulated_text[:idx]
return False, accumulated_text
def _count_tokens_approx(self, text: str) -> int:
"""
Approximate token count (4 chars per token).
Args:
text: Text to count
Returns:
int: Approximate token count
"""
return len(text) // 4
def _check_max_tokens(
self,
current_tokens: int,
max_tokens: int | None
) -> bool:
"""
Check if max tokens limit reached.
Args:
current_tokens: Current token count
max_tokens: Maximum allowed tokens (None for unlimited)
Returns:
bool: True if limit reached
"""
if max_tokens is None:
return False
return current_tokens >= max_tokens