mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
ec5340bdce
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.
459 lines
17 KiB
Python
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
|