chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
"""Minimal FastAPI server for handling OpenAI Realtime SIP calls with Twilio."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
import websockets
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from openai import APIStatusError, AsyncOpenAI, InvalidWebhookSignatureError
|
||||
|
||||
from agents.realtime.config import RealtimeSessionModelSettings
|
||||
from agents.realtime.items import (
|
||||
AssistantAudio,
|
||||
AssistantMessageItem,
|
||||
AssistantText,
|
||||
InputText,
|
||||
UserMessageItem,
|
||||
)
|
||||
from agents.realtime.model_inputs import RealtimeModelSendRawMessage
|
||||
from agents.realtime.openai_realtime import OpenAIRealtimeSIPModel
|
||||
from agents.realtime.runner import RealtimeRunner
|
||||
|
||||
from .agents import WELCOME_MESSAGE, get_starting_agent
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
logger = logging.getLogger("twilio_sip_example")
|
||||
|
||||
|
||||
def _get_env(name: str) -> str:
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
raise RuntimeError(f"Missing environment variable: {name}")
|
||||
return value
|
||||
|
||||
|
||||
OPENAI_API_KEY = _get_env("OPENAI_API_KEY")
|
||||
OPENAI_WEBHOOK_SECRET = _get_env("OPENAI_WEBHOOK_SECRET")
|
||||
|
||||
client = AsyncOpenAI(api_key=OPENAI_API_KEY, webhook_secret=OPENAI_WEBHOOK_SECRET)
|
||||
|
||||
# Build the multi-agent graph (triage + specialist agents) from agents.py.
|
||||
assistant_agent = get_starting_agent()
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Track background tasks so repeated webhooks do not spawn duplicates.
|
||||
active_call_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
|
||||
|
||||
async def accept_call(call_id: str) -> None:
|
||||
"""Accept the incoming SIP call and configure the realtime session."""
|
||||
|
||||
# The starting agent uses static instructions, so we can forward them directly to the accept
|
||||
# call payload. If someone swaps in a dynamic prompt, fall back to a sensible default.
|
||||
instructions_payload = (
|
||||
assistant_agent.instructions
|
||||
if isinstance(assistant_agent.instructions, str)
|
||||
else "You are a helpful triage agent for ABC customer service."
|
||||
)
|
||||
|
||||
try:
|
||||
# AsyncOpenAI does not yet expose high-level helpers like client.realtime.calls.accept, so
|
||||
# we call the REST endpoint directly via client.post(). Keep this until the SDK grows an
|
||||
# async helper.
|
||||
await client.post(
|
||||
f"/realtime/calls/{call_id}/accept",
|
||||
body={
|
||||
"type": "realtime",
|
||||
"model": "gpt-realtime-2.1",
|
||||
"instructions": instructions_payload,
|
||||
},
|
||||
cast_to=dict,
|
||||
)
|
||||
except APIStatusError as exc:
|
||||
if exc.status_code == 404:
|
||||
# Twilio occasionally retries webhooks after the caller hangs up; treat as a no-op so
|
||||
# the webhook still returns 200.
|
||||
logger.warning(
|
||||
"Call %s no longer exists when attempting accept (404). Skipping.", call_id
|
||||
)
|
||||
return
|
||||
|
||||
detail = exc.message
|
||||
if exc.response is not None:
|
||||
try:
|
||||
detail = exc.response.text
|
||||
except Exception: # noqa: BLE001
|
||||
detail = str(exc.response)
|
||||
|
||||
logger.error("Failed to accept call %s: %s %s", call_id, exc.status_code, detail)
|
||||
raise HTTPException(status_code=500, detail="Failed to accept call") from exc
|
||||
|
||||
logger.info("Accepted call %s", call_id)
|
||||
|
||||
|
||||
async def observe_call(call_id: str) -> None:
|
||||
"""Attach to the realtime session and log conversation events."""
|
||||
|
||||
runner = RealtimeRunner(assistant_agent, model=OpenAIRealtimeSIPModel())
|
||||
|
||||
try:
|
||||
initial_model_settings: RealtimeSessionModelSettings = {
|
||||
"turn_detection": {
|
||||
"type": "semantic_vad",
|
||||
"interrupt_response": True,
|
||||
}
|
||||
}
|
||||
async with await runner.run(
|
||||
model_config={
|
||||
"call_id": call_id,
|
||||
"initial_model_settings": initial_model_settings,
|
||||
}
|
||||
) as session:
|
||||
# Trigger an initial greeting so callers hear the agent right away.
|
||||
# Issue a response.create immediately after the WebSocket attaches so the model speaks
|
||||
# before the caller says anything. Using the raw client message ensures zero latency
|
||||
# and avoids threading the greeting through history.
|
||||
await session.model.send_event(
|
||||
RealtimeModelSendRawMessage(
|
||||
message={
|
||||
"type": "response.create",
|
||||
"other_data": {
|
||||
"response": {
|
||||
"instructions": (
|
||||
"Say exactly '"
|
||||
f"{WELCOME_MESSAGE}"
|
||||
"' now before continuing the conversation."
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
async for event in session:
|
||||
if event.type == "history_added":
|
||||
item = event.item
|
||||
if isinstance(item, UserMessageItem):
|
||||
for user_content in item.content:
|
||||
if isinstance(user_content, InputText) and user_content.text:
|
||||
logger.info("Caller: %s", user_content.text)
|
||||
elif isinstance(item, AssistantMessageItem):
|
||||
for assistant_content in item.content:
|
||||
if (
|
||||
isinstance(assistant_content, AssistantText)
|
||||
and assistant_content.text
|
||||
):
|
||||
logger.info("Assistant (text): %s", assistant_content.text)
|
||||
elif (
|
||||
isinstance(assistant_content, AssistantAudio)
|
||||
and assistant_content.transcript
|
||||
):
|
||||
logger.info(
|
||||
"Assistant (audio transcript): %s",
|
||||
assistant_content.transcript,
|
||||
)
|
||||
elif event.type == "error":
|
||||
logger.error("Realtime session error: %s", event.error)
|
||||
|
||||
except websockets.exceptions.ConnectionClosedError:
|
||||
# Callers hanging up causes the WebSocket to close without a frame; log at info level so it
|
||||
# does not surface as an error.
|
||||
logger.info("Realtime WebSocket closed for call %s", call_id)
|
||||
except Exception as exc: # noqa: BLE001 - demo logging only
|
||||
logger.exception("Error while observing call %s", call_id, exc_info=exc)
|
||||
finally:
|
||||
logger.info("Call %s ended", call_id)
|
||||
active_call_tasks.pop(call_id, None)
|
||||
|
||||
|
||||
def _track_call_task(call_id: str) -> None:
|
||||
existing = active_call_tasks.get(call_id)
|
||||
if existing:
|
||||
if not existing.done():
|
||||
logger.info(
|
||||
"Call %s already has an active observer; ignoring duplicate webhook delivery.",
|
||||
call_id,
|
||||
)
|
||||
return
|
||||
# Remove completed tasks so a new observer can start for a fresh call.
|
||||
active_call_tasks.pop(call_id, None)
|
||||
|
||||
task = asyncio.create_task(observe_call(call_id))
|
||||
active_call_tasks[call_id] = task
|
||||
|
||||
|
||||
@app.post("/openai/webhook")
|
||||
async def openai_webhook(request: Request) -> Response:
|
||||
body = await request.body()
|
||||
|
||||
try:
|
||||
event = client.webhooks.unwrap(body, request.headers)
|
||||
except InvalidWebhookSignatureError as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid webhook signature") from exc
|
||||
|
||||
if event.type == "realtime.call.incoming":
|
||||
call_id = event.data.call_id
|
||||
await accept_call(call_id)
|
||||
_track_call_task(call_id)
|
||||
return Response(status_code=200)
|
||||
|
||||
# Ignore other webhook event types for brevity.
|
||||
return Response(status_code=200)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def healthcheck() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
Reference in New Issue
Block a user