2026-04-02 05:38:19 +00:00
|
|
|
|
"""Tests for the shared openai_responses converters and parsers."""
|
2026-03-31 08:30:11 +00:00
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
|
import json
|
2026-07-30 22:39:43 +08:00
|
|
|
|
from io import StringIO
|
2026-08-05 18:26:39 +08:00
|
|
|
|
from types import SimpleNamespace
|
2026-03-31 09:22:50 +00:00
|
|
|
|
from unittest.mock import MagicMock, patch
|
2026-03-31 08:30:11 +00:00
|
|
|
|
|
|
|
|
|
|
import pytest
|
2026-07-30 22:39:43 +08:00
|
|
|
|
from loguru import logger
|
2026-03-31 08:30:11 +00:00
|
|
|
|
|
2026-08-22 00:53:16 +08:00
|
|
|
|
from nanobot.providers.base import LLMUsage
|
2026-04-02 05:38:19 +00:00
|
|
|
|
from nanobot.providers.openai_responses.converters import (
|
2026-03-31 08:30:11 +00:00
|
|
|
|
convert_messages,
|
|
|
|
|
|
convert_tools,
|
|
|
|
|
|
convert_user_message,
|
|
|
|
|
|
split_tool_call_id,
|
|
|
|
|
|
)
|
2026-04-02 05:38:19 +00:00
|
|
|
|
from nanobot.providers.openai_responses.parsing import (
|
2026-07-30 22:39:43 +08:00
|
|
|
|
ResponsesStreamCapture,
|
2026-03-31 08:30:11 +00:00
|
|
|
|
consume_sdk_stream,
|
2026-05-29 03:42:53 +08:00
|
|
|
|
consume_sse,
|
|
|
|
|
|
consume_sse_with_reasoning,
|
2026-07-30 22:39:43 +08:00
|
|
|
|
is_replayable_finish_reason,
|
2026-03-31 08:30:11 +00:00
|
|
|
|
map_finish_reason,
|
|
|
|
|
|
parse_response_output,
|
|
|
|
|
|
)
|
2026-07-30 22:39:43 +08:00
|
|
|
|
from nanobot.providers.openai_responses.state import (
|
|
|
|
|
|
build_responses_state,
|
|
|
|
|
|
is_compaction_compatibility_error,
|
|
|
|
|
|
prepare_responses_input,
|
|
|
|
|
|
resolve_compact_threshold,
|
|
|
|
|
|
responses_state_context_tokens,
|
|
|
|
|
|
responses_state_items,
|
|
|
|
|
|
)
|
2026-03-31 08:30:11 +00:00
|
|
|
|
|
|
|
|
|
|
# ======================================================================
|
2026-04-02 05:38:19 +00:00
|
|
|
|
# converters - split_tool_call_id
|
2026-03-31 08:30:11 +00:00
|
|
|
|
# ======================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSplitToolCallId:
|
|
|
|
|
|
def test_plain_id(self):
|
|
|
|
|
|
assert split_tool_call_id("call_abc") == ("call_abc", None)
|
|
|
|
|
|
|
|
|
|
|
|
def test_compound_id(self):
|
|
|
|
|
|
assert split_tool_call_id("call_abc|fc_1") == ("call_abc", "fc_1")
|
|
|
|
|
|
|
|
|
|
|
|
def test_compound_empty_item_id(self):
|
|
|
|
|
|
assert split_tool_call_id("call_abc|") == ("call_abc", None)
|
|
|
|
|
|
|
|
|
|
|
|
def test_none(self):
|
|
|
|
|
|
assert split_tool_call_id(None) == ("call_0", None)
|
|
|
|
|
|
|
|
|
|
|
|
def test_empty_string(self):
|
|
|
|
|
|
assert split_tool_call_id("") == ("call_0", None)
|
|
|
|
|
|
|
|
|
|
|
|
def test_non_string(self):
|
|
|
|
|
|
assert split_tool_call_id(42) == ("call_0", None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ======================================================================
|
2026-04-02 05:38:19 +00:00
|
|
|
|
# converters - convert_user_message
|
2026-03-31 08:30:11 +00:00
|
|
|
|
# ======================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConvertUserMessage:
|
|
|
|
|
|
def test_string_content(self):
|
|
|
|
|
|
result = convert_user_message("hello")
|
|
|
|
|
|
assert result == {"role": "user", "content": [{"type": "input_text", "text": "hello"}]}
|
|
|
|
|
|
|
|
|
|
|
|
def test_text_block(self):
|
|
|
|
|
|
result = convert_user_message([{"type": "text", "text": "hi"}])
|
|
|
|
|
|
assert result["content"] == [{"type": "input_text", "text": "hi"}]
|
|
|
|
|
|
|
|
|
|
|
|
def test_image_url_block(self):
|
|
|
|
|
|
result = convert_user_message([
|
|
|
|
|
|
{"type": "image_url", "image_url": {"url": "https://img.example/a.png"}},
|
|
|
|
|
|
])
|
|
|
|
|
|
assert result["content"] == [
|
|
|
|
|
|
{"type": "input_image", "image_url": "https://img.example/a.png", "detail": "auto"},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
def test_mixed_text_and_image(self):
|
|
|
|
|
|
result = convert_user_message([
|
|
|
|
|
|
{"type": "text", "text": "what's this?"},
|
|
|
|
|
|
{"type": "image_url", "image_url": {"url": "https://img.example/b.png"}},
|
|
|
|
|
|
])
|
|
|
|
|
|
assert len(result["content"]) == 2
|
|
|
|
|
|
assert result["content"][0]["type"] == "input_text"
|
|
|
|
|
|
assert result["content"][1]["type"] == "input_image"
|
|
|
|
|
|
|
|
|
|
|
|
def test_empty_list_falls_back(self):
|
|
|
|
|
|
result = convert_user_message([])
|
|
|
|
|
|
assert result["content"] == [{"type": "input_text", "text": ""}]
|
|
|
|
|
|
|
|
|
|
|
|
def test_none_falls_back(self):
|
|
|
|
|
|
result = convert_user_message(None)
|
|
|
|
|
|
assert result["content"] == [{"type": "input_text", "text": ""}]
|
|
|
|
|
|
|
|
|
|
|
|
def test_image_without_url_skipped(self):
|
|
|
|
|
|
result = convert_user_message([{"type": "image_url", "image_url": {}}])
|
|
|
|
|
|
assert result["content"] == [{"type": "input_text", "text": ""}]
|
|
|
|
|
|
|
|
|
|
|
|
def test_meta_fields_not_leaked(self):
|
|
|
|
|
|
"""_meta on content blocks must never appear in converted output."""
|
|
|
|
|
|
result = convert_user_message([
|
|
|
|
|
|
{"type": "text", "text": "hi", "_meta": {"path": "/tmp/x"}},
|
|
|
|
|
|
])
|
|
|
|
|
|
assert "_meta" not in result["content"][0]
|
|
|
|
|
|
|
|
|
|
|
|
def test_non_dict_items_skipped(self):
|
|
|
|
|
|
result = convert_user_message(["just a string", 42])
|
|
|
|
|
|
assert result["content"] == [{"type": "input_text", "text": ""}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ======================================================================
|
2026-04-02 05:38:19 +00:00
|
|
|
|
# converters - convert_messages
|
2026-03-31 08:30:11 +00:00
|
|
|
|
# ======================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConvertMessages:
|
|
|
|
|
|
def test_system_extracted_as_instructions(self):
|
|
|
|
|
|
msgs = [
|
|
|
|
|
|
{"role": "system", "content": "You are helpful."},
|
|
|
|
|
|
{"role": "user", "content": "Hi"},
|
|
|
|
|
|
]
|
|
|
|
|
|
instructions, items = convert_messages(msgs)
|
|
|
|
|
|
assert instructions == "You are helpful."
|
|
|
|
|
|
assert len(items) == 1
|
|
|
|
|
|
assert items[0]["role"] == "user"
|
|
|
|
|
|
|
|
|
|
|
|
def test_multiple_system_messages_last_wins(self):
|
|
|
|
|
|
msgs = [
|
|
|
|
|
|
{"role": "system", "content": "first"},
|
|
|
|
|
|
{"role": "system", "content": "second"},
|
|
|
|
|
|
{"role": "user", "content": "x"},
|
|
|
|
|
|
]
|
|
|
|
|
|
instructions, _ = convert_messages(msgs)
|
|
|
|
|
|
assert instructions == "second"
|
|
|
|
|
|
|
|
|
|
|
|
def test_user_message_converted(self):
|
|
|
|
|
|
_, items = convert_messages([{"role": "user", "content": "hello"}])
|
|
|
|
|
|
assert items[0]["role"] == "user"
|
|
|
|
|
|
assert items[0]["content"][0]["type"] == "input_text"
|
|
|
|
|
|
|
|
|
|
|
|
def test_assistant_text_message(self):
|
|
|
|
|
|
_, items = convert_messages([
|
|
|
|
|
|
{"role": "assistant", "content": "I'll help"},
|
|
|
|
|
|
])
|
|
|
|
|
|
assert items[0]["type"] == "message"
|
|
|
|
|
|
assert items[0]["role"] == "assistant"
|
|
|
|
|
|
assert items[0]["content"][0]["type"] == "output_text"
|
|
|
|
|
|
assert items[0]["content"][0]["text"] == "I'll help"
|
|
|
|
|
|
|
2026-08-01 11:53:51 +08:00
|
|
|
|
def test_preserves_deepseek_reasoning_content(self):
|
|
|
|
|
|
_, items = convert_messages([
|
|
|
|
|
|
{"role": "assistant", "reasoning_content": "think first", "content": "answer"},
|
|
|
|
|
|
], preserve_reasoning=True)
|
|
|
|
|
|
|
|
|
|
|
|
assert items == [
|
2026-08-03 10:40:19 +02:00
|
|
|
|
{
|
|
|
|
|
|
"type": "reasoning",
|
|
|
|
|
|
"content": [{"type": "output_text", "text": "think first"}],
|
|
|
|
|
|
},
|
2026-08-01 11:53:51 +08:00
|
|
|
|
{
|
|
|
|
|
|
"type": "message",
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": [{"type": "output_text", "text": "answer"}],
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"id": "msg_0",
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-08-03 10:40:19 +02:00
|
|
|
|
def test_reasoning_content_serialized_as_array_for_deepseek(self):
|
|
|
|
|
|
# Regression for PR #5214: DeepSeek's Responses gateway rejects
|
|
|
|
|
|
# reasoning items whose ``content`` is a plain string with
|
|
|
|
|
|
# "input: invalid type: string ..., expected a sequence" (observed
|
|
|
|
|
|
# after context consolidation cleared provider state and forced
|
|
|
|
|
|
# full-history conversion). ``content`` must be a list of parts,
|
|
|
|
|
|
# matching both the OpenAI Responses schema and DeepSeek's accepted
|
|
|
|
|
|
# wire shape.
|
|
|
|
|
|
_, items = convert_messages([
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"reasoning_content": "Michael topped up DeepSeek with $10.",
|
|
|
|
|
|
"content": "",
|
|
|
|
|
|
"tool_calls": [{
|
|
|
|
|
|
"id": "call_1|fc_1",
|
|
|
|
|
|
"function": {"name": "list_dir", "arguments": "{}"},
|
|
|
|
|
|
}],
|
|
|
|
|
|
},
|
|
|
|
|
|
], preserve_reasoning=True)
|
|
|
|
|
|
|
|
|
|
|
|
assert items[0]["type"] == "reasoning"
|
|
|
|
|
|
assert items[0]["content"] == [
|
|
|
|
|
|
{"type": "output_text", "text": "Michael topped up DeepSeek with $10."},
|
|
|
|
|
|
]
|
|
|
|
|
|
assert items[1]["type"] == "function_call"
|
|
|
|
|
|
|
2026-03-31 08:30:11 +00:00
|
|
|
|
def test_assistant_empty_content_skipped(self):
|
|
|
|
|
|
_, items = convert_messages([{"role": "assistant", "content": ""}])
|
|
|
|
|
|
assert len(items) == 0
|
|
|
|
|
|
|
|
|
|
|
|
def test_assistant_with_tool_calls(self):
|
|
|
|
|
|
_, items = convert_messages([{
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": None,
|
|
|
|
|
|
"tool_calls": [{
|
|
|
|
|
|
"id": "call_abc|fc_1",
|
|
|
|
|
|
"function": {"name": "get_weather", "arguments": '{"city":"SF"}'},
|
|
|
|
|
|
}],
|
|
|
|
|
|
}])
|
|
|
|
|
|
assert items[0]["type"] == "function_call"
|
|
|
|
|
|
assert items[0]["call_id"] == "call_abc"
|
|
|
|
|
|
assert items[0]["id"] == "fc_1"
|
|
|
|
|
|
assert items[0]["name"] == "get_weather"
|
2026-06-09 14:50:40 +08:00
|
|
|
|
assert items[0]["arguments"] == '{"city": "SF"}'
|
|
|
|
|
|
|
|
|
|
|
|
def test_assistant_tool_call_history_repairs_malformed_arguments(self):
|
|
|
|
|
|
_, items = convert_messages([{
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": None,
|
|
|
|
|
|
"tool_calls": [{
|
|
|
|
|
|
"id": "call_abc|fc_1",
|
|
|
|
|
|
"function": {"name": "read_file", "arguments": '{path:"foo.txt"}'},
|
|
|
|
|
|
}],
|
|
|
|
|
|
}])
|
|
|
|
|
|
|
|
|
|
|
|
assert json.loads(items[0]["arguments"]) == {"path": "foo.txt"}
|
2026-03-31 08:30:11 +00:00
|
|
|
|
|
2026-05-22 21:39:38 +08:00
|
|
|
|
def test_duplicate_response_item_ids_are_made_unique(self):
|
|
|
|
|
|
"""Codex rejects replayed Responses input items with duplicate ids."""
|
|
|
|
|
|
_, items = convert_messages([
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": None,
|
|
|
|
|
|
"tool_calls": [{
|
|
|
|
|
|
"id": "call_a|rs_same",
|
|
|
|
|
|
"function": {"name": "first", "arguments": "{}"},
|
|
|
|
|
|
}],
|
|
|
|
|
|
},
|
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_a|rs_same", "content": "ok"},
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": None,
|
|
|
|
|
|
"tool_calls": [{
|
|
|
|
|
|
"id": "call_b|rs_same",
|
|
|
|
|
|
"function": {"name": "second", "arguments": "{}"},
|
|
|
|
|
|
}],
|
|
|
|
|
|
},
|
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_b|rs_same", "content": "ok"},
|
|
|
|
|
|
])
|
|
|
|
|
|
function_call_ids = [
|
|
|
|
|
|
item["id"] for item in items if item.get("type") == "function_call"
|
|
|
|
|
|
]
|
|
|
|
|
|
assert function_call_ids == ["rs_same", "rs_same_2"]
|
|
|
|
|
|
assert len(function_call_ids) == len(set(function_call_ids))
|
|
|
|
|
|
|
|
|
|
|
|
def test_fallback_response_item_ids_are_unique_with_multiple_tool_calls(self):
|
|
|
|
|
|
_, items = convert_messages([{
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": None,
|
|
|
|
|
|
"tool_calls": [
|
|
|
|
|
|
{"id": "call_a", "function": {"name": "first", "arguments": "{}"}},
|
|
|
|
|
|
{"id": "call_b", "function": {"name": "second", "arguments": "{}"}},
|
|
|
|
|
|
],
|
|
|
|
|
|
}])
|
|
|
|
|
|
function_call_ids = [
|
|
|
|
|
|
item["id"] for item in items if item.get("type") == "function_call"
|
|
|
|
|
|
]
|
|
|
|
|
|
assert function_call_ids == ["fc_0", "fc_0_2"]
|
|
|
|
|
|
assert len(function_call_ids) == len(set(function_call_ids))
|
|
|
|
|
|
|
2026-03-31 08:30:11 +00:00
|
|
|
|
def test_assistant_with_tool_calls_no_id(self):
|
|
|
|
|
|
"""Fallback IDs when tool_call.id is missing."""
|
|
|
|
|
|
_, items = convert_messages([{
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": None,
|
|
|
|
|
|
"tool_calls": [{"function": {"name": "f1", "arguments": "{}"}}],
|
|
|
|
|
|
}])
|
|
|
|
|
|
assert items[0]["call_id"] == "call_0"
|
|
|
|
|
|
assert items[0]["id"].startswith("fc_")
|
|
|
|
|
|
|
|
|
|
|
|
def test_tool_message(self):
|
|
|
|
|
|
_, items = convert_messages([{
|
|
|
|
|
|
"role": "tool",
|
|
|
|
|
|
"tool_call_id": "call_abc",
|
|
|
|
|
|
"content": "result text",
|
|
|
|
|
|
}])
|
|
|
|
|
|
assert items[0]["type"] == "function_call_output"
|
|
|
|
|
|
assert items[0]["call_id"] == "call_abc"
|
|
|
|
|
|
assert items[0]["output"] == "result text"
|
|
|
|
|
|
|
|
|
|
|
|
def test_tool_message_dict_content(self):
|
|
|
|
|
|
_, items = convert_messages([{
|
|
|
|
|
|
"role": "tool",
|
|
|
|
|
|
"tool_call_id": "call_1",
|
|
|
|
|
|
"content": {"key": "value"},
|
|
|
|
|
|
}])
|
|
|
|
|
|
assert items[0]["output"] == '{"key": "value"}'
|
|
|
|
|
|
|
2026-07-24 14:55:38 +08:00
|
|
|
|
def test_tool_message_preserves_image_content(self):
|
|
|
|
|
|
_, items = convert_messages([{
|
|
|
|
|
|
"role": "tool",
|
|
|
|
|
|
"tool_call_id": "call_1",
|
|
|
|
|
|
"content": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "image_url",
|
|
|
|
|
|
"image_url": {"url": "data:image/png;base64,abc"},
|
|
|
|
|
|
"_meta": {"path": "/private/image.png"},
|
|
|
|
|
|
},
|
|
|
|
|
|
{"type": "text", "text": "(Image file: image.png)"},
|
|
|
|
|
|
],
|
|
|
|
|
|
}])
|
|
|
|
|
|
|
|
|
|
|
|
assert items[0]["output"] == [
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "input_image",
|
|
|
|
|
|
"image_url": "data:image/png;base64,abc",
|
|
|
|
|
|
"detail": "auto",
|
|
|
|
|
|
},
|
|
|
|
|
|
{"type": "input_text", "text": "(Image file: image.png)"},
|
|
|
|
|
|
]
|
|
|
|
|
|
assert "_meta" not in str(items[0])
|
|
|
|
|
|
|
|
|
|
|
|
def test_tool_message_preserves_file_content(self):
|
|
|
|
|
|
_, items = convert_messages([{
|
|
|
|
|
|
"role": "tool",
|
|
|
|
|
|
"tool_call_id": "call_1",
|
|
|
|
|
|
"content": [{
|
|
|
|
|
|
"type": "input_file",
|
|
|
|
|
|
"file_id": "file_123",
|
|
|
|
|
|
"filename": "report.pdf",
|
|
|
|
|
|
"_meta": {"path": "/private/report.pdf"},
|
|
|
|
|
|
}],
|
|
|
|
|
|
}])
|
|
|
|
|
|
|
|
|
|
|
|
assert items[0]["output"] == [{
|
|
|
|
|
|
"type": "input_file",
|
|
|
|
|
|
"file_id": "file_123",
|
|
|
|
|
|
"filename": "report.pdf",
|
|
|
|
|
|
}]
|
|
|
|
|
|
assert "_meta" not in str(items[0])
|
|
|
|
|
|
|
2026-07-24 17:16:15 +08:00
|
|
|
|
def test_tool_message_preserves_unknown_list_as_json(self):
|
|
|
|
|
|
content = [
|
|
|
|
|
|
{"type": "text", "text": "status", "code": 7},
|
|
|
|
|
|
{"kind": "record", "value": 42},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
_, items = convert_messages([{
|
|
|
|
|
|
"role": "tool",
|
|
|
|
|
|
"tool_call_id": "call_1",
|
|
|
|
|
|
"content": content,
|
|
|
|
|
|
}])
|
|
|
|
|
|
|
|
|
|
|
|
assert items[0]["output"] == json.dumps(content, ensure_ascii=False)
|
|
|
|
|
|
|
2026-03-31 08:30:11 +00:00
|
|
|
|
def test_non_standard_keys_not_leaked(self):
|
|
|
|
|
|
"""Extra keys on messages must not appear in converted items."""
|
|
|
|
|
|
_, items = convert_messages([{
|
|
|
|
|
|
"role": "user",
|
|
|
|
|
|
"content": "hi",
|
|
|
|
|
|
"extra_field": "should vanish",
|
|
|
|
|
|
"_meta": {"path": "/tmp"},
|
|
|
|
|
|
}])
|
|
|
|
|
|
item = items[0]
|
|
|
|
|
|
assert "extra_field" not in str(item)
|
|
|
|
|
|
assert "_meta" not in str(item)
|
|
|
|
|
|
|
|
|
|
|
|
def test_full_conversation_roundtrip(self):
|
2026-04-02 05:38:19 +00:00
|
|
|
|
"""System + user + assistant(tool_call) + tool -> correct structure."""
|
2026-03-31 08:30:11 +00:00
|
|
|
|
msgs = [
|
|
|
|
|
|
{"role": "system", "content": "Be concise."},
|
|
|
|
|
|
{"role": "user", "content": "Weather in SF?"},
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "assistant", "content": None,
|
|
|
|
|
|
"tool_calls": [{
|
|
|
|
|
|
"id": "c1|fc1",
|
|
|
|
|
|
"function": {"name": "get_weather", "arguments": '{"city":"SF"}'},
|
|
|
|
|
|
}],
|
|
|
|
|
|
},
|
|
|
|
|
|
{"role": "tool", "tool_call_id": "c1", "content": '{"temp":72}'},
|
|
|
|
|
|
]
|
|
|
|
|
|
instructions, items = convert_messages(msgs)
|
|
|
|
|
|
assert instructions == "Be concise."
|
|
|
|
|
|
assert len(items) == 3 # user, function_call, function_call_output
|
|
|
|
|
|
assert items[0]["role"] == "user"
|
|
|
|
|
|
assert items[1]["type"] == "function_call"
|
|
|
|
|
|
assert items[2]["type"] == "function_call_output"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ======================================================================
|
2026-04-02 05:38:19 +00:00
|
|
|
|
# converters - convert_tools
|
2026-03-31 08:30:11 +00:00
|
|
|
|
# ======================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConvertTools:
|
|
|
|
|
|
def test_standard_function_tool(self):
|
|
|
|
|
|
tools = [{"type": "function", "function": {
|
|
|
|
|
|
"name": "get_weather",
|
|
|
|
|
|
"description": "Get weather",
|
|
|
|
|
|
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
|
|
|
|
|
|
}}]
|
|
|
|
|
|
result = convert_tools(tools)
|
|
|
|
|
|
assert len(result) == 1
|
|
|
|
|
|
assert result[0]["type"] == "function"
|
|
|
|
|
|
assert result[0]["name"] == "get_weather"
|
|
|
|
|
|
assert result[0]["description"] == "Get weather"
|
|
|
|
|
|
assert "properties" in result[0]["parameters"]
|
|
|
|
|
|
|
|
|
|
|
|
def test_tool_without_name_skipped(self):
|
|
|
|
|
|
tools = [{"type": "function", "function": {"parameters": {}}}]
|
|
|
|
|
|
assert convert_tools(tools) == []
|
|
|
|
|
|
|
|
|
|
|
|
def test_tool_without_function_wrapper(self):
|
|
|
|
|
|
"""Direct dict without type=function wrapper."""
|
|
|
|
|
|
tools = [{"name": "f1", "description": "d", "parameters": {}}]
|
|
|
|
|
|
result = convert_tools(tools)
|
|
|
|
|
|
assert result[0]["name"] == "f1"
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_optional_fields_default(self):
|
|
|
|
|
|
tools = [{"type": "function", "function": {"name": "f"}}]
|
|
|
|
|
|
result = convert_tools(tools)
|
|
|
|
|
|
assert result[0]["description"] == ""
|
|
|
|
|
|
assert result[0]["parameters"] == {}
|
|
|
|
|
|
|
|
|
|
|
|
def test_multiple_tools(self):
|
|
|
|
|
|
tools = [
|
|
|
|
|
|
{"type": "function", "function": {"name": "a", "parameters": {}}},
|
|
|
|
|
|
{"type": "function", "function": {"name": "b", "parameters": {}}},
|
|
|
|
|
|
]
|
|
|
|
|
|
assert len(convert_tools(tools)) == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ======================================================================
|
2026-04-02 05:38:19 +00:00
|
|
|
|
# parsing - map_finish_reason
|
2026-03-31 08:30:11 +00:00
|
|
|
|
# ======================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMapFinishReason:
|
|
|
|
|
|
def test_completed(self):
|
|
|
|
|
|
assert map_finish_reason("completed") == "stop"
|
|
|
|
|
|
|
|
|
|
|
|
def test_incomplete(self):
|
|
|
|
|
|
assert map_finish_reason("incomplete") == "length"
|
|
|
|
|
|
|
|
|
|
|
|
def test_failed(self):
|
|
|
|
|
|
assert map_finish_reason("failed") == "error"
|
|
|
|
|
|
|
|
|
|
|
|
def test_cancelled(self):
|
|
|
|
|
|
assert map_finish_reason("cancelled") == "error"
|
|
|
|
|
|
|
|
|
|
|
|
def test_none_defaults_to_stop(self):
|
|
|
|
|
|
assert map_finish_reason(None) == "stop"
|
|
|
|
|
|
|
|
|
|
|
|
def test_unknown_defaults_to_stop(self):
|
|
|
|
|
|
assert map_finish_reason("some_new_status") == "stop"
|
|
|
|
|
|
|
2026-07-30 22:39:43 +08:00
|
|
|
|
@pytest.mark.parametrize("finish_reason", ["stop", "tool_calls", "function_call"])
|
|
|
|
|
|
def test_replayable_finish_reasons(self, finish_reason):
|
|
|
|
|
|
assert is_replayable_finish_reason(finish_reason) is True
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"finish_reason",
|
|
|
|
|
|
["length", "refusal", "content_filter", "error"],
|
|
|
|
|
|
)
|
|
|
|
|
|
def test_non_replayable_finish_reasons(self, finish_reason):
|
|
|
|
|
|
assert is_replayable_finish_reason(finish_reason) is False
|
|
|
|
|
|
|
2026-03-31 08:30:11 +00:00
|
|
|
|
|
|
|
|
|
|
# ======================================================================
|
2026-04-02 05:38:19 +00:00
|
|
|
|
# parsing - parse_response_output
|
2026-03-31 08:30:11 +00:00
|
|
|
|
# ======================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestParseResponseOutput:
|
|
|
|
|
|
def test_text_response(self):
|
|
|
|
|
|
resp = {
|
|
|
|
|
|
"output": [{"type": "message", "role": "assistant",
|
|
|
|
|
|
"content": [{"type": "output_text", "text": "Hello!"}]}],
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
|
|
|
|
|
}
|
|
|
|
|
|
result = parse_response_output(resp)
|
|
|
|
|
|
assert result.content == "Hello!"
|
|
|
|
|
|
assert result.finish_reason == "stop"
|
2026-08-22 00:53:16 +08:00
|
|
|
|
assert result.usage == LLMUsage.reported(input_tokens=10, output_tokens=5)
|
2026-03-31 08:30:11 +00:00
|
|
|
|
assert result.tool_calls == []
|
|
|
|
|
|
|
2026-07-30 22:39:43 +08:00
|
|
|
|
def test_refusal_response_surfaces_text_without_advancing_state(self):
|
|
|
|
|
|
refusal = "I can’t help with that request."
|
|
|
|
|
|
resp = {
|
|
|
|
|
|
"output": [{
|
|
|
|
|
|
"type": "message",
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": [{"type": "refusal", "refusal": refusal}],
|
|
|
|
|
|
}],
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"usage": {},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result = parse_response_output(
|
|
|
|
|
|
resp,
|
|
|
|
|
|
state_provider="openai:test",
|
|
|
|
|
|
state_model="gpt-5.6",
|
|
|
|
|
|
state_input_items=[{"role": "user", "content": "request"}],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert result.content == refusal
|
|
|
|
|
|
assert result.finish_reason == "refusal"
|
|
|
|
|
|
assert result.provider_state is None
|
|
|
|
|
|
|
2026-03-31 08:30:11 +00:00
|
|
|
|
def test_tool_call_response(self):
|
|
|
|
|
|
resp = {
|
|
|
|
|
|
"output": [{
|
|
|
|
|
|
"type": "function_call",
|
|
|
|
|
|
"call_id": "call_1", "id": "fc_1",
|
|
|
|
|
|
"name": "get_weather",
|
|
|
|
|
|
"arguments": '{"city": "SF"}',
|
|
|
|
|
|
}],
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"usage": {},
|
|
|
|
|
|
}
|
2026-07-30 22:39:43 +08:00
|
|
|
|
result = parse_response_output(
|
|
|
|
|
|
resp,
|
|
|
|
|
|
state_provider="openai:test",
|
|
|
|
|
|
state_model="gpt-5.6",
|
|
|
|
|
|
state_input_items=[{"role": "user", "content": "weather?"}],
|
|
|
|
|
|
)
|
2026-03-31 08:30:11 +00:00
|
|
|
|
assert result.content is None
|
|
|
|
|
|
assert len(result.tool_calls) == 1
|
|
|
|
|
|
assert result.tool_calls[0].name == "get_weather"
|
|
|
|
|
|
assert result.tool_calls[0].arguments == {"city": "SF"}
|
|
|
|
|
|
assert result.tool_calls[0].id == "call_1|fc_1"
|
2026-07-30 22:39:43 +08:00
|
|
|
|
assert result.provider_state is not None
|
2026-03-31 08:30:11 +00:00
|
|
|
|
|
2026-03-31 09:22:50 +00:00
|
|
|
|
def test_malformed_tool_arguments_logged(self):
|
2026-06-09 14:50:40 +08:00
|
|
|
|
"""Malformed JSON arguments should log a warning and remain non-object."""
|
2026-03-31 08:30:11 +00:00
|
|
|
|
resp = {
|
|
|
|
|
|
"output": [{
|
|
|
|
|
|
"type": "function_call",
|
|
|
|
|
|
"call_id": "c1", "id": "fc1",
|
|
|
|
|
|
"name": "f", "arguments": "{bad json",
|
|
|
|
|
|
}],
|
|
|
|
|
|
"status": "completed", "usage": {},
|
|
|
|
|
|
}
|
2026-04-02 05:38:19 +00:00
|
|
|
|
with patch("nanobot.providers.openai_responses.parsing.logger") as mock_logger:
|
2026-03-31 09:22:50 +00:00
|
|
|
|
result = parse_response_output(resp)
|
2026-06-09 14:50:40 +08:00
|
|
|
|
assert result.tool_calls[0].arguments == "{bad json"
|
2026-03-31 09:22:50 +00:00
|
|
|
|
mock_logger.warning.assert_called_once()
|
|
|
|
|
|
assert "Failed to parse tool call arguments" in str(mock_logger.warning.call_args)
|
2026-03-31 08:30:11 +00:00
|
|
|
|
|
2026-06-09 14:50:40 +08:00
|
|
|
|
@pytest.mark.parametrize("arguments", [[], False, 0])
|
|
|
|
|
|
def test_falsy_non_object_tool_arguments_preserved(self, arguments):
|
|
|
|
|
|
resp = {
|
|
|
|
|
|
"output": [{
|
|
|
|
|
|
"type": "function_call",
|
|
|
|
|
|
"call_id": "c1",
|
|
|
|
|
|
"id": "fc1",
|
|
|
|
|
|
"name": "f",
|
|
|
|
|
|
"arguments": arguments,
|
|
|
|
|
|
}],
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"usage": {},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result = parse_response_output(resp)
|
|
|
|
|
|
|
|
|
|
|
|
assert result.tool_calls[0].arguments == arguments
|
|
|
|
|
|
assert type(result.tool_calls[0].arguments) is type(arguments)
|
|
|
|
|
|
|
2026-03-31 08:30:11 +00:00
|
|
|
|
def test_reasoning_content_extracted(self):
|
|
|
|
|
|
resp = {
|
|
|
|
|
|
"output": [
|
|
|
|
|
|
{"type": "reasoning", "summary": [
|
|
|
|
|
|
{"type": "summary_text", "text": "I think "},
|
|
|
|
|
|
{"type": "summary_text", "text": "therefore I am."},
|
|
|
|
|
|
]},
|
|
|
|
|
|
{"type": "message", "role": "assistant",
|
|
|
|
|
|
"content": [{"type": "output_text", "text": "42"}]},
|
|
|
|
|
|
],
|
|
|
|
|
|
"status": "completed", "usage": {},
|
|
|
|
|
|
}
|
|
|
|
|
|
result = parse_response_output(resp)
|
|
|
|
|
|
assert result.content == "42"
|
|
|
|
|
|
assert result.reasoning_content == "I think therefore I am."
|
|
|
|
|
|
|
2026-08-01 11:53:51 +08:00
|
|
|
|
def test_deepseek_reasoning_content_extracted(self):
|
|
|
|
|
|
resp = {
|
|
|
|
|
|
"output": [
|
|
|
|
|
|
{"type": "reasoning", "content": "think first"},
|
|
|
|
|
|
{"type": "message", "content": [
|
|
|
|
|
|
{"type": "output_text", "text": "answer"},
|
|
|
|
|
|
]},
|
|
|
|
|
|
],
|
|
|
|
|
|
"status": "completed", "usage": {},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result = parse_response_output(resp)
|
|
|
|
|
|
|
|
|
|
|
|
assert result.content == "answer"
|
|
|
|
|
|
assert result.reasoning_content == "think first"
|
|
|
|
|
|
|
2026-03-31 08:30:11 +00:00
|
|
|
|
def test_empty_output(self):
|
|
|
|
|
|
resp = {"output": [], "status": "completed", "usage": {}}
|
|
|
|
|
|
result = parse_response_output(resp)
|
|
|
|
|
|
assert result.content is None
|
|
|
|
|
|
assert result.tool_calls == []
|
|
|
|
|
|
|
2026-07-30 22:39:43 +08:00
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
("reason", "expected_finish_reason"),
|
|
|
|
|
|
[
|
|
|
|
|
|
("max_output_tokens", "length"),
|
|
|
|
|
|
("content_filter", "content_filter"),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
def test_incomplete_status(self, reason, expected_finish_reason):
|
|
|
|
|
|
resp = {
|
|
|
|
|
|
"output": [],
|
|
|
|
|
|
"status": "incomplete",
|
|
|
|
|
|
"incomplete_details": {"reason": reason},
|
|
|
|
|
|
"usage": {},
|
|
|
|
|
|
}
|
|
|
|
|
|
result = parse_response_output(
|
|
|
|
|
|
resp,
|
|
|
|
|
|
state_provider="openai:test",
|
|
|
|
|
|
state_model="gpt-5.6",
|
|
|
|
|
|
state_input_items=[{"role": "user", "content": "prompt"}],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert result.finish_reason == expected_finish_reason
|
|
|
|
|
|
assert result.provider_state is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_unknown_status_does_not_advance_provider_state(self):
|
|
|
|
|
|
result = parse_response_output(
|
|
|
|
|
|
{"output": [], "status": "future_terminal_status", "usage": {}},
|
|
|
|
|
|
state_provider="openai:test",
|
|
|
|
|
|
state_model="gpt-5.6",
|
|
|
|
|
|
state_input_items=[{"role": "user", "content": "prompt"}],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert result.finish_reason == "stop"
|
|
|
|
|
|
assert result.provider_state is None
|
2026-03-31 08:30:11 +00:00
|
|
|
|
|
|
|
|
|
|
def test_sdk_model_object(self):
|
|
|
|
|
|
"""parse_response_output should handle SDK objects with model_dump()."""
|
|
|
|
|
|
mock = MagicMock()
|
|
|
|
|
|
mock.model_dump.return_value = {
|
|
|
|
|
|
"output": [{"type": "message", "role": "assistant",
|
|
|
|
|
|
"content": [{"type": "output_text", "text": "sdk"}]}],
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"usage": {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3},
|
|
|
|
|
|
}
|
|
|
|
|
|
result = parse_response_output(mock)
|
|
|
|
|
|
assert result.content == "sdk"
|
2026-08-22 00:53:16 +08:00
|
|
|
|
assert result.usage is not None
|
|
|
|
|
|
assert result.usage.input_tokens == 1
|
2026-03-31 08:30:11 +00:00
|
|
|
|
|
|
|
|
|
|
def test_usage_maps_responses_api_keys(self):
|
|
|
|
|
|
"""Responses API uses input_tokens/output_tokens, not prompt_tokens/completion_tokens."""
|
|
|
|
|
|
resp = {
|
|
|
|
|
|
"output": [],
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"usage": {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150},
|
|
|
|
|
|
}
|
|
|
|
|
|
result = parse_response_output(resp)
|
2026-08-22 00:53:16 +08:00
|
|
|
|
assert result.usage == LLMUsage.reported(input_tokens=100, output_tokens=50)
|
|
|
|
|
|
|
|
|
|
|
|
def test_non_stream_preserves_provider_reported_total(self):
|
|
|
|
|
|
result = parse_response_output({
|
|
|
|
|
|
"output": [],
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 999},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
assert result.usage == LLMUsage.reported(
|
|
|
|
|
|
input_tokens=10,
|
|
|
|
|
|
output_tokens=5,
|
|
|
|
|
|
total_tokens=999,
|
|
|
|
|
|
)
|
2026-03-31 08:30:11 +00:00
|
|
|
|
|
2026-07-30 22:39:43 +08:00
|
|
|
|
def test_preserves_every_output_item_as_opaque_state(self):
|
|
|
|
|
|
input_items = [{"role": "user", "content": "inspect the repo"}]
|
|
|
|
|
|
output = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": "rs_1",
|
|
|
|
|
|
"type": "reasoning",
|
|
|
|
|
|
"encrypted_content": "opaque-secret",
|
|
|
|
|
|
"summary": [],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": "future_1",
|
|
|
|
|
|
"type": "future_item_type",
|
|
|
|
|
|
"provider_field": {"nested": True},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": "msg_1",
|
|
|
|
|
|
"type": "message",
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"content": [{"type": "output_text", "text": "done"}],
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
result = parse_response_output(
|
|
|
|
|
|
{"output": output, "status": "completed", "usage": {}},
|
|
|
|
|
|
state_provider="openai:test",
|
|
|
|
|
|
state_model="gpt-5.6",
|
|
|
|
|
|
state_input_items=input_items,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert result.provider_state is not None
|
|
|
|
|
|
assert responses_state_items(result.provider_state) == [*input_items, *output]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestResponsesConversationState:
|
|
|
|
|
|
def test_server_compaction_prunes_superseded_prefix(self):
|
|
|
|
|
|
state = build_responses_state(
|
|
|
|
|
|
provider="openai:test",
|
|
|
|
|
|
model="gpt-5.6",
|
|
|
|
|
|
input_items=[
|
|
|
|
|
|
{"type": "message", "role": "user", "content": "old"},
|
|
|
|
|
|
{"type": "reasoning", "encrypted_content": "old-reasoning"},
|
|
|
|
|
|
],
|
|
|
|
|
|
output_items=[
|
|
|
|
|
|
{"type": "compaction", "encrypted_content": "compact"},
|
|
|
|
|
|
{"type": "message", "role": "assistant", "content": "new"},
|
|
|
|
|
|
],
|
2026-08-22 00:53:16 +08:00
|
|
|
|
usage=LLMUsage.reported(
|
|
|
|
|
|
input_tokens=90,
|
|
|
|
|
|
output_tokens=10,
|
|
|
|
|
|
total_tokens=175,
|
|
|
|
|
|
),
|
2026-07-30 22:39:43 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert responses_state_items(state) == [
|
|
|
|
|
|
{"type": "compaction", "encrypted_content": "compact"},
|
|
|
|
|
|
{"type": "message", "role": "assistant", "content": "new"},
|
|
|
|
|
|
]
|
2026-08-22 00:53:16 +08:00
|
|
|
|
assert responses_state_context_tokens(state) == 175
|
2026-07-30 22:39:43 +08:00
|
|
|
|
|
|
|
|
|
|
def test_existing_compaction_keeps_canonical_retained_prefix(self):
|
|
|
|
|
|
canonical_input = [
|
|
|
|
|
|
{"type": "message", "role": "user", "content": "retained"},
|
|
|
|
|
|
{"type": "compaction", "encrypted_content": "compact"},
|
|
|
|
|
|
]
|
|
|
|
|
|
output = [{"type": "message", "role": "assistant", "content": "new"}]
|
|
|
|
|
|
|
|
|
|
|
|
state = build_responses_state(
|
|
|
|
|
|
provider="openai:test",
|
|
|
|
|
|
model="gpt-5.6",
|
|
|
|
|
|
input_items=canonical_input,
|
|
|
|
|
|
output_items=output,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert responses_state_items(state) == [*canonical_input, *output]
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
("context_window", "max_output", "expected"),
|
|
|
|
|
|
[
|
|
|
|
|
|
(200_000, 20_000, 180_000),
|
|
|
|
|
|
(100_000, 30_000, 70_000),
|
|
|
|
|
|
(0, 4_096, None),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
def test_compact_threshold_reserves_codex_style_headroom(
|
|
|
|
|
|
self,
|
|
|
|
|
|
context_window,
|
|
|
|
|
|
max_output,
|
|
|
|
|
|
expected,
|
|
|
|
|
|
):
|
|
|
|
|
|
assert resolve_compact_threshold(context_window, max_output) == expected
|
|
|
|
|
|
|
|
|
|
|
|
def test_compaction_compatibility_recognizes_old_sdk_signature_error(self):
|
|
|
|
|
|
error = TypeError("create() got an unexpected keyword argument 'context_management'")
|
|
|
|
|
|
assert is_compaction_compatibility_error(error) is True
|
|
|
|
|
|
assert is_compaction_compatibility_error(TypeError("unrelated argument")) is False
|
|
|
|
|
|
|
|
|
|
|
|
def test_state_observability_logs_counts_without_opaque_content(self):
|
|
|
|
|
|
secret = "opaque-secret-that-must-not-be-logged"
|
|
|
|
|
|
state = build_responses_state(
|
|
|
|
|
|
provider=f"openai:https://example.test/?key={secret}",
|
|
|
|
|
|
model=f"secret-model-{secret}",
|
|
|
|
|
|
input_items=[{"role": "user", "content": secret}],
|
|
|
|
|
|
output_items=[{"type": "reasoning", "encrypted_content": secret}],
|
|
|
|
|
|
).with_pending_messages([{"role": "user", "content": secret}])
|
|
|
|
|
|
sink = StringIO()
|
|
|
|
|
|
sink_id = logger.add(sink, level="DEBUG", format="{message}")
|
|
|
|
|
|
try:
|
|
|
|
|
|
prepare_responses_input(
|
|
|
|
|
|
[{"role": "user", "content": secret}],
|
|
|
|
|
|
state=state,
|
|
|
|
|
|
provider=state.provider,
|
|
|
|
|
|
model=state.model,
|
|
|
|
|
|
)
|
|
|
|
|
|
build_responses_state(
|
|
|
|
|
|
provider=state.provider,
|
|
|
|
|
|
model=state.model,
|
|
|
|
|
|
input_items=[
|
|
|
|
|
|
{"role": "user", "content": secret},
|
|
|
|
|
|
{"type": "reasoning", "encrypted_content": secret},
|
|
|
|
|
|
],
|
|
|
|
|
|
output_items=[
|
|
|
|
|
|
{"type": "compaction", "encrypted_content": secret},
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
logger.remove(sink_id)
|
|
|
|
|
|
|
|
|
|
|
|
log_text = sink.getvalue()
|
|
|
|
|
|
assert "prior_items=2" in log_text
|
|
|
|
|
|
assert "pending_messages=1" in log_text
|
|
|
|
|
|
assert "dropped_items=2" in log_text
|
|
|
|
|
|
assert secret not in log_text
|
|
|
|
|
|
|
|
|
|
|
|
def test_replays_exact_items_then_only_pending_and_new_messages(self):
|
|
|
|
|
|
prior_items = [
|
|
|
|
|
|
{"role": "user", "content": "first"},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "reasoning",
|
|
|
|
|
|
"id": "rs_1",
|
|
|
|
|
|
"encrypted_content": "opaque-secret",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "function_call",
|
|
|
|
|
|
"id": "fc_1",
|
|
|
|
|
|
"call_id": "call_1",
|
|
|
|
|
|
"name": "read_file",
|
|
|
|
|
|
"arguments": '{"path":"a.py"}',
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
state = build_responses_state(
|
|
|
|
|
|
provider="openai:test",
|
|
|
|
|
|
model="gpt-5.6",
|
|
|
|
|
|
input_items=prior_items[:1],
|
|
|
|
|
|
output_items=prior_items[1:],
|
|
|
|
|
|
).with_pending_messages([
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "tool",
|
|
|
|
|
|
"tool_call_id": "call_1|fc_1",
|
|
|
|
|
|
"content": "file contents",
|
|
|
|
|
|
},
|
|
|
|
|
|
{"role": "user", "content": "continue"},
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
instructions, items, replayed = prepare_responses_input(
|
|
|
|
|
|
[
|
|
|
|
|
|
{"role": "system", "content": "current instructions"},
|
|
|
|
|
|
{"role": "user", "content": "a lossy public transcript"},
|
|
|
|
|
|
],
|
|
|
|
|
|
state=state,
|
|
|
|
|
|
provider="openai:test",
|
|
|
|
|
|
model="gpt-5.6",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert instructions == "current instructions"
|
|
|
|
|
|
assert replayed is True
|
|
|
|
|
|
assert items[:3] == prior_items
|
|
|
|
|
|
assert items[3] == {
|
|
|
|
|
|
"type": "function_call_output",
|
|
|
|
|
|
"call_id": "call_1",
|
|
|
|
|
|
"output": "file contents",
|
|
|
|
|
|
}
|
|
|
|
|
|
assert items[4] == {
|
|
|
|
|
|
"role": "user",
|
|
|
|
|
|
"content": [{"type": "input_text", "text": "continue"}],
|
|
|
|
|
|
}
|
|
|
|
|
|
assert "lossy public transcript" not in str(items)
|
|
|
|
|
|
|
2026-08-03 10:40:19 +02:00
|
|
|
|
def test_replayed_and_delta_reasoning_items_keep_array_content(self):
|
|
|
|
|
|
# Regression for PR #5214: token consolidation clears
|
|
|
|
|
|
# ``provider_state``, so the next turn converts the full history
|
|
|
|
|
|
# (including assistant reasoning) instead of replaying server items.
|
|
|
|
|
|
# Both paths must keep reasoning ``content`` as a list - DeepSeek's
|
|
|
|
|
|
# Responses gateway rejects the string form with a serde error.
|
|
|
|
|
|
prior_items = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "reasoning",
|
|
|
|
|
|
"id": "rs_1",
|
|
|
|
|
|
"content": [{"type": "output_text", "text": "prior reasoning"}],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "message",
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": [{"type": "output_text", "text": "prior answer"}],
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"id": "msg_0",
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
state = build_responses_state(
|
|
|
|
|
|
provider="openai:test",
|
|
|
|
|
|
model="deepseek-v4-flash",
|
|
|
|
|
|
input_items=prior_items,
|
|
|
|
|
|
output_items=[],
|
|
|
|
|
|
).with_pending_messages([
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"reasoning_content": "think before acting",
|
|
|
|
|
|
"content": "answer",
|
|
|
|
|
|
},
|
|
|
|
|
|
{"role": "user", "content": "audit the tools"},
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
instructions, items, replayed = prepare_responses_input(
|
|
|
|
|
|
[
|
|
|
|
|
|
{"role": "system", "content": "You are KITT."},
|
|
|
|
|
|
{"role": "user", "content": "audit the tools"},
|
|
|
|
|
|
],
|
|
|
|
|
|
state=state,
|
|
|
|
|
|
provider="openai:test",
|
|
|
|
|
|
model="deepseek-v4-flash",
|
|
|
|
|
|
preserve_reasoning=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert instructions == "You are KITT."
|
|
|
|
|
|
assert replayed is True
|
|
|
|
|
|
reasoning_items = [item for item in items if item.get("type") == "reasoning"]
|
|
|
|
|
|
assert len(reasoning_items) == 2 # one replayed, one converted delta
|
|
|
|
|
|
for item in reasoning_items:
|
|
|
|
|
|
assert isinstance(item["content"], list)
|
|
|
|
|
|
assert item["content"][0]["type"] == "output_text"
|
|
|
|
|
|
|
2026-03-31 08:30:11 +00:00
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
|
# ======================================================================
|
|
|
|
|
|
# parsing - consume_sse
|
|
|
|
|
|
# ======================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _SseResponse:
|
|
|
|
|
|
def __init__(self, events: list[dict]):
|
|
|
|
|
|
self._events = events
|
|
|
|
|
|
|
|
|
|
|
|
async def aiter_lines(self):
|
|
|
|
|
|
for event in self._events:
|
|
|
|
|
|
yield f"data: {json.dumps(event)}"
|
|
|
|
|
|
yield ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConsumeSse:
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_legacy_consume_sse_returns_three_tuple(self):
|
|
|
|
|
|
response = _SseResponse([
|
|
|
|
|
|
{"type": "response.output_text.delta", "delta": "hi"},
|
|
|
|
|
|
{"type": "response.completed", "response": {"status": "completed"}},
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
content, tool_calls, finish_reason = await consume_sse(response)
|
|
|
|
|
|
|
|
|
|
|
|
assert content == "hi"
|
|
|
|
|
|
assert tool_calls == []
|
|
|
|
|
|
assert finish_reason == "stop"
|
|
|
|
|
|
|
2026-07-30 22:39:43 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_refusal_events_reconcile_parts_and_terminal_output(self):
|
|
|
|
|
|
refusal = "First and second sentence. Done-only. Terminal suffix."
|
|
|
|
|
|
terminal_response = {
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"output": [{
|
|
|
|
|
|
"type": "message",
|
|
|
|
|
|
"id": "msg_2",
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": [{"type": "refusal", "refusal": refusal}],
|
|
|
|
|
|
}],
|
|
|
|
|
|
}
|
|
|
|
|
|
response = _SseResponse([
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.refusal.delta",
|
|
|
|
|
|
"item_id": "msg_1",
|
|
|
|
|
|
"content_index": 0,
|
|
|
|
|
|
"delta": "First",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.refusal.delta",
|
|
|
|
|
|
"item_id": "msg_1",
|
|
|
|
|
|
"content_index": 1,
|
|
|
|
|
|
"delta": " and second",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.refusal.done",
|
|
|
|
|
|
"item_id": "msg_1",
|
|
|
|
|
|
"content_index": 0,
|
|
|
|
|
|
"refusal": "First",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.refusal.done",
|
|
|
|
|
|
"item_id": "msg_1",
|
|
|
|
|
|
"content_index": 1,
|
|
|
|
|
|
"refusal": " and second sentence.",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.refusal.done",
|
|
|
|
|
|
"item_id": "msg_2",
|
|
|
|
|
|
"content_index": 0,
|
|
|
|
|
|
"refusal": " Done-only.",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.refusal.delta",
|
|
|
|
|
|
"item_id": "msg_2",
|
|
|
|
|
|
"content_index": 1,
|
|
|
|
|
|
"delta": " Terminal",
|
|
|
|
|
|
},
|
|
|
|
|
|
{"type": "response.completed", "response": terminal_response},
|
|
|
|
|
|
])
|
|
|
|
|
|
capture = ResponsesStreamCapture()
|
|
|
|
|
|
deltas: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
async def on_content(delta: str) -> None:
|
|
|
|
|
|
deltas.append(delta)
|
|
|
|
|
|
|
|
|
|
|
|
content, _, finish_reason, _, _ = await consume_sse_with_reasoning(
|
|
|
|
|
|
response,
|
|
|
|
|
|
on_content_delta=on_content,
|
|
|
|
|
|
capture=capture,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert content == refusal
|
|
|
|
|
|
assert deltas == [
|
|
|
|
|
|
"First",
|
|
|
|
|
|
" and second",
|
|
|
|
|
|
" sentence.",
|
|
|
|
|
|
" Done-only.",
|
|
|
|
|
|
" Terminal",
|
|
|
|
|
|
" suffix.",
|
|
|
|
|
|
]
|
|
|
|
|
|
assert finish_reason == "refusal"
|
|
|
|
|
|
assert capture.completed is True
|
|
|
|
|
|
assert is_replayable_finish_reason(finish_reason) is False
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
@pytest.mark.parametrize("source", ["events", "terminal"])
|
|
|
|
|
|
async def test_refusal_without_deltas_has_non_replayable_finish(self, source: str):
|
|
|
|
|
|
refusal = "I can’t help with that request."
|
|
|
|
|
|
terminal_response = {
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"output": [{
|
|
|
|
|
|
"type": "message",
|
|
|
|
|
|
"id": "msg_1",
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": [{"type": "refusal", "refusal": refusal}],
|
|
|
|
|
|
}],
|
|
|
|
|
|
}
|
|
|
|
|
|
events = (
|
|
|
|
|
|
[
|
|
|
|
|
|
{"type": "response.refusal.done", "refusal": refusal},
|
|
|
|
|
|
{"type": "response.completed", "response": {"status": "completed"}},
|
|
|
|
|
|
]
|
|
|
|
|
|
if source == "events"
|
|
|
|
|
|
else [{"type": "response.completed", "response": terminal_response}]
|
|
|
|
|
|
)
|
|
|
|
|
|
response = _SseResponse(events)
|
|
|
|
|
|
capture = ResponsesStreamCapture()
|
|
|
|
|
|
deltas: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
async def on_content(delta: str) -> None:
|
|
|
|
|
|
deltas.append(delta)
|
|
|
|
|
|
|
|
|
|
|
|
content, _, finish_reason, _, _ = await consume_sse_with_reasoning(
|
|
|
|
|
|
response,
|
|
|
|
|
|
on_content_delta=on_content,
|
|
|
|
|
|
capture=capture,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert content == refusal
|
|
|
|
|
|
assert deltas == [refusal]
|
|
|
|
|
|
assert finish_reason == "refusal"
|
|
|
|
|
|
assert capture.completed is True
|
|
|
|
|
|
assert is_replayable_finish_reason(finish_reason) is False
|
|
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_reasoning_summary_delta_extracted(self):
|
|
|
|
|
|
response = _SseResponse([
|
2026-08-22 19:41:37 +08:00
|
|
|
|
{
|
|
|
|
|
|
"type": "response.reasoning_summary_text.delta",
|
|
|
|
|
|
"item_id": "rs_1",
|
|
|
|
|
|
"summary_index": 0,
|
|
|
|
|
|
"delta": "thinking ",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.reasoning_summary_text.delta",
|
|
|
|
|
|
"item_id": "rs_1",
|
|
|
|
|
|
"summary_index": 0,
|
|
|
|
|
|
"delta": "briefly",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.reasoning_summary_text.delta",
|
|
|
|
|
|
"item_id": "rs_1",
|
|
|
|
|
|
"summary_index": 1,
|
|
|
|
|
|
"delta": "Checking result",
|
|
|
|
|
|
},
|
2026-05-29 03:42:53 +08:00
|
|
|
|
{"type": "response.output_text.delta", "delta": "answer"},
|
|
|
|
|
|
{"type": "response.completed", "response": {"status": "completed"}},
|
|
|
|
|
|
])
|
|
|
|
|
|
deltas: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
async def on_reasoning(delta: str) -> None:
|
|
|
|
|
|
deltas.append(delta)
|
|
|
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
|
content, tool_calls, finish_reason, usage, reasoning = await consume_sse_with_reasoning(
|
2026-05-29 03:42:53 +08:00
|
|
|
|
response,
|
|
|
|
|
|
on_reasoning_delta=on_reasoning,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert content == "answer"
|
|
|
|
|
|
assert tool_calls == []
|
|
|
|
|
|
assert finish_reason == "stop"
|
2026-08-22 00:53:16 +08:00
|
|
|
|
assert usage is None
|
2026-08-22 19:41:37 +08:00
|
|
|
|
assert reasoning == "thinking briefly\nChecking result"
|
|
|
|
|
|
assert deltas == ["thinking ", "briefly", "\nChecking result"]
|
2026-05-29 03:42:53 +08:00
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_reasoning_summary_from_completed_response(self):
|
|
|
|
|
|
response = _SseResponse([
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.completed",
|
|
|
|
|
|
"response": {
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"output": [
|
|
|
|
|
|
{"type": "reasoning", "summary": [
|
2026-08-22 19:41:37 +08:00
|
|
|
|
{"type": "summary_text", "text": "cached"},
|
2026-05-29 03:42:53 +08:00
|
|
|
|
{"type": "summary_text", "text": "summary"},
|
|
|
|
|
|
]},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
])
|
|
|
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
|
_, _, _, _, reasoning = await consume_sse_with_reasoning(response)
|
2026-05-29 03:42:53 +08:00
|
|
|
|
|
2026-08-22 19:41:37 +08:00
|
|
|
|
assert reasoning == "cached\nsummary"
|
2026-05-29 03:42:53 +08:00
|
|
|
|
|
2026-07-30 22:39:43 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_capture_commits_exact_items_only_after_completed_event(self):
|
|
|
|
|
|
output = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "reasoning",
|
|
|
|
|
|
"id": "rs_1",
|
|
|
|
|
|
"encrypted_content": "opaque-secret",
|
|
|
|
|
|
},
|
|
|
|
|
|
{"type": "future_item_type", "id": "future_1", "value": 7},
|
|
|
|
|
|
]
|
|
|
|
|
|
capture = ResponsesStreamCapture()
|
|
|
|
|
|
response = _SseResponse([
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.output_item.done",
|
|
|
|
|
|
"output_index": 0,
|
|
|
|
|
|
"item": output[0],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.output_item.done",
|
|
|
|
|
|
"output_index": 1,
|
|
|
|
|
|
"item": output[1],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.completed",
|
|
|
|
|
|
"response": {"status": "completed", "output": output},
|
|
|
|
|
|
},
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
await consume_sse_with_reasoning(response, capture=capture)
|
|
|
|
|
|
|
|
|
|
|
|
assert capture.completed is True
|
|
|
|
|
|
assert capture.output_items == output
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_capture_keeps_done_items_when_completed_output_is_empty(self):
|
|
|
|
|
|
output = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "reasoning",
|
|
|
|
|
|
"id": "rs_1",
|
|
|
|
|
|
"encrypted_content": "opaque-secret",
|
|
|
|
|
|
"summary": [],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "function_call",
|
|
|
|
|
|
"id": "fc_1",
|
|
|
|
|
|
"call_id": "call_1",
|
|
|
|
|
|
"name": "read_file",
|
|
|
|
|
|
"arguments": '{"path":"weather/SKILL.md"}',
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
capture = ResponsesStreamCapture()
|
|
|
|
|
|
response = _SseResponse([
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.output_item.done",
|
|
|
|
|
|
"output_index": index,
|
|
|
|
|
|
"item": item,
|
|
|
|
|
|
}
|
|
|
|
|
|
for index, item in enumerate(output)
|
|
|
|
|
|
] + [{
|
|
|
|
|
|
"type": "response.completed",
|
|
|
|
|
|
"response": {"status": "completed", "output": []},
|
|
|
|
|
|
}])
|
|
|
|
|
|
|
|
|
|
|
|
await consume_sse_with_reasoning(response, capture=capture)
|
|
|
|
|
|
|
|
|
|
|
|
assert capture.completed is True
|
|
|
|
|
|
assert capture.output_items == output
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
("reason", "expected_finish_reason"),
|
|
|
|
|
|
[
|
|
|
|
|
|
("max_output_tokens", "length"),
|
|
|
|
|
|
("content_filter", "content_filter"),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
async def test_incomplete_event_commits_capture_usage(
|
|
|
|
|
|
self,
|
|
|
|
|
|
reason,
|
|
|
|
|
|
expected_finish_reason,
|
|
|
|
|
|
):
|
|
|
|
|
|
output = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "message",
|
|
|
|
|
|
"id": "msg_1",
|
|
|
|
|
|
"status": "incomplete",
|
|
|
|
|
|
"content": [{"type": "output_text", "text": "partial"}],
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
terminal_response = {
|
|
|
|
|
|
"id": "resp_1",
|
|
|
|
|
|
"status": "incomplete",
|
|
|
|
|
|
"incomplete_details": {"reason": reason},
|
|
|
|
|
|
"output": output,
|
|
|
|
|
|
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
|
|
|
|
|
}
|
|
|
|
|
|
capture = ResponsesStreamCapture()
|
|
|
|
|
|
response = _SseResponse([
|
|
|
|
|
|
{"type": "response.output_text.delta", "delta": "partial"},
|
|
|
|
|
|
{"type": "response.incomplete", "response": terminal_response},
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
content, _, finish_reason, usage, _ = await consume_sse_with_reasoning(
|
|
|
|
|
|
response,
|
|
|
|
|
|
capture=capture,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert content == "partial"
|
|
|
|
|
|
assert finish_reason == expected_finish_reason
|
2026-08-22 00:53:16 +08:00
|
|
|
|
assert usage == LLMUsage.reported(input_tokens=10, output_tokens=5)
|
2026-07-30 22:39:43 +08:00
|
|
|
|
assert capture.completed is True
|
|
|
|
|
|
assert capture.response == terminal_response
|
|
|
|
|
|
assert capture.output_items == output
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_capture_does_not_commit_interrupted_stream(self):
|
|
|
|
|
|
capture = ResponsesStreamCapture()
|
|
|
|
|
|
response = _SseResponse([
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.output_item.done",
|
|
|
|
|
|
"output_index": 0,
|
|
|
|
|
|
"item": {
|
|
|
|
|
|
"type": "reasoning",
|
|
|
|
|
|
"id": "rs_1",
|
|
|
|
|
|
"encrypted_content": "opaque-secret",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
await consume_sse_with_reasoning(response, capture=capture)
|
|
|
|
|
|
|
|
|
|
|
|
assert capture.completed is False
|
|
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_reasoning_summary_from_done_item(self):
|
|
|
|
|
|
response = _SseResponse([
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.output_item.done",
|
|
|
|
|
|
"item": {
|
|
|
|
|
|
"type": "reasoning",
|
|
|
|
|
|
"summary": [{"type": "summary_text", "text": "done summary"}],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{"type": "response.completed", "response": {"status": "completed", "output": []}},
|
|
|
|
|
|
])
|
|
|
|
|
|
deltas: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
async def on_reasoning(delta: str) -> None:
|
|
|
|
|
|
deltas.append(delta)
|
|
|
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
|
_, _, _, _, reasoning = await consume_sse_with_reasoning(
|
2026-05-29 03:42:53 +08:00
|
|
|
|
response,
|
|
|
|
|
|
on_reasoning_delta=on_reasoning,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert reasoning == "done summary"
|
|
|
|
|
|
assert deltas == ["done summary"]
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_reasoning_summary_part_done_extracted(self):
|
|
|
|
|
|
response = _SseResponse([
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.reasoning_summary_part.done",
|
|
|
|
|
|
"part": {"type": "summary_text", "text": "part summary"},
|
|
|
|
|
|
},
|
|
|
|
|
|
{"type": "response.completed", "response": {"status": "completed"}},
|
|
|
|
|
|
])
|
|
|
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
|
_, _, _, _, reasoning = await consume_sse_with_reasoning(response)
|
2026-05-29 03:42:53 +08:00
|
|
|
|
|
|
|
|
|
|
assert reasoning == "part summary"
|
|
|
|
|
|
|
2026-06-06 19:49:33 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_raw_sse_usage_extracted(self):
|
|
|
|
|
|
response = _SseResponse([
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.completed",
|
|
|
|
|
|
"response": {
|
|
|
|
|
|
"status": "completed",
|
2026-08-21 13:41:15 +08:00
|
|
|
|
"usage": {
|
|
|
|
|
|
"input_tokens": 10,
|
2026-08-22 00:53:16 +08:00
|
|
|
|
"input_tokens_details": {
|
|
|
|
|
|
"cached_tokens": 8,
|
|
|
|
|
|
"cache_write_tokens": 0,
|
|
|
|
|
|
},
|
2026-08-21 13:41:15 +08:00
|
|
|
|
"output_tokens": 5,
|
|
|
|
|
|
"total_tokens": 15,
|
|
|
|
|
|
},
|
2026-06-06 19:49:33 +08:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
_, _, _, usage, _ = await consume_sse_with_reasoning(response)
|
|
|
|
|
|
|
2026-08-22 00:53:16 +08:00
|
|
|
|
assert usage == LLMUsage.reported(
|
|
|
|
|
|
input_tokens=10,
|
|
|
|
|
|
output_tokens=5,
|
|
|
|
|
|
cache_read_tokens=8,
|
|
|
|
|
|
cache_write_tokens=0,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_stream_and_non_stream_share_usage_normalization(self):
|
|
|
|
|
|
terminal = {
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"output": [],
|
|
|
|
|
|
"usage": {
|
|
|
|
|
|
"input_tokens": 15,
|
|
|
|
|
|
"input_tokens_details": {
|
|
|
|
|
|
"cached_tokens": 0,
|
|
|
|
|
|
"cache_write_tokens": 7,
|
|
|
|
|
|
},
|
|
|
|
|
|
"output_tokens": 18,
|
|
|
|
|
|
"total_tokens": 175,
|
|
|
|
|
|
},
|
2026-08-21 13:41:15 +08:00
|
|
|
|
}
|
2026-08-22 00:53:16 +08:00
|
|
|
|
non_stream = parse_response_output(terminal).usage
|
|
|
|
|
|
sse = _SseResponse([
|
|
|
|
|
|
{"type": "response.completed", "response": terminal},
|
|
|
|
|
|
])
|
|
|
|
|
|
_, _, _, streamed, _ = await consume_sse_with_reasoning(sse)
|
|
|
|
|
|
|
|
|
|
|
|
sdk_response = SimpleNamespace(**terminal)
|
|
|
|
|
|
sdk_response.usage = SimpleNamespace(
|
|
|
|
|
|
input_tokens=15,
|
|
|
|
|
|
input_tokens_details=SimpleNamespace(
|
|
|
|
|
|
cached_tokens=0,
|
|
|
|
|
|
cache_write_tokens=7,
|
|
|
|
|
|
),
|
|
|
|
|
|
output_tokens=18,
|
|
|
|
|
|
total_tokens=175,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def sdk_stream():
|
|
|
|
|
|
yield SimpleNamespace(type="response.completed", response=sdk_response)
|
|
|
|
|
|
|
|
|
|
|
|
_, _, _, sdk_streamed, _ = await consume_sdk_stream(sdk_stream())
|
|
|
|
|
|
expected = LLMUsage.reported(
|
|
|
|
|
|
input_tokens=15,
|
|
|
|
|
|
output_tokens=18,
|
|
|
|
|
|
total_tokens=175,
|
|
|
|
|
|
cache_read_tokens=0,
|
|
|
|
|
|
cache_write_tokens=7,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert non_stream == streamed == sdk_streamed == expected
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_usage_is_not_explicit_zero_usage(self):
|
|
|
|
|
|
missing = parse_response_output({"status": "completed", "output": []})
|
|
|
|
|
|
explicit_zero = parse_response_output({
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"output": [],
|
|
|
|
|
|
"usage": {"input_tokens": 0, "output_tokens": 0},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
assert missing.usage is None
|
|
|
|
|
|
assert explicit_zero.usage == LLMUsage.reported(input_tokens=0, output_tokens=0)
|
2026-06-06 19:49:33 +08:00
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_tool_call_done_arguments_callback(self):
|
|
|
|
|
|
response = _SseResponse([
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.output_item.added",
|
|
|
|
|
|
"item": {
|
|
|
|
|
|
"type": "function_call",
|
|
|
|
|
|
"call_id": "c1",
|
|
|
|
|
|
"id": "fc1",
|
|
|
|
|
|
"name": "write_file",
|
|
|
|
|
|
"arguments": "",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.function_call_arguments.done",
|
|
|
|
|
|
"call_id": "c1",
|
|
|
|
|
|
"arguments": '{"path":"a.txt","content":"hello\\n"}',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.output_item.done",
|
|
|
|
|
|
"item": {
|
|
|
|
|
|
"type": "function_call",
|
|
|
|
|
|
"call_id": "c1",
|
|
|
|
|
|
"id": "fc1",
|
|
|
|
|
|
"name": "write_file",
|
|
|
|
|
|
"arguments": '{"path":"a.txt","content":"hello\\n"}',
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{"type": "response.completed", "response": {"status": "completed"}},
|
|
|
|
|
|
])
|
|
|
|
|
|
deltas: list[dict] = []
|
|
|
|
|
|
|
|
|
|
|
|
async def cb(delta: dict) -> None:
|
|
|
|
|
|
deltas.append(delta)
|
|
|
|
|
|
|
|
|
|
|
|
await consume_sse_with_reasoning(response, on_tool_call_delta=cb)
|
|
|
|
|
|
|
|
|
|
|
|
assert deltas == [
|
|
|
|
|
|
{"call_id": "c1", "name": "write_file", "arguments_delta": ""},
|
|
|
|
|
|
{
|
|
|
|
|
|
"call_id": "c1",
|
|
|
|
|
|
"name": "write_file",
|
|
|
|
|
|
"arguments": '{"path":"a.txt","content":"hello\\n"}',
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-06-09 14:50:40 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
@pytest.mark.parametrize("arguments", [[], False, 0])
|
|
|
|
|
|
async def test_falsy_non_object_tool_arguments_preserved(self, arguments):
|
|
|
|
|
|
response = _SseResponse([
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.output_item.added",
|
|
|
|
|
|
"item": {
|
|
|
|
|
|
"type": "function_call",
|
|
|
|
|
|
"call_id": "c1",
|
|
|
|
|
|
"id": "fc1",
|
|
|
|
|
|
"name": "f",
|
|
|
|
|
|
"arguments": "",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "response.output_item.done",
|
|
|
|
|
|
"item": {
|
|
|
|
|
|
"type": "function_call",
|
|
|
|
|
|
"call_id": "c1",
|
|
|
|
|
|
"id": "fc1",
|
|
|
|
|
|
"name": "f",
|
|
|
|
|
|
"arguments": arguments,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{"type": "response.completed", "response": {"status": "completed"}},
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
_, tool_calls, _, _, _ = await consume_sse_with_reasoning(response)
|
|
|
|
|
|
|
|
|
|
|
|
assert tool_calls[0].arguments == arguments
|
|
|
|
|
|
assert type(tool_calls[0].arguments) is type(arguments)
|
|
|
|
|
|
|
2026-05-29 03:42:53 +08:00
|
|
|
|
|
2026-03-31 08:30:11 +00:00
|
|
|
|
# ======================================================================
|
2026-04-02 05:38:19 +00:00
|
|
|
|
# parsing - consume_sdk_stream
|
2026-03-31 08:30:11 +00:00
|
|
|
|
# ======================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConsumeSdkStream:
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_text_stream(self):
|
|
|
|
|
|
ev1 = MagicMock(type="response.output_text.delta", delta="Hello")
|
|
|
|
|
|
ev2 = MagicMock(type="response.output_text.delta", delta=" world")
|
|
|
|
|
|
resp_obj = MagicMock(status="completed", usage=None, output=[])
|
|
|
|
|
|
ev3 = MagicMock(type="response.completed", response=resp_obj)
|
|
|
|
|
|
|
|
|
|
|
|
async def stream():
|
|
|
|
|
|
for e in [ev1, ev2, ev3]:
|
|
|
|
|
|
yield e
|
|
|
|
|
|
|
|
|
|
|
|
content, tool_calls, finish_reason, usage, reasoning = await consume_sdk_stream(stream())
|
|
|
|
|
|
assert content == "Hello world"
|
|
|
|
|
|
assert tool_calls == []
|
|
|
|
|
|
assert finish_reason == "stop"
|
|
|
|
|
|
|
2026-08-05 18:26:39 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_hosted_web_search_lifecycle_is_streamed_as_tool_progress(self):
|
|
|
|
|
|
search_added = SimpleNamespace(
|
|
|
|
|
|
type="web_search_call",
|
|
|
|
|
|
id="ws_1",
|
|
|
|
|
|
status="in_progress",
|
|
|
|
|
|
action=SimpleNamespace(type="search"),
|
|
|
|
|
|
)
|
|
|
|
|
|
search_done = SimpleNamespace(
|
|
|
|
|
|
type="web_search_call",
|
|
|
|
|
|
id="ws_1",
|
|
|
|
|
|
status="completed",
|
|
|
|
|
|
action=SimpleNamespace(
|
|
|
|
|
|
type="search",
|
|
|
|
|
|
queries=["nanobot DeepSeek", "nanobot latest release"],
|
|
|
|
|
|
sources=[
|
|
|
|
|
|
SimpleNamespace(
|
|
|
|
|
|
title="DeepSeek Responses API",
|
|
|
|
|
|
url="https://api-docs.deepseek.com/guides/responses_api/",
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
response = SimpleNamespace(status="completed", usage=None, output=[search_done])
|
|
|
|
|
|
events = [
|
|
|
|
|
|
SimpleNamespace(
|
|
|
|
|
|
type="response.output_item.added",
|
|
|
|
|
|
output_index=0,
|
|
|
|
|
|
item=search_added,
|
|
|
|
|
|
),
|
|
|
|
|
|
SimpleNamespace(
|
|
|
|
|
|
type="response.web_search_call.searching",
|
|
|
|
|
|
item_id="ws_1",
|
|
|
|
|
|
output_index=0,
|
|
|
|
|
|
),
|
|
|
|
|
|
SimpleNamespace(
|
|
|
|
|
|
type="response.web_search_call.completed",
|
|
|
|
|
|
item_id="ws_1",
|
|
|
|
|
|
output_index=0,
|
|
|
|
|
|
),
|
|
|
|
|
|
SimpleNamespace(
|
|
|
|
|
|
type="response.output_item.done",
|
|
|
|
|
|
output_index=0,
|
|
|
|
|
|
item=search_done,
|
|
|
|
|
|
),
|
|
|
|
|
|
SimpleNamespace(type="response.completed", response=response),
|
|
|
|
|
|
]
|
|
|
|
|
|
tool_events: list[dict] = []
|
|
|
|
|
|
|
|
|
|
|
|
async def stream():
|
|
|
|
|
|
for event in events:
|
|
|
|
|
|
yield event
|
|
|
|
|
|
|
|
|
|
|
|
async def on_tool_event(event: dict) -> None:
|
|
|
|
|
|
tool_events.append(event)
|
|
|
|
|
|
|
|
|
|
|
|
await consume_sdk_stream(stream(), on_tool_call_delta=on_tool_event)
|
|
|
|
|
|
|
|
|
|
|
|
assert tool_events == [
|
|
|
|
|
|
{
|
|
|
|
|
|
"kind": "hosted_tool",
|
|
|
|
|
|
"phase": "start",
|
|
|
|
|
|
"call_id": "ws_1",
|
|
|
|
|
|
"name": "web_search",
|
|
|
|
|
|
"arguments": {},
|
|
|
|
|
|
"result": None,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"kind": "hosted_tool",
|
|
|
|
|
|
"phase": "end",
|
|
|
|
|
|
"call_id": "ws_1",
|
|
|
|
|
|
"name": "web_search",
|
|
|
|
|
|
"arguments": {
|
|
|
|
|
|
"query": "nanobot DeepSeek · nanobot latest release",
|
|
|
|
|
|
},
|
|
|
|
|
|
"result": {
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"sources": [{
|
|
|
|
|
|
"title": "DeepSeek Responses API",
|
|
|
|
|
|
"url": "https://api-docs.deepseek.com/guides/responses_api/",
|
|
|
|
|
|
}],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-07-30 22:39:43 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_refusal_events_reconcile_parts_and_terminal_output(self):
|
|
|
|
|
|
refusal = "First and second sentence. Done-only. Terminal suffix."
|
|
|
|
|
|
terminal_response = {
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"output": [{
|
|
|
|
|
|
"type": "message",
|
|
|
|
|
|
"id": "msg_2",
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": [{"type": "refusal", "refusal": refusal}],
|
|
|
|
|
|
}],
|
|
|
|
|
|
}
|
|
|
|
|
|
resp_obj = MagicMock(status="completed", usage=None, output=[])
|
|
|
|
|
|
resp_obj.model_dump.return_value = terminal_response
|
|
|
|
|
|
events = [
|
|
|
|
|
|
MagicMock(
|
|
|
|
|
|
type="response.refusal.delta",
|
|
|
|
|
|
item_id="msg_1",
|
|
|
|
|
|
content_index=0,
|
|
|
|
|
|
delta="First",
|
|
|
|
|
|
),
|
|
|
|
|
|
MagicMock(
|
|
|
|
|
|
type="response.refusal.delta",
|
|
|
|
|
|
item_id="msg_1",
|
|
|
|
|
|
content_index=1,
|
|
|
|
|
|
delta=" and second",
|
|
|
|
|
|
),
|
|
|
|
|
|
MagicMock(
|
|
|
|
|
|
type="response.refusal.done",
|
|
|
|
|
|
item_id="msg_1",
|
|
|
|
|
|
content_index=0,
|
|
|
|
|
|
refusal="First",
|
|
|
|
|
|
),
|
|
|
|
|
|
MagicMock(
|
|
|
|
|
|
type="response.refusal.done",
|
|
|
|
|
|
item_id="msg_1",
|
|
|
|
|
|
content_index=1,
|
|
|
|
|
|
refusal=" and second sentence.",
|
|
|
|
|
|
),
|
|
|
|
|
|
MagicMock(
|
|
|
|
|
|
type="response.refusal.done",
|
|
|
|
|
|
item_id="msg_2",
|
|
|
|
|
|
content_index=0,
|
|
|
|
|
|
refusal=" Done-only.",
|
|
|
|
|
|
),
|
|
|
|
|
|
MagicMock(
|
|
|
|
|
|
type="response.refusal.delta",
|
|
|
|
|
|
item_id="msg_2",
|
|
|
|
|
|
content_index=1,
|
|
|
|
|
|
delta=" Terminal",
|
|
|
|
|
|
),
|
|
|
|
|
|
MagicMock(type="response.completed", response=resp_obj),
|
|
|
|
|
|
]
|
|
|
|
|
|
capture = ResponsesStreamCapture()
|
|
|
|
|
|
deltas: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
async def on_content(delta: str) -> None:
|
|
|
|
|
|
deltas.append(delta)
|
|
|
|
|
|
|
|
|
|
|
|
async def stream():
|
|
|
|
|
|
for event in events:
|
|
|
|
|
|
yield event
|
|
|
|
|
|
|
|
|
|
|
|
content, _, finish_reason, _, _ = await consume_sdk_stream(
|
|
|
|
|
|
stream(),
|
|
|
|
|
|
on_content_delta=on_content,
|
|
|
|
|
|
capture=capture,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert content == refusal
|
|
|
|
|
|
assert deltas == [
|
|
|
|
|
|
"First",
|
|
|
|
|
|
" and second",
|
|
|
|
|
|
" sentence.",
|
|
|
|
|
|
" Done-only.",
|
|
|
|
|
|
" Terminal",
|
|
|
|
|
|
" suffix.",
|
|
|
|
|
|
]
|
|
|
|
|
|
assert finish_reason == "refusal"
|
|
|
|
|
|
assert capture.completed is True
|
|
|
|
|
|
assert is_replayable_finish_reason(finish_reason) is False
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
@pytest.mark.parametrize("source", ["events", "terminal"])
|
|
|
|
|
|
async def test_refusal_without_deltas_has_non_replayable_finish(self, source: str):
|
|
|
|
|
|
refusal = "I can’t help with that request."
|
|
|
|
|
|
terminal_response = {
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"output": [{
|
|
|
|
|
|
"type": "message",
|
|
|
|
|
|
"id": "msg_1",
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": [{"type": "refusal", "refusal": refusal}],
|
|
|
|
|
|
}],
|
|
|
|
|
|
}
|
|
|
|
|
|
resp_obj = MagicMock(status="completed", usage=None, output=[])
|
|
|
|
|
|
resp_obj.model_dump.return_value = terminal_response
|
|
|
|
|
|
capture = ResponsesStreamCapture()
|
|
|
|
|
|
deltas: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
async def on_content(delta: str) -> None:
|
|
|
|
|
|
deltas.append(delta)
|
|
|
|
|
|
|
|
|
|
|
|
async def stream():
|
|
|
|
|
|
if source == "events":
|
|
|
|
|
|
yield MagicMock(type="response.refusal.done", refusal=refusal)
|
|
|
|
|
|
yield MagicMock(
|
|
|
|
|
|
type="response.completed",
|
|
|
|
|
|
response={"status": "completed"},
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
yield MagicMock(type="response.completed", response=resp_obj)
|
|
|
|
|
|
|
|
|
|
|
|
content, _, finish_reason, _, _ = await consume_sdk_stream(
|
|
|
|
|
|
stream(),
|
|
|
|
|
|
on_content_delta=on_content,
|
|
|
|
|
|
capture=capture,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert content == refusal
|
|
|
|
|
|
assert deltas == [refusal]
|
|
|
|
|
|
assert finish_reason == "refusal"
|
|
|
|
|
|
assert capture.completed is True
|
|
|
|
|
|
assert is_replayable_finish_reason(finish_reason) is False
|
|
|
|
|
|
|
2026-03-31 08:30:11 +00:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_on_content_delta_called(self):
|
|
|
|
|
|
ev1 = MagicMock(type="response.output_text.delta", delta="hi")
|
|
|
|
|
|
resp_obj = MagicMock(status="completed", usage=None, output=[])
|
|
|
|
|
|
ev2 = MagicMock(type="response.completed", response=resp_obj)
|
|
|
|
|
|
deltas = []
|
|
|
|
|
|
|
|
|
|
|
|
async def cb(text):
|
|
|
|
|
|
deltas.append(text)
|
|
|
|
|
|
|
|
|
|
|
|
async def stream():
|
|
|
|
|
|
for e in [ev1, ev2]:
|
|
|
|
|
|
yield e
|
|
|
|
|
|
|
|
|
|
|
|
await consume_sdk_stream(stream(), on_content_delta=cb)
|
|
|
|
|
|
assert deltas == ["hi"]
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_tool_call_stream(self):
|
|
|
|
|
|
item_added = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="")
|
|
|
|
|
|
item_added.name = "get_weather"
|
|
|
|
|
|
ev1 = MagicMock(type="response.output_item.added", item=item_added)
|
|
|
|
|
|
ev2 = MagicMock(type="response.function_call_arguments.delta", call_id="c1", delta='{"ci')
|
|
|
|
|
|
ev3 = MagicMock(type="response.function_call_arguments.done", call_id="c1", arguments='{"city":"SF"}')
|
|
|
|
|
|
item_done = MagicMock(type="function_call", call_id="c1", id="fc1", arguments='{"city":"SF"}')
|
|
|
|
|
|
item_done.name = "get_weather"
|
|
|
|
|
|
ev4 = MagicMock(type="response.output_item.done", item=item_done)
|
|
|
|
|
|
resp_obj = MagicMock(status="completed", usage=None, output=[])
|
|
|
|
|
|
ev5 = MagicMock(type="response.completed", response=resp_obj)
|
|
|
|
|
|
|
|
|
|
|
|
async def stream():
|
|
|
|
|
|
for e in [ev1, ev2, ev3, ev4, ev5]:
|
|
|
|
|
|
yield e
|
|
|
|
|
|
|
|
|
|
|
|
content, tool_calls, finish_reason, usage, reasoning = await consume_sdk_stream(stream())
|
|
|
|
|
|
assert content == ""
|
|
|
|
|
|
assert len(tool_calls) == 1
|
|
|
|
|
|
assert tool_calls[0].name == "get_weather"
|
|
|
|
|
|
assert tool_calls[0].arguments == {"city": "SF"}
|
|
|
|
|
|
|
2026-05-18 19:10:38 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_tool_call_argument_delta_callback(self):
|
|
|
|
|
|
item_added = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="")
|
|
|
|
|
|
item_added.name = "write_file"
|
|
|
|
|
|
ev1 = MagicMock(type="response.output_item.added", item=item_added)
|
|
|
|
|
|
ev2 = MagicMock(
|
|
|
|
|
|
type="response.function_call_arguments.delta",
|
|
|
|
|
|
call_id="c1",
|
|
|
|
|
|
delta='{"path":"a.txt","content":"',
|
|
|
|
|
|
)
|
|
|
|
|
|
ev3 = MagicMock(
|
|
|
|
|
|
type="response.function_call_arguments.delta",
|
|
|
|
|
|
call_id="c1",
|
|
|
|
|
|
delta='hello\\n',
|
|
|
|
|
|
)
|
|
|
|
|
|
ev4 = MagicMock(
|
|
|
|
|
|
type="response.function_call_arguments.done",
|
|
|
|
|
|
call_id="c1",
|
|
|
|
|
|
arguments='{"path":"a.txt","content":"hello\\n"}',
|
|
|
|
|
|
)
|
|
|
|
|
|
item_done = MagicMock(
|
|
|
|
|
|
type="function_call",
|
|
|
|
|
|
call_id="c1",
|
|
|
|
|
|
id="fc1",
|
|
|
|
|
|
arguments='{"path":"a.txt","content":"hello\\n"}',
|
|
|
|
|
|
)
|
|
|
|
|
|
item_done.name = "write_file"
|
|
|
|
|
|
ev5 = MagicMock(type="response.output_item.done", item=item_done)
|
|
|
|
|
|
resp_obj = MagicMock(status="completed", usage=None, output=[])
|
|
|
|
|
|
ev6 = MagicMock(type="response.completed", response=resp_obj)
|
|
|
|
|
|
deltas: list[dict] = []
|
|
|
|
|
|
|
|
|
|
|
|
async def cb(delta: dict) -> None:
|
|
|
|
|
|
deltas.append(delta)
|
|
|
|
|
|
|
|
|
|
|
|
async def stream():
|
|
|
|
|
|
for e in [ev1, ev2, ev3, ev4, ev5, ev6]:
|
|
|
|
|
|
yield e
|
|
|
|
|
|
|
|
|
|
|
|
await consume_sdk_stream(stream(), on_tool_call_delta=cb)
|
|
|
|
|
|
assert deltas == [
|
|
|
|
|
|
{"call_id": "c1", "name": "write_file", "arguments_delta": ""},
|
|
|
|
|
|
{
|
|
|
|
|
|
"call_id": "c1",
|
|
|
|
|
|
"name": "write_file",
|
|
|
|
|
|
"arguments_delta": '{"path":"a.txt","content":"',
|
|
|
|
|
|
},
|
|
|
|
|
|
{"call_id": "c1", "name": "write_file", "arguments_delta": "hello\\n"},
|
2026-05-29 03:42:53 +08:00
|
|
|
|
{
|
|
|
|
|
|
"call_id": "c1",
|
|
|
|
|
|
"name": "write_file",
|
|
|
|
|
|
"arguments": '{"path":"a.txt","content":"hello\\n"}',
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_tool_call_done_item_arguments_callback_without_delta(self):
|
|
|
|
|
|
item_added = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="")
|
|
|
|
|
|
item_added.name = "write_file"
|
|
|
|
|
|
ev1 = MagicMock(type="response.output_item.added", item=item_added)
|
|
|
|
|
|
item_done = MagicMock(
|
|
|
|
|
|
type="function_call",
|
|
|
|
|
|
call_id="c1",
|
|
|
|
|
|
id="fc1",
|
|
|
|
|
|
arguments='{"path":"late.txt","content":"done\\n"}',
|
|
|
|
|
|
)
|
|
|
|
|
|
item_done.name = "write_file"
|
|
|
|
|
|
ev2 = MagicMock(type="response.output_item.done", item=item_done)
|
|
|
|
|
|
resp_obj = MagicMock(status="completed", usage=None, output=[])
|
|
|
|
|
|
ev3 = MagicMock(type="response.completed", response=resp_obj)
|
|
|
|
|
|
deltas: list[dict] = []
|
|
|
|
|
|
|
|
|
|
|
|
async def cb(delta: dict) -> None:
|
|
|
|
|
|
deltas.append(delta)
|
|
|
|
|
|
|
|
|
|
|
|
async def stream():
|
|
|
|
|
|
for e in [ev1, ev2, ev3]:
|
|
|
|
|
|
yield e
|
|
|
|
|
|
|
|
|
|
|
|
await consume_sdk_stream(stream(), on_tool_call_delta=cb)
|
|
|
|
|
|
|
|
|
|
|
|
assert deltas == [
|
|
|
|
|
|
{"call_id": "c1", "name": "write_file", "arguments_delta": ""},
|
|
|
|
|
|
{
|
|
|
|
|
|
"call_id": "c1",
|
|
|
|
|
|
"name": "write_file",
|
|
|
|
|
|
"arguments": '{"path":"late.txt","content":"done\\n"}',
|
|
|
|
|
|
},
|
2026-05-18 19:10:38 +08:00
|
|
|
|
]
|
|
|
|
|
|
|
2026-06-09 14:50:40 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
@pytest.mark.parametrize("arguments", [[], False, 0])
|
|
|
|
|
|
async def test_falsy_non_object_tool_arguments_preserved(self, arguments):
|
|
|
|
|
|
item_added = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="")
|
|
|
|
|
|
item_added.name = "f"
|
|
|
|
|
|
ev1 = MagicMock(type="response.output_item.added", item=item_added)
|
|
|
|
|
|
item_done = MagicMock(type="function_call", call_id="c1", id="fc1")
|
|
|
|
|
|
item_done.name = "f"
|
|
|
|
|
|
item_done.arguments = arguments
|
|
|
|
|
|
ev2 = MagicMock(type="response.output_item.done", item=item_done)
|
|
|
|
|
|
resp_obj = MagicMock(status="completed", usage=None, output=[])
|
|
|
|
|
|
ev3 = MagicMock(type="response.completed", response=resp_obj)
|
|
|
|
|
|
|
|
|
|
|
|
async def stream():
|
|
|
|
|
|
for e in [ev1, ev2, ev3]:
|
|
|
|
|
|
yield e
|
|
|
|
|
|
|
|
|
|
|
|
_, tool_calls, _, _, _ = await consume_sdk_stream(stream())
|
|
|
|
|
|
|
|
|
|
|
|
assert tool_calls[0].arguments == arguments
|
|
|
|
|
|
assert type(tool_calls[0].arguments) is type(arguments)
|
|
|
|
|
|
|
2026-03-31 08:30:11 +00:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_usage_extracted(self):
|
2026-08-22 00:53:16 +08:00
|
|
|
|
usage_obj = SimpleNamespace(
|
2026-08-21 13:41:15 +08:00
|
|
|
|
input_tokens=10,
|
2026-08-22 00:53:16 +08:00
|
|
|
|
input_tokens_details=SimpleNamespace(cached_tokens=8),
|
2026-08-21 13:41:15 +08:00
|
|
|
|
output_tokens=5,
|
|
|
|
|
|
total_tokens=15,
|
|
|
|
|
|
)
|
2026-08-22 00:53:16 +08:00
|
|
|
|
resp_obj = SimpleNamespace(status="completed", usage=usage_obj, output=[])
|
|
|
|
|
|
ev = SimpleNamespace(type="response.completed", response=resp_obj)
|
2026-03-31 08:30:11 +00:00
|
|
|
|
|
|
|
|
|
|
async def stream():
|
|
|
|
|
|
yield ev
|
|
|
|
|
|
|
|
|
|
|
|
_, _, _, usage, _ = await consume_sdk_stream(stream())
|
2026-08-22 00:53:16 +08:00
|
|
|
|
assert usage == LLMUsage.reported(
|
|
|
|
|
|
input_tokens=10,
|
|
|
|
|
|
output_tokens=5,
|
|
|
|
|
|
cache_read_tokens=8,
|
|
|
|
|
|
)
|
2026-03-31 08:30:11 +00:00
|
|
|
|
|
2026-07-30 22:39:43 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
("reason", "expected_finish_reason"),
|
|
|
|
|
|
[
|
|
|
|
|
|
("max_output_tokens", "length"),
|
|
|
|
|
|
("content_filter", "content_filter"),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
async def test_incomplete_event_commits_capture_usage(
|
|
|
|
|
|
self,
|
|
|
|
|
|
reason,
|
|
|
|
|
|
expected_finish_reason,
|
|
|
|
|
|
):
|
|
|
|
|
|
output = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "message",
|
|
|
|
|
|
"id": "msg_1",
|
|
|
|
|
|
"status": "incomplete",
|
|
|
|
|
|
"content": [{"type": "output_text", "text": "partial"}],
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
usage_obj = MagicMock(input_tokens=10, output_tokens=5, total_tokens=15)
|
|
|
|
|
|
output_item = MagicMock(type="message")
|
|
|
|
|
|
terminal_response = {
|
|
|
|
|
|
"id": "resp_1",
|
|
|
|
|
|
"status": "incomplete",
|
|
|
|
|
|
"incomplete_details": {"reason": reason},
|
|
|
|
|
|
"output": output,
|
|
|
|
|
|
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
|
|
|
|
|
}
|
|
|
|
|
|
resp_obj = MagicMock(
|
|
|
|
|
|
status="incomplete",
|
|
|
|
|
|
usage=usage_obj,
|
|
|
|
|
|
output=[output_item],
|
|
|
|
|
|
)
|
|
|
|
|
|
resp_obj.model_dump.return_value = terminal_response
|
|
|
|
|
|
events = [
|
|
|
|
|
|
MagicMock(type="response.output_text.delta", delta="partial"),
|
|
|
|
|
|
MagicMock(type="response.incomplete", response=resp_obj),
|
|
|
|
|
|
]
|
|
|
|
|
|
capture = ResponsesStreamCapture()
|
|
|
|
|
|
|
|
|
|
|
|
async def stream():
|
|
|
|
|
|
for event in events:
|
|
|
|
|
|
yield event
|
|
|
|
|
|
|
|
|
|
|
|
content, _, finish_reason, usage, _ = await consume_sdk_stream(
|
|
|
|
|
|
stream(),
|
|
|
|
|
|
capture=capture,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert content == "partial"
|
|
|
|
|
|
assert finish_reason == expected_finish_reason
|
2026-08-22 00:53:16 +08:00
|
|
|
|
assert usage == LLMUsage.reported(input_tokens=10, output_tokens=5)
|
2026-07-30 22:39:43 +08:00
|
|
|
|
assert capture.completed is True
|
|
|
|
|
|
assert capture.response == terminal_response
|
|
|
|
|
|
assert capture.output_items == output
|
|
|
|
|
|
|
2026-03-31 08:30:11 +00:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_reasoning_extracted(self):
|
|
|
|
|
|
summary_item = MagicMock(type="summary_text", text="thinking...")
|
|
|
|
|
|
reasoning_item = MagicMock(type="reasoning", summary=[summary_item])
|
|
|
|
|
|
resp_obj = MagicMock(status="completed", usage=None, output=[reasoning_item])
|
|
|
|
|
|
ev = MagicMock(type="response.completed", response=resp_obj)
|
|
|
|
|
|
|
|
|
|
|
|
async def stream():
|
|
|
|
|
|
yield ev
|
|
|
|
|
|
|
|
|
|
|
|
_, _, _, _, reasoning = await consume_sdk_stream(stream())
|
|
|
|
|
|
assert reasoning == "thinking..."
|
|
|
|
|
|
|
2026-08-01 11:53:51 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_deepseek_reasoning_text_streamed(self):
|
|
|
|
|
|
events = [
|
|
|
|
|
|
MagicMock(type="response.reasoning_text.delta", delta="step 1 "),
|
|
|
|
|
|
MagicMock(type="response.reasoning_text.delta", delta="step 2"),
|
|
|
|
|
|
MagicMock(type="response.reasoning_text.done", text="step 1 step 2"),
|
|
|
|
|
|
]
|
|
|
|
|
|
emitted: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
async def stream():
|
|
|
|
|
|
for event in events:
|
|
|
|
|
|
yield event
|
|
|
|
|
|
|
|
|
|
|
|
async def on_reasoning_delta(delta: str) -> None:
|
|
|
|
|
|
emitted.append(delta)
|
|
|
|
|
|
|
|
|
|
|
|
_, _, _, _, reasoning = await consume_sdk_stream(
|
|
|
|
|
|
stream(),
|
|
|
|
|
|
on_reasoning_delta=on_reasoning_delta,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert reasoning == "step 1 step 2"
|
|
|
|
|
|
assert emitted == ["step 1 ", "step 2"]
|
|
|
|
|
|
|
2026-03-31 08:30:11 +00:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_error_event_raises(self):
|
2026-03-31 08:37:41 +00:00
|
|
|
|
ev = MagicMock(type="error", error="rate_limit_exceeded")
|
2026-03-31 08:30:11 +00:00
|
|
|
|
|
|
|
|
|
|
async def stream():
|
|
|
|
|
|
yield ev
|
|
|
|
|
|
|
2026-03-31 08:37:41 +00:00
|
|
|
|
with pytest.raises(RuntimeError, match="Response failed.*rate_limit_exceeded"):
|
2026-03-31 08:30:11 +00:00
|
|
|
|
await consume_sdk_stream(stream())
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_failed_event_raises(self):
|
2026-03-31 08:37:41 +00:00
|
|
|
|
ev = MagicMock(type="response.failed", error="server_error")
|
2026-03-31 08:30:11 +00:00
|
|
|
|
|
|
|
|
|
|
async def stream():
|
|
|
|
|
|
yield ev
|
|
|
|
|
|
|
2026-03-31 08:37:41 +00:00
|
|
|
|
with pytest.raises(RuntimeError, match="Response failed.*server_error"):
|
2026-03-31 08:30:11 +00:00
|
|
|
|
await consume_sdk_stream(stream())
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2026-03-31 09:22:50 +00:00
|
|
|
|
async def test_malformed_tool_args_logged(self):
|
2026-06-09 14:50:40 +08:00
|
|
|
|
"""Malformed JSON in streaming tool args should log a warning and remain non-object."""
|
2026-03-31 08:30:11 +00:00
|
|
|
|
item_added = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="")
|
|
|
|
|
|
item_added.name = "f"
|
|
|
|
|
|
ev1 = MagicMock(type="response.output_item.added", item=item_added)
|
|
|
|
|
|
ev2 = MagicMock(type="response.function_call_arguments.done", call_id="c1", arguments="{bad")
|
|
|
|
|
|
item_done = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="{bad")
|
|
|
|
|
|
item_done.name = "f"
|
|
|
|
|
|
ev3 = MagicMock(type="response.output_item.done", item=item_done)
|
|
|
|
|
|
resp_obj = MagicMock(status="completed", usage=None, output=[])
|
|
|
|
|
|
ev4 = MagicMock(type="response.completed", response=resp_obj)
|
|
|
|
|
|
|
|
|
|
|
|
async def stream():
|
|
|
|
|
|
for e in [ev1, ev2, ev3, ev4]:
|
|
|
|
|
|
yield e
|
|
|
|
|
|
|
2026-04-02 05:38:19 +00:00
|
|
|
|
with patch("nanobot.providers.openai_responses.parsing.logger") as mock_logger:
|
2026-03-31 09:22:50 +00:00
|
|
|
|
_, tool_calls, _, _, _ = await consume_sdk_stream(stream())
|
2026-06-09 14:50:40 +08:00
|
|
|
|
assert tool_calls[0].arguments == "{bad"
|
2026-03-31 09:22:50 +00:00
|
|
|
|
mock_logger.warning.assert_called_once()
|
|
|
|
|
|
assert "Failed to parse tool call arguments" in str(mock_logger.warning.call_args)
|