356 lines
12 KiB
Python
356 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import unittest
|
|
from datetime import UTC, datetime
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from models import AssistantConfig
|
|
from pipecat.frames.frames import (
|
|
LLMContextFrame,
|
|
LLMFullResponseEndFrame,
|
|
LLMFullResponseStartFrame,
|
|
LLMTextFrame,
|
|
)
|
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
|
from services.brains.base import BaseBrain, BrainSpec
|
|
from services.openai_responses.protocol import ResponsesAPIError, parse_request
|
|
from services.openai_responses.runner import (
|
|
ResponsesClientToolPort,
|
|
ResponsesTextRunner,
|
|
_context_snapshot,
|
|
)
|
|
from services.openai_responses.state import _validate_tool_outputs, items_to_messages
|
|
from routes.openai_responses import _stream_response
|
|
|
|
|
|
class ResponsesProtocolTests(unittest.TestCase):
|
|
def test_first_turn_requires_model_and_normalizes_text(self):
|
|
request = parse_request(
|
|
{
|
|
"model": "assistant:asst_1",
|
|
"input": "你好",
|
|
"x_interactive_media": {"variables": {"customer": "王女士"}},
|
|
}
|
|
)
|
|
|
|
self.assertEqual(request.model, "assistant:asst_1")
|
|
self.assertFalse(request.stream)
|
|
self.assertEqual(request.dynamic_variables, {"customer": "王女士"})
|
|
self.assertEqual(request.input_items[0]["role"], "user")
|
|
self.assertEqual(request.input_items[0]["content"][0]["text"], "你好")
|
|
|
|
def test_continuation_can_inherit_model(self):
|
|
request = parse_request(
|
|
{
|
|
"previous_response_id": "resp_parent",
|
|
"input": [
|
|
{
|
|
"type": "function_call_output",
|
|
"call_id": "call_1",
|
|
"output": {"ok": True},
|
|
}
|
|
],
|
|
"stream": True,
|
|
}
|
|
)
|
|
|
|
self.assertIsNone(request.model)
|
|
self.assertTrue(request.stream)
|
|
self.assertEqual(request.input_items[0]["output"], '{"ok": true}')
|
|
|
|
def test_rejects_stateless_and_assistant_owned_options(self):
|
|
with self.assertRaisesRegex(ResponsesAPIError, "store must be true"):
|
|
parse_request(
|
|
{"model": "assistant:asst_1", "input": "hi", "store": False}
|
|
)
|
|
with self.assertRaisesRegex(ResponsesAPIError, "store must be true"):
|
|
parse_request(
|
|
{"model": "assistant:asst_1", "input": "hi", "store": None}
|
|
)
|
|
with self.assertRaisesRegex(ResponsesAPIError, "owned"):
|
|
parse_request(
|
|
{
|
|
"model": "assistant:asst_1",
|
|
"input": "hi",
|
|
"instructions": "override",
|
|
}
|
|
)
|
|
|
|
def test_rejects_unknown_top_level_parameters(self):
|
|
with self.assertRaises(ResponsesAPIError) as raised:
|
|
parse_request(
|
|
{"model": "assistant:asst_1", "input": "hi", "metadata": {}}
|
|
)
|
|
|
|
self.assertEqual(raised.exception.code, "unsupported_parameter")
|
|
self.assertEqual(raised.exception.param, "metadata")
|
|
|
|
def test_rejects_images_and_unknown_items(self):
|
|
with self.assertRaises(ResponsesAPIError) as raised:
|
|
parse_request(
|
|
{
|
|
"model": "assistant:asst_1",
|
|
"input": [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_image", "image_url": "x"}],
|
|
}
|
|
],
|
|
}
|
|
)
|
|
self.assertEqual(raised.exception.code, "invalid_request_error")
|
|
|
|
|
|
class ResponsesStateTests(unittest.TestCase):
|
|
def test_context_items_become_llm_messages(self):
|
|
messages = items_to_messages(
|
|
[
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "查订单"}],
|
|
},
|
|
{
|
|
"type": "function_call",
|
|
"call_id": "call_1",
|
|
"name": "lookup_order",
|
|
"arguments": '{"id":"A1"}',
|
|
},
|
|
{
|
|
"type": "function_call_output",
|
|
"call_id": "call_1",
|
|
"output": '{"status":"ok"}',
|
|
},
|
|
]
|
|
)
|
|
|
|
self.assertEqual(messages[0], {"role": "user", "content": "查订单"})
|
|
self.assertEqual(messages[1]["tool_calls"][0]["id"], "call_1")
|
|
self.assertEqual(messages[2]["role"], "tool")
|
|
|
|
def test_tool_output_must_resolve_parent_pending_call(self):
|
|
parent = SimpleNamespace(
|
|
output_items=[{"type": "function_call", "call_id": "call_1"}],
|
|
input_items=[],
|
|
)
|
|
_validate_tool_outputs(
|
|
[parent],
|
|
[
|
|
{
|
|
"type": "function_call_output",
|
|
"call_id": "call_1",
|
|
"output": "ok",
|
|
}
|
|
],
|
|
)
|
|
|
|
with self.assertRaises(ResponsesAPIError) as raised:
|
|
_validate_tool_outputs(
|
|
[parent],
|
|
[
|
|
{
|
|
"type": "function_call_output",
|
|
"call_id": "call_other",
|
|
"output": "ok",
|
|
}
|
|
],
|
|
)
|
|
self.assertEqual(raised.exception.code, "invalid_tool_output")
|
|
|
|
def test_context_snapshot_aligns_provider_and_public_call_ids(self):
|
|
context = SimpleNamespace(
|
|
get_messages=lambda: [
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [
|
|
{
|
|
"id": "provider_call",
|
|
"type": "function",
|
|
"function": {"name": "show_message", "arguments": "{}"},
|
|
}
|
|
],
|
|
}
|
|
]
|
|
)
|
|
snapshot = _context_snapshot(
|
|
context,
|
|
function_calls=[
|
|
{
|
|
"call_id": "call_public",
|
|
"name": "show_message",
|
|
"arguments": "{}",
|
|
}
|
|
],
|
|
)
|
|
|
|
self.assertEqual(snapshot[0]["tool_calls"][0]["id"], "call_public")
|
|
|
|
def test_tool_output_cannot_be_replayed(self):
|
|
resolved = SimpleNamespace(
|
|
output_items=[{"type": "function_call", "call_id": "call_1"}],
|
|
input_items=[
|
|
{
|
|
"type": "function_call_output",
|
|
"call_id": "call_1",
|
|
"output": "ok",
|
|
}
|
|
],
|
|
)
|
|
with self.assertRaises(ResponsesAPIError) as raised:
|
|
_validate_tool_outputs(
|
|
[resolved],
|
|
[
|
|
{
|
|
"type": "function_call_output",
|
|
"call_id": "call_1",
|
|
"output": "again",
|
|
}
|
|
],
|
|
)
|
|
self.assertEqual(raised.exception.code, "invalid_tool_output")
|
|
|
|
|
|
class ResponsesClientToolTests(unittest.IsolatedAsyncioTestCase):
|
|
async def test_client_tool_is_exposed_without_waiting_for_browser(self):
|
|
port = ResponsesClientToolPort()
|
|
task = asyncio.create_task(
|
|
port.call("show_message", {"message": "确认"}, timeout_seconds=3)
|
|
)
|
|
await port.called.wait()
|
|
|
|
self.assertEqual(port.calls[0]["type"], "function_call")
|
|
self.assertEqual(port.calls[0]["name"], "show_message")
|
|
self.assertIn('"message": "确认"', port.calls[0]["arguments"])
|
|
|
|
task.cancel()
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
|
|
async def test_persisted_result_resumes_deterministic_client_tool(self):
|
|
port = ResponsesClientToolPort(
|
|
[
|
|
{
|
|
"name": "show_message",
|
|
"result": {"status": "ok", "data": {"action": "confirmed"}},
|
|
}
|
|
]
|
|
)
|
|
|
|
result = await port.call(
|
|
"show_message",
|
|
{"message": "确认"},
|
|
timeout_seconds=3,
|
|
)
|
|
|
|
self.assertEqual(result["data"]["action"], "confirmed")
|
|
self.assertFalse(port.called.is_set())
|
|
|
|
|
|
class _FakeTextLLM(FrameProcessor):
|
|
async def process_frame(self, frame, direction: FrameDirection):
|
|
await super().process_frame(frame, direction)
|
|
if not isinstance(frame, LLMContextFrame):
|
|
await self.push_frame(frame, direction)
|
|
return
|
|
await self.push_frame(LLMFullResponseStartFrame())
|
|
await self.push_frame(LLMTextFrame("状态已恢复"))
|
|
await self.push_frame(LLMFullResponseEndFrame())
|
|
|
|
|
|
class _FakeBrain(BaseBrain):
|
|
spec = BrainSpec(
|
|
type="dify",
|
|
supported_runtime_modes=frozenset({"pipeline"}),
|
|
owns_context=False,
|
|
)
|
|
|
|
def build_llm(self, _cfg, _context):
|
|
return _FakeTextLLM()
|
|
|
|
|
|
class ResponsesTextRunnerTests(unittest.IsolatedAsyncioTestCase):
|
|
async def test_transportless_runner_streams_and_finishes_text(self):
|
|
deltas: list[str] = []
|
|
|
|
async def on_delta(value: str) -> None:
|
|
deltas.append(value)
|
|
|
|
with patch(
|
|
"services.openai_responses.runner.build_brain",
|
|
return_value=_FakeBrain(),
|
|
):
|
|
result = await asyncio.wait_for(
|
|
ResponsesTextRunner().run(
|
|
AssistantConfig(
|
|
type="dify",
|
|
runtimeMode="pipeline",
|
|
greeting="",
|
|
),
|
|
history_messages=[{"role": "user", "content": "上一轮"}],
|
|
current_messages=[{"role": "user", "content": "继续"}],
|
|
on_delta=on_delta,
|
|
),
|
|
timeout=3,
|
|
)
|
|
|
|
self.assertEqual(result.text, "状态已恢复")
|
|
self.assertEqual(deltas, ["状态已恢复"])
|
|
|
|
|
|
class ResponsesStreamingTests(unittest.IsolatedAsyncioTestCase):
|
|
async def test_stream_uses_responses_event_lifecycle(self):
|
|
now = datetime.now(UTC)
|
|
row = SimpleNamespace(
|
|
id="resp_1",
|
|
api_key_id="rtkey_1",
|
|
assistant_id="asst_1",
|
|
previous_response_id=None,
|
|
status="completed",
|
|
error=None,
|
|
output_items=[
|
|
{
|
|
"id": "msg_1",
|
|
"type": "message",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "output_text",
|
|
"text": "完成",
|
|
"annotations": [],
|
|
}
|
|
],
|
|
}
|
|
],
|
|
created_at=now,
|
|
completed_at=now,
|
|
)
|
|
prepared = SimpleNamespace(row=row)
|
|
|
|
async def run_prepared(_prepared, *, on_delta, text_item_id):
|
|
self.assertEqual(text_item_id, "msg_1")
|
|
await on_delta("完")
|
|
await on_delta("成")
|
|
return row
|
|
|
|
with patch(
|
|
"routes.openai_responses._run_prepared",
|
|
new=AsyncMock(side_effect=run_prepared),
|
|
):
|
|
chunks = [
|
|
chunk.decode("utf-8")
|
|
async for chunk in _stream_response(prepared, text_item_id="msg_1")
|
|
]
|
|
|
|
body = "".join(chunks)
|
|
self.assertIn("event: response.created", body)
|
|
self.assertIn("event: response.output_text.delta", body)
|
|
self.assertIn("event: response.output_text.done", body)
|
|
self.assertIn("event: response.completed", body)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|