Files
2026-07-13 13:39:38 +08:00

493 lines
17 KiB
Python

from __future__ import annotations
import asyncio
import os
import wave
from collections.abc import Callable
from pathlib import Path
import numpy as np
import pytest
from dotenv import load_dotenv
from openai.types import realtime
from livekit import rtc
from livekit.agents import RunContext, function_tool, llm, utils
from livekit.plugins import openai, xai
pytestmark = pytest.mark.realtime
TESTS_DIR = Path(__file__).parent
SAMPLE_RATE = 24000
@pytest.fixture(scope="session", autouse=True)
def load_env():
load_dotenv(override=True)
def _openai_model() -> openai.realtime.RealtimeModel:
return openai.realtime.RealtimeModel(
voice="alloy",
input_audio_transcription=realtime.AudioTranscription(model="gpt-4o-mini-transcribe"),
)
def _azure_model() -> openai.realtime.RealtimeModel:
return openai.realtime.RealtimeModel(
azure_deployment=os.environ["AZURE_OPENAI_DEPLOYMENT"],
api_key=os.environ["AZURE_OPENAI_API_KEY"],
api_version=os.environ["AZURE_OPENAI_API_VERSION"],
base_url=os.environ["AZURE_OPENAI_ENDPOINT"] + "/openai",
voice="alloy",
)
def _xai_model() -> xai.realtime.RealtimeModel:
return xai.realtime.RealtimeModel()
_skip_xai = pytest.mark.skipif(not os.environ.get("XAI_API_KEY"), reason="XAI_API_KEY not set")
REALTIME_MODELS: list = [
pytest.param(_openai_model, id="openai"),
pytest.param(_azure_model, id="azure"),
pytest.param(_xai_model, id="xai", marks=_skip_xai),
]
# xAI doesn't support conversation.item.delete or sequential response.create
OPENAI_AND_AZURE: list = [
pytest.param(_openai_model, id="openai"),
pytest.param(_azure_model, id="azure"),
]
@pytest.fixture
async def rt_session(request, job_process):
factory: Callable = request.param
model = factory()
session = model.session()
yield session
await session.aclose()
# small delay between tests to avoid rate limiting (especially xAI)
await asyncio.sleep(0.5)
# -- Helpers --
async def _collect_text(gen_ev: llm.GenerationCreatedEvent) -> str:
parts: list[str] = []
async for msg_gen in gen_ev.message_stream:
async for chunk in msg_gen.text_stream:
parts.append(chunk)
return "".join(parts)
def _silence(duration_s: float = 2.0) -> rtc.AudioFrame:
n = int(SAMPLE_RATE * duration_s)
return rtc.AudioFrame(
data=bytes(n * 2), sample_rate=SAMPLE_RATE, num_channels=1, samples_per_channel=n
)
def _load_wav(name: str) -> list[rtc.AudioFrame]:
with wave.open(str(TESTS_DIR / f"{name}.wav"), "rb") as wf:
sr, ch = wf.getframerate(), wf.getnchannels()
raw = wf.readframes(wf.getnframes())
chunk = sr // 50 # 20ms
frames, off = [], 0
while off + chunk <= len(raw) // (ch * 2):
frames.append(
rtc.AudioFrame(
data=raw[off * 2 * ch : (off + chunk) * 2 * ch],
sample_rate=sr,
num_channels=ch,
samples_per_channel=chunk,
)
)
off += chunk
return frames
async def _push_speech(session: llm.RealtimeSession, wav_name: str = "hello_world") -> None:
for frame in _load_wav(wav_name):
session.push_audio(frame)
await asyncio.sleep(0.02)
session.push_audio(_silence())
# -- Basic generation --
@pytest.mark.parametrize("rt_session", REALTIME_MODELS, indirect=True)
async def test_generate_reply(rt_session: llm.RealtimeSession):
gen_ev = await asyncio.wait_for(rt_session.generate_reply(), timeout=15)
assert gen_ev.user_initiated
text = await asyncio.wait_for(_collect_text(gen_ev), timeout=15)
assert len(text) > 0
@pytest.mark.parametrize("rt_session", REALTIME_MODELS, indirect=True)
async def test_generate_reply_with_instructions(rt_session: llm.RealtimeSession):
gen_ev = await asyncio.wait_for(
rt_session.generate_reply(instructions="Say exactly: pineapple"),
timeout=15,
)
text = await asyncio.wait_for(_collect_text(gen_ev), timeout=15)
assert "pineapple" in text.lower()
@pytest.mark.parametrize("rt_session", REALTIME_MODELS, indirect=True)
async def test_generate_reply_preserves_session_instructions(rt_session: llm.RealtimeSession):
await rt_session.update_instructions("Your name is Kelly. Always respond in English.")
gen_ev = await asyncio.wait_for(
rt_session.generate_reply(instructions="Tell the user what your name is."),
timeout=15,
)
text = await asyncio.wait_for(_collect_text(gen_ev), timeout=15)
assert "kelly" in text.lower(), f"Expected 'kelly' in response, got: {text}"
@pytest.mark.parametrize("rt_session", OPENAI_AND_AZURE, indirect=True)
async def test_multiple_sequential_replies(rt_session: llm.RealtimeSession):
for i in range(2):
gen_ev = await asyncio.wait_for(
rt_session.generate_reply(instructions=f"Say the number {i}"),
timeout=20,
)
text = await asyncio.wait_for(_collect_text(gen_ev), timeout=15)
assert len(text) > 0, f"Reply {i} was empty"
# -- Audio I/O --
@pytest.mark.parametrize("rt_session", REALTIME_MODELS, indirect=True)
async def test_response_includes_audio(rt_session: llm.RealtimeSession):
gen_ev = await asyncio.wait_for(rt_session.generate_reply(), timeout=15)
got_audio = False
async for msg_gen in gen_ev.message_stream:
modalities = await asyncio.wait_for(msg_gen.modalities, timeout=10)
assert "audio" in modalities
async for frame in msg_gen.audio_stream:
assert frame.sample_rate == SAMPLE_RATE
assert frame.num_channels == 1
assert len(frame.data) > 0
got_audio = True
break
assert got_audio
@pytest.mark.parametrize("rt_session", REALTIME_MODELS, indirect=True)
async def test_push_audio(rt_session: llm.RealtimeSession):
t = np.linspace(0, 0.5, SAMPLE_RATE // 2, dtype=np.float32)
samples = (np.sin(2 * np.pi * 440 * t) * 16000).astype(np.int16)
frame = rtc.AudioFrame(
data=samples.tobytes(),
sample_rate=SAMPLE_RATE,
num_channels=1,
samples_per_channel=len(samples),
)
rt_session.push_audio(frame)
rt_session.commit_audio()
@pytest.mark.parametrize("rt_session", REALTIME_MODELS, indirect=True)
async def test_push_real_audio_triggers_response(rt_session: llm.RealtimeSession):
generation_received = asyncio.Event()
gen_events: list[llm.GenerationCreatedEvent] = []
def on_gen(ev: llm.GenerationCreatedEvent):
gen_events.append(ev)
generation_received.set()
rt_session.on("generation_created", on_gen)
await _push_speech(rt_session)
await asyncio.wait_for(generation_received.wait(), timeout=15)
assert not gen_events[0].user_initiated
text = await asyncio.wait_for(_collect_text(gen_events[0]), timeout=15)
assert len(text) > 0
@pytest.mark.parametrize("rt_session", REALTIME_MODELS, indirect=True)
async def test_vad_speech_events(rt_session: llm.RealtimeSession):
speech_started = asyncio.Event()
speech_stopped = asyncio.Event()
rt_session.on("input_speech_started", lambda _: speech_started.set())
rt_session.on("input_speech_stopped", lambda _: speech_stopped.set())
await _push_speech(rt_session)
await asyncio.wait_for(speech_started.wait(), timeout=10)
await asyncio.wait_for(speech_stopped.wait(), timeout=10)
@pytest.mark.parametrize("rt_session", REALTIME_MODELS, indirect=True)
async def test_input_audio_transcription(rt_session: llm.RealtimeSession):
transcripts: list[str] = []
final_received = asyncio.Event()
def on_transcript(ev: llm.InputTranscriptionCompleted):
transcripts.append(ev.transcript)
if ev.is_final:
final_received.set()
rt_session.on("input_audio_transcription_completed", on_transcript)
await _push_speech(rt_session, "weather_question")
rt_session.commit_audio()
await asyncio.wait_for(final_received.wait(), timeout=15)
assert transcripts, "no transcript received"
full = transcripts[-1].lower()
assert "weather" in full or "paris" in full
# -- Chat context --
@pytest.mark.parametrize("rt_session", REALTIME_MODELS, indirect=True)
async def test_update_chat_ctx(rt_session: llm.RealtimeSession):
chat_ctx = llm.ChatContext()
# TODO(long): fix assistant message for openai azure
# chat_ctx.add_message(role="assistant", content="What is your favorite number?")
chat_ctx.add_message(role="user", content="My favorite number is seven")
await asyncio.wait_for(rt_session.update_chat_ctx(chat_ctx), timeout=10)
gen_ev = await asyncio.wait_for(
rt_session.generate_reply(
instructions="What is the user's favorite number? Reply with just the number."
),
timeout=15,
)
text = await asyncio.wait_for(_collect_text(gen_ev), timeout=15)
assert "seven" in text.lower() or "7" in text
@pytest.mark.parametrize("rt_session", REALTIME_MODELS, indirect=True)
async def test_chat_ctx_populated_after_reply(rt_session: llm.RealtimeSession):
gen_ev = await asyncio.wait_for(rt_session.generate_reply(), timeout=15)
await asyncio.wait_for(_collect_text(gen_ev), timeout=15)
ctx = rt_session.chat_ctx
assert len(ctx.items) > 0
assert any(item.type == "message" and item.role == "assistant" for item in ctx.items)
# xAI doesn't support conversation.item.delete
@pytest.mark.parametrize("rt_session", OPENAI_AND_AZURE, indirect=True)
async def test_update_chat_ctx_replaces_history(rt_session: llm.RealtimeSession):
ctx1 = llm.ChatContext()
ctx1.add_message(role="user", content="Remember: color is red")
await asyncio.wait_for(rt_session.update_chat_ctx(ctx1), timeout=10)
ctx2 = llm.ChatContext()
ctx2.add_message(role="user", content="Remember: color is blue")
await asyncio.wait_for(rt_session.update_chat_ctx(ctx2), timeout=10)
gen_ev = await asyncio.wait_for(
rt_session.generate_reply(instructions="What color did the user mention?"),
timeout=15,
)
text = await asyncio.wait_for(_collect_text(gen_ev), timeout=15)
assert "blue" in text.lower()
# -- Interruption --
@pytest.mark.parametrize("rt_session", REALTIME_MODELS, indirect=True)
async def test_interrupt(rt_session: llm.RealtimeSession):
gen_ev = await asyncio.wait_for(
rt_session.generate_reply(
instructions="Write a very long essay about the history of computing."
),
timeout=15,
)
got_chunk = False
async for msg_gen in gen_ev.message_stream:
async for _ in msg_gen.text_stream:
got_chunk = True
rt_session.interrupt()
break
break
assert got_chunk
@pytest.mark.parametrize("rt_session", OPENAI_AND_AZURE, indirect=True)
async def test_generate_reply_cancellation(rt_session: llm.RealtimeSession):
"""Cancelling the generate_reply future before response.created arrives
should cancel the in-flight response server-side so a subsequent
generate_reply does not conflict with an active response."""
fut = rt_session.generate_reply(
instructions="Write a very long essay about the history of computing."
)
fut.cancel()
assert fut.cancelled()
# Brief wait so the response.cancel reaches the server before the next call.
await asyncio.sleep(1.0)
gen_ev = await asyncio.wait_for(
rt_session.generate_reply(instructions="Say exactly: pineapple"),
timeout=15,
)
text = await asyncio.wait_for(_collect_text(gen_ev), timeout=15)
assert "pineapple" in text.lower()
# -- Function tools --
@pytest.mark.parametrize("rt_session", REALTIME_MODELS, indirect=True)
async def test_function_tool(rt_session: llm.RealtimeSession):
@function_tool
async def get_weather(ctx: RunContext, city: str) -> str:
"""Get the weather for a city.
Args:
city: The city name
"""
return f"Weather in {city}: sunny, 72F"
await rt_session.update_tools([get_weather])
gen_ev = await asyncio.wait_for(
rt_session.generate_reply(instructions="What's the weather in Paris? Use the tool."),
timeout=15,
)
got_function_call = False
async for fn_call in gen_ev.function_stream:
assert fn_call.name == "get_weather"
got_function_call = True
assert got_function_call
@pytest.mark.parametrize("rt_session", REALTIME_MODELS, indirect=True)
async def test_function_tool_reply(rt_session: llm.RealtimeSession):
@function_tool
async def get_capital(ctx: RunContext, country: str) -> str:
"""Get the capital of a country.
Args:
country: The country name
"""
return "Paris"
await rt_session.update_tools([get_capital])
gen_ev = await asyncio.wait_for(
rt_session.generate_reply(instructions="What is the capital of France? Use the tool."),
timeout=15,
)
fn_call: llm.FunctionCall | None = None
async for fc in gen_ev.function_stream:
fn_call = fc
assert fn_call is not None
chat_ctx = rt_session.chat_ctx.copy()
chat_ctx.items.append(
llm.FunctionCallOutput(
id=utils.shortuuid(),
call_id=fn_call.call_id,
output="Paris",
is_error=False,
)
)
await asyncio.wait_for(rt_session.update_chat_ctx(chat_ctx), timeout=10)
gen_ev2 = await asyncio.wait_for(rt_session.generate_reply(), timeout=15)
text = await asyncio.wait_for(_collect_text(gen_ev2), timeout=15)
assert "paris" in text.lower()
# -- Session reuse across handoffs --
@pytest.mark.parametrize(
"model_factory",
[
pytest.param(_openai_model, id="openai"),
],
)
async def test_reuse_session_across_handoff(model_factory: Callable[[], llm.RealtimeModel]):
"""Populate a session with long history, simulate agent handoffs in four modes,
validate correctness, and print handoff + first reply timing."""
model = model_factory()
loop = asyncio.get_event_loop()
# build a long chat context
long_ctx = llm.ChatContext()
for i in range(10):
long_ctx.add_message(role="user", content=f"Tell me fact #{i + 1}")
long_ctx.add_message(role="assistant", content=f"Fact #{i + 1}: {i * 7} is interesting.")
new_instructions = "You are a math tutor. Answer in one sentence."
question = "What is 2 + 2?"
timings: list[tuple[str, float, float]] = [] # (label, handoff_time, reply_time)
async def _run_trial(label: str, *, reuse: bool, chat_ctx: llm.ChatContext) -> None:
if reuse:
session = model.session()
await asyncio.wait_for(session.update_chat_ctx(long_ctx), timeout=15)
else:
session = model.session()
try:
t0 = loop.time()
# measure handoff
await asyncio.wait_for(
session._update_session(instructions=new_instructions, chat_ctx=chat_ctx),
timeout=15,
)
handoff_time = loop.time() - t0
# measure first reply after handoff
ctx = session.chat_ctx.copy()
ctx.add_message(role="user", content=question)
await asyncio.wait_for(session.update_chat_ctx(ctx), timeout=15)
# t1 = loop.time()
gen = await asyncio.wait_for(session.generate_reply(), timeout=15)
reply_time = loop.time() - t0
text = await asyncio.wait_for(_collect_text(gen), timeout=15)
assert "4" in text
print(f"{label} handoff time: {handoff_time:.3f}s, reply time: {reply_time:.3f}s")
timings.append((label, handoff_time, reply_time))
finally:
await session.aclose()
await asyncio.sleep(0.5)
await _run_trial("reuse (copy ctx)", reuse=True, chat_ctx=long_ctx.copy())
await _run_trial("reuse (empty ctx)", reuse=True, chat_ctx=llm.ChatContext())
await _run_trial("fresh (copy ctx)", reuse=False, chat_ctx=long_ctx.copy())
await _run_trial("fresh (empty ctx)", reuse=False, chat_ctx=llm.ChatContext())
# print timing table
print("\n┌──────────────────────┬──────────────┬──────────────┐")
print("│ Mode │ Handoff (s) │ Reply (s) │")
print("├──────────────────────┼──────────────┼──────────────┤")
for label, ht, rt in timings:
print(f"│ {label:<20}{ht:>12.3f}{rt:>12.3f} │")
print("└──────────────────────┴──────────────┴──────────────┘")
# -- Remote item tracking --
@pytest.mark.parametrize("rt_session", REALTIME_MODELS, indirect=True)
async def test_remote_item_added_event(rt_session: llm.RealtimeSession):
items_added: list[llm.RemoteItemAddedEvent] = []
rt_session.on("remote_item_added", lambda ev: items_added.append(ev))
gen_ev = await asyncio.wait_for(rt_session.generate_reply(), timeout=15)
await asyncio.wait_for(_collect_text(gen_ev), timeout=15)
assert len(items_added) > 0
assert any(
item.item.type == "message" and item.item.role == "assistant" for item in items_added
)