refactor(backend): 启动环境初始化与资源项目化(零跨项目依赖)
- main.py:uv run main.py 一键启动——读取 backend/.env、TORCH_HOME/NLTK_DATA 指向项目内缓存、certifi SSL + NO_PROXY 网络直连、默认开启 s2s,启动横幅 - vendor/:补丁后 s2s 云化栈移入项目(backend/vendor/s2s-cloud),pyproject sources 改相对路径 - .gitignore:忽略 .torch-cache / nltk_data 缓存 - AGENTS.md:进程管理铁律(AI 不启动/重启服务,由用户操作)
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
from queue import Queue
|
||||
from threading import Event as ThreadingEvent
|
||||
|
||||
import pytest
|
||||
from openai.types.realtime import RealtimeSessionCreateRequest
|
||||
from openai.types.realtime.realtime_audio_config import RealtimeAudioConfig
|
||||
from openai.types.realtime.realtime_audio_config_input import RealtimeAudioConfigInput
|
||||
from openai.types.realtime.realtime_audio_config_output import RealtimeAudioConfigOutput
|
||||
from openai.types.realtime.realtime_audio_formats import AudioPCM
|
||||
|
||||
from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
|
||||
from speech_to_speech.api.openai_realtime.service import RealtimeService
|
||||
|
||||
|
||||
def _session_16k() -> RealtimeSessionCreateRequest:
|
||||
"""Build a test session with 16 kHz audio rates (matches PIPELINE_SAMPLE_RATE)."""
|
||||
fmt = AudioPCM.model_construct(rate=16000, type="audio/pcm")
|
||||
return RealtimeSessionCreateRequest.model_construct(
|
||||
type="realtime",
|
||||
audio=RealtimeAudioConfig.model_construct(
|
||||
input=RealtimeAudioConfigInput.model_construct(format=fmt),
|
||||
output=RealtimeAudioConfigOutput.model_construct(format=fmt),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runtime_config():
|
||||
cfg = RuntimeConfig()
|
||||
cfg.session = _session_16k()
|
||||
return cfg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def text_prompt_queue():
|
||||
return Queue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def should_listen():
|
||||
ev = ThreadingEvent()
|
||||
ev.set()
|
||||
return ev
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(runtime_config, text_prompt_queue, should_listen):
|
||||
svc = RealtimeService(
|
||||
text_prompt_queue=text_prompt_queue,
|
||||
should_listen=should_listen,
|
||||
)
|
||||
return svc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn_id(service, runtime_config):
|
||||
cid = service.register()
|
||||
service._state(cid).runtime_config = runtime_config
|
||||
yield cid
|
||||
service.unregister(cid)
|
||||
@@ -0,0 +1,676 @@
|
||||
"""Tests using the real OpenAI Python SDK client connected to our local server.
|
||||
|
||||
``AsyncOpenAI.realtime.connect()`` (non-beta) establishes a WebSocket
|
||||
connection and returns parsed event objects from ``openai.types.realtime``.
|
||||
These tests start our FastAPI app on a local port with uvicorn, then drive
|
||||
a real SDK client against it — exactly as the production client does.
|
||||
|
||||
The pipeline side (audio output, text events) is driven through the queues,
|
||||
while the client side uses ``conn.send()`` and ``async for event in conn``.
|
||||
|
||||
We use ``client.realtime.connect()`` (non-beta), **not**
|
||||
``client.realtime.connect()``. The non-beta path expects the GA type
|
||||
strings our server emits (e.g. ``response.output_audio.delta``), whereas
|
||||
the beta path expects the older ``response.audio.delta`` variants.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from queue import Queue
|
||||
from threading import Event as ThreadingEvent
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from speech_to_speech.api.openai_realtime.pipeline_unit import PipelineUnit
|
||||
from speech_to_speech.api.openai_realtime.service import RealtimeService
|
||||
from speech_to_speech.api.openai_realtime.websocket_router import create_app
|
||||
from speech_to_speech.pipeline.cancel_scope import CancelScope
|
||||
from speech_to_speech.pipeline.events import (
|
||||
AssistantTextEvent,
|
||||
PartialTranscriptionEvent,
|
||||
SpeechStartedEvent,
|
||||
SpeechStoppedEvent,
|
||||
TranscriptionCompletedEvent,
|
||||
)
|
||||
from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, PIPELINE_END
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _pcm_bytes(n_samples: int) -> bytes:
|
||||
return b"\x00" * (n_samples * 2)
|
||||
|
||||
|
||||
class _ServerEnv:
|
||||
"""Wraps a running uvicorn server + all pipeline queues."""
|
||||
|
||||
def __init__(self):
|
||||
self.text_prompt_queue: Queue = Queue()
|
||||
self.should_listen = ThreadingEvent()
|
||||
self.should_listen.set()
|
||||
self.service = RealtimeService(
|
||||
text_prompt_queue=self.text_prompt_queue,
|
||||
should_listen=self.should_listen,
|
||||
)
|
||||
self.input_queue: Queue = Queue()
|
||||
self.output_queue: Queue = Queue()
|
||||
self.text_output_queue: Queue = Queue()
|
||||
self.stop_event = ThreadingEvent()
|
||||
self.response_playing = ThreadingEvent()
|
||||
self.cancel_scope = CancelScope()
|
||||
self.unit = PipelineUnit(
|
||||
index=0,
|
||||
service=self.service,
|
||||
cancel_scope=self.cancel_scope,
|
||||
should_listen=self.should_listen,
|
||||
response_playing=self.response_playing,
|
||||
input_queue=self.input_queue,
|
||||
output_queue=self.output_queue,
|
||||
text_output_queue=self.text_output_queue,
|
||||
text_prompt_queue=self.text_prompt_queue,
|
||||
handlers=[],
|
||||
)
|
||||
self.app = create_app(pool=[self.unit], stop_event=self.stop_event)
|
||||
self.port = _free_port()
|
||||
self._server_thread: threading.Thread | None = None
|
||||
|
||||
def start(self):
|
||||
config = uvicorn.Config(
|
||||
self.app,
|
||||
host="127.0.0.1",
|
||||
port=self.port,
|
||||
log_level="warning",
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
self._server = server
|
||||
self._server_thread = threading.Thread(target=server.run, daemon=True)
|
||||
self._server_thread.start()
|
||||
for _ in range(50):
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", self.port), timeout=0.1):
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError("Server did not start in time")
|
||||
|
||||
def stop(self):
|
||||
self.stop_event.set()
|
||||
self._server.should_exit = True
|
||||
if self._server_thread:
|
||||
self._server_thread.join(timeout=5)
|
||||
|
||||
def make_client(self) -> AsyncOpenAI:
|
||||
return AsyncOpenAI(
|
||||
api_key="test-key",
|
||||
base_url=f"http://127.0.0.1:{self.port}/v1",
|
||||
websocket_base_url=f"ws://127.0.0.1:{self.port}/v1",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server_env():
|
||||
env = _ServerEnv()
|
||||
env.start()
|
||||
yield env
|
||||
env.stop()
|
||||
|
||||
|
||||
async def _recv(conn, timeout: float = 3.0):
|
||||
"""Receive next event with a timeout to avoid hanging tests."""
|
||||
return await asyncio.wait_for(conn.recv(), timeout=timeout)
|
||||
|
||||
|
||||
# Our server uses the openai.types.realtime type strings (e.g.
|
||||
# "response.output_audio.done"). The production client code matches on
|
||||
# event.type using both GA and legacy names for compatibility. These
|
||||
# constants match the Literal values from openai.types.realtime.
|
||||
SESSION_CREATED = "session.created"
|
||||
SPEECH_STARTED = "input_audio_buffer.speech_started"
|
||||
SPEECH_STOPPED = "input_audio_buffer.speech_stopped"
|
||||
TRANSCRIPTION_DELTA = "conversation.item.input_audio_transcription.delta"
|
||||
TRANSCRIPTION_COMPLETED = "conversation.item.input_audio_transcription.completed"
|
||||
ITEM_CREATED = "conversation.item.created"
|
||||
RESPONSE_CREATED = "response.created"
|
||||
RESPONSE_DONE = "response.done"
|
||||
AUDIO_DELTA = "response.output_audio.delta"
|
||||
AUDIO_DONE = "response.output_audio.done"
|
||||
TRANSCRIPT_DONE = "response.output_audio_transcript.done"
|
||||
FUNCTION_CALL_DONE = "response.function_call_arguments.done"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 1. Connection and session.created
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKConnection:
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_receives_session_created(self, server_env):
|
||||
"""SDK connect yields session.created as the first event."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
event = await _recv(conn)
|
||||
assert event.type == SESSION_CREATED
|
||||
assert event.event_id.startswith("event_")
|
||||
assert event.session is not None
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 2. Session update
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKSessionUpdate:
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_update_applies_config(self, server_env):
|
||||
"""conn.session.update() applies config server-side."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn) # session.created
|
||||
|
||||
await conn.send(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"type": "realtime",
|
||||
"instructions": "You are a helpful robot",
|
||||
"audio": {
|
||||
"input": {
|
||||
"transcription": {"model": "gpt-4o-transcribe", "language": "en"},
|
||||
"turn_detection": {
|
||||
"type": "server_vad",
|
||||
"interrupt_response": True,
|
||||
},
|
||||
},
|
||||
"output": {
|
||||
"voice": "alloy",
|
||||
},
|
||||
},
|
||||
"tools": [{"type": "function", "name": "get_weather"}],
|
||||
"tool_choice": "auto",
|
||||
},
|
||||
}
|
||||
)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
cid = server_env.service.connection_ids[0]
|
||||
s = server_env.service._state(cid).runtime_config.session
|
||||
assert s.audio.output.voice == "alloy"
|
||||
assert s.instructions == "You are a helpful robot"
|
||||
assert s.audio.input.turn_detection.type == "server_vad"
|
||||
assert s.tools is not None
|
||||
assert s.tool_choice == "auto"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 3. Full voice conversation turn
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKVoiceTurn:
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_voice_turn(self, server_env):
|
||||
"""
|
||||
Pipeline-driven voice turn through the real SDK:
|
||||
speech_started → partial transcription → speech_stopped →
|
||||
transcription_completed → audio response → transcript → done
|
||||
"""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn) # session.created
|
||||
|
||||
# -- User speech --
|
||||
server_env.text_output_queue.put(SpeechStartedEvent())
|
||||
event = await _recv(conn)
|
||||
assert event.type == SPEECH_STARTED
|
||||
assert event.audio_start_ms == 0
|
||||
item_id = event.item_id
|
||||
|
||||
server_env.text_output_queue.put(PartialTranscriptionEvent(delta="hel"))
|
||||
event = await _recv(conn)
|
||||
assert event.type == TRANSCRIPTION_DELTA
|
||||
assert event.delta == "hel"
|
||||
assert event.item_id == item_id
|
||||
|
||||
server_env.text_output_queue.put(SpeechStoppedEvent(duration_s=1.9))
|
||||
event = await _recv(conn)
|
||||
assert event.type == SPEECH_STOPPED
|
||||
assert event.audio_end_ms == 0
|
||||
assert event.item_id == item_id
|
||||
|
||||
server_env.text_output_queue.put(TranscriptionCompletedEvent(transcript="hello"))
|
||||
event = await _recv(conn)
|
||||
assert event.type == TRANSCRIPTION_COMPLETED
|
||||
assert event.transcript == "hello"
|
||||
assert event.usage.seconds == 1.9
|
||||
|
||||
# -- Server audio response --
|
||||
server_env.output_queue.put(_pcm_bytes(256))
|
||||
event = await _recv(conn)
|
||||
assert event.type == RESPONSE_CREATED
|
||||
assert event.response.status == "in_progress"
|
||||
assert event.response.object == "realtime.response"
|
||||
conversation_id = event.response.conversation_id
|
||||
|
||||
event = await _recv(conn)
|
||||
assert event.type == AUDIO_DELTA
|
||||
decoded = base64.b64decode(event.delta)
|
||||
assert len(decoded) == len(_pcm_bytes(256))
|
||||
|
||||
server_env.text_output_queue.put(AssistantTextEvent(text="Hi there!"))
|
||||
event = await _recv(conn)
|
||||
assert event.type == TRANSCRIPT_DONE
|
||||
assert event.transcript == "Hi there!"
|
||||
|
||||
server_env.output_queue.put(PIPELINE_END)
|
||||
event = await _recv(conn)
|
||||
assert event.type == AUDIO_DONE
|
||||
|
||||
event = await _recv(conn)
|
||||
assert event.type == RESPONSE_DONE
|
||||
assert event.response.status == "completed"
|
||||
assert event.response.conversation_id == conversation_id
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 4. Interruption (barge-in)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKBargeIn:
|
||||
@pytest.mark.asyncio
|
||||
async def test_speech_interrupts_active_response(self, server_env):
|
||||
"""User speech during audio streaming cancels with turn_detected."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn) # session.created
|
||||
|
||||
server_env.output_queue.put(_pcm_bytes(256))
|
||||
event = await _recv(conn)
|
||||
assert event.type == RESPONSE_CREATED
|
||||
await _recv(conn) # audio delta
|
||||
|
||||
server_env.text_output_queue.put(SpeechStartedEvent())
|
||||
|
||||
events = []
|
||||
for _ in range(3):
|
||||
events.append(await _recv(conn))
|
||||
|
||||
types = [e.type for e in events]
|
||||
assert AUDIO_DONE in types
|
||||
assert RESPONSE_DONE in types
|
||||
assert SPEECH_STARTED in types
|
||||
|
||||
done = next(e for e in events if e.type == RESPONSE_DONE)
|
||||
assert done.response.status == "cancelled"
|
||||
assert done.response.status_details.reason == "turn_detected"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_assistant_text_flushed_on_interruption(self, server_env):
|
||||
"""Stale assistant_text queued during interruption is flushed, not reopened as a new response."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn) # session.created
|
||||
|
||||
server_env.output_queue.put(_pcm_bytes(256))
|
||||
event = await _recv(conn)
|
||||
assert event.type == RESPONSE_CREATED
|
||||
await _recv(conn) # audio delta
|
||||
|
||||
server_env.text_output_queue.put(SpeechStartedEvent())
|
||||
server_env.text_output_queue.put(AssistantTextEvent(text="stale response text"))
|
||||
|
||||
events = []
|
||||
for _ in range(3):
|
||||
events.append(await _recv(conn))
|
||||
|
||||
types = [e.type for e in events]
|
||||
assert AUDIO_DONE in types
|
||||
assert RESPONSE_DONE in types
|
||||
assert SPEECH_STARTED in types
|
||||
|
||||
done = next(e for e in events if e.type == RESPONSE_DONE)
|
||||
assert done.response.status == "cancelled"
|
||||
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(conn.recv(), timeout=0.5)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 4b. Phantom speech & interruption state
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKPhantomSpeech:
|
||||
@pytest.mark.asyncio
|
||||
async def test_phantom_speech_does_not_block_pipeline(self, server_env):
|
||||
"""speech_started + speech_stopped(duration=0) doesn't hang; a normal turn follows."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn) # session.created
|
||||
|
||||
server_env.text_output_queue.put(SpeechStartedEvent())
|
||||
event = await _recv(conn)
|
||||
assert event.type == SPEECH_STARTED
|
||||
|
||||
server_env.text_output_queue.put(SpeechStoppedEvent())
|
||||
event = await _recv(conn)
|
||||
assert event.type == SPEECH_STOPPED
|
||||
|
||||
server_env.text_output_queue.put(SpeechStartedEvent())
|
||||
event = await _recv(conn)
|
||||
assert event.type == SPEECH_STARTED
|
||||
|
||||
server_env.text_output_queue.put(SpeechStoppedEvent(duration_s=2.0))
|
||||
event = await _recv(conn)
|
||||
assert event.type == SPEECH_STOPPED
|
||||
|
||||
server_env.output_queue.put(_pcm_bytes(256))
|
||||
event = await _recv(conn)
|
||||
assert event.type == RESPONSE_CREATED
|
||||
await _recv(conn) # audio delta
|
||||
|
||||
server_env.output_queue.put(AUDIO_RESPONSE_DONE)
|
||||
event = await _recv(conn)
|
||||
assert event.type == AUDIO_DONE
|
||||
event = await _recv(conn)
|
||||
assert event.type == RESPONSE_DONE
|
||||
assert event.response.status == "completed"
|
||||
|
||||
|
||||
class TestSDKInterruptionState:
|
||||
@pytest.mark.asyncio
|
||||
async def test_interruption_resets_pipeline_state(self, server_env):
|
||||
"""After interruption, response_playing is cleared and cancel_scope
|
||||
enters discarding mode until __RESPONSE_DONE__ arrives."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn) # session.created
|
||||
|
||||
assert not server_env.response_playing.is_set()
|
||||
assert not server_env.cancel_scope.discarding
|
||||
|
||||
server_env.output_queue.put(_pcm_bytes(256))
|
||||
await _recv(conn) # response.created
|
||||
await _recv(conn) # audio delta
|
||||
assert server_env.response_playing.is_set()
|
||||
|
||||
server_env.text_output_queue.put(SpeechStartedEvent())
|
||||
events = []
|
||||
for _ in range(3):
|
||||
events.append(await _recv(conn))
|
||||
|
||||
types = [e.type for e in events]
|
||||
assert SPEECH_STARTED in types
|
||||
assert RESPONSE_DONE in types
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
assert not server_env.response_playing.is_set()
|
||||
assert server_env.cancel_scope.discarding
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 5. Tool calling
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKToolCalling:
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_events(self, server_env):
|
||||
"""Tool calls produce events with name, call_id, arguments."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn)
|
||||
|
||||
server_env.text_output_queue.put(
|
||||
AssistantTextEvent(
|
||||
text="Checking weather",
|
||||
tools=[
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_xyz",
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "Tokyo"}',
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
event = await _recv(conn)
|
||||
assert event.type == TRANSCRIPT_DONE
|
||||
assert event.transcript == "Checking weather"
|
||||
|
||||
event = await _recv(conn)
|
||||
assert event.type == FUNCTION_CALL_DONE
|
||||
assert event.name == "get_weather"
|
||||
assert event.call_id == "call_xyz"
|
||||
assert json.loads(event.arguments) == {"city": "Tokyo"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_tool_calls_output_index(self, server_env):
|
||||
"""Multiple tool calls have incrementing output_index."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn)
|
||||
|
||||
server_env.text_output_queue.put(
|
||||
AssistantTextEvent(
|
||||
text="",
|
||||
tools=[
|
||||
{"type": "function_call", "call_id": "c1", "name": "tool_a", "arguments": "{}"},
|
||||
{"type": "function_call", "call_id": "c2", "name": "tool_b", "arguments": '{"x": 1}'},
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
e1 = await _recv(conn)
|
||||
e2 = await _recv(conn)
|
||||
assert e1.type == FUNCTION_CALL_DONE
|
||||
assert e2.type == FUNCTION_CALL_DONE
|
||||
assert e1.output_index == 0
|
||||
assert e2.output_index == 1
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 6. Text input via SDK
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKTextInput:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_conversation_item_create(self, server_env):
|
||||
"""Sending conversation.item.create produces an item.created event."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn)
|
||||
|
||||
await conn.send(
|
||||
{
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"id": "msg_sdk_1",
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "Hello from SDK"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
event = await _recv(conn)
|
||||
assert event.type == ITEM_CREATED
|
||||
assert event.item.role == "user"
|
||||
assert event.item.content[0].text == "Hello from SDK"
|
||||
assert event.previous_item_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_input_previous_item_id_chain(self, server_env):
|
||||
"""Sequential text items chain via previous_item_id."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn)
|
||||
|
||||
await conn.send(
|
||||
{
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"id": "msg_a",
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "first"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
e1 = await _recv(conn)
|
||||
assert e1.previous_item_id is None
|
||||
|
||||
await conn.send(
|
||||
{
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"id": "msg_b",
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "second"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
e2 = await _recv(conn)
|
||||
assert e2.previous_item_id == e1.item.id
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 7. Error handling
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKErrorHandling:
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_event_returns_error(self, server_env):
|
||||
"""Unknown event type returns an error event."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn)
|
||||
|
||||
await conn.send({"type": "bogus.nonexistent"})
|
||||
event = await _recv(conn)
|
||||
assert event.type == ERROR
|
||||
assert event.error is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_response_create_error(self, server_env):
|
||||
"""response.create while response is active returns error."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn)
|
||||
|
||||
server_env.output_queue.put(_pcm_bytes(256))
|
||||
await _recv(conn) # response.created
|
||||
await _recv(conn) # audio delta
|
||||
|
||||
await conn.send({"type": "response.create"})
|
||||
event = await _recv(conn)
|
||||
assert event.type == ERROR
|
||||
assert event.error.type == "conversation_already_has_active_response"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 8. Response cancel
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKResponseCancel:
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_active_response(self, server_env):
|
||||
"""response.cancel produces done events with cancelled status."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn)
|
||||
|
||||
server_env.output_queue.put(_pcm_bytes(256))
|
||||
await _recv(conn) # response.created
|
||||
await _recv(conn) # audio delta
|
||||
|
||||
await conn.send({"type": "response.cancel"})
|
||||
|
||||
event = await _recv(conn)
|
||||
assert event.type == AUDIO_DONE
|
||||
|
||||
event = await _recv(conn)
|
||||
assert event.type == RESPONSE_DONE
|
||||
assert event.response.status == "cancelled"
|
||||
assert event.response.status_details.reason == "client_cancelled"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 9. Multi-turn conversation_id consistency
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKMultiTurn:
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_turns_same_conversation(self, server_env):
|
||||
"""Two voice turns share the same conversation_id."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn) # session.created
|
||||
|
||||
# Turn 1
|
||||
server_env.text_output_queue.put(SpeechStartedEvent())
|
||||
await _recv(conn)
|
||||
|
||||
server_env.text_output_queue.put(SpeechStoppedEvent())
|
||||
await _recv(conn)
|
||||
|
||||
server_env.text_output_queue.put(TranscriptionCompletedEvent(transcript="hi"))
|
||||
await _recv(conn)
|
||||
|
||||
server_env.output_queue.put(_pcm_bytes(128))
|
||||
t1_created = await _recv(conn)
|
||||
assert t1_created.type == RESPONSE_CREATED
|
||||
await _recv(conn) # audio delta
|
||||
|
||||
# Barge-in
|
||||
server_env.text_output_queue.put(SpeechStartedEvent())
|
||||
events = []
|
||||
for _ in range(3):
|
||||
events.append(await _recv(conn))
|
||||
|
||||
t1_done = next(e for e in events if e.type == RESPONSE_DONE)
|
||||
|
||||
# Simulate pipeline acknowledging cancellation so discard guard clears
|
||||
server_env.output_queue.put(AUDIO_RESPONSE_DONE)
|
||||
await asyncio.sleep(0.15)
|
||||
|
||||
# Turn 2
|
||||
server_env.text_output_queue.put(SpeechStoppedEvent())
|
||||
await _recv(conn)
|
||||
|
||||
server_env.text_output_queue.put(TranscriptionCompletedEvent(transcript="bye"))
|
||||
await _recv(conn)
|
||||
|
||||
server_env.output_queue.put(_pcm_bytes(128))
|
||||
t2_created = await _recv(conn)
|
||||
assert t2_created.type == RESPONSE_CREATED
|
||||
await _recv(conn) # audio delta
|
||||
|
||||
server_env.output_queue.put(PIPELINE_END)
|
||||
await _recv(conn) # audio done
|
||||
t2_done = await _recv(conn)
|
||||
assert t2_done.type == RESPONSE_DONE
|
||||
|
||||
assert t1_done.response.conversation_id == t2_done.response.conversation_id
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
||||
"""Unit tests for RuntimeConfig.apply_session_update / _apply_update.
|
||||
|
||||
Verifies the merge semantics: only explicitly-set fields (model_fields_set)
|
||||
are written, nested BaseModels recurse without clobbering siblings, and
|
||||
explicit None clears a previously-set value.
|
||||
"""
|
||||
|
||||
from openai.types.realtime import RealtimeSessionCreateRequest
|
||||
from openai.types.realtime.session_update_event import SessionUpdateEvent
|
||||
|
||||
from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
|
||||
|
||||
|
||||
def _parse_session(**session_fields) -> RealtimeSessionCreateRequest:
|
||||
"""Parse a session dict the same way the SDK does (tracks model_fields_set)."""
|
||||
session_fields.setdefault("type", "realtime")
|
||||
evt = SessionUpdateEvent.model_validate(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": session_fields,
|
||||
}
|
||||
)
|
||||
return evt.session # type: ignore[return-value]
|
||||
|
||||
|
||||
class TestApplySessionUpdate:
|
||||
def test_partial_update_preserves_untouched_fields(self):
|
||||
cfg = RuntimeConfig()
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
instructions="Be a pirate",
|
||||
audio={"output": {"voice": "coral"}},
|
||||
tool_choice="auto",
|
||||
)
|
||||
)
|
||||
assert cfg.session.instructions == "Be a pirate"
|
||||
assert cfg.session.audio.output.voice == "coral"
|
||||
assert cfg.session.tool_choice == "auto"
|
||||
|
||||
cfg.apply_session_update(_parse_session(instructions="Be an astronaut"))
|
||||
assert cfg.session.instructions == "Be an astronaut"
|
||||
assert cfg.session.audio.output.voice == "coral"
|
||||
assert cfg.session.tool_choice == "auto"
|
||||
|
||||
def test_explicit_none_clears_field(self):
|
||||
"""Sending ``"turn_detection": null`` clears a previously-set value."""
|
||||
cfg = RuntimeConfig()
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
audio={"input": {"turn_detection": {"type": "server_vad"}}},
|
||||
)
|
||||
)
|
||||
assert cfg.session.audio.input.turn_detection is not None
|
||||
assert cfg.session.audio.input.turn_detection.type == "server_vad"
|
||||
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
audio={"input": {"turn_detection": None}},
|
||||
)
|
||||
)
|
||||
assert cfg.session.audio.input.turn_detection is None
|
||||
|
||||
def test_nested_sibling_preserved(self):
|
||||
"""Updating audio.output.voice must not touch audio.input.turn_detection."""
|
||||
cfg = RuntimeConfig()
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
audio={
|
||||
"input": {"turn_detection": {"type": "server_vad", "threshold": 0.6}},
|
||||
"output": {"voice": "echo"},
|
||||
},
|
||||
)
|
||||
)
|
||||
assert cfg.session.audio.output.voice == "echo"
|
||||
assert cfg.session.audio.input.turn_detection.threshold == 0.6
|
||||
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
audio={"output": {"voice": "shimmer"}},
|
||||
)
|
||||
)
|
||||
assert cfg.session.audio.output.voice == "shimmer"
|
||||
assert cfg.session.audio.input.turn_detection.type == "server_vad"
|
||||
assert cfg.session.audio.input.turn_detection.threshold == 0.6
|
||||
|
||||
def test_sequential_updates_accumulate(self):
|
||||
cfg = RuntimeConfig()
|
||||
cfg.apply_session_update(_parse_session(instructions="Step 1"))
|
||||
cfg.apply_session_update(_parse_session(audio={"output": {"voice": "alloy"}}))
|
||||
cfg.apply_session_update(_parse_session(tool_choice="required"))
|
||||
|
||||
assert cfg.session.instructions == "Step 1"
|
||||
assert cfg.session.audio.output.voice == "alloy"
|
||||
assert cfg.session.tool_choice == "required"
|
||||
|
||||
def test_deep_nested_leaf_update(self):
|
||||
"""Changing only turn_detection.threshold preserves the rest."""
|
||||
cfg = RuntimeConfig()
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
audio={
|
||||
"input": {
|
||||
"turn_detection": {
|
||||
"type": "server_vad",
|
||||
"threshold": 0.5,
|
||||
"silence_duration_ms": 800,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
assert cfg.session.audio.input.turn_detection.threshold == 0.5
|
||||
assert cfg.session.audio.input.turn_detection.silence_duration_ms == 800
|
||||
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
audio={"input": {"turn_detection": {"type": "server_vad", "threshold": 0.8}}},
|
||||
)
|
||||
)
|
||||
assert cfg.session.audio.input.turn_detection.threshold == 0.8
|
||||
assert cfg.session.audio.input.turn_detection.silence_duration_ms == 800
|
||||
|
||||
def test_tools_replaced_wholesale(self):
|
||||
"""Tools is a list, not a BaseModel — the whole list is replaced."""
|
||||
cfg = RuntimeConfig()
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
tools=[{"type": "function", "name": "get_weather"}],
|
||||
)
|
||||
)
|
||||
assert len(cfg.session.tools) == 1
|
||||
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
tools=[
|
||||
{"type": "function", "name": "get_weather"},
|
||||
{"type": "function", "name": "get_time"},
|
||||
],
|
||||
)
|
||||
)
|
||||
assert len(cfg.session.tools) == 2
|
||||
|
||||
def test_update_after_fresh_init(self):
|
||||
"""apply_session_update works on a freshly-constructed RuntimeConfig."""
|
||||
cfg = RuntimeConfig()
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
instructions="Hello",
|
||||
audio={"output": {"voice": "sage"}},
|
||||
)
|
||||
)
|
||||
assert cfg.session.instructions == "Hello"
|
||||
assert cfg.session.audio.output.voice == "sage"
|
||||
|
||||
def test_validator_ensures_audio_structure_on_init(self):
|
||||
"""Default construction guarantees audio.input and audio.output are not None."""
|
||||
cfg = RuntimeConfig()
|
||||
assert cfg.session.audio is not None
|
||||
assert cfg.session.audio.input is not None
|
||||
assert cfg.session.audio.output is not None
|
||||
@@ -0,0 +1,778 @@
|
||||
"""Integration tests for api.openai_realtime.websocket_router.
|
||||
|
||||
Uses Starlette's synchronous TestClient with WebSocket support to exercise
|
||||
the full FastAPI app produced by ``create_app``. Each test gets a fresh
|
||||
PipelineUnit pool (size 1, matching the single-session semantics of the
|
||||
old tests) so there is no cross-test state.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import time
|
||||
from queue import Empty, Queue
|
||||
from threading import Event as ThreadingEvent
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
import speech_to_speech.api.openai_realtime.websocket_router as router_module
|
||||
from speech_to_speech.api.openai_realtime.pipeline_unit import PipelineUnit
|
||||
from speech_to_speech.api.openai_realtime.service import CHUNK_SIZE_BYTES, RealtimeService
|
||||
from speech_to_speech.api.openai_realtime.websocket_router import create_app
|
||||
from speech_to_speech.pipeline.cancel_scope import CancelScope
|
||||
from speech_to_speech.pipeline.control import SESSION_END, PipelineControlMessage, is_control_message
|
||||
from speech_to_speech.pipeline.events import AssistantTextEvent, SpeechStartedEvent, TokenUsageEvent
|
||||
from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, PIPELINE_END, AudioOutput
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def short_drain_timeout(monkeypatch):
|
||||
"""Shorten the SESSION_END drain warning threshold so tests don't wait 10s.
|
||||
|
||||
The constant only controls when the release task logs a warning about a
|
||||
slow-draining unit — there is no longer a release-anyway timeout, so the
|
||||
unit stays unavailable until SESSION_END actually drains.
|
||||
"""
|
||||
monkeypatch.setattr(router_module, "SESSION_END_DRAIN_TIMEOUT_S", 0.1)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup():
|
||||
"""Return (app, service, input_queue, output_queue, text_output_queue,
|
||||
should_listen, stop_event, response_playing, cancel_scope) for a pool of one.
|
||||
|
||||
There is no real handler chain in this fixture, so SESSION_END enqueued by
|
||||
the route handler on disconnect never reaches output_queue. Tests that need
|
||||
the release task to complete (verifying unit.session is cleared and the
|
||||
service unregistered) must drain SESSION_END themselves — see
|
||||
`_simulate_session_end_drain` below.
|
||||
"""
|
||||
text_prompt_queue: Queue = Queue()
|
||||
should_listen = ThreadingEvent()
|
||||
should_listen.set()
|
||||
service = RealtimeService(
|
||||
text_prompt_queue=text_prompt_queue,
|
||||
should_listen=should_listen,
|
||||
)
|
||||
input_queue: Queue = Queue()
|
||||
output_queue: Queue = Queue()
|
||||
text_output_queue: Queue = Queue()
|
||||
stop_event = ThreadingEvent()
|
||||
response_playing = ThreadingEvent()
|
||||
cancel_scope = CancelScope()
|
||||
unit = PipelineUnit(
|
||||
index=0,
|
||||
service=service,
|
||||
cancel_scope=cancel_scope,
|
||||
should_listen=should_listen,
|
||||
response_playing=response_playing,
|
||||
input_queue=input_queue,
|
||||
output_queue=output_queue,
|
||||
text_output_queue=text_output_queue,
|
||||
text_prompt_queue=text_prompt_queue,
|
||||
handlers=[],
|
||||
)
|
||||
app = create_app(pool=[unit], stop_event=stop_event)
|
||||
return (
|
||||
app,
|
||||
service,
|
||||
input_queue,
|
||||
output_queue,
|
||||
text_output_queue,
|
||||
should_listen,
|
||||
stop_event,
|
||||
response_playing,
|
||||
cancel_scope,
|
||||
)
|
||||
|
||||
|
||||
def _simulate_session_end_drain(input_queue: Queue, output_queue: Queue, timeout: float = 1.0) -> None:
|
||||
"""Wait for SESSION_END to land in input_queue (from the route handler's
|
||||
release path) and forward it to output_queue — simulating the handler chain.
|
||||
The send loop will then observe SESSION_END and set `session.drained`,
|
||||
letting the release task complete (unregister + clear `unit.session`).
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
item = input_queue.get(timeout=0.05)
|
||||
except Empty:
|
||||
continue
|
||||
if isinstance(item, PipelineControlMessage) and is_control_message(item, SESSION_END.kind):
|
||||
output_queue.put(item)
|
||||
return
|
||||
raise AssertionError("SESSION_END did not appear on input_queue within timeout")
|
||||
|
||||
|
||||
def _pcm_bytes(n_samples: int) -> bytes:
|
||||
return b"\x00" * (n_samples * 2)
|
||||
|
||||
|
||||
class _FakeWebSocket:
|
||||
application_state = WebSocketState.CONNECTED
|
||||
|
||||
def __init__(self):
|
||||
self.sent: list[dict] = []
|
||||
|
||||
async def send_json(self, payload: dict) -> None:
|
||||
self.sent.append(payload)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Connection
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestConnection:
|
||||
def test_connect_receives_session_created(self, setup):
|
||||
app, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
msg = ws.receive_json()
|
||||
assert msg["type"] == "session.created"
|
||||
assert msg["event_id"].startswith("event_")
|
||||
assert "session" in msg
|
||||
|
||||
def test_second_connection_rejected(self, setup):
|
||||
app, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws1:
|
||||
ws1.receive_json() # session.created
|
||||
with client.websocket_connect("/v1/realtime") as ws2:
|
||||
msg = ws2.receive_json()
|
||||
assert msg["type"] == "error"
|
||||
# Rejection uses the stateless build_error_event helper —
|
||||
# the error type identifies pool exhaustion specifically.
|
||||
assert msg["error"]["type"] == "session_limit_reached"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Client event dispatch
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestClientEventDispatch:
|
||||
def test_audio_append_forwarded_to_input_queue(self, setup):
|
||||
app, _, input_queue, *_ = setup
|
||||
audio_b64 = base64.b64encode(_pcm_bytes(512)).decode("ascii")
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": audio_b64,
|
||||
}
|
||||
)
|
||||
time.sleep(0.1)
|
||||
item = input_queue.get(timeout=1)
|
||||
assert isinstance(item, tuple) and len(item) == 2
|
||||
chunk, rt_cfg = item
|
||||
assert isinstance(chunk, bytes)
|
||||
assert len(chunk) == CHUNK_SIZE_BYTES
|
||||
|
||||
def test_session_update_applied(self, setup):
|
||||
app, service, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"type": "realtime",
|
||||
"audio": {"output": {"voice": "coral"}},
|
||||
},
|
||||
}
|
||||
)
|
||||
time.sleep(0.1)
|
||||
cid = service.connection_ids[0]
|
||||
assert service._state(cid).runtime_config.session.audio.output.voice == "coral"
|
||||
|
||||
def test_conversation_item_create_returns_events(self, setup):
|
||||
app, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "ping"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
msg = ws.receive_json()
|
||||
assert msg["type"] == "conversation.item.created"
|
||||
assert msg["item"]["content"][0]["text"] == "ping"
|
||||
|
||||
def test_response_create_error_when_active(self, setup):
|
||||
app, service, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
service.response._ensure_response(conn_id)
|
||||
ws.send_json({"type": "response.create"})
|
||||
msg = ws.receive_json()
|
||||
assert msg["type"] == "error"
|
||||
assert "another response is in progress" in msg["error"]["message"].lower()
|
||||
|
||||
def test_response_cancel_returns_events(self, setup):
|
||||
app, service, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
service.response._ensure_response(conn_id)
|
||||
ws.send_json({"type": "response.cancel"})
|
||||
msg1 = ws.receive_json()
|
||||
msg2 = ws.receive_json()
|
||||
types = {msg1["type"], msg2["type"]}
|
||||
assert "response.output_audio.done" in types
|
||||
assert "response.done" in types
|
||||
|
||||
def test_response_cancel_flushes_queues(self, setup):
|
||||
app, service, _, output_queue, text_output_queue, _, _, response_playing, cancel_scope = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
service.response._ensure_response(conn_id)
|
||||
response_playing.set()
|
||||
output_queue.put(_pcm_bytes(256))
|
||||
output_queue.put(_pcm_bytes(256))
|
||||
text_output_queue.put(AssistantTextEvent(text="stale"))
|
||||
ws.send_json({"type": "response.cancel"})
|
||||
ws.receive_json() # response.output_audio.done
|
||||
ws.receive_json() # response.done
|
||||
time.sleep(0.1)
|
||||
assert output_queue.empty()
|
||||
assert text_output_queue.empty()
|
||||
assert not response_playing.is_set()
|
||||
assert cancel_scope.discarding
|
||||
|
||||
def test_response_cancel_spurious_does_not_set_discarding(self, setup):
|
||||
"""response.cancel when no response is active must NOT enable discarding,
|
||||
otherwise it would stick True forever (no __RESPONSE_DONE__ to clear it)."""
|
||||
app, service, _, _, _, _, _, _, cancel_scope = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
assert not service._state(list(service._conns.keys())[0]).in_response
|
||||
ws.send_json({"type": "response.cancel"})
|
||||
time.sleep(0.1)
|
||||
assert not cancel_scope.discarding
|
||||
|
||||
def test_response_cancel_late_audio_is_discarded(self, setup):
|
||||
"""Audio arriving after response.cancel is silently dropped (discard guard)."""
|
||||
app, service, _, output_queue, _, _, _, response_playing, cancel_scope = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
service.response._ensure_response(conn_id)
|
||||
response_playing.set()
|
||||
ws.send_json({"type": "response.cancel"})
|
||||
ws.receive_json() # response.output_audio.done
|
||||
ws.receive_json() # response.done
|
||||
time.sleep(0.1)
|
||||
assert cancel_scope.discarding
|
||||
output_queue.put(_pcm_bytes(256))
|
||||
time.sleep(0.15)
|
||||
# No response.created or audio delta should appear; only
|
||||
# __RESPONSE_DONE__ will eventually clear the guard.
|
||||
output_queue.put(AUDIO_RESPONSE_DONE)
|
||||
time.sleep(0.15)
|
||||
assert not cancel_scope.discarding
|
||||
|
||||
def test_unknown_event_returns_error(self, setup):
|
||||
app, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
ws.send_json({"type": "bogus.event"})
|
||||
msg = ws.receive_json()
|
||||
assert msg["type"] == "error"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Send loop (pipeline -> client)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSendLoop:
|
||||
def test_audio_output_ignores_session_end_control_message(self, setup):
|
||||
app, _, _, output_queue, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
output_queue.put(SESSION_END)
|
||||
output_queue.put(_pcm_bytes(256))
|
||||
|
||||
msg1 = ws.receive_json()
|
||||
assert msg1["type"] == "response.created"
|
||||
msg2 = ws.receive_json()
|
||||
assert msg2["type"] == "response.output_audio.delta"
|
||||
|
||||
def test_audio_output_sends_response_created_and_delta(self, setup):
|
||||
app, _, _, output_queue, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
output_queue.put(_pcm_bytes(256))
|
||||
msg1 = ws.receive_json()
|
||||
assert msg1["type"] == "response.created"
|
||||
assert msg1["response"]["status"] == "in_progress"
|
||||
msg2 = ws.receive_json()
|
||||
assert msg2["type"] == "response.output_audio.delta"
|
||||
assert "delta" in msg2
|
||||
|
||||
def test_audio_output_batches_immediately_available_chunks(self, setup):
|
||||
app, _, _, output_queue, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
output_queue.put(_pcm_bytes(256))
|
||||
output_queue.put(_pcm_bytes(256))
|
||||
output_queue.put(PIPELINE_END)
|
||||
|
||||
msg1 = ws.receive_json()
|
||||
assert msg1["type"] == "response.created"
|
||||
|
||||
msg2 = ws.receive_json()
|
||||
assert msg2["type"] == "response.output_audio.delta"
|
||||
decoded = base64.b64decode(msg2["delta"])
|
||||
assert len(decoded) == len(_pcm_bytes(512))
|
||||
|
||||
msg3 = ws.receive_json()
|
||||
msg4 = ws.receive_json()
|
||||
types = {msg3["type"], msg4["type"]}
|
||||
assert "response.output_audio.done" in types
|
||||
assert "response.done" in types
|
||||
|
||||
def test_end_marker_sends_finish_events(self, setup):
|
||||
app, _, _, output_queue, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
output_queue.put(_pcm_bytes(256))
|
||||
ws.receive_json() # response.created
|
||||
ws.receive_json() # audio delta
|
||||
output_queue.put(PIPELINE_END)
|
||||
msg1 = ws.receive_json()
|
||||
msg2 = ws.receive_json()
|
||||
types = {msg1["type"], msg2["type"]}
|
||||
assert "response.output_audio.done" in types
|
||||
assert "response.done" in types
|
||||
|
||||
def test_text_output_sends_pipeline_events(self, setup):
|
||||
app, _, _, _, text_output_queue, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
text_output_queue.put(SpeechStartedEvent())
|
||||
msg = ws.receive_json()
|
||||
assert msg["type"] == "input_audio_buffer.speech_started"
|
||||
assert msg["audio_start_ms"] == 0
|
||||
|
||||
def test_barge_in_discard_clears_after_response_done(self, setup):
|
||||
"""After barge-in sets discarding=True, __RESPONSE_DONE__ must clear it back to False."""
|
||||
app, service, _, output_queue, text_output_queue, _, _, response_playing, cancel_scope = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
service.response._ensure_response(conn_id)
|
||||
response_playing.set()
|
||||
# Trigger barge-in
|
||||
text_output_queue.put(SpeechStartedEvent())
|
||||
ws.receive_json() # input_audio_buffer.speech_started
|
||||
ws.receive_json() # response.output_audio.done
|
||||
ws.receive_json() # response.done
|
||||
time.sleep(0.1)
|
||||
assert cancel_scope.discarding
|
||||
output_queue.put(AUDIO_RESPONSE_DONE)
|
||||
time.sleep(0.15)
|
||||
assert not cancel_scope.discarding
|
||||
|
||||
def test_speech_started_cancels_pending_implicit_response(self, setup):
|
||||
app, service, _, output_queue, text_output_queue, _, _, response_playing, cancel_scope = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
stale_generation = cancel_scope.generation
|
||||
service._state(conn_id).response_pending = True
|
||||
|
||||
text_output_queue.put(SpeechStartedEvent())
|
||||
msg = ws.receive_json()
|
||||
|
||||
assert msg["type"] == "input_audio_buffer.speech_started"
|
||||
time.sleep(0.15)
|
||||
assert cancel_scope.discarding
|
||||
assert cancel_scope.generation == stale_generation + 1
|
||||
assert service._state(conn_id).response_pending is False
|
||||
assert service._state(conn_id).in_response is False
|
||||
assert not response_playing.is_set()
|
||||
|
||||
output_queue.put(AudioOutput(audio=AUDIO_RESPONSE_DONE, cancel_generation=stale_generation))
|
||||
time.sleep(0.15)
|
||||
assert not cancel_scope.discarding
|
||||
|
||||
def test_speech_started_does_not_cancel_pending_when_internal_non_interrupt(self, setup):
|
||||
app, service, _, _, text_output_queue, _, _, _, cancel_scope = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
service._state(conn_id).response_pending = True
|
||||
|
||||
text_output_queue.put(SpeechStartedEvent(interrupt_response=False))
|
||||
msg = ws.receive_json()
|
||||
|
||||
assert msg["type"] == "input_audio_buffer.speech_started"
|
||||
time.sleep(0.15)
|
||||
assert not cancel_scope.discarding
|
||||
assert service._state(conn_id).response_pending is True
|
||||
|
||||
def test_stale_tagged_audio_is_dropped_after_interruption(self, setup):
|
||||
app, _, _, output_queue, _, _, _, _, cancel_scope = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
stale_generation = cancel_scope.generation
|
||||
cancel_scope.cancel()
|
||||
current_generation = cancel_scope.generation
|
||||
output_queue.put(AudioOutput(audio=_pcm_bytes(64), cancel_generation=stale_generation))
|
||||
output_queue.put(AudioOutput(audio=_pcm_bytes(512), cancel_generation=current_generation))
|
||||
|
||||
assert ws.receive_json()["type"] == "response.created"
|
||||
delta = ws.receive_json()
|
||||
|
||||
assert delta["type"] == "response.output_audio.delta"
|
||||
assert len(base64.b64decode(delta["delta"])) == len(_pcm_bytes(512))
|
||||
|
||||
def test_current_generation_text_survives_stuck_discarding(self, setup):
|
||||
"""Regression: a fresh response's transcript must survive a stuck discard guard.
|
||||
|
||||
A superseded speculative turn can leave ``cancel_scope.discarding`` stuck True
|
||||
(its TTS dropped the stale ``EndOfResponse`` without emitting AUDIO_RESPONSE_DONE,
|
||||
so ``response_done()`` never cleared the flag). The next response's audio is tagged
|
||||
with the current generation and streams fine, but the assistant text used to be
|
||||
blanket-dropped while discarding — leaving audio + ``response.done`` with no
|
||||
``response.output_audio_transcript.done``. The text is now discarded by the same
|
||||
generation-aware rule as audio, so a current-generation transcript is kept.
|
||||
"""
|
||||
app, _, _, output_queue, text_output_queue, _, _, _, cancel_scope = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
cancel_scope.cancel() # discarding=True, generation bumped; sentinel never arrived
|
||||
current_generation = cancel_scope.generation
|
||||
assert cancel_scope.discarding
|
||||
|
||||
text_output_queue.put(AssistantTextEvent(text="hello there", cancel_generation=current_generation))
|
||||
output_queue.put(AudioOutput(audio=_pcm_bytes(256), cancel_generation=current_generation))
|
||||
output_queue.put(AudioOutput(audio=AUDIO_RESPONSE_DONE, cancel_generation=current_generation))
|
||||
|
||||
types: list[str] = []
|
||||
transcript = None
|
||||
for _ in range(8):
|
||||
msg = ws.receive_json()
|
||||
types.append(msg["type"])
|
||||
if msg["type"] == "response.output_audio_transcript.done":
|
||||
transcript = msg["transcript"]
|
||||
if msg["type"] == "response.done":
|
||||
break
|
||||
assert "response.output_audio_transcript.done" in types
|
||||
assert transcript == "hello there"
|
||||
|
||||
def test_stale_tagged_response_done_does_not_finish_current_response(self, setup):
|
||||
app, service, _, output_queue, _, _, _, _, cancel_scope = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
stale_generation = cancel_scope.generation
|
||||
service.response._ensure_response(conn_id)
|
||||
service.finish_response(conn_id, status="cancelled")
|
||||
cancel_scope.cancel()
|
||||
current_response_id, _ = service.response._ensure_response(conn_id)
|
||||
|
||||
output_queue.put(AudioOutput(audio=AUDIO_RESPONSE_DONE, cancel_generation=stale_generation))
|
||||
time.sleep(0.15)
|
||||
|
||||
state = service._state(conn_id)
|
||||
assert state.in_response
|
||||
assert state.current_response_id == current_response_id
|
||||
|
||||
def test_response_done_drains_pending_token_usage_before_finish(self, setup):
|
||||
app, service, _, output_queue, text_output_queue, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
|
||||
text_output_queue.put(
|
||||
AssistantTextEvent(
|
||||
text="",
|
||||
tools=[{"type": "function_call", "call_id": "c1", "name": "f1", "arguments": "{}"}],
|
||||
)
|
||||
)
|
||||
text_output_queue.put(TokenUsageEvent(input_tokens=10, output_tokens=5))
|
||||
output_queue.put(AUDIO_RESPONSE_DONE)
|
||||
|
||||
assert ws.receive_json()["type"] == "response.function_call_arguments.done"
|
||||
msg1 = ws.receive_json()
|
||||
msg2 = ws.receive_json()
|
||||
assert {msg1["type"], msg2["type"]} == {"response.output_audio.done", "response.done"}
|
||||
|
||||
assert service.total_usage.input_tokens == 10
|
||||
assert service.total_usage.output_tokens == 5
|
||||
assert service._state(conn_id).response_usage.input_tokens == 0
|
||||
assert service._state(conn_id).response_usage.output_tokens == 0
|
||||
|
||||
def test_response_completion_drain_sends_pending_tool_before_done(self, setup):
|
||||
_, service, input_queue, output_queue, text_output_queue, should_listen, _, response_playing, cancel_scope = (
|
||||
setup
|
||||
)
|
||||
unit = PipelineUnit(
|
||||
index=0,
|
||||
service=service,
|
||||
cancel_scope=cancel_scope,
|
||||
should_listen=should_listen,
|
||||
response_playing=response_playing,
|
||||
input_queue=input_queue,
|
||||
output_queue=output_queue,
|
||||
text_output_queue=text_output_queue,
|
||||
text_prompt_queue=Queue(),
|
||||
handlers=[],
|
||||
)
|
||||
conn_id = service.register()
|
||||
response_id, _ = service.response._ensure_response(conn_id)
|
||||
text_output_queue.put(
|
||||
AssistantTextEvent(
|
||||
text="",
|
||||
tools=[
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "c1",
|
||||
"name": "play_emotion",
|
||||
"arguments": '{"emotion":"loving"}',
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
text_output_queue.put(TokenUsageEvent(input_tokens=10, output_tokens=5))
|
||||
ws = _FakeWebSocket()
|
||||
|
||||
asyncio.run(router_module._drain_pending_response_events(ws, unit, conn_id))
|
||||
done_events = service.finish_response(conn_id)
|
||||
|
||||
assert [payload["type"] for payload in ws.sent] == ["response.function_call_arguments.done"]
|
||||
assert [event.type for event in done_events] == ["response.output_audio.done", "response.done"]
|
||||
assert ws.sent[0]["response_id"] == response_id
|
||||
assert done_events[1].response.id == response_id
|
||||
assert done_events[1].response.usage.input_tokens == 10
|
||||
assert done_events[1].response.usage.output_tokens == 5
|
||||
assert text_output_queue.empty()
|
||||
|
||||
def test_response_completion_drain_preserves_usage_across_non_response_boundary(self, setup):
|
||||
_, service, input_queue, output_queue, text_output_queue, should_listen, _, response_playing, cancel_scope = (
|
||||
setup
|
||||
)
|
||||
unit = PipelineUnit(
|
||||
index=0,
|
||||
service=service,
|
||||
cancel_scope=cancel_scope,
|
||||
should_listen=should_listen,
|
||||
response_playing=response_playing,
|
||||
input_queue=input_queue,
|
||||
output_queue=output_queue,
|
||||
text_output_queue=text_output_queue,
|
||||
text_prompt_queue=Queue(),
|
||||
handlers=[],
|
||||
)
|
||||
conn_id = service.register()
|
||||
response_id, _ = service.response._ensure_response(conn_id)
|
||||
text_output_queue.put(
|
||||
AssistantTextEvent(
|
||||
text="",
|
||||
tools=[{"type": "function_call", "call_id": "c1", "name": "play_emotion", "arguments": "{}"}],
|
||||
)
|
||||
)
|
||||
text_output_queue.put(SpeechStartedEvent())
|
||||
text_output_queue.put(TokenUsageEvent(input_tokens=10, output_tokens=5))
|
||||
text_output_queue.put(AssistantTextEvent(text="queued after boundary"))
|
||||
ws = _FakeWebSocket()
|
||||
|
||||
asyncio.run(router_module._drain_pending_response_events(ws, unit, conn_id))
|
||||
done_events = service.finish_response(conn_id)
|
||||
|
||||
assert [payload["type"] for payload in ws.sent] == ["response.function_call_arguments.done"]
|
||||
assert ws.sent[0]["response_id"] == response_id
|
||||
assert done_events[1].response.usage.input_tokens == 10
|
||||
assert done_events[1].response.usage.output_tokens == 5
|
||||
|
||||
boundary = text_output_queue.get_nowait()
|
||||
queued_assistant = text_output_queue.get_nowait()
|
||||
assert isinstance(boundary, SpeechStartedEvent)
|
||||
assert isinstance(queued_assistant, AssistantTextEvent)
|
||||
assert queued_assistant.text == "queued after boundary"
|
||||
assert text_output_queue.empty()
|
||||
|
||||
def test_speech_started_does_not_cancel_when_interrupt_disabled(self, setup):
|
||||
"""With interrupt_response=False, speech during playback should NOT cancel or flush."""
|
||||
from openai.types.realtime.realtime_audio_input_turn_detection import ServerVad
|
||||
|
||||
app, service, _, output_queue, text_output_queue, _, _, response_playing, cancel_scope = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
service._state(conn_id).runtime_config.session.audio.input.turn_detection = ServerVad(
|
||||
type="server_vad",
|
||||
interrupt_response=False,
|
||||
)
|
||||
_, response_item_id = service.response._ensure_response(conn_id)
|
||||
response_playing.set()
|
||||
text_output_queue.put(SpeechStartedEvent())
|
||||
msg = ws.receive_json()
|
||||
assert msg["type"] == "input_audio_buffer.speech_started"
|
||||
time.sleep(0.15)
|
||||
assert response_playing.is_set(), "response_playing should remain set"
|
||||
assert not cancel_scope.discarding, "cancel_scope should not be discarding"
|
||||
assert service._state(conn_id).in_response, "response should still be active"
|
||||
assert service._state(conn_id).current_item_id == response_item_id
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Cleanup
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestCleanup:
|
||||
def test_new_connection_resets_discard_after_invalidating_generation(self, setup):
|
||||
"""connect-time _clean_unit cancels+resets: stale work is invalidated, discarding cleared."""
|
||||
app, _, *_rest, cancel_scope = setup
|
||||
cancel_scope.cancel()
|
||||
assert cancel_scope.discarding
|
||||
assert cancel_scope.generation == 1
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
assert not cancel_scope.discarding
|
||||
assert cancel_scope.generation == 2
|
||||
|
||||
def test_disconnect_bumps_cancel_scope_generation(self, setup):
|
||||
"""_clean_unit() on disconnect calls cancel() so in-flight generations go stale."""
|
||||
app, _, _, _, _, _, _, _, cancel_scope = setup
|
||||
assert cancel_scope.generation == 0
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
assert cancel_scope.generation == 1
|
||||
# disconnect triggers _clean_unit again + drain (short timeout in tests)
|
||||
time.sleep(0.3)
|
||||
assert cancel_scope.generation == 2
|
||||
|
||||
def test_disconnect_unregisters(self, setup):
|
||||
app, service, input_queue, output_queue, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
assert len(service._conns) == 1
|
||||
# Simulate the handler chain consuming SESSION_END so the release
|
||||
# task can complete and unregister the session.
|
||||
_simulate_session_end_drain(input_queue, output_queue)
|
||||
time.sleep(0.3)
|
||||
assert len(service._conns) == 0
|
||||
|
||||
def test_last_disconnect_cancels_and_clears_response_state(self, setup):
|
||||
app, service, input_queue, output_queue, text_output_queue, _, _, response_playing, cancel_scope = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
service.response._ensure_response(conn_id)
|
||||
response_playing.set()
|
||||
output_queue.put(_pcm_bytes(256))
|
||||
text_output_queue.put(AssistantTextEvent(text="stale"))
|
||||
_simulate_session_end_drain(input_queue, output_queue)
|
||||
time.sleep(0.3)
|
||||
|
||||
assert not cancel_scope.discarding
|
||||
assert cancel_scope.generation == 2
|
||||
assert not response_playing.is_set()
|
||||
assert text_output_queue.empty()
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Pool semantics (new in pool refactor)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
def _make_unit(index: int) -> PipelineUnit:
|
||||
text_prompt_queue: Queue = Queue()
|
||||
should_listen = ThreadingEvent()
|
||||
should_listen.set()
|
||||
return PipelineUnit(
|
||||
index=index,
|
||||
service=RealtimeService(text_prompt_queue=text_prompt_queue, should_listen=should_listen),
|
||||
cancel_scope=CancelScope(),
|
||||
should_listen=should_listen,
|
||||
response_playing=ThreadingEvent(),
|
||||
input_queue=Queue(),
|
||||
output_queue=Queue(),
|
||||
text_output_queue=Queue(),
|
||||
text_prompt_queue=text_prompt_queue,
|
||||
handlers=[],
|
||||
)
|
||||
|
||||
|
||||
class TestPool:
|
||||
def test_pool_endpoint_reports_idle_state(self):
|
||||
pool = [_make_unit(0), _make_unit(1)]
|
||||
app = create_app(pool=pool, stop_event=ThreadingEvent())
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/v1/pool")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["size"] == 2
|
||||
assert data["in_use"] == 0
|
||||
assert [u["session_id"] for u in data["units"]] == [None, None]
|
||||
|
||||
def test_two_clients_claim_two_slots_third_rejected(self):
|
||||
pool = [_make_unit(0), _make_unit(1)]
|
||||
app = create_app(pool=pool, stop_event=ThreadingEvent())
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws1:
|
||||
ws1.receive_json() # session.created
|
||||
with client.websocket_connect("/v1/realtime") as ws2:
|
||||
ws2.receive_json() # session.created (different unit)
|
||||
with client.websocket_connect("/v1/realtime") as ws3:
|
||||
msg = ws3.receive_json()
|
||||
assert msg["type"] == "error"
|
||||
assert msg["error"]["type"] == "session_limit_reached"
|
||||
# Pool now reports 2 in_use
|
||||
r = client.get("/v1/pool")
|
||||
assert r.json()["in_use"] == 2
|
||||
|
||||
def test_usage_aggregates_errors_by_type_across_units(self):
|
||||
pool = [_make_unit(0), _make_unit(1)]
|
||||
pool[0].service.total_usage.record_error("foo")
|
||||
pool[0].service.total_usage.record_error("foo")
|
||||
pool[1].service.total_usage.record_error("bar")
|
||||
app = create_app(pool=pool, stop_event=ThreadingEvent())
|
||||
with TestClient(app) as client:
|
||||
data = client.get("/v1/usage").json()
|
||||
assert data["errors_by_type"] == {"foo": 2, "bar": 1}
|
||||
assert data["total_errors"] == 3
|
||||
Reference in New Issue
Block a user