chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:39:17 +08:00
commit 4ed4e9ff99
1368 changed files with 334957 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
# Twilio SIP Realtime Example
This example shows how to handle OpenAI Realtime SIP calls with the Agents SDK. Incoming calls are accepted through the Realtime Calls API, a triage agent answers with a fixed greeting, and handoffs route the caller to specialist agents (FAQ lookup and record updates) similar to the realtime UI demo.
## Prerequisites
- Python 3.10+
- An OpenAI API key with Realtime API access
- A configured webhook secret for your OpenAI project
- A Twilio account with a phone number and Elastic SIP Trunking enabled
- A public HTTPS endpoint for local development (for example, [ngrok](https://ngrok.com/))
## Configure OpenAI
1. In [platform settings](https://platform.openai.com/settings) select your project.
2. Create a webhook pointing to `https://<your-public-host>/openai/webhook` with "realtime.call.incoming" event type and note the signing secret. The example verifies each webhook with `OPENAI_WEBHOOK_SECRET`.
## Configure Twilio Elastic SIP Trunking
1. Create (or edit) an Elastic SIP trunk.
2. On the **Origination** tab, add an origination SIP URI of `sip:proj_<your_project_id>@sip.api.openai.com;transport=tls` so Twilio sends inbound calls to OpenAI. (The Termination tab always ends with `.pstn.twilio.com`, so leave it unchanged.)
3. Add at least one phone number to the trunk so inbound calls are forwarded to OpenAI.
## Setup
1. Install dependencies:
```bash
uv pip install -r examples/realtime/twilio_sip/requirements.txt
```
2. Export required environment variables:
```bash
export OPENAI_API_KEY="sk-..."
export OPENAI_WEBHOOK_SECRET="whsec_..."
```
3. (Optional) Adjust the multi-agent logic in `examples/realtime/twilio_sip/agents.py` if you want to change the specialist agents or tools.
4. Run the FastAPI server:
```bash
uv run uvicorn examples.realtime.twilio_sip.server:app --host 0.0.0.0 --port 8000
```
5. Expose the server publicly (example with ngrok):
```bash
ngrok http 8000
```
## Test a Call
1. Place a call to the Twilio number attached to the SIP trunk.
2. Twilio sends the call to `sip.api.openai.com`; OpenAI fires `realtime.call.incoming`, which this example accepts.
3. The triage agent greets the caller, then either keeps the conversation or hands off to:
- **FAQ Agent** answers common questions via `faq_lookup_tool`.
- **Records Agent** writes short notes using `update_customer_record`.
4. The background task attaches to the call and logs transcripts plus basic events in the console.
You can edit `server.py` to change instructions, add tools, or integrate with internal systems once the SIP session is active.
+1
View File
@@ -0,0 +1 @@
"""OpenAI Realtime SIP example package."""
+94
View File
@@ -0,0 +1,94 @@
"""Realtime agent definitions shared by the Twilio SIP example."""
from __future__ import annotations
import asyncio
from agents import function_tool
from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX
from agents.realtime import RealtimeAgent, realtime_handoff
# --- Tools -----------------------------------------------------------------
WELCOME_MESSAGE = "Hello, this is ABC customer service. How can I help you today?"
@function_tool(
name_override="faq_lookup_tool", description_override="Lookup frequently asked questions."
)
async def faq_lookup_tool(question: str) -> str:
"""Fetch FAQ answers for the caller."""
await asyncio.sleep(3)
q = question.lower()
if "plan" in q or "wifi" in q or "wi-fi" in q:
return "We provide complimentary Wi-Fi. Join the ABC-Customer network." # demo data
if "billing" in q or "invoice" in q:
return "Your latest invoice is available in the ABC portal under Billing > History."
if "hours" in q or "support" in q:
return "Human support agents are available 24/7; transfer to the specialist if needed."
return "I'm not sure about that. Let me transfer you back to the triage agent."
@function_tool
async def update_customer_record(customer_id: str, note: str) -> str:
"""Record a short note about the caller."""
await asyncio.sleep(1)
return f"Recorded note for {customer_id}: {note}"
# --- Agents ----------------------------------------------------------------
faq_agent = RealtimeAgent(
name="FAQ Agent",
handoff_description="Handles frequently asked questions and general account inquiries.",
instructions=f"""{RECOMMENDED_PROMPT_PREFIX}
You are an FAQ specialist. Always rely on the faq_lookup_tool for answers and keep replies
concise. If the caller needs hands-on help, transfer back to the triage agent.
""",
tools=[faq_lookup_tool],
)
records_agent = RealtimeAgent(
name="Records Agent",
handoff_description="Updates customer records with brief notes and confirmation numbers.",
instructions=f"""{RECOMMENDED_PROMPT_PREFIX}
You handle structured updates. Confirm the customer's ID, capture their request in a short
note, and use the update_customer_record tool. For anything outside data updates, return to the
triage agent.
""",
tools=[update_customer_record],
)
triage_agent = RealtimeAgent(
name="Triage Agent",
handoff_description="Greets callers and routes them to the most appropriate specialist.",
instructions=(
f"{RECOMMENDED_PROMPT_PREFIX} "
"Always begin the call by saying exactly: '"
f"{WELCOME_MESSAGE}' "
"before collecting details. Once the greeting is complete, gather context and hand off to "
"the FAQ or Records agents when appropriate."
),
handoffs=[
realtime_handoff(faq_agent, tool_name_override="transfer_to_faq_agent"),
realtime_handoff(records_agent, tool_name_override="transfer_to_records_agent"),
],
)
faq_agent.handoffs.append(
realtime_handoff(triage_agent, tool_name_override="transfer_to_triage_agent")
)
records_agent.handoffs.append(
realtime_handoff(triage_agent, tool_name_override="transfer_to_triage_agent")
)
def get_starting_agent() -> RealtimeAgent:
"""Return the agent used to start each realtime call."""
return triage_agent
@@ -0,0 +1,3 @@
fastapi>=0.120.0
openai>=2.2,<3
uvicorn[standard]>=0.38.0
+211
View File
@@ -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"}