Files
openai--openai-agents-python/tests/models/test_reasoning_content_replay_hook.py
2026-07-13 12:39:17 +08:00

404 lines
15 KiB
Python

from __future__ import annotations
from typing import Any, cast
import httpx
import litellm
import pytest
from litellm.types.utils import Choices, Message, ModelResponse, Usage
from openai.types.chat.chat_completion import ChatCompletion, Choice
from openai.types.chat.chat_completion_message import ChatCompletionMessage
from openai.types.completion_usage import CompletionUsage
from agents.extensions.models.litellm_model import LitellmModel
from agents.items import TResponseInputItem
from agents.model_settings import ModelSettings
from agents.models.chatcmpl_converter import Converter
from agents.models.interface import ModelTracing
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
from agents.models.reasoning_content_replay import ReasoningContentReplayContext
REASONING_CONTENT_MODEL_A = "reasoning-content-model-a"
REASONING_CONTENT_MODEL_B = "reasoning-content-model-b"
# The converter currently keys Anthropic thinking-block reconstruction off the model name,
# so this test model keeps the "anthropic" substring while staying otherwise generic.
REASONING_CONTENT_MODEL_C = "reasoning-content-model-c-anthropic"
def _second_turn_input_items(model_name: str) -> list[TResponseInputItem]:
return cast(
list[TResponseInputItem],
[
{"role": "user", "content": "What's the weather in Tokyo?"},
{
"id": "__fake_id__",
"summary": [
{"text": "I should call the weather tool first.", "type": "summary_text"}
],
"type": "reasoning",
"content": None,
"encrypted_content": None,
"status": None,
"provider_data": {"model": model_name, "response_id": "chatcmpl-test"},
},
{
"arguments": '{"city": "Tokyo"}',
"call_id": "call_weather_123",
"name": "get_weather",
"type": "function_call",
"id": "__fake_id__",
"status": None,
"provider_data": {"model": model_name},
},
{
"type": "function_call_output",
"call_id": "call_weather_123",
"output": "The weather in Tokyo is sunny and 22°C.",
},
],
)
def _second_turn_input_items_with_message(model_name: str) -> list[TResponseInputItem]:
return cast(
list[TResponseInputItem],
[
{"role": "user", "content": "What's the weather in Tokyo?"},
{
"id": "__fake_id__",
"summary": [
{"text": "I should call the weather tool first.", "type": "summary_text"}
],
"type": "reasoning",
"content": None,
"encrypted_content": None,
"status": None,
"provider_data": {"model": model_name, "response_id": "chatcmpl-test"},
},
{
"id": "__fake_id__",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [
{
"type": "output_text",
"text": "I'll call the weather tool now.",
"annotations": [],
"logprobs": [],
}
],
"provider_data": {"model": model_name, "response_id": "chatcmpl-test"},
},
{
"arguments": '{"city": "Tokyo"}',
"call_id": "call_weather_123",
"name": "get_weather",
"type": "function_call",
"id": "__fake_id__",
"status": None,
"provider_data": {"model": model_name},
},
{
"type": "function_call_output",
"call_id": "call_weather_123",
"output": "The weather in Tokyo is sunny and 22°C.",
},
],
)
def _second_turn_input_items_with_file_search(model_name: str) -> list[TResponseInputItem]:
return cast(
list[TResponseInputItem],
[
{"role": "user", "content": "Find notes about Tokyo weather."},
{
"id": "__fake_id__",
"summary": [
{"text": "I should search the knowledge base first.", "type": "summary_text"}
],
"type": "reasoning",
"content": None,
"encrypted_content": None,
"status": None,
"provider_data": {"model": model_name, "response_id": "chatcmpl-test"},
},
{
"id": "__fake_file_search_id__",
"queries": ["Tokyo weather"],
"status": "completed",
"type": "file_search_call",
},
],
)
def _second_turn_input_items_with_message_then_reasoning(
model_name: str,
) -> list[TResponseInputItem]:
return cast(
list[TResponseInputItem],
[
{"role": "user", "content": "What's the weather in Tokyo?"},
{
"id": "__fake_id__",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [
{
"type": "output_text",
"text": "I'll call the weather tool now.",
"annotations": [],
"logprobs": [],
}
],
"provider_data": {"model": model_name, "response_id": "chatcmpl-test"},
},
{
"id": "__fake_id__",
"summary": [
{"text": "I should call the weather tool first.", "type": "summary_text"}
],
"type": "reasoning",
"content": None,
"encrypted_content": None,
"status": None,
"provider_data": {"model": model_name, "response_id": "chatcmpl-test"},
},
{
"arguments": '{"city": "Tokyo"}',
"call_id": "call_weather_123",
"name": "get_weather",
"type": "function_call",
"id": "__fake_id__",
"status": None,
"provider_data": {"model": model_name},
},
{
"type": "function_call_output",
"call_id": "call_weather_123",
"output": "The weather in Tokyo is sunny and 22°C.",
},
],
)
def _second_turn_input_items_with_thinking_blocks(model_name: str) -> list[TResponseInputItem]:
return cast(
list[TResponseInputItem],
[
{"role": "user", "content": "What's the weather in Tokyo?"},
{
"id": "__fake_id__",
"summary": [
{"text": "I should call the weather tool first.", "type": "summary_text"}
],
"type": "reasoning",
"content": [
{
"type": "reasoning_text",
"text": "First, I need to inspect the request.",
}
],
"encrypted_content": "test-signature",
"status": None,
"provider_data": {"model": model_name, "response_id": "chatcmpl-test"},
},
{
"arguments": '{"city": "Tokyo"}',
"call_id": "call_weather_123",
"name": "get_weather",
"type": "function_call",
"id": "__fake_id__",
"status": None,
"provider_data": {"model": model_name},
},
{
"type": "function_call_output",
"call_id": "call_weather_123",
"output": "The weather in Tokyo is sunny and 22°C.",
},
],
)
def _assistant_with_tool_calls(messages: list[Any]) -> dict[str, Any]:
for msg in messages:
if isinstance(msg, dict) and msg.get("role") == "assistant" and msg.get("tool_calls"):
return msg
raise AssertionError("Expected an assistant message with tool_calls.")
def test_converter_keeps_default_reasoning_replay_behavior_for_non_default_model() -> None:
messages = Converter.items_to_messages(
_second_turn_input_items(REASONING_CONTENT_MODEL_A),
model=REASONING_CONTENT_MODEL_A,
)
assistant = _assistant_with_tool_calls(messages)
assert "reasoning_content" not in assistant
def test_converter_preserves_reasoning_content_across_output_message_with_hook() -> None:
def should_replay_reasoning_content(_context: ReasoningContentReplayContext) -> bool:
return True
messages = Converter.items_to_messages(
_second_turn_input_items_with_message(REASONING_CONTENT_MODEL_A),
model=REASONING_CONTENT_MODEL_A,
should_replay_reasoning_content=should_replay_reasoning_content,
)
assistant = _assistant_with_tool_calls(messages)
assert assistant["content"] == "I'll call the weather tool now."
assert assistant["reasoning_content"] == "I should call the weather tool first."
def test_converter_replays_reasoning_content_when_reasoning_follows_message_with_hook() -> None:
def should_replay_reasoning_content(_context: ReasoningContentReplayContext) -> bool:
return True
messages = Converter.items_to_messages(
_second_turn_input_items_with_message_then_reasoning(REASONING_CONTENT_MODEL_A),
model=REASONING_CONTENT_MODEL_A,
should_replay_reasoning_content=should_replay_reasoning_content,
)
assistant = _assistant_with_tool_calls(messages)
assert assistant["content"] == "I'll call the weather tool now."
assert assistant["reasoning_content"] == "I should call the weather tool first."
def test_converter_replays_reasoning_content_for_file_search_call_with_hook() -> None:
def should_replay_reasoning_content(_context: ReasoningContentReplayContext) -> bool:
return True
messages = Converter.items_to_messages(
_second_turn_input_items_with_file_search(REASONING_CONTENT_MODEL_A),
model=REASONING_CONTENT_MODEL_A,
should_replay_reasoning_content=should_replay_reasoning_content,
)
assistant = _assistant_with_tool_calls(messages)
assert assistant["reasoning_content"] == "I should search the knowledge base first."
assert assistant["tool_calls"][0]["function"]["name"] == "file_search_call"
def test_converter_replays_reasoning_content_with_thinking_blocks_and_hook() -> None:
def should_replay_reasoning_content(_context: ReasoningContentReplayContext) -> bool:
return True
messages = Converter.items_to_messages(
_second_turn_input_items_with_thinking_blocks(REASONING_CONTENT_MODEL_C),
model=REASONING_CONTENT_MODEL_C,
preserve_thinking_blocks=True,
should_replay_reasoning_content=should_replay_reasoning_content,
)
assistant = _assistant_with_tool_calls(messages)
assert assistant["reasoning_content"] == "I should call the weather tool first."
assert assistant["content"][0]["type"] == "thinking"
assert assistant["content"][0]["thinking"] == "First, I need to inspect the request."
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_openai_chatcompletions_hook_can_enable_reasoning_content_replay() -> None:
captured: dict[str, Any] = {}
contexts: list[ReasoningContentReplayContext] = []
def should_replay_reasoning_content(context: ReasoningContentReplayContext) -> bool:
contexts.append(context)
return context.model == REASONING_CONTENT_MODEL_B
class MockChatCompletions:
async def create(self, **kwargs):
captured.update(kwargs)
msg = ChatCompletionMessage(role="assistant", content="done")
choice = Choice(index=0, message=msg, finish_reason="stop")
return ChatCompletion(
id="test-id",
created=0,
model=REASONING_CONTENT_MODEL_B,
object="chat.completion",
choices=[choice],
usage=CompletionUsage(completion_tokens=5, prompt_tokens=10, total_tokens=15),
)
class MockChat:
def __init__(self):
self.completions = MockChatCompletions()
class MockClient:
def __init__(self):
self.chat = MockChat()
self.base_url = httpx.URL("https://example.com/v1/")
model = OpenAIChatCompletionsModel(
model=REASONING_CONTENT_MODEL_B,
openai_client=cast(Any, MockClient()),
should_replay_reasoning_content=should_replay_reasoning_content,
)
await model.get_response(
system_instructions=None,
input=_second_turn_input_items(REASONING_CONTENT_MODEL_B),
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
)
assistant = _assistant_with_tool_calls(cast(list[dict[str, Any]], captured["messages"]))
assert assistant["reasoning_content"] == "I should call the weather tool first."
assert len(contexts) == 1
assert contexts[0].model == REASONING_CONTENT_MODEL_B
assert contexts[0].base_url == "https://example.com/v1"
assert contexts[0].reasoning.origin_model == REASONING_CONTENT_MODEL_B
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_litellm_hook_can_enable_reasoning_content_replay(monkeypatch) -> None:
captured: dict[str, Any] = {}
contexts: list[ReasoningContentReplayContext] = []
def should_replay_reasoning_content(context: ReasoningContentReplayContext) -> bool:
contexts.append(context)
return context.model == REASONING_CONTENT_MODEL_B
async def fake_acompletion(model, messages=None, **kwargs):
captured["messages"] = messages
msg = Message(role="assistant", content="done")
choice = Choices(index=0, message=msg)
return ModelResponse(choices=[choice], usage=Usage(0, 0, 0))
monkeypatch.setattr(litellm, "acompletion", fake_acompletion)
model = LitellmModel(
model=REASONING_CONTENT_MODEL_B,
should_replay_reasoning_content=should_replay_reasoning_content,
)
await model.get_response(
system_instructions=None,
input=_second_turn_input_items(REASONING_CONTENT_MODEL_B),
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
)
assistant = _assistant_with_tool_calls(cast(list[dict[str, Any]], captured["messages"]))
assert assistant["reasoning_content"] == "I should call the weather tool first."
assert len(contexts) == 1
assert contexts[0].model == REASONING_CONTENT_MODEL_B
assert contexts[0].base_url is None
assert contexts[0].reasoning.origin_model == REASONING_CONTENT_MODEL_B