responses api behind use-responses-api flag (env-22..24, d-021) — tools + reasoning combinable, stateless with encrypted reasoning, multi-round tool loop
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
"""Unit coverage for the Responses API path (ENV-22..24, D-021)."""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from fjerkroa_bot.openai_responder import ENVELOPE_TEXT_FORMAT, OpenAIResponder
|
||||
|
||||
from .test_bdd_envelope import envelope
|
||||
|
||||
CONFIG = {
|
||||
"openai-token": "t",
|
||||
"model": "main-model",
|
||||
"system": "s",
|
||||
"history-limit": 5,
|
||||
"use-responses-api": True,
|
||||
"reasoning-effort": "medium",
|
||||
}
|
||||
|
||||
|
||||
def _msg_item():
|
||||
part = Mock()
|
||||
part.type = "output_text"
|
||||
item = Mock()
|
||||
item.type = "message"
|
||||
item.content = [part]
|
||||
item.model_dump = lambda: {"type": "message"}
|
||||
return item
|
||||
|
||||
|
||||
def _refusal_item():
|
||||
part = Mock()
|
||||
part.type = "refusal"
|
||||
item = Mock()
|
||||
item.type = "message"
|
||||
item.content = [part]
|
||||
return item
|
||||
|
||||
|
||||
def _reasoning_item():
|
||||
item = Mock()
|
||||
item.type = "reasoning"
|
||||
item.model_dump = lambda: {"type": "reasoning", "encrypted_content": "opaque-cot"}
|
||||
return item
|
||||
|
||||
|
||||
def _call_item(name, args, call_id="call-1"):
|
||||
item = Mock()
|
||||
item.type = "function_call"
|
||||
item.name = name
|
||||
item.arguments = json.dumps(args)
|
||||
item.call_id = call_id
|
||||
item.model_dump = lambda: {"type": "function_call", "name": name, "arguments": json.dumps(args), "call_id": call_id}
|
||||
return item
|
||||
|
||||
|
||||
def _response(output, text=""):
|
||||
result = Mock()
|
||||
result.output = output
|
||||
result.output_text = text
|
||||
result.usage = Mock(prompt_tokens=None, completion_tokens=None, input_tokens=5, output_tokens=7)
|
||||
return result
|
||||
|
||||
|
||||
class TestResponsesPath(unittest.IsolatedAsyncioTestCase):
|
||||
def _responder(self, **extra):
|
||||
return OpenAIResponder(dict(CONFIG, **extra), "chat")
|
||||
|
||||
async def test_flag_routes_to_responses_with_reasoning(self):
|
||||
"""ENV-22: flag on -> /v1/responses with envelope text.format, reasoning from config, stateless kwargs."""
|
||||
responder = self._responder()
|
||||
with patch("fjerkroa_bot.openai_responder.openai_responses", new_callable=AsyncMock) as responses_mock:
|
||||
with patch("fjerkroa_bot.openai_responder.openai_chat", new_callable=AsyncMock) as chat_mock:
|
||||
responses_mock.return_value = _response([_msg_item()], envelope(answer="hi", answer_needed=True))
|
||||
answer, _ = await responder.chat([{"role": "user", "content": "hei"}], 10)
|
||||
chat_mock.assert_not_awaited()
|
||||
self.assertEqual(json.loads(answer["content"])["answer"], "hi")
|
||||
kwargs = responses_mock.await_args.kwargs
|
||||
self.assertEqual(kwargs["text"], ENVELOPE_TEXT_FORMAT)
|
||||
self.assertEqual(kwargs["reasoning"], {"effort": "medium"})
|
||||
self.assertFalse(kwargs["store"]) # ENV-23
|
||||
self.assertIn("reasoning.encrypted_content", kwargs["include"])
|
||||
|
||||
async def test_flag_off_stays_on_chat_completions(self):
|
||||
"""ENV-22: flag off (default) -> openai_responses never called."""
|
||||
from .test_spec_structured import ok_result
|
||||
|
||||
responder = OpenAIResponder({k: v for k, v in CONFIG.items() if k != "use-responses-api"}, "chat")
|
||||
with patch("fjerkroa_bot.openai_responder.openai_responses", new_callable=AsyncMock) as responses_mock:
|
||||
with patch("fjerkroa_bot.openai_responder.openai_chat", new_callable=AsyncMock) as chat_mock:
|
||||
chat_mock.return_value = ok_result()
|
||||
await responder.chat([{"role": "user", "content": "hei"}], 10)
|
||||
responses_mock.assert_not_awaited()
|
||||
chat_mock.assert_awaited()
|
||||
|
||||
async def test_tools_flat_shape(self):
|
||||
"""ENV-22: tools are sent in the flat Responses shape (name at top level)."""
|
||||
responder = self._responder(**{"enable-news-tool": True})
|
||||
responder.store = Mock() # store present -> get_news offered
|
||||
with patch("fjerkroa_bot.openai_responder.openai_responses", new_callable=AsyncMock) as responses_mock:
|
||||
responses_mock.return_value = _response([_msg_item()], envelope(answer="x", answer_needed=True))
|
||||
await responder.chat([{"role": "user", "content": "hei"}], 10)
|
||||
tools = responses_mock.await_args.kwargs["tools"]
|
||||
self.assertTrue(all(tool["type"] == "function" and "name" in tool and "function" not in tool for tool in tools))
|
||||
|
||||
async def test_tool_loop_passes_reasoning_and_outputs_back(self):
|
||||
"""ENV-23: function_call -> dispatch; next call carries reasoning item + function_call_output."""
|
||||
responder = self._responder(**{"enable-news-tool": True})
|
||||
responder.store = Mock()
|
||||
responder._dispatch_tool = AsyncMock(return_value={"results": ["ok"]})
|
||||
first = _response([_reasoning_item(), _call_item("get_news", {"topic": "x"}, "call-9")])
|
||||
second = _response([_msg_item()], envelope(answer="done", answer_needed=True))
|
||||
with patch("fjerkroa_bot.openai_responder.openai_responses", new_callable=AsyncMock) as responses_mock:
|
||||
responses_mock.side_effect = [first, second]
|
||||
answer, _ = await responder.chat([{"role": "user", "content": "news?"}], 10)
|
||||
self.assertEqual(json.loads(answer["content"])["answer"], "done")
|
||||
responder._dispatch_tool.assert_awaited_once()
|
||||
followup_input = responses_mock.await_args_list[1].kwargs["input"]
|
||||
self.assertIn({"type": "reasoning", "encrypted_content": "opaque-cot"}, followup_input)
|
||||
outputs = [item for item in followup_input if isinstance(item, dict) and item.get("type") == "function_call_output"]
|
||||
self.assertEqual(len(outputs), 1)
|
||||
self.assertEqual(outputs[0]["call_id"], "call-9")
|
||||
|
||||
async def test_exhausted_rounds_force_toolless_answer(self):
|
||||
"""ENV-23: after responses-tool-rounds rounds the final call drops tools."""
|
||||
responder = self._responder(**{"enable-news-tool": True, "responses-tool-rounds": 1})
|
||||
responder.store = Mock()
|
||||
responder._dispatch_tool = AsyncMock(return_value={"results": []})
|
||||
looping = _response([_call_item("get_news", {}, "c")])
|
||||
final = _response([_msg_item()], envelope(answer="forced", answer_needed=True))
|
||||
with patch("fjerkroa_bot.openai_responder.openai_responses", new_callable=AsyncMock) as responses_mock:
|
||||
responses_mock.side_effect = [looping, final]
|
||||
answer, _ = await responder.chat([{"role": "user", "content": "go"}], 10)
|
||||
self.assertEqual(json.loads(answer["content"])["answer"], "forced")
|
||||
self.assertNotIn("tools", responses_mock.await_args_list[1].kwargs)
|
||||
|
||||
async def test_refusal_is_failed_attempt(self):
|
||||
"""ENV-24: a refusal part -> no answer."""
|
||||
responder = self._responder()
|
||||
with patch("fjerkroa_bot.openai_responder.openai_responses", new_callable=AsyncMock) as responses_mock:
|
||||
responses_mock.return_value = _response([_refusal_item()])
|
||||
answer, _ = await responder.chat([{"role": "user", "content": "hei"}], 10)
|
||||
self.assertIsNone(answer)
|
||||
|
||||
async def test_vision_parts_mapped(self):
|
||||
"""ENV-22: chat-format image parts become input_image items."""
|
||||
items = OpenAIResponder._responses_input(
|
||||
[
|
||||
{"role": "user", "content": [{"type": "text", "text": "look"}, {"type": "image_url", "image_url": {"url": "data:x"}}]},
|
||||
{"role": "tool", "content": "dropped"},
|
||||
{"role": "assistant", "content": "{}"},
|
||||
]
|
||||
)
|
||||
self.assertEqual(items[0]["content"][0], {"type": "input_text", "text": "look"})
|
||||
self.assertEqual(items[0]["content"][1], {"type": "input_image", "image_url": "data:x"})
|
||||
self.assertEqual(len(items), 2) # tool row dropped
|
||||
Reference in New Issue
Block a user