Files
ai-video-fullstack/backend/tests/test_openai_sdk_contract.py
T
Xin Wang 2649d64b55
Quality gate / backend (push) Canceled after 0s
Quality gate / frontend (push) Canceled after 0s
feat: harden public OpenAI API contracts
2026-09-07 14:25:48 +08:00

450 lines
15 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import unittest
from unittest.mock import AsyncMock, patch
import httpx
from openai import AsyncOpenAI, BadRequestError, ConflictError, NotFoundError
from app import app
from routes import openai_models, openai_responses
from services.openai_realtime.auth import RealtimeAuthError, RealtimeCredential
from services.openai_responses.protocol import ResponsesAPIError
class OpenAIPythonSDKContractTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.rows: dict[str, SimpleNamespace] = {}
self.run_count = 0
self._patches = [
patch.object(
openai_responses,
"_credential",
new=AsyncMock(side_effect=self._credential),
),
patch.object(
openai_responses,
"prepare_response",
new=AsyncMock(side_effect=self._prepare_response),
),
patch.object(
openai_responses,
"_run_prepared",
new=AsyncMock(side_effect=self._run_prepared),
),
patch.object(
openai_responses,
"get_response",
new=AsyncMock(side_effect=self._get_response),
),
patch.object(
openai_responses,
"get_idempotent_response",
new=AsyncMock(side_effect=self._get_idempotent_response),
),
patch.object(
openai_responses,
"delete_response",
new=AsyncMock(side_effect=self._delete_response),
),
patch.object(
openai_models,
"_authenticate",
new=AsyncMock(side_effect=self._model_authenticate),
),
patch.object(
openai_models,
"SessionLocal",
side_effect=lambda: self._model_session(),
),
]
for patcher in self._patches:
patcher.start()
self.http_client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://contract.test",
)
self.client = AsyncOpenAI(
api_key="sk-rt-key-a",
base_url="http://contract.test/v1",
http_client=self.http_client,
max_retries=0,
)
def _model_session(self):
assistant = SimpleNamespace(
id="asst_contract",
name="Contract assistant",
type="prompt",
runtime_mode="pipeline",
created_at=datetime.now(UTC),
)
class Scalars:
@staticmethod
def all():
return [assistant]
class Result:
@staticmethod
def scalars():
return Scalars()
class Session:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def execute(self, _statement):
return Result()
return Session()
async def asyncTearDown(self):
await self.client.close()
for patcher in reversed(self._patches):
patcher.stop()
async def _credential(self, request):
token = request.headers["authorization"].removeprefix("Bearer ")
return RealtimeCredential(api_key_id=token.removeprefix("sk-rt-"))
async def _model_authenticate(self, request):
if "authorization" not in request.headers:
raise RealtimeAuthError("Missing bearer token")
async def _prepare_response(
self,
request,
*,
api_key_id,
idempotency_key=None,
request_hash=None,
):
parent = self.rows.get(request.previous_response_id)
assistant_id = (
parent.assistant_id
if parent is not None
else str(request.model).removeprefix("assistant:")
)
index = len(self.rows) + 1
row = SimpleNamespace(
id=f"resp_contract_{index}",
api_key_id=api_key_id,
assistant_id=assistant_id,
conversation_id=(
parent.conversation_id if parent else f"conv_contract_{index}"
),
previous_response_id=request.previous_response_id,
status="in_progress",
error=None,
usage=None,
input_items=request.input_items,
output_items=[],
created_at=datetime.now(UTC),
completed_at=None,
deleted_at=None,
idempotency_key=idempotency_key,
request_hash=request_hash,
)
self.rows[row.id] = row
return SimpleNamespace(row=row)
async def _run_prepared(
self,
prepared,
*,
on_delta=None,
text_item_id,
):
self.run_count += 1
row = prepared.row
input_text = " ".join(
str(part.get("text") or "")
for item in row.input_items
if item.get("type") == "message"
for part in item.get("content") or []
)
if input_text == "调用客户端工具":
row.output_items = [
{
"id": "fc_contract",
"type": "function_call",
"status": "completed",
"call_id": "call_contract",
"name": "show_message",
"arguments": '{"message":"请确认"}',
}
]
else:
text = f"第 {self.run_count} 轮回复"
if on_delta is not None:
await on_delta(text[:3])
await on_delta(text[3:])
row.output_items = [
{
"id": text_item_id,
"type": "message",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": text,
"annotations": [],
}
],
}
]
row.status = "completed"
row.completed_at = datetime.now(UTC)
return row
async def _get_response(self, response_id, *, api_key_id, **_kwargs):
row = self.rows.get(response_id)
if (
row is None
or row.api_key_id != api_key_id
or row.deleted_at is not None
):
raise ResponsesAPIError(
"Response not found",
code="response_not_found",
status_code=404,
)
return row
async def _get_idempotent_response(
self,
*,
api_key_id,
idempotency_key,
request_hash,
):
if idempotency_key is None:
return None
for row in self.rows.values():
if row.api_key_id != api_key_id or row.idempotency_key != idempotency_key:
continue
if row.request_hash != request_hash:
raise ResponsesAPIError(
"Idempotency-Key was already used with a different request",
code="idempotency_key_conflict",
param="Idempotency-Key",
status_code=409,
)
return row
return None
async def _delete_response(self, response_id, *, api_key_id):
row = await self._get_response(response_id, api_key_id=api_key_id)
row.deleted_at = datetime.now(UTC)
return row
async def test_three_turn_state_retrieve_input_items_and_delete(self):
models = await self.client.models.list()
self.assertEqual(models.data[0].id, "assistant:asst_contract")
first = await self.client.responses.create(
model="assistant:asst_contract",
input="第一轮",
)
second = await self.client.responses.create(
previous_response_id=first.id,
input="第二轮",
)
third = await self.client.responses.create(
previous_response_id=second.id,
input="第三轮",
)
self.assertEqual(third.previous_response_id, second.id)
self.assertEqual(third.model, "assistant:asst_contract")
self.assertIn("第 3 轮回复", third.output_text)
retrieved = await self.client.responses.retrieve(third.id)
self.assertEqual(retrieved.id, third.id)
self.assertEqual(retrieved.output_text, third.output_text)
raw = await self.http_client.get(
f"/v1/responses/{third.id}",
headers={
"Authorization": "Bearer sk-rt-key-a",
"X-Request-ID": "req_contract_caller",
},
)
self.assertEqual(raw.headers["x-request-id"], "req_contract_caller")
page = await self.client.responses.input_items.list(third.id, order="asc")
self.assertEqual(page.data[0].type, "message")
self.assertTrue(page.data[0].id.startswith("msg_"))
deleted = await self.client.responses.delete(first.id)
# openai-python 2.x deliberately exposes this operation as returning None,
# even though the HTTP endpoint returns the standard deletion object.
self.assertIsNone(deleted)
with self.assertRaises(NotFoundError):
await self.client.responses.retrieve(first.id)
async def test_sse_events_are_parsed_by_official_sdk(self):
request = {
"model": "assistant:asst_contract",
"input": "流式输出",
"stream": True,
}
headers = {"Idempotency-Key": "stream-contract-key"}
stream = await self.client.responses.create(
**request,
extra_headers=headers,
)
events = [event async for event in stream]
replay_stream = await self.client.responses.create(
**request,
extra_headers=headers,
)
replay_events = [event async for event in replay_stream]
event_types = [event.type for event in events]
self.assertEqual(self.run_count, 1)
self.assertEqual(
replay_events[-1].response.id,
events[-1].response.id,
)
self.assertEqual(
event_types[0:2],
["response.created", "response.in_progress"],
)
self.assertIn("response.output_text.delta", event_types)
self.assertEqual(event_types[-1], "response.completed")
self.assertEqual(
[event.sequence_number for event in events],
list(range(len(events))),
)
self.assertEqual(
[event.sequence_number for event in replay_events],
list(range(len(replay_events))),
)
retrieved = await self.client.responses.retrieve(
events[-1].response.id
)
self.assertEqual(retrieved.output_text, events[-1].response.output_text)
async def test_client_tool_output_continues_with_sdk_types(self):
tool_stream = await self.client.responses.create(
model="assistant:asst_contract",
input="调用客户端工具",
stream=True,
)
events = [event async for event in tool_stream]
self.assertIn(
"response.function_call_arguments.done",
[event.type for event in events],
)
tool_response = events[-1].response
function_call = tool_response.output[0]
self.assertEqual(function_call.type, "function_call")
continued = await self.client.responses.create(
previous_response_id=tool_response.id,
input=[
{
"type": "function_call_output",
"call_id": function_call.call_id,
"output": '{"status":"ok"}',
}
],
)
self.assertEqual(continued.status, "completed")
page = await self.client.responses.input_items.list(
continued.id,
order="asc",
)
self.assertEqual(page.data[0].type, "function_call_output")
async def test_idempotency_unsupported_field_and_cross_key_hiding(self):
headers = {"Idempotency-Key": "contract-key"}
first = await self.client.responses.create(
model="assistant:asst_contract",
input="幂等请求",
extra_headers=headers,
)
replayed = await self.client.responses.create(
model="assistant:asst_contract",
input="幂等请求",
extra_headers=headers,
)
self.assertEqual(replayed.id, first.id)
self.assertEqual(self.run_count, 1)
with self.assertRaises(BadRequestError):
await self.client.responses.cancel(first.id)
malformed = await self.http_client.post(
"/v1/responses",
headers={"Authorization": "Bearer sk-rt-key-a"},
json={"model": "assistant:asst_contract"},
)
self.assertEqual(malformed.status_code, 422)
self.assertEqual(malformed.json()["error"]["type"], "invalid_request_error")
self.assertTrue(malformed.headers["x-request-id"].startswith("req_"))
caller_owned_item_id = await self.http_client.post(
"/v1/responses",
headers={"Authorization": "Bearer sk-rt-key-a"},
json={
"model": "assistant:asst_contract",
"input": [
{
"type": "message",
"id": "caller_owned_id",
"role": "user",
"content": "hello",
}
],
},
)
self.assertEqual(caller_owned_item_id.status_code, 422)
unauthenticated = await self.http_client.get("/v1/models")
self.assertEqual(unauthenticated.status_code, 401)
self.assertEqual(
unauthenticated.json()["error"]["code"],
"invalid_api_key",
)
with self.assertRaises(ConflictError):
await self.client.responses.create(
model="assistant:asst_contract",
input="不同请求",
extra_headers=headers,
)
with self.assertRaises(BadRequestError):
await self.client.responses.create(
model="assistant:asst_contract",
input="不支持",
extra_body={"background": True},
)
other_http_client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://contract.test",
)
other = AsyncOpenAI(
api_key="sk-rt-key-b",
base_url="http://contract.test/v1",
http_client=other_http_client,
max_retries=0,
)
try:
with self.assertRaises(NotFoundError):
await other.responses.retrieve(first.id)
finally:
await other.close()
if __name__ == "__main__":
unittest.main()