450 lines
15 KiB
Python
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()
|