feat: harden public OpenAI API contracts
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user