mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
d7293eeb16
A user turn's `content` may now be a part array (`ChatTextPart` + `ChatImagePart`), which chat completions take as-is; the Anthropic and Responses bridges translate it into `image` blocks and `input_image` parts. A tool result keeps its text in `content` and carries `images` beside it: Anthropic and Responses put them in the result item itself (a live probe reads both back), while chat completions cannot, so `compat.chat_messages_with_images` moves them into the user turn after the tool run. Prose-only readers go through `content_to_text`, which renders an image as `[image]`. `agent.images` sniffs the real format from the bytes and builds the data URL, capped at 8 MiB inline because every stored message is re-sent on each round.
594 lines
21 KiB
Python
594 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import msgspec
|
|
|
|
from plyngent.agent.responses_bridge import (
|
|
chat_messages_to_responses_input,
|
|
chat_param_to_responses_kwargs,
|
|
reasoning_summary_text,
|
|
response_to_assistant_message,
|
|
response_to_chat_completion,
|
|
responses_status_to_finish_reason,
|
|
tool_items_to_response_tools,
|
|
)
|
|
from plyngent.lmproto.openai.model import Response, ResponseEasyInputMessage, ResponseFunctionToolCallOutput
|
|
from plyngent.lmproto.openai_compatible.model import (
|
|
AssistantChatMessage,
|
|
AssistantFunctionTool,
|
|
AssistantFunctionToolCall,
|
|
ChatCompletionsParam,
|
|
ChatImagePart,
|
|
ChatImageUrl,
|
|
ChatTextPart,
|
|
DeveloperChatMessage,
|
|
SystemChatMessage,
|
|
ToolChatMessage,
|
|
ToolFunction,
|
|
ToolFunctionItem,
|
|
UserChatMessage,
|
|
)
|
|
|
|
|
|
def _input_item_types(
|
|
items: list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput],
|
|
) -> list[str]:
|
|
"""Item ``type`` tags, in stream order."""
|
|
types: list[str] = []
|
|
for item in items:
|
|
tag = item.get("type") if isinstance(item, dict) else item.__struct_config__.tag
|
|
assert isinstance(tag, str)
|
|
types.append(tag)
|
|
return types
|
|
|
|
|
|
def test_tool_items_to_response_tools() -> None:
|
|
items = [
|
|
ToolFunctionItem(
|
|
function=ToolFunction(
|
|
name="read_file",
|
|
description="Read a file",
|
|
parameters={"type": "object", "properties": {"path": {"type": "string"}}},
|
|
)
|
|
)
|
|
]
|
|
tools = tool_items_to_response_tools(items)
|
|
assert len(tools) == 1
|
|
assert tools[0].name == "read_file"
|
|
|
|
|
|
def test_chat_messages_to_input_and_instructions() -> None:
|
|
messages = [
|
|
SystemChatMessage(content="You are helpful."),
|
|
UserChatMessage(content="hi"),
|
|
AssistantChatMessage(
|
|
content=None,
|
|
tool_calls=[
|
|
AssistantFunctionToolCall(
|
|
id="call_1",
|
|
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
|
|
)
|
|
],
|
|
),
|
|
ToolChatMessage(content="file body", tool_call_id="call_1"),
|
|
]
|
|
instructions, items = chat_messages_to_responses_input(messages)
|
|
assert instructions == "You are helpful."
|
|
# user message, then the call turn's reasoning + call, then its output.
|
|
assert _input_item_types(items) == ["message", "reasoning", "function_call", "function_call_output"]
|
|
|
|
|
|
def test_a_user_turn_carries_its_images_as_input_parts() -> None:
|
|
messages = [
|
|
UserChatMessage(
|
|
content=[
|
|
ChatTextPart(text="what is this?"),
|
|
ChatImagePart(image_url=ChatImageUrl(url="https://example.com/a.jpg", detail="low")),
|
|
]
|
|
)
|
|
]
|
|
_, items = chat_messages_to_responses_input(messages)
|
|
|
|
assert items[0] == ResponseEasyInputMessage(
|
|
role="user",
|
|
content=[
|
|
{"type": "input_text", "text": "what is this?"},
|
|
{"type": "input_image", "image_url": "https://example.com/a.jpg", "detail": "low"},
|
|
],
|
|
)
|
|
|
|
|
|
def test_a_tool_output_carries_the_images_the_tool_returned() -> None:
|
|
messages = [
|
|
AssistantChatMessage(
|
|
content=None,
|
|
tool_calls=[
|
|
AssistantFunctionToolCall(
|
|
id="call_1", function=AssistantFunctionTool(name="view_image", arguments="{}")
|
|
)
|
|
],
|
|
),
|
|
ToolChatMessage(
|
|
content="image: shot.png (2 KB)",
|
|
tool_call_id="call_1",
|
|
images=[ChatImagePart(image_url=ChatImageUrl(url="data:image/png;base64,AAAA"))],
|
|
),
|
|
]
|
|
_, items = chat_messages_to_responses_input(messages)
|
|
|
|
output = items[-1]
|
|
assert isinstance(output, ResponseFunctionToolCallOutput)
|
|
assert output.call_id == "call_1"
|
|
assert output.output == [
|
|
{"type": "input_text", "text": "image: shot.png (2 KB)"},
|
|
{"type": "input_image", "image_url": "data:image/png;base64,AAAA"},
|
|
]
|
|
|
|
|
|
def test_a_plain_tool_output_stays_a_string() -> None:
|
|
messages = [ToolChatMessage(content="file body", tool_call_id="call_1")]
|
|
_, items = chat_messages_to_responses_input(messages)
|
|
|
|
output = items[0]
|
|
assert isinstance(output, ResponseFunctionToolCallOutput)
|
|
assert output.output == "file body"
|
|
|
|
|
|
def test_parallel_tool_outputs_group_after_the_rounds_calls() -> None:
|
|
"""Regression: DeepSeek Responses rejected an interleaved batch.
|
|
|
|
A round is the assistant message its ``reasoning`` and ``function_call``
|
|
items merge into, so its calls must stay contiguous: sent as
|
|
``call, output, call, output`` the second call reads as a round of its own
|
|
without reasoning, and the request comes back 400 ("The ``reasoning_text`` in
|
|
the thinking mode must be passed back to the API").
|
|
"""
|
|
messages = [
|
|
UserChatMessage(content="run both"),
|
|
AssistantChatMessage(
|
|
content=None,
|
|
tool_calls=[
|
|
AssistantFunctionToolCall(
|
|
id="call_01_LPBuUG3KTaMDIwUoLwTE9127",
|
|
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
|
|
),
|
|
AssistantFunctionToolCall(
|
|
id="call_02_ABCD",
|
|
function=AssistantFunctionTool(name="regex_files", arguments='{"pattern":"todo"}'),
|
|
),
|
|
],
|
|
),
|
|
ToolChatMessage(content="file body", tool_call_id="call_01_LPBuUG3KTaMDIwUoLwTE9127"),
|
|
ToolChatMessage(content="grep hit", tool_call_id="call_02_ABCD"),
|
|
]
|
|
_, items = chat_messages_to_responses_input(messages)
|
|
types = _input_item_types(items)
|
|
assert types == [
|
|
"message",
|
|
"reasoning",
|
|
"function_call",
|
|
"function_call",
|
|
"function_call_output",
|
|
"function_call_output",
|
|
]
|
|
# Each output follows the round's calls, paired with its own call.
|
|
call_a = items[2]
|
|
call_b = items[3]
|
|
out_a = items[4]
|
|
out_b = items[5]
|
|
assert isinstance(call_a, dict) and isinstance(call_b, dict)
|
|
assert isinstance(out_a, ResponseFunctionToolCallOutput)
|
|
assert isinstance(out_b, ResponseFunctionToolCallOutput)
|
|
assert out_a.call_id == call_a["call_id"]
|
|
assert out_b.call_id == call_b["call_id"]
|
|
|
|
|
|
def test_tool_output_after_another_item_still_follows_its_call() -> None:
|
|
"""A notice recorded between a call and its output does not split the pair.
|
|
|
|
The output is moved up beside the calls of its round; left where it was it
|
|
would trail the notice's assistant message and be answered with "No tool
|
|
output found for tool call …".
|
|
"""
|
|
messages = [
|
|
AssistantChatMessage(
|
|
content=None,
|
|
reasoning_content="Read the file.",
|
|
tool_calls=[
|
|
AssistantFunctionToolCall(
|
|
id="call_1",
|
|
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
|
|
)
|
|
],
|
|
),
|
|
DeveloperChatMessage(content="reminder: keep going"),
|
|
ToolChatMessage(content="file body", tool_call_id="call_1"),
|
|
]
|
|
_, items = chat_messages_to_responses_input(messages)
|
|
assert _input_item_types(items) == [
|
|
"reasoning",
|
|
"function_call",
|
|
"function_call_output",
|
|
"message",
|
|
]
|
|
assert items[2] == ResponseFunctionToolCallOutput(call_id="call_1", output="file body")
|
|
|
|
|
|
def test_tool_call_turn_carries_its_reasoning() -> None:
|
|
"""Regression: DeepSeek rejected the tool-call round with a 422.
|
|
|
|
The reasoning item leads, directly ahead of the call it produced — DeepSeek
|
|
merges it into that call's assistant message. Its ``content`` is a list of
|
|
``reasoning_text`` parts, the only part the API reference documents for a
|
|
``reasoning`` input item (a bare string is a deserialization error).
|
|
"""
|
|
messages = [
|
|
UserChatMessage(content="read it"),
|
|
AssistantChatMessage(
|
|
content=None,
|
|
reasoning_content="Open the file first.",
|
|
tool_calls=[
|
|
AssistantFunctionToolCall(
|
|
id="call_1",
|
|
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
|
|
)
|
|
],
|
|
),
|
|
ToolChatMessage(content="file body", tool_call_id="call_1"),
|
|
]
|
|
_, items = chat_messages_to_responses_input(messages)
|
|
assert _input_item_types(items) == ["message", "reasoning", "function_call", "function_call_output"]
|
|
assert items[1] == {
|
|
"type": "reasoning",
|
|
"content": [{"type": "reasoning_text", "text": "Open the file first."}],
|
|
}
|
|
|
|
|
|
def test_parallel_call_turn_keeps_one_reasoning_item() -> None:
|
|
"""A batch keeps the round's single reasoning item, ahead of its calls."""
|
|
messages = [
|
|
AssistantChatMessage(
|
|
content=None,
|
|
reasoning_content="Both are independent.",
|
|
tool_calls=[
|
|
AssistantFunctionToolCall(
|
|
id="call_a",
|
|
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
|
|
),
|
|
AssistantFunctionToolCall(
|
|
id="call_b",
|
|
function=AssistantFunctionTool(name="regex_files", arguments='{"pattern":"todo"}'),
|
|
),
|
|
],
|
|
),
|
|
ToolChatMessage(content="file body", tool_call_id="call_a"),
|
|
ToolChatMessage(content="grep hit", tool_call_id="call_b"),
|
|
]
|
|
_, items = chat_messages_to_responses_input(messages)
|
|
assert _input_item_types(items) == [
|
|
"reasoning",
|
|
"function_call",
|
|
"function_call",
|
|
"function_call_output",
|
|
"function_call_output",
|
|
]
|
|
assert items[0] == {
|
|
"type": "reasoning",
|
|
"content": [{"type": "reasoning_text", "text": "Both are independent."}],
|
|
}
|
|
|
|
|
|
def test_round_with_text_and_a_call_puts_the_text_before_the_call() -> None:
|
|
"""A call round ends on its tool output, never on a trailing text message.
|
|
|
|
A request that ended with the round's text *after* its own output was
|
|
answered with a 400 ("the `reasoning_text` … must be passed back"): the
|
|
text has to stay with its reasoning, ahead of the calls.
|
|
"""
|
|
messages = [
|
|
UserChatMessage(content="read it"),
|
|
AssistantChatMessage(
|
|
content="Reading the file now.",
|
|
reasoning_content="Open the file first.",
|
|
tool_calls=[
|
|
AssistantFunctionToolCall(
|
|
id="call_1",
|
|
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
|
|
)
|
|
],
|
|
),
|
|
ToolChatMessage(content="file body", tool_call_id="call_1"),
|
|
]
|
|
_, items = chat_messages_to_responses_input(messages)
|
|
assert _input_item_types(items) == ["message", "reasoning", "message", "function_call", "function_call_output"]
|
|
|
|
|
|
def test_answer_round_without_reasoning_sends_a_blank_part() -> None:
|
|
"""A full part is required, not just the item.
|
|
|
|
An empty ``reasoning_text`` is answered with the same 400 as a missing item,
|
|
so a round that kept no chain-of-thought — the seed a compacted session
|
|
starts with, a round recorded before the reasoning was read back — sends a
|
|
blank line rather than words of the bridge's own. The rule is per round: an
|
|
answer that called nothing is checked too.
|
|
"""
|
|
messages = [
|
|
UserChatMessage(content="hi"),
|
|
AssistantChatMessage(content="hello"),
|
|
]
|
|
_, items = chat_messages_to_responses_input(messages)
|
|
assert _input_item_types(items) == ["message", "reasoning", "message"]
|
|
assert items[1] == {
|
|
"type": "reasoning",
|
|
"content": [{"type": "reasoning_text", "text": " "}],
|
|
}
|
|
|
|
|
|
def test_answer_turn_with_reasoning_carries_it() -> None:
|
|
"""A round that called nothing still hands its reasoning back.
|
|
|
|
Thinking Mode → Tool Calls: a request carrying ``tools`` must return the
|
|
reasoning of every round, "even if the round did not actually call a tool";
|
|
a missing one is a 400.
|
|
"""
|
|
messages = [
|
|
UserChatMessage(content="hi"),
|
|
AssistantChatMessage(content="hello", reasoning_content="They greet me, so I greet back."),
|
|
]
|
|
_, items = chat_messages_to_responses_input(messages)
|
|
assert _input_item_types(items) == ["message", "reasoning", "message"]
|
|
assert items[1] == {
|
|
"type": "reasoning",
|
|
"content": [{"type": "reasoning_text", "text": "They greet me, so I greet back."}],
|
|
}
|
|
|
|
|
|
def test_forged_call_round_sends_a_blank_part() -> None:
|
|
"""A synthetic nag assistant carries ``reasoning_content=""``.
|
|
|
|
The round still needs reasoning text, so a blank line goes back with it
|
|
rather than an empty part (which the API rejects just like a missing item).
|
|
"""
|
|
messages = [
|
|
AssistantChatMessage(
|
|
content=None,
|
|
reasoning_content="",
|
|
tool_calls=[
|
|
AssistantFunctionToolCall(
|
|
id="call_nag_1",
|
|
function=AssistantFunctionTool(name="todo_list", arguments="{}"),
|
|
)
|
|
],
|
|
),
|
|
ToolChatMessage(content="stack", tool_call_id="call_nag_1"),
|
|
]
|
|
_, items = chat_messages_to_responses_input(messages)
|
|
assert _input_item_types(items) == ["reasoning", "function_call", "function_call_output"]
|
|
assert items[0] == {
|
|
"type": "reasoning",
|
|
"content": [{"type": "reasoning_text", "text": " "}],
|
|
}
|
|
|
|
|
|
def test_rolled_back_call_round_sends_a_blank_part() -> None:
|
|
"""A kept call that recorded no reasoning still carries reasoning text.
|
|
|
|
History from before the chain-of-thought was read back, and a carrier
|
|
``rollback_tail`` wrote without one, hold a call with no reasoning at all;
|
|
the round must return text, so a blank line goes back with it (both a
|
|
missing item and an empty part are a 400).
|
|
"""
|
|
messages = [
|
|
AssistantChatMessage(
|
|
content=None,
|
|
tool_calls=[
|
|
AssistantFunctionToolCall(
|
|
id="call_kept",
|
|
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
|
|
)
|
|
],
|
|
),
|
|
ToolChatMessage(content="file body", tool_call_id="call_kept"),
|
|
]
|
|
_, items = chat_messages_to_responses_input(messages)
|
|
assert _input_item_types(items) == ["reasoning", "function_call", "function_call_output"]
|
|
assert items[0] == {
|
|
"type": "reasoning",
|
|
"content": [{"type": "reasoning_text", "text": " "}],
|
|
}
|
|
|
|
|
|
def test_response_to_assistant_with_tools() -> None:
|
|
raw = {
|
|
"id": "resp_1",
|
|
"object": "response",
|
|
"created_at": 1,
|
|
"model": "gpt-test",
|
|
"status": "completed",
|
|
"output": [
|
|
{
|
|
"id": "msg_1",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"status": "completed",
|
|
"content": [{"type": "output_text", "text": "done", "annotations": []}],
|
|
},
|
|
{
|
|
"type": "function_call",
|
|
"call_id": "call_9",
|
|
"name": "add",
|
|
"arguments": '{"a":1}',
|
|
"status": "completed",
|
|
},
|
|
],
|
|
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
|
}
|
|
response = msgspec.convert(raw, Response)
|
|
from msgspec import UNSET
|
|
|
|
assistant = response_to_assistant_message(response)
|
|
assert assistant.content == "done"
|
|
assert assistant.tool_calls is not UNSET
|
|
assert isinstance(assistant.tool_calls, list)
|
|
call0 = assistant.tool_calls[0]
|
|
assert isinstance(call0, AssistantFunctionToolCall)
|
|
assert call0.id == "call_9"
|
|
assert call0.function.name == "add"
|
|
completion = response_to_chat_completion(response)
|
|
assert completion.choices[0].message.content == "done"
|
|
assert isinstance(completion.usage, dict)
|
|
assert completion.usage["input_tokens"] == 10
|
|
assert completion.choices[0].finish_reason == "tool_calls"
|
|
|
|
|
|
def _reasoning_response(
|
|
*,
|
|
output_reasoning: bool = False,
|
|
top_level: dict[str, Any] | None = None,
|
|
) -> Response:
|
|
output: list[dict[str, Any]] = []
|
|
if output_reasoning:
|
|
output.append(
|
|
{
|
|
"id": "reason_1",
|
|
"type": "reasoning",
|
|
"summary": [{"type": "summary_text", "text": "openai-style reasoning"}],
|
|
}
|
|
)
|
|
return Response(
|
|
id="resp_r",
|
|
created_at=1,
|
|
model="deepseek-flash",
|
|
output=output,
|
|
reasoning=top_level,
|
|
)
|
|
|
|
|
|
def test_reasoning_summary_text_deepseek_top_level() -> None:
|
|
"""DeepSeek Responses returns reasoning in the top-level ``reasoning`` field."""
|
|
response = _reasoning_response(top_level={"content": [{"type": "reasoning_content", "text": "think step by step"}]})
|
|
assert reasoning_summary_text(response) == "think step by step"
|
|
assistant = response_to_assistant_message(response)
|
|
assert assistant.reasoning_content == "think step by step"
|
|
|
|
|
|
def test_reasoning_summary_text_prefers_top_level() -> None:
|
|
response = _reasoning_response(
|
|
output_reasoning=True,
|
|
top_level={"summary": [{"type": "summary_text", "text": "top-level reasoning"}]},
|
|
)
|
|
assert reasoning_summary_text(response) == "top-level reasoning"
|
|
|
|
|
|
def test_reasoning_summary_text_falls_back_to_output_items() -> None:
|
|
response = _reasoning_response(output_reasoning=True)
|
|
assert reasoning_summary_text(response) == "openai-style reasoning"
|
|
|
|
|
|
def test_reasoning_summary_text_reads_output_item_content() -> None:
|
|
"""DeepSeek's reasoning item carries the chain-of-thought in ``content``.
|
|
|
|
The parts are ``reasoning_text`` and ``summary`` comes back empty (see the
|
|
API reference sample), so reading ``summary`` alone finds nothing — and a
|
|
tool-call turn then goes back without the reasoning thinking mode wants.
|
|
"""
|
|
body = {
|
|
"id": "resp_c",
|
|
"object": "response",
|
|
"created_at": 1,
|
|
"model": "deepseek-flash",
|
|
"status": "completed",
|
|
"output": [
|
|
{
|
|
"type": "reasoning",
|
|
"id": "rs_1",
|
|
"status": "completed",
|
|
"content": [{"type": "reasoning_text", "text": "The user greets me."}],
|
|
"summary": [],
|
|
},
|
|
{
|
|
"type": "function_call",
|
|
"call_id": "call_1",
|
|
"name": "read_file",
|
|
"arguments": '{"path":"a"}',
|
|
"status": "completed",
|
|
},
|
|
],
|
|
}
|
|
response = msgspec.convert(body, Response)
|
|
assert reasoning_summary_text(response) == "The user greets me."
|
|
assert response_to_assistant_message(response).reasoning_content == "The user greets me."
|
|
|
|
|
|
def test_reasoning_summary_text_empty_when_absent() -> None:
|
|
assert reasoning_summary_text(_reasoning_response()) == ""
|
|
|
|
|
|
def test_responses_status_to_finish_reason_incomplete() -> None:
|
|
body = {
|
|
"id": "resp_i",
|
|
"object": "response",
|
|
"created_at": 1,
|
|
"model": "gpt-test",
|
|
"status": "incomplete",
|
|
"incomplete_details": {"reason": "max_output_tokens"},
|
|
"output": [
|
|
{
|
|
"id": "msg_1",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"status": "incomplete",
|
|
"content": [{"type": "output_text", "text": "partial", "annotations": []}],
|
|
}
|
|
],
|
|
}
|
|
resp = msgspec.convert(body, Response)
|
|
assert responses_status_to_finish_reason(resp, has_tool_calls=False) == "length"
|
|
completion = response_to_chat_completion(resp)
|
|
assert completion.choices[0].finish_reason == "length"
|
|
|
|
|
|
def test_chat_param_to_responses_kwargs() -> None:
|
|
param = ChatCompletionsParam(
|
|
model="gpt-test",
|
|
messages=[SystemChatMessage(content="sys"), UserChatMessage(content="hi")],
|
|
tools=[ToolFunctionItem(function=ToolFunction(name="t", parameters={"type": "object"}))],
|
|
temperature=0.2,
|
|
)
|
|
kwargs = chat_param_to_responses_kwargs(param)
|
|
assert kwargs["model"] == "gpt-test"
|
|
assert kwargs["instructions"] == "sys"
|
|
assert kwargs["store"] is False
|
|
assert kwargs["temperature"] == 0.2
|
|
assert len(kwargs["tools"]) == 1
|
|
# No effort configured → no reasoning block.
|
|
assert "reasoning" not in kwargs
|
|
|
|
|
|
def test_chat_param_to_responses_kwargs_reasoning_effort() -> None:
|
|
param = ChatCompletionsParam(
|
|
model="gpt-test",
|
|
messages=[UserChatMessage(content="hi")],
|
|
reasoning_effort="xhigh",
|
|
)
|
|
kwargs = chat_param_to_responses_kwargs(param)
|
|
assert kwargs["reasoning"].effort == "xhigh"
|
|
|
|
|
|
def test_provider_tools_merged_after_local_functions() -> None:
|
|
param = ChatCompletionsParam(
|
|
model="gpt-test",
|
|
messages=[UserChatMessage(content="hi")],
|
|
tools=[ToolFunctionItem(function=ToolFunction(name="local_tool", parameters={"type": "object"}))],
|
|
)
|
|
kwargs = chat_param_to_responses_kwargs(
|
|
param,
|
|
provider_tools=[{"type": "web_search"}, {"type": "file_search", "vector_store_ids": ["vs_1"]}],
|
|
)
|
|
tools = kwargs["tools"]
|
|
assert len(tools) == 3
|
|
assert tools[0].name == "local_tool"
|
|
assert tools[1]["type"] == "web_search"
|
|
assert tools[2]["vector_store_ids"] == ["vs_1"]
|