feat: add stateful responses API
This commit is contained in:
@@ -315,6 +315,35 @@ class DifyLLMServiceTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_responses_state_skips_completed_prompt_startup(self):
|
||||
cfg = AssistantConfig(type="prompt")
|
||||
brain = build_brain(cfg)
|
||||
brain.restore_text_state(
|
||||
{
|
||||
"type": "prompt",
|
||||
"preflight_finished": True,
|
||||
"opening_finished": True,
|
||||
"entry_dispatched": True,
|
||||
}
|
||||
)
|
||||
|
||||
await brain.setup(
|
||||
cfg,
|
||||
BrainRuntime(
|
||||
context=LLMContext(messages=[]),
|
||||
llm=FakeLLM(),
|
||||
queue_frame=noop_queue_frame,
|
||||
set_system_prompt=lambda _prompt: None,
|
||||
set_tools=lambda _tools: None,
|
||||
call_end=FakeCallEnd(),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertTrue(brain._preflight_finished)
|
||||
self.assertTrue(brain._opening_finished)
|
||||
self.assertTrue(brain._entry_dispatched)
|
||||
self.assertEqual(brain.text_state_snapshot()["type"], "prompt")
|
||||
|
||||
async def test_startup_only_tool_is_not_registered_with_llm(self):
|
||||
startup_tool = RuntimeTool(
|
||||
id="opening_message",
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user