Files
simonw--llm/tests/test_openai_messages.py
2026-07-13 12:48:46 +08:00

614 lines
22 KiB
Python

import json
import pytest
from pytest_httpx import IteratorStream
import llm
from llm.default_plugins.openai_models import Chat
from llm.models import Prompt
API_KEY = "badkey"
def _sse(delta, finish_reason=None, usage=None, tool_calls=None):
chunk = {
"id": "c1",
"object": "chat.completion.chunk",
"created": 1700000000,
"model": "gpt-4o-mini",
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
}
if tool_calls is not None:
chunk["choices"][0]["delta"]["tool_calls"] = tool_calls
if usage is not None:
chunk["usage"] = usage
return f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
def _text_stream():
yield _sse({"role": "assistant", "content": ""})
yield _sse({"content": "Hel"})
yield _sse({"content": "lo"})
yield _sse({}, finish_reason="stop")
yield b"data: [DONE]\n\n"
def _tool_call_stream():
"""Mimic an OpenAI stream with a tool call (no preceding text)."""
yield _sse({"role": "assistant", "content": None})
yield _sse(
{},
tool_calls=[
{
"index": 0,
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": ""},
}
],
)
yield _sse(
{},
tool_calls=[
{
"index": 0,
"function": {"arguments": '{"city":'},
}
],
)
yield _sse(
{},
tool_calls=[
{
"index": 0,
"function": {"arguments": '"Paris"}'},
}
],
)
yield _sse({}, finish_reason="tool_calls")
yield b"data: [DONE]\n\n"
def _text_then_tool_call_stream():
"""Text arrives first, then a tool call — the tool call must get
a part_index past the text so assembly doesn't mix families."""
yield _sse({"role": "assistant", "content": ""})
yield _sse({"content": "Looking up"})
yield _sse(
{},
tool_calls=[
{
"index": 0,
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": '{"c":1}'},
}
],
)
yield _sse({}, finish_reason="tool_calls")
yield b"data: [DONE]\n\n"
@pytest.fixture
def chat_model():
# A plain Chat instance with vision and tools enabled — enough
# capabilities for the Part subtypes we translate.
return Chat("gpt-4o-mini", vision=True, supports_tools=True)
class TestBuildMessagesFromExplicitMessages:
def test_single_user_message(self, chat_model):
prompt = Prompt(None, model=chat_model, messages=[llm.user("hi")])
result = chat_model.build_messages(prompt, None)
assert result == [{"role": "user", "content": "hi"}]
def test_system_plus_user(self, chat_model):
prompt = Prompt(
None,
model=chat_model,
messages=[llm.system("be brief"), llm.user("hi")],
)
result = chat_model.build_messages(prompt, None)
assert result == [
{"role": "system", "content": "be brief"},
{"role": "user", "content": "hi"},
]
def test_user_with_attachment(self, chat_model):
att = llm.Attachment(type="image/jpeg", url="http://example.com/cat.jpg")
prompt = Prompt(
None,
model=chat_model,
messages=[llm.user("describe", att)],
)
result = chat_model.build_messages(prompt, None)
assert result == [
{
"role": "user",
"content": [
{"type": "text", "text": "describe"},
{
"type": "image_url",
"image_url": {"url": "http://example.com/cat.jpg"},
},
],
}
]
def test_assistant_with_tool_call(self, chat_model):
tool_call = llm.parts.ToolCallPart(
name="search",
arguments={"q": "weather"},
tool_call_id="c1",
)
prompt = Prompt(
None,
model=chat_model,
messages=[
llm.user("search weather"),
llm.assistant("on it", tool_call),
],
)
result = chat_model.build_messages(prompt, None)
assert result == [
{"role": "user", "content": "search weather"},
{
"role": "assistant",
"content": "on it",
"tool_calls": [
{
"type": "function",
"id": "c1",
"function": {
"name": "search",
"arguments": json.dumps({"q": "weather"}),
},
}
],
},
]
def test_assistant_tool_call_only_no_text(self, chat_model):
"""When an assistant message has tool_calls but no text, OpenAI
expects content=null."""
tool_call = llm.parts.ToolCallPart(
name="search", arguments={"q": "x"}, tool_call_id="c1"
)
prompt = Prompt(
None,
model=chat_model,
messages=[llm.user("q"), llm.assistant(tool_call)],
)
result = chat_model.build_messages(prompt, None)
assert result[1] == {
"role": "assistant",
"content": None,
"tool_calls": [
{
"type": "function",
"id": "c1",
"function": {
"name": "search",
"arguments": json.dumps({"q": "x"}),
},
}
],
}
def test_tool_role_message_with_tool_result(self, chat_model):
tr = llm.parts.ToolResultPart(name="search", output="sunny", tool_call_id="c1")
prompt = Prompt(
None,
model=chat_model,
messages=[
llm.user("q"),
llm.tool_message(tr),
],
)
result = chat_model.build_messages(prompt, None)
assert result == [
{"role": "user", "content": "q"},
{"role": "tool", "tool_call_id": "c1", "content": "sunny"},
]
def test_multiple_tool_results_emit_multiple_messages(self, chat_model):
"""Parallel tool results: one OpenAI 'tool' message per result."""
a = llm.parts.ToolResultPart(name="t", output="A", tool_call_id="c1")
b = llm.parts.ToolResultPart(name="t", output="B", tool_call_id="c2")
prompt = Prompt(
None,
model=chat_model,
messages=[llm.user("q"), llm.tool_message(a, b)],
)
result = chat_model.build_messages(prompt, None)
assert result == [
{"role": "user", "content": "q"},
{"role": "tool", "tool_call_id": "c1", "content": "A"},
{"role": "tool", "tool_call_id": "c2", "content": "B"},
]
class TestBuildMessagesLegacyFieldsStillWork:
"""prompt=, system=, attachments= keep working — they synthesize
messages via Prompt.messages before build_messages sees them."""
def test_prompt_only(self, chat_model):
prompt = Prompt("hi", model=chat_model)
result = chat_model.build_messages(prompt, None)
assert result == [{"role": "user", "content": "hi"}]
def test_system_and_prompt(self, chat_model):
prompt = Prompt("hi", model=chat_model, system="be brief")
result = chat_model.build_messages(prompt, None)
assert result == [
{"role": "system", "content": "be brief"},
{"role": "user", "content": "hi"},
]
def test_attachments(self, chat_model):
att = llm.Attachment(type="image/jpeg", url="http://example.com/a.jpg")
prompt = Prompt("look", model=chat_model, attachments=[att])
result = chat_model.build_messages(prompt, None)
assert result == [
{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {"url": "http://example.com/a.jpg"},
},
],
}
]
class TestBuildMessagesSystemDedup:
"""Explicit messages with repeated system messages dedupe
repeated unchanged systems; OpenAI accepts one."""
def test_same_system_not_repeated(self, chat_model):
prompt = Prompt(
None,
model=chat_model,
messages=[
llm.system("be brief"),
llm.user("q1"),
llm.assistant("a1"),
llm.system("be brief"),
llm.user("q2"),
],
)
result = chat_model.build_messages(prompt, None)
system_msgs = [m for m in result if m["role"] == "system"]
assert len(system_msgs) == 1
assert system_msgs[0]["content"] == "be brief"
def test_system_change_emitted(self, chat_model):
prompt = Prompt(
None,
model=chat_model,
messages=[
llm.system("be brief"),
llm.user("q1"),
llm.assistant("a1"),
llm.system("be expansive"),
llm.user("q2"),
],
)
result = chat_model.build_messages(prompt, None)
system_msgs = [m for m in result if m["role"] == "system"]
assert [m["content"] for m in system_msgs] == [
"be brief",
"be expansive",
]
class TestBuildMessagesConversationHistory:
def test_prior_turn_text_plus_current_user(self, chat_model):
new_prompt = Prompt(
None,
model=chat_model,
messages=[
llm.user("what's 1+1?"),
llm.assistant("2"),
llm.user("what about 2+2?"),
],
)
result = chat_model.build_messages(new_prompt, None)
assert result == [
{"role": "user", "content": "what's 1+1?"},
{"role": "assistant", "content": "2"},
{"role": "user", "content": "what about 2+2?"},
]
def test_no_double_emission_from_conversation_prompt_flow(
self, chat_model, httpx_mock
):
# Two staged responses so conv.prompt twice can complete.
httpx_mock.add_response(
method="POST",
url="https://api.openai.com/v1/chat/completions",
json={
"model": "gpt-4o-mini",
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
"choices": [
{
"message": {"role": "assistant", "content": "A1"},
"finish_reason": "stop",
}
],
},
headers={"Content-Type": "application/json"},
)
httpx_mock.add_response(
method="POST",
url="https://api.openai.com/v1/chat/completions",
json={
"model": "gpt-4o-mini",
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
"choices": [
{
"message": {"role": "assistant", "content": "A2"},
"finish_reason": "stop",
}
],
},
headers={"Content-Type": "application/json"},
)
model = llm.get_model("gpt-4o-mini")
conv = model.conversation()
r1 = conv.prompt("Q1", key=API_KEY, stream=False)
r1.text()
r2 = conv.prompt("Q2", key=API_KEY, stream=False)
r2.text()
# Inspect what was sent on the SECOND turn.
sent_body = json.loads(httpx_mock.get_requests()[-1].content)
sent_messages = sent_body["messages"]
# Exactly three: user(Q1), assistant(A1), user(Q2).
assert sent_messages == [
{"role": "user", "content": "Q1"},
{"role": "assistant", "content": "A1"},
{"role": "user", "content": "Q2"},
]
class TestStreamingExecuteYieldsStreamEvents:
def test_text_stream_yields_text_events(self, httpx_mock):
httpx_mock.add_response(
method="POST",
url="https://api.openai.com/v1/chat/completions",
stream=IteratorStream(_text_stream()),
headers={"Content-Type": "text/event-stream"},
)
model = llm.get_model("gpt-4o-mini")
response = model.prompt("hi", key=API_KEY)
events = list(response.stream_events())
# At least one StreamEvent, all text, all at part_index=0.
assert events, "expected stream events"
assert all(isinstance(e, llm.parts.StreamEvent) for e in events)
assert all(e.type == "text" for e in events)
assert all(e.part_index == 0 for e in events)
# Text chunks concatenate to the expected full text.
assert "".join(e.chunk for e in events) == "Hello"
def test_text_stream_plain_iteration_still_returns_strings(self, httpx_mock):
httpx_mock.add_response(
method="POST",
url="https://api.openai.com/v1/chat/completions",
stream=IteratorStream(_text_stream()),
headers={"Content-Type": "text/event-stream"},
)
model = llm.get_model("gpt-4o-mini")
response = model.prompt("hi", key=API_KEY)
chunks = list(response)
assert all(isinstance(c, str) for c in chunks)
assert "".join(chunks) == "Hello"
def test_text_stream_messages_assembled(self, httpx_mock):
httpx_mock.add_response(
method="POST",
url="https://api.openai.com/v1/chat/completions",
stream=IteratorStream(_text_stream()),
headers={"Content-Type": "text/event-stream"},
)
model = llm.get_model("gpt-4o-mini")
response = model.prompt("hi", key=API_KEY)
response.text()
assert response.messages() == [
llm.Message(role="assistant", parts=[llm.parts.TextPart(text="Hello")])
]
def test_tool_call_stream_yields_name_and_args_events(self, httpx_mock):
httpx_mock.add_response(
method="POST",
url="https://api.openai.com/v1/chat/completions",
stream=IteratorStream(_tool_call_stream()),
headers={"Content-Type": "text/event-stream"},
)
def get_weather(city: str) -> str:
"Look up the weather."
return "sunny"
model = llm.get_model("gpt-4o-mini")
response = model.prompt("weather?", tools=[get_weather], key=API_KEY)
events = list(response.stream_events())
types = [e.type for e in events]
assert "tool_call_name" in types
assert "tool_call_args" in types
# Name event carries the tool_call_id and name.
name_ev = next(e for e in events if e.type == "tool_call_name")
assert name_ev.tool_call_id == "call_1"
assert name_ev.chunk == "get_weather"
# Args events share the same part_index and concatenate to
# valid JSON.
args_events = [e for e in events if e.type == "tool_call_args"]
assert all(e.part_index == name_ev.part_index for e in args_events)
assert json.loads("".join(e.chunk for e in args_events)) == {"city": "Paris"}
def test_tool_call_registered_via_add_tool_call(self, httpx_mock):
"""response.tool_calls() still works — chain/execute relies on it."""
httpx_mock.add_response(
method="POST",
url="https://api.openai.com/v1/chat/completions",
stream=IteratorStream(_tool_call_stream()),
headers={"Content-Type": "text/event-stream"},
)
def get_weather(city: str) -> str:
"Look up the weather."
return "sunny"
model = llm.get_model("gpt-4o-mini")
response = model.prompt("weather?", tools=[get_weather], key=API_KEY)
response.text()
tcs = response.tool_calls()
assert len(tcs) == 1
assert tcs[0].name == "get_weather"
assert tcs[0].arguments == {"city": "Paris"}
assert tcs[0].tool_call_id == "call_1"
def test_text_then_tool_call_part_index_advances(self, httpx_mock):
httpx_mock.add_response(
method="POST",
url="https://api.openai.com/v1/chat/completions",
stream=IteratorStream(_text_then_tool_call_stream()),
headers={"Content-Type": "text/event-stream"},
)
def get_weather(c: int) -> str:
"Weather."
return "sunny"
model = llm.get_model("gpt-4o-mini")
response = model.prompt("q", tools=[get_weather], key=API_KEY)
response.text()
# After streaming, messages has both a TextPart and a ToolCallPart.
parts = response.messages()[0].parts
assert any(isinstance(p, llm.parts.TextPart) for p in parts)
assert any(isinstance(p, llm.parts.ToolCallPart) for p in parts)
text_part = next(p for p in parts if isinstance(p, llm.parts.TextPart))
tc_part = next(p for p in parts if isinstance(p, llm.parts.ToolCallPart))
assert text_part.text == "Looking up"
assert tc_part.name == "get_weather"
assert tc_part.arguments == {"c": 1}
class TestAsyncStreamingExecuteYieldsStreamEvents:
@pytest.mark.asyncio
async def test_text_stream_yields_text_events(self, httpx_mock):
httpx_mock.add_response(
method="POST",
url="https://api.openai.com/v1/chat/completions",
stream=IteratorStream(_text_stream()),
headers={"Content-Type": "text/event-stream"},
)
model = llm.get_async_model("gpt-4o-mini")
response = model.prompt("hi", key=API_KEY)
events = []
async for event in response.astream_events():
events.append(event)
assert all(isinstance(e, llm.parts.StreamEvent) for e in events)
assert [e.type for e in events] == ["text"] * len(events)
assert "".join(e.chunk for e in events) == "Hello"
def _text_stream_with_reasoning_usage(reasoning_tokens):
"""Stream with usage in the final chunk reporting reasoning_tokens."""
yield _sse({"role": "assistant", "content": ""})
yield _sse({"content": "Hel"})
yield _sse({"content": "lo"})
yield _sse({}, finish_reason="stop")
# Final chunk with usage — OpenAI streams usage once at the end
# when stream_options.include_usage=True.
yield _sse(
{},
usage={
"prompt_tokens": 5,
"completion_tokens": 2,
"total_tokens": 7,
"completion_tokens_details": {"reasoning_tokens": reasoning_tokens},
},
)
yield b"data: [DONE]\n\n"
class TestReasoningTokenCount:
def test_redacted_reasoning_part_emitted_when_count_present(self, httpx_mock):
httpx_mock.add_response(
method="POST",
url="https://api.openai.com/v1/chat/completions",
stream=IteratorStream(_text_stream_with_reasoning_usage(150)),
headers={"Content-Type": "text/event-stream"},
)
model = llm.get_model("gpt-4o-mini")
response = model.prompt("hi", key=API_KEY)
response.text()
assert response.messages() == [
llm.Message(
role="assistant",
parts=[
llm.parts.ReasoningPart(text="", redacted=True),
llm.parts.TextPart(text="Hello"),
],
)
]
def test_no_reasoning_part_when_zero_or_absent(self, httpx_mock):
httpx_mock.add_response(
method="POST",
url="https://api.openai.com/v1/chat/completions",
stream=IteratorStream(_text_stream_with_reasoning_usage(0)),
headers={"Content-Type": "text/event-stream"},
)
model = llm.get_model("gpt-4o-mini")
response = model.prompt("hi", key=API_KEY)
response.text()
parts = response.messages()[0].parts
assert not any(
isinstance(p, llm.parts.ReasoningPart) for p in parts
), "should not add a redacted reasoning part when count=0"
class TestNonStreamingExecuteYieldsStreamEvents:
def test_non_streaming_text_yields_single_event(self, httpx_mock):
httpx_mock.add_response(
method="POST",
url="https://api.openai.com/v1/chat/completions",
json={
"model": "gpt-4o-mini",
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
"choices": [
{
"message": {"role": "assistant", "content": "Hello"},
"finish_reason": "stop",
}
],
},
headers={"Content-Type": "application/json"},
)
model = llm.get_model("gpt-4o-mini")
response = model.prompt("hi", key=API_KEY, stream=False)
events = list(response.stream_events())
assert events == [
llm.parts.StreamEvent(type="text", chunk="Hello", part_index=0)
]
assert response.messages() == [
llm.Message(role="assistant", parts=[llm.parts.TextPart(text="Hello")])
]