187 lines
7.0 KiB
Python
187 lines
7.0 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import unittest
|
|
|
|
from jsonschema import Draft202012Validator
|
|
from jsonschema.exceptions import ValidationError as JSONSchemaValidationError
|
|
from scripts.export_contracts import CONTRACT_DIRECTORY, contract_documents
|
|
from services.openai_realtime.bridge import OpenAIRealtimeBridge
|
|
from services.openai_realtime.events import (
|
|
RealtimeEventError,
|
|
error_event,
|
|
require_event,
|
|
)
|
|
from services.openai_realtime.schemas import (
|
|
EXTENSION_SERVER_EVENT_TYPES,
|
|
SERVER_EVENT_TYPES,
|
|
RealtimeServerEvent,
|
|
)
|
|
|
|
|
|
class PublicContractSnapshotTests(unittest.TestCase):
|
|
def test_generated_contracts_match_committed_snapshots(self):
|
|
for name, generated in contract_documents().items():
|
|
path = CONTRACT_DIRECTORY / name
|
|
self.assertTrue(path.exists(), f"Missing contract snapshot: {path}")
|
|
committed = json.loads(path.read_text(encoding="utf-8"))
|
|
self.assertEqual(committed, generated, f"Regenerate {name}")
|
|
|
|
def test_openapi_declares_every_public_http_media_type(self):
|
|
openapi = contract_documents()["openapi.json"]
|
|
paths = openapi["paths"]
|
|
|
|
self.assertIn("application/json", paths["/v1/responses"]["post"]["requestBody"]["content"])
|
|
response_content = paths["/v1/responses"]["post"]["responses"]["200"]["content"]
|
|
self.assertIn("application/json", response_content)
|
|
self.assertIn("text/event-stream", response_content)
|
|
|
|
call_content = paths["/v1/realtime/calls"]["post"]["requestBody"]["content"]
|
|
self.assertEqual(
|
|
set(call_content),
|
|
{"multipart/form-data", "application/sdp"},
|
|
)
|
|
self.assertIn(
|
|
"application/sdp",
|
|
paths["/v1/realtime/calls"]["post"]["responses"]["200"]["content"],
|
|
)
|
|
|
|
public_operations = [
|
|
operation
|
|
for path, methods in paths.items()
|
|
if path.startswith("/v1/")
|
|
for method, operation in methods.items()
|
|
if method in {"get", "post", "delete"}
|
|
]
|
|
for operation in public_operations:
|
|
self.assertIn("401", operation["responses"])
|
|
self.assertIn("application/json", operation["responses"]["401"]["content"])
|
|
self.assertIn("security", operation)
|
|
for response in operation["responses"].values():
|
|
self.assertIn("X-Request-ID", response.get("headers", {}))
|
|
|
|
|
|
class RealtimeEventContractTests(unittest.TestCase):
|
|
def test_client_schema_validates_supported_event_and_links_errors(self):
|
|
schema = contract_documents()[
|
|
"openai-realtime-client-events.schema.json"
|
|
]
|
|
Draft202012Validator.check_schema(schema)
|
|
validator = Draft202012Validator(schema)
|
|
event = {
|
|
"type": "conversation.item.create",
|
|
"event_id": "evt_123",
|
|
"item": {
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "你好"}],
|
|
},
|
|
}
|
|
validator.validate(event)
|
|
event = require_event(event)
|
|
self.assertEqual(event["event_id"], "evt_123")
|
|
|
|
invalid_event = {
|
|
"type": "input_audio_buffer.append",
|
|
"event_id": "evt_bad",
|
|
}
|
|
with self.assertRaises(JSONSchemaValidationError):
|
|
validator.validate(invalid_event)
|
|
with self.assertRaises(RealtimeEventError) as raised:
|
|
require_event(invalid_event)
|
|
wire_error = error_event(raised.exception)
|
|
self.assertEqual(wire_error["error"]["event_id"], "evt_bad")
|
|
|
|
extension_event = {
|
|
"type": "x.interactive_media.capabilities.update",
|
|
"event_id": "evt_extension",
|
|
"version": "1",
|
|
"capabilities": ["workflow_events"],
|
|
}
|
|
validator.validate(extension_event)
|
|
require_event(extension_event)
|
|
extension_without_version = {
|
|
key: value
|
|
for key, value in extension_event.items()
|
|
if key != "version"
|
|
}
|
|
with self.assertRaises(JSONSchemaValidationError):
|
|
validator.validate(extension_without_version)
|
|
with self.assertRaises(RealtimeEventError):
|
|
require_event(extension_without_version)
|
|
|
|
def test_server_schema_enumerates_every_emitted_event_name(self):
|
|
schema = contract_documents()[
|
|
"openai-realtime-server-events.schema.json"
|
|
]
|
|
Draft202012Validator.check_schema(schema)
|
|
validator = Draft202012Validator(schema)
|
|
for event_type in SERVER_EVENT_TYPES:
|
|
event = {"type": event_type, "event_id": "event_contract"}
|
|
if event_type in EXTENSION_SERVER_EVENT_TYPES:
|
|
event["version"] = "1"
|
|
validator.validate(event)
|
|
parsed = RealtimeServerEvent.model_validate(
|
|
event
|
|
)
|
|
self.assertEqual(parsed.type, event_type)
|
|
|
|
with self.assertRaises(JSONSchemaValidationError):
|
|
validator.validate(
|
|
{
|
|
"type": "x.interactive_media.workflow.event",
|
|
"event_id": "event_without_version",
|
|
}
|
|
)
|
|
|
|
def test_text_response_events_follow_realtime_order(self):
|
|
class FakeSession:
|
|
output_is_audio = False
|
|
active_response_id: str | None = None
|
|
active_output_item_id: str | None = None
|
|
response_text = ""
|
|
|
|
def begin_response(self):
|
|
self.active_response_id = self.active_response_id or "resp_1"
|
|
self.active_output_item_id = self.active_output_item_id or "item_1"
|
|
return self.active_response_id, self.active_output_item_id
|
|
|
|
def finish_response(self):
|
|
result = (
|
|
self.active_response_id,
|
|
self.active_output_item_id,
|
|
self.response_text,
|
|
)
|
|
self.active_response_id = None
|
|
self.active_output_item_id = None
|
|
self.response_text = ""
|
|
return result
|
|
|
|
bridge = OpenAIRealtimeBridge(FakeSession(), channel="websocket")
|
|
events = [
|
|
*bridge._start_assistant_output(),
|
|
*bridge._assistant_delta("你好"),
|
|
*bridge._end_assistant_output(False),
|
|
]
|
|
event_types = [event["type"] for event in events]
|
|
|
|
self.assertEqual(event_types[0:3], [
|
|
"response.created",
|
|
"response.output_item.added",
|
|
"response.content_part.added",
|
|
])
|
|
self.assertIn("response.output_text.delta", event_types)
|
|
self.assertEqual(event_types[-1], "response.done")
|
|
validator = Draft202012Validator(
|
|
contract_documents()[
|
|
"openai-realtime-server-events.schema.json"
|
|
]
|
|
)
|
|
for event in events:
|
|
validator.validate(event)
|
|
RealtimeServerEvent.model_validate(event)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|