Files
plyngent/tests/test_agent/test_responses_dispatch.py
T
NCBM ec5340bdce config: rename the DeepSeek flash model to deepseek-flash
DeepSeek dropped the `v4` prefix from its flash model, and the old id stays
valid only as a compatibility alias. Seed the default catalog with the current
name so a provider that omits `models` no longer offers a deprecated id, and
follow the rename in the wire fixtures and the minimal config template. An
explicit `deepseek-v4-flash` in TOML still passes through unchanged.
2026-10-06 21:22:35 +08:00

459 lines
17 KiB
Python

"""Tests for kind-based OpenAI Responses dispatch in the agent loop."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, cast, overload
import pytest
from msgspec import UNSET
from plyngent.agent import (
AssistantMessageEvent,
TextDeltaEvent,
ToolCallEvent,
ToolRegistry,
ToolResultEvent,
UsageEvent,
run_chat_loop,
tool,
)
from plyngent.agent.responses_bridge import chat_param_to_responses_kwargs
from plyngent.agent.responses_dispatch import dispatch_responses
from plyngent.lmproto.openai.model import Response, ResponsesCreateParam, ResponseStreamEvent
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
ChatCompletionsParam,
SystemChatMessage,
ToolFunction,
ToolFunctionItem,
UserChatMessage,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Sequence
def _completed_response(
*,
text: str = "hello",
tool_calls: list[dict[str, Any]] | None = None,
usage: dict[str, Any] | None = None,
status: str = "completed",
) -> Response:
output: list[dict[str, Any]] = []
if text:
output.append(
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": text, "annotations": []}],
}
)
if tool_calls:
output.extend(tool_calls)
return Response(
id="resp_test",
created_at=1,
model="gpt-test",
status=cast("Any", status),
output=output,
usage=usage if usage is not None else {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
)
class ScriptedResponsesClient:
"""Fake OpenAIClient that scripts ``responses()`` results."""
kind: str = "responses"
calls: list[ResponsesCreateParam]
_non_stream: list[Response]
_stream_events: list[list[ResponseStreamEvent]]
def __init__(
self,
*,
non_stream: Sequence[Response] | None = None,
stream_events: Sequence[Sequence[ResponseStreamEvent]] | None = None,
) -> None:
self.calls = []
self._non_stream = list(non_stream or ())
self._stream_events = [list(events) for events in (stream_events or ())]
@overload
async def responses(self, param: ResponsesCreateParam, *, stream: Literal[False] = False) -> Response: ...
@overload
async def responses(
self, param: ResponsesCreateParam, *, stream: Literal[True]
) -> AsyncIterator[ResponseStreamEvent]: ...
async def responses(
self, param: ResponsesCreateParam, *, stream: bool = False
) -> Response | AsyncIterator[ResponseStreamEvent]:
self.calls.append(param)
if stream:
if not self._stream_events:
msg = "no more scripted stream events"
raise RuntimeError(msg)
events = self._stream_events.pop(0)
async def _gen() -> AsyncIterator[ResponseStreamEvent]:
for event in events:
yield event
return _gen()
if not self._non_stream:
msg = "no more scripted responses"
raise RuntimeError(msg)
return self._non_stream.pop(0)
async def test_dispatch_responses_non_stream_text() -> None:
client = ScriptedResponsesClient(non_stream=[_completed_response(text="hi there")])
param = ChatCompletionsParam(
model="gpt-test",
messages=[UserChatMessage(content="hello")],
)
result = await dispatch_responses(cast("Any", client), param, stream=False)
assert result.choices[0].message.content == "hi there"
assert result.choices[0].finish_reason == "stop"
assert len(client.calls) == 1
assert client.calls[0].store is False
async def test_dispatch_responses_merges_provider_tools() -> None:
client = ScriptedResponsesClient(non_stream=[_completed_response()])
param = ChatCompletionsParam(
model="gpt-test",
messages=[UserChatMessage(content="search")],
tools=[
ToolFunctionItem(
function=ToolFunction(
name="read_file",
description="Read a file",
parameters={"type": "object", "properties": {}},
)
)
],
)
_ = await dispatch_responses(
cast("Any", client),
param,
provider_tools=[{"type": "web_search"}],
stream=False,
)
create = client.calls[0]
assert create.tools is not UNSET
tools = cast("list[Any]", create.tools)
assert len(tools) == 2
# Local function tool first, hosted tool second.
names_or_types: list[str] = []
for item in tools:
if isinstance(item, dict):
names_or_types.append(str(item.get("type", "")))
else:
names_or_types.append(getattr(item, "name", ""))
assert "read_file" in names_or_types
assert "web_search" in names_or_types
async def test_dispatch_responses_stream_text_and_usage() -> None:
final = _completed_response(text="streamed")
# Final response body is carried on the completed event as a dict.
import msgspec
final_dict = msgspec.to_builtins(final)
events = [
ResponseStreamEvent(type="response.output_text.delta", delta="stream"),
ResponseStreamEvent(type="response.output_text.delta", delta="ed"),
ResponseStreamEvent(type="response.completed", response=final_dict),
]
client = ScriptedResponsesClient(stream_events=[events])
param = ChatCompletionsParam(
model="gpt-test",
messages=[UserChatMessage(content="hi")],
)
stream = await dispatch_responses(cast("Any", client), param, stream=True)
chunks = [chunk async for chunk in cast("Any", stream)]
texts = [c.choices[0].delta.content for c in chunks if c.choices and isinstance(c.choices[0].delta.content, str)]
assert texts == ["stream", "ed"]
assert any(c.choices and c.choices[0].finish_reason == "stop" for c in chunks)
assert any(token_usage_present(c) for c in chunks)
async def _streamed_reasoning(
events: list[ResponseStreamEvent],
) -> str:
"""Run one Responses stream and join streamed ``reasoning_content`` deltas.
Mirrors the agent loop: ``.done`` (``reasoning_full``) replaces accumulated
fragments instead of appending.
"""
import msgspec
final = _completed_response(text="ok")
final.reasoning = {"content": [{"type": "reasoning_content", "text": "terminal reasoning"}]}
events.append(ResponseStreamEvent(type="response.completed", response=msgspec.to_builtins(final)))
client = ScriptedResponsesClient(stream_events=[events])
param = ChatCompletionsParam(model="gpt-test", messages=[UserChatMessage(content="hi")])
stream = await dispatch_responses(cast("Any", client), param, stream=True)
chunks = [chunk async for chunk in cast("Any", stream)]
parts: list[str] = []
for chunk in chunks:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
if delta.reasoning_full is True:
parts = [delta.reasoning_content]
else:
parts.append(delta.reasoning_content)
return "".join(parts)
async def test_dispatch_responses_stream_reasoning_text_done() -> None:
"""DeepSeek ``response.reasoning_text.done`` carries the full reasoning text."""
reasoning = await _streamed_reasoning(
[ResponseStreamEvent(type="response.reasoning_text.done", delta="full chain of thought")]
)
assert reasoning == "full chain of thought"
async def test_dispatch_responses_stream_reasoning_deltas() -> None:
reasoning = await _streamed_reasoning(
[
ResponseStreamEvent(type="response.reasoning_text.delta", delta="step one "),
ResponseStreamEvent(type="response.reasoning_text.delta", delta="step two"),
]
)
assert reasoning == "step one step two"
async def test_dispatch_responses_stream_reasoning_full_replaces_fragments() -> None:
"""A ``.done`` event with the full CoT must replace streamed fragments, not
append — otherwise the chain-of-thought is concatenated twice."""
reasoning = await _streamed_reasoning(
[
ResponseStreamEvent(type="response.reasoning_text.delta", delta="partial "),
ResponseStreamEvent(type="response.reasoning_text.delta", delta="thinking"),
ResponseStreamEvent(type="response.reasoning_text.done", delta="full chain of thought"),
]
)
assert reasoning == "full chain of thought"
async def test_dispatch_responses_stream_terminal_only_reasoning() -> None:
"""DeepSeek sometimes delivers reasoning only on the terminal response."""
reasoning = await _streamed_reasoning([])
assert reasoning == "terminal reasoning"
async def test_dispatch_responses_stream_reasoning_no_duplicate() -> None:
"""Streamed deltas win; the terminal reasoning is not re-emitted."""
reasoning = await _streamed_reasoning(
[ResponseStreamEvent(type="response.reasoning_text.delta", delta="streamed only")]
)
assert reasoning == "streamed only"
def token_usage_present(chunk: object) -> bool:
usage = getattr(chunk, "usage", UNSET)
return usage is not UNSET and usage is not None
async def test_run_chat_loop_responses_kind_text() -> None:
client = ScriptedResponsesClient(non_stream=[_completed_response(text="done")])
messages: list[Any] = [UserChatMessage(content="go")]
events = [
event
async for event in run_chat_loop(
cast("Any", client),
messages,
model="gpt-test",
stream=False,
)
]
assert any(isinstance(e, TextDeltaEvent) and e.content == "done" for e in events)
assert any(isinstance(e, AssistantMessageEvent) for e in events)
assert any(isinstance(e, UsageEvent) for e in events)
assert isinstance(messages[-1], AssistantChatMessage)
assert messages[-1].content == "done"
async def test_run_chat_loop_responses_kind_tool_round() -> None:
tool_call_item = {
"type": "function_call",
"call_id": "call_1",
"name": "add",
"arguments": '{"a": 1, "b": 2}',
"status": "completed",
}
first = _completed_response(text="", tool_calls=[tool_call_item])
second = _completed_response(text="sum is 3")
client = ScriptedResponsesClient(non_stream=[first, second])
@tool(register=False)
def add(a: int, b: int) -> str:
return str(a + b)
registry = ToolRegistry([add])
messages: list[Any] = [UserChatMessage(content="add 1 and 2")]
events = [
event
async for event in run_chat_loop(
cast("Any", client),
messages,
model="gpt-test",
tools=registry,
stream=False,
)
]
assert any(isinstance(e, ToolCallEvent) for e in events)
assert any(isinstance(e, ToolResultEvent) and "3" in e.message.content for e in events)
assert any(isinstance(e, AssistantMessageEvent) and e.message.content == "sum is 3" for e in events)
assert len(client.calls) == 2
# Second turn must include the function_call_output from the tool result.
from plyngent.lmproto.openai.model import ResponseFunctionToolCallOutput
second_input = client.calls[1].input
assert isinstance(second_input, list)
assert any(
isinstance(item, ResponseFunctionToolCallOutput)
or (isinstance(item, dict) and item.get("type") == "function_call_output")
for item in second_input
)
async def test_run_chat_loop_responses_provider_tools_passed() -> None:
client = ScriptedResponsesClient(non_stream=[_completed_response(text="ok")])
messages: list[Any] = [UserChatMessage(content="hi")]
_ = [
event
async for event in run_chat_loop(
cast("Any", client),
messages,
model="gpt-test",
stream=False,
provider_tools=[{"type": "web_search"}],
)
]
create = client.calls[0]
assert create.tools is not UNSET
tools = cast("list[Any]", create.tools)
assert any(isinstance(t, dict) and t.get("type") == "web_search" for t in tools)
async def test_dispatch_stream_missing_completed_yields_no_terminal() -> None:
"""Stream without response.completed produces only early deltas (if any)."""
events = [
ResponseStreamEvent(type="response.output_text.delta", delta="partial"),
# no completed event
]
client = ScriptedResponsesClient(stream_events=[events])
param = ChatCompletionsParam(model="gpt-test", messages=[UserChatMessage(content="x")])
stream = await dispatch_responses(cast("Any", client), param, stream=True)
chunks = [chunk async for chunk in cast("Any", stream)]
assert len(chunks) == 1 # only the text delta
assert chunks[0].choices[0].delta.content == "partial"
assert chunks[0].choices[0].finish_reason is None or chunks[0].choices[0].finish_reason is UNSET
def test_chat_param_to_responses_kwargs_system_and_tools() -> None:
param = ChatCompletionsParam(
model="gpt-test",
messages=[
SystemChatMessage(content="Be brief."),
UserChatMessage(content="hi"),
],
tools=[ToolFunctionItem(function=ToolFunction(name="f", description="d", parameters={"type": "object"}))],
)
kwargs = chat_param_to_responses_kwargs(param, provider_tools=[{"type": "web_search"}])
assert kwargs["instructions"] == "Be brief."
assert kwargs["store"] is False
assert len(kwargs["tools"]) == 2
async def test_unknown_kind_not_implemented() -> None:
class FakeOther:
kind = "weird"
with pytest.raises(NotImplementedError, match="weird"):
async for _ in run_chat_loop(
cast("Any", FakeOther()),
[UserChatMessage(content="hi")],
model="x",
stream=False,
):
pass
async def test_dispatch_stream_raises_on_error_event() -> None:
import pytest
events = [
ResponseStreamEvent(type="error", code="server_error", message="boom"),
]
client = ScriptedResponsesClient(stream_events=[events])
param = ChatCompletionsParam(model="gpt-test", messages=[UserChatMessage(content="x")])
stream = await dispatch_responses(cast("Any", client), param, stream=True)
with pytest.raises(RuntimeError, match="boom"):
async for _ in cast("Any", stream):
pass
async def test_dispatch_stream_incomplete_maps_to_length() -> None:
"""response.incomplete carries the full response; finish becomes ``length``."""
import msgspec
final = _completed_response(text="cut short", status="incomplete")
final_dict = msgspec.to_builtins(final)
events = [
ResponseStreamEvent(type="response.output_text.delta", delta="cut "),
ResponseStreamEvent(type="response.incomplete", response=final_dict),
]
client = ScriptedResponsesClient(stream_events=[events])
param = ChatCompletionsParam(model="gpt-test", messages=[UserChatMessage(content="x")])
stream = await dispatch_responses(cast("Any", client), param, stream=True)
chunks = [chunk async for chunk in cast("Any", stream)]
assert any(c.choices and c.choices[0].finish_reason == "length" for c in chunks)
async def test_dispatch_stream_failed_raises_with_error_detail() -> None:
"""response.failed surfaces the embedded error instead of a missing terminal."""
events = [
ResponseStreamEvent(
type="response.failed",
response={
"id": "resp_1",
"object": "response",
"created_at": 1,
"model": "deepseek-flash",
"status": "failed",
"output": [],
"error": {"code": "rate_limit_exceeded", "message": "slow down"},
},
),
]
client = ScriptedResponsesClient(stream_events=[events])
param = ChatCompletionsParam(model="gpt-test", messages=[UserChatMessage(content="x")])
stream = await dispatch_responses(cast("Any", client), param, stream=True)
with pytest.raises(RuntimeError, match="slow down"):
async for _ in cast("Any", stream):
pass
async def test_dispatch_stream_failed_falls_back_to_event_message() -> None:
"""response.failed without an embedded error uses the event message."""
events = [
ResponseStreamEvent(type="response.failed", message="backend error", code="server_error"),
]
client = ScriptedResponsesClient(stream_events=[events])
param = ChatCompletionsParam(model="gpt-test", messages=[UserChatMessage(content="x")])
stream = await dispatch_responses(cast("Any", client), param, stream=True)
with pytest.raises(RuntimeError, match="backend error"):
async for _ in cast("Any", stream):
pass