refactor(backend): 启动环境初始化与资源项目化(零跨项目依赖)
- main.py:uv run main.py 一键启动——读取 backend/.env、TORCH_HOME/NLTK_DATA 指向项目内缓存、certifi SSL + NO_PROXY 网络直连、默认开启 s2s,启动横幅 - vendor/:补丁后 s2s 云化栈移入项目(backend/vendor/s2s-cloud),pyproject sources 改相对路径 - .gitignore:忽略 .torch-cache / nltk_data 缓存 - AGENTS.md:进程管理铁律(AI 不启动/重启服务,由用户操作)
This commit is contained in:
@@ -0,0 +1,594 @@
|
||||
"""Unit tests for the chat-completions LLM backend.
|
||||
|
||||
These run without a GPU or a live server: the OpenAI client is faked at the
|
||||
module level, so the streaming/non-streaming parse logic and the format
|
||||
converters are exercised purely in-process.
|
||||
|
||||
Run with pytest, or standalone: python tests/test_chat_completions_backend.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
|
||||
from openai.types.realtime.conversation_item import (
|
||||
RealtimeConversationItemFunctionCall,
|
||||
RealtimeConversationItemFunctionCallOutput,
|
||||
RealtimeConversationItemUserMessage,
|
||||
)
|
||||
from openai.types.realtime.realtime_conversation_item_user_message import Content as UserContent
|
||||
from openai.types.realtime.realtime_response_create_params import RealtimeResponseCreateParams
|
||||
from openai.types.realtime.realtime_session_create_request import RealtimeSessionCreateRequest
|
||||
from openai.types.responses import ResponseFunctionToolCall
|
||||
|
||||
import speech_to_speech.LLM.base_openai_compatible_language_model as base_mod
|
||||
import speech_to_speech.LLM.chat_completions_language_model as ccm
|
||||
from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
|
||||
from speech_to_speech.LLM.chat import Chat, make_user_message
|
||||
from speech_to_speech.LLM.chat_completions_language_model import (
|
||||
ChatCompletionsApiModelHandler,
|
||||
_to_chat_tool_choice,
|
||||
_to_chat_tools,
|
||||
)
|
||||
from speech_to_speech.pipeline.messages import (
|
||||
EndOfResponse,
|
||||
GenerateResponseRequest,
|
||||
LLMResponseChunk,
|
||||
TokenUsage,
|
||||
)
|
||||
|
||||
# ── Fakes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
"""Iterable stand-in for openai.Stream; yields preset chunks."""
|
||||
|
||||
def __init__(self, chunks):
|
||||
self._chunks = chunks
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._chunks)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
# Make the handler's ``isinstance(resp, Stream)`` check recognise our fake as a
|
||||
# stream. Non-streaming fakes stay plain SimpleNamespace, so they still take the
|
||||
# non-stream branch.
|
||||
ccm.Stream = _FakeStream
|
||||
|
||||
|
||||
class _FakeCompletions:
|
||||
def __init__(self):
|
||||
self.next_result = SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content="ok", tool_calls=[]))],
|
||||
usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1),
|
||||
)
|
||||
self.last_kwargs = None
|
||||
|
||||
def create(self, **kwargs):
|
||||
self.last_kwargs = kwargs
|
||||
return self.next_result
|
||||
|
||||
|
||||
class _FakeChat:
|
||||
def __init__(self):
|
||||
self.completions = _FakeCompletions()
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *a, **k):
|
||||
self.chat = _FakeChat()
|
||||
self.last_options = None
|
||||
|
||||
def with_options(self, **kwargs):
|
||||
self.last_options = kwargs
|
||||
return self
|
||||
|
||||
|
||||
def _make_handler(stream=True):
|
||||
"""Build a handler whose warmup hits the fake client (no network)."""
|
||||
orig_openai = base_mod.OpenAI
|
||||
base_mod.OpenAI = _FakeClient
|
||||
try:
|
||||
h = ChatCompletionsApiModelHandler(
|
||||
threading.Event(),
|
||||
queue.Queue(),
|
||||
queue.Queue(),
|
||||
setup_kwargs=dict(
|
||||
model_name="test-model",
|
||||
base_url="http://fake/v1",
|
||||
api_key="k",
|
||||
stream=stream,
|
||||
disable_thinking=True,
|
||||
compact_history=False,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
base_mod.OpenAI = orig_openai
|
||||
return h
|
||||
|
||||
|
||||
def test_warmup_uses_request_scoped_sdk_retries():
|
||||
handler = _make_handler()
|
||||
|
||||
assert handler.client.last_options == {"max_retries": base_mod.WARMUP_MAX_RETRIES}
|
||||
|
||||
|
||||
def _chunk(content=None, tool_calls=None, usage=None):
|
||||
choices = []
|
||||
if content is not None or tool_calls is not None:
|
||||
choices = [SimpleNamespace(delta=SimpleNamespace(content=content, tool_calls=tool_calls), finish_reason=None)]
|
||||
return SimpleNamespace(choices=choices, usage=usage)
|
||||
|
||||
|
||||
def _tc_delta(index, id=None, name=None, arguments=None):
|
||||
return SimpleNamespace(index=index, id=id, function=SimpleNamespace(name=name, arguments=arguments))
|
||||
|
||||
|
||||
def _drive(
|
||||
handler,
|
||||
*,
|
||||
tools=None,
|
||||
tool_choice=None,
|
||||
user="Hallo",
|
||||
chat=None,
|
||||
response=None,
|
||||
instructions="Du bist ein Roboter.",
|
||||
):
|
||||
chat = chat or Chat(10)
|
||||
if user:
|
||||
chat.add_item(make_user_message(user))
|
||||
session = RealtimeSessionCreateRequest(type="realtime", instructions=instructions)
|
||||
if tools is not None:
|
||||
session.tools = tools
|
||||
if tool_choice is not None:
|
||||
session.tool_choice = tool_choice
|
||||
rc = RuntimeConfig(chat=chat, session=session)
|
||||
req = GenerateResponseRequest(
|
||||
runtime_config=rc, response=response, language_code="de", turn_id="t", turn_revision=0
|
||||
)
|
||||
text, tools_out, usage, end = "", [], None, None
|
||||
for out in handler.process(req):
|
||||
if isinstance(out, LLMResponseChunk):
|
||||
text += out.text
|
||||
tools_out += list(out.tools)
|
||||
elif isinstance(out, TokenUsage):
|
||||
usage = (out.input_tokens, out.output_tokens)
|
||||
elif isinstance(out, EndOfResponse):
|
||||
end = out
|
||||
return text, tools_out, usage, chat, end
|
||||
|
||||
|
||||
# ── Converter tests ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_to_chat_tools_flat_to_nested():
|
||||
out = _to_chat_tools([{"type": "function", "name": "f", "description": "d", "parameters": {"type": "object"}}])
|
||||
assert out == [
|
||||
{"type": "function", "function": {"name": "f", "description": "d", "parameters": {"type": "object"}}}
|
||||
]
|
||||
|
||||
|
||||
def test_to_chat_tools_passthrough_and_none():
|
||||
nested = [{"type": "function", "function": {"name": "f"}}]
|
||||
assert _to_chat_tools(nested) == nested
|
||||
assert _to_chat_tools(None) is None
|
||||
assert _to_chat_tools([]) is None
|
||||
|
||||
|
||||
def test_to_chat_tool_choice():
|
||||
assert _to_chat_tool_choice("auto") == "auto"
|
||||
assert _to_chat_tool_choice("required") == "required"
|
||||
assert _to_chat_tool_choice({"type": "function", "name": "f"}) == {"type": "function", "function": {"name": "f"}}
|
||||
|
||||
|
||||
def test_build_extra_body_variants():
|
||||
f = ChatCompletionsApiModelHandler._build_extra_body
|
||||
assert f("http://x/v1", True, None) == {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
assert f("http://x/v1", True, "none") == {"reasoning_effort": "none"} # explicit effort wins
|
||||
assert f("https://api.openai.com/v1", True, "none") is None # official OpenAI: no extra_body
|
||||
assert f("https://api.openai.com/v1/", True, "none") is None # trailing slash still official
|
||||
assert f("http://x/v1", True, "") == {"chat_template_kwargs": {"enable_thinking": False}} # empty effort ignored
|
||||
assert f("http://x/v1", False, None) is None
|
||||
assert f(None, True, None) is None
|
||||
|
||||
|
||||
def test_chat_messages_encodes_tool_arguments_as_string():
|
||||
"""to_transformers_chat emits arguments as a dict; the chat API needs a string."""
|
||||
chat = Chat(10)
|
||||
chat.add_item(make_user_message("Kopf links"))
|
||||
chat.add_item(
|
||||
RealtimeConversationItemFunctionCall(
|
||||
type="function_call", name="move_head", arguments='{"direction": "left"}', call_id="call_1", id="fc_1"
|
||||
)
|
||||
)
|
||||
chat.add_item(
|
||||
RealtimeConversationItemFunctionCallOutput(type="function_call_output", call_id="call_1", output="ok")
|
||||
)
|
||||
messages = ChatCompletionsApiModelHandler._chat_messages(chat)
|
||||
tool_call_msgs = [m for m in messages if m.get("tool_calls")]
|
||||
assert tool_call_msgs, "expected an assistant message carrying tool_calls"
|
||||
args = tool_call_msgs[0]["tool_calls"][0]["function"]["arguments"]
|
||||
assert isinstance(args, str), f"arguments must be a JSON string, got {type(args)}"
|
||||
assert json.loads(args) == {"direction": "left"}
|
||||
|
||||
|
||||
def test_chat_messages_strips_tool_output_name():
|
||||
"""to_transformers_chat adds a tool name for HF templates; Chat Completions
|
||||
tool messages only accept role/tool_call_id/content."""
|
||||
chat = Chat(10)
|
||||
chat.add_item(make_user_message("Search for x"))
|
||||
chat.add_item(
|
||||
RealtimeConversationItemFunctionCall(
|
||||
type="function_call",
|
||||
name="search",
|
||||
arguments='{"q": "x"}',
|
||||
call_id="call_1",
|
||||
id="fc_1",
|
||||
status="completed",
|
||||
)
|
||||
)
|
||||
chat.add_item(
|
||||
RealtimeConversationItemFunctionCallOutput(type="function_call_output", call_id="call_1", output="found")
|
||||
)
|
||||
|
||||
messages = ChatCompletionsApiModelHandler._chat_messages(chat)
|
||||
tool_message = [m for m in messages if m.get("role") == "tool"][0]
|
||||
assert tool_message == {"role": "tool", "tool_call_id": "call_1", "content": "found"}
|
||||
|
||||
|
||||
def test_chat_messages_converts_image_and_text_parts_to_chat_shape():
|
||||
"""to_transformers_chat emits Realtime-shaped parts (input_text / input_image
|
||||
with a bare-string image_url); the Chat Completions API needs text / image_url
|
||||
with a nested object."""
|
||||
chat = Chat(10)
|
||||
chat.add_item(
|
||||
RealtimeConversationItemUserMessage(
|
||||
type="message",
|
||||
role="user",
|
||||
content=[
|
||||
UserContent(type="input_text", text="What is this?"),
|
||||
UserContent(type="input_image", image_url="https://example.com/img.png", detail="auto"),
|
||||
],
|
||||
)
|
||||
)
|
||||
messages = ChatCompletionsApiModelHandler._chat_messages(chat)
|
||||
user = [m for m in messages if m.get("role") == "user"][0]
|
||||
assert isinstance(user["content"], list)
|
||||
parts = {p["type"]: p for p in user["content"]}
|
||||
assert parts["text"]["text"] == "What is this?"
|
||||
assert parts["image_url"]["image_url"] == {"url": "https://example.com/img.png", "detail": "auto"}
|
||||
# No Realtime-shaped parts leak through.
|
||||
assert all(p["type"] not in ("input_text", "input_image") for p in user["content"])
|
||||
|
||||
|
||||
# ── Streaming / non-streaming parse tests ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_streaming_text_and_usage():
|
||||
h = _make_handler(stream=True)
|
||||
h.client.chat.completions.create = lambda **k: _FakeStream(
|
||||
[
|
||||
_chunk(content="Hallo. "),
|
||||
_chunk(content="Wie geht es dir?"),
|
||||
_chunk(usage=SimpleNamespace(prompt_tokens=12, completion_tokens=5)),
|
||||
]
|
||||
)
|
||||
text, tools, usage, chat, _end = _drive(h)
|
||||
assert "Hallo" in text and "Wie geht es dir" in text
|
||||
assert usage == (12, 5)
|
||||
assert tools == []
|
||||
# assistant text was stored back into the conversation history
|
||||
assert any(getattr(i, "role", None) == "assistant" for i in chat.buffer)
|
||||
|
||||
|
||||
def test_streaming_tool_call_accumulates_arguments():
|
||||
h = _make_handler(stream=True)
|
||||
# Arguments arrive split across deltas, as real servers stream them.
|
||||
h.client.chat.completions.create = lambda **k: _FakeStream(
|
||||
[
|
||||
_chunk(tool_calls=[_tc_delta(0, id="srv_1", name="move_head", arguments='{"direction"')]),
|
||||
_chunk(tool_calls=[_tc_delta(0, arguments=': "left"}')]),
|
||||
_chunk(usage=SimpleNamespace(prompt_tokens=20, completion_tokens=8)),
|
||||
]
|
||||
)
|
||||
text, tools, usage, chat, _end = _drive(
|
||||
h,
|
||||
tools=[{"type": "function", "name": "move_head", "parameters": {"type": "object"}}],
|
||||
tool_choice="required",
|
||||
)
|
||||
assert len(tools) == 1
|
||||
tc = tools[0]
|
||||
assert isinstance(tc, ResponseFunctionToolCall)
|
||||
assert tc.name == "move_head"
|
||||
assert json.loads(tc.arguments) == {"direction": "left"} # reassembled from two deltas
|
||||
assert usage == (20, 8)
|
||||
# the function_call was stored in history with a freshly minted call_id
|
||||
assert chat._pending_tool_calls, "tool call should be recorded in chat history"
|
||||
|
||||
|
||||
def test_tool_call_recorded_before_chunk_is_emitted():
|
||||
"""Regression: a fast client can return function_call_output before the
|
||||
deferred end-of-turn write-back runs. The call must already be in history
|
||||
the instant its chunk is yielded, otherwise the output is rejected with
|
||||
'No function_call with call_id ... found' and the model re-issues the call."""
|
||||
h = _make_handler(stream=True)
|
||||
h.client.chat.completions.create = lambda **k: _FakeStream(
|
||||
[
|
||||
_chunk(content="Sure."),
|
||||
_chunk(tool_calls=[_tc_delta(0, id="srv_1", name="camera_snapshot", arguments="{}")]),
|
||||
_chunk(usage=SimpleNamespace(prompt_tokens=5, completion_tokens=2)),
|
||||
]
|
||||
)
|
||||
chat = Chat(10)
|
||||
chat.add_item(make_user_message("take a photo"))
|
||||
session = RealtimeSessionCreateRequest(type="realtime", instructions="Du bist ein Roboter.")
|
||||
session.tools = [{"type": "function", "name": "camera_snapshot", "parameters": {"type": "object"}}]
|
||||
rc = RuntimeConfig(chat=chat, session=session)
|
||||
req = GenerateResponseRequest(runtime_config=rc, language_code="de", turn_id="t", turn_revision=0)
|
||||
|
||||
emitted_call_id = None
|
||||
for out in h.process(req):
|
||||
if isinstance(out, LLMResponseChunk) and out.tools:
|
||||
emitted_call_id = out.tools[0].call_id
|
||||
# At the moment the client receives the call, it must exist in history.
|
||||
assert emitted_call_id in chat._pending_tool_calls, (
|
||||
"function_call must be recorded BEFORE its chunk is forwarded to the client"
|
||||
)
|
||||
# A fast client returning the output here must pair cleanly (no raise).
|
||||
chat.add_item(
|
||||
RealtimeConversationItemFunctionCallOutput(
|
||||
type="function_call_output", call_id=emitted_call_id, output="ok"
|
||||
)
|
||||
)
|
||||
assert emitted_call_id is not None, "a tool call should have been emitted"
|
||||
assert chat._has_call_id_in_buffer(emitted_call_id), "call+output should be paired in the buffer"
|
||||
|
||||
|
||||
def test_non_streaming_tool_call():
|
||||
h = _make_handler(stream=False)
|
||||
h.client.chat.completions.create = lambda **k: SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(
|
||||
content="",
|
||||
tool_calls=[
|
||||
SimpleNamespace(
|
||||
id="srv_9",
|
||||
function=SimpleNamespace(name="move_head", arguments='{"direction": "right"}'),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
],
|
||||
usage=SimpleNamespace(prompt_tokens=7, completion_tokens=3),
|
||||
)
|
||||
text, tools, usage, chat, _end = _drive(
|
||||
h,
|
||||
tools=[{"type": "function", "name": "move_head", "parameters": {"type": "object"}}],
|
||||
tool_choice="required",
|
||||
)
|
||||
assert len(tools) == 1 and tools[0].name == "move_head"
|
||||
assert json.loads(tools[0].arguments) == {"direction": "right"}
|
||||
assert usage == (7, 3)
|
||||
|
||||
|
||||
def test_streaming_refusal_is_spoken_and_stored():
|
||||
"""A refusal streams as delta.refusal (content None); it must be surfaced as
|
||||
assistant text and written to history, not silently dropped."""
|
||||
h = _make_handler(stream=True)
|
||||
h.client.chat.completions.create = lambda **k: _FakeStream(
|
||||
[
|
||||
SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
delta=SimpleNamespace(content=None, refusal="I cannot help with that.", tool_calls=None),
|
||||
finish_reason=None,
|
||||
)
|
||||
],
|
||||
usage=None,
|
||||
),
|
||||
_chunk(usage=SimpleNamespace(prompt_tokens=4, completion_tokens=6)),
|
||||
]
|
||||
)
|
||||
text, tools, usage, chat, _end = _drive(h)
|
||||
assert "I cannot help with that." in text
|
||||
assert any(getattr(i, "role", None) == "assistant" for i in chat.buffer)
|
||||
|
||||
|
||||
def test_non_streaming_refusal_is_spoken_and_stored():
|
||||
h = _make_handler(stream=False)
|
||||
h.client.chat.completions.create = lambda **k: SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content=None, refusal="No can do.", tool_calls=[]))],
|
||||
usage=SimpleNamespace(prompt_tokens=2, completion_tokens=2),
|
||||
)
|
||||
text, tools, usage, chat, _end = _drive(h)
|
||||
assert text == "No can do."
|
||||
assert any(getattr(i, "role", None) == "assistant" for i in chat.buffer)
|
||||
|
||||
|
||||
def test_non_streaming_empty_choices_completes_cleanly():
|
||||
"""A valid response with no choices (e.g. content filter) completes with no
|
||||
assistant text and no error, instead of raising IndexError."""
|
||||
h = _make_handler(stream=False)
|
||||
h.client.chat.completions.create = lambda **k: SimpleNamespace(
|
||||
choices=[], usage=SimpleNamespace(prompt_tokens=1, completion_tokens=0)
|
||||
)
|
||||
text, tools, usage, chat, end = _drive(h)
|
||||
assert text == ""
|
||||
assert tools == []
|
||||
assert end is not None and end.error is None # clean end, not a generation failure
|
||||
|
||||
|
||||
def test_tools_converted_to_chat_format_on_request():
|
||||
"""The request sent to the server must carry Chat-Completions-shaped tools."""
|
||||
h = _make_handler(stream=True)
|
||||
captured = {}
|
||||
|
||||
def fake_create(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return _FakeStream([_chunk(content="ok.")])
|
||||
|
||||
h.client.chat.completions.create = fake_create
|
||||
_drive(h, tools=[{"type": "function", "name": "f", "parameters": {"type": "object"}}], tool_choice="auto")
|
||||
assert captured["tools"] == [{"type": "function", "function": {"name": "f", "parameters": {"type": "object"}}}]
|
||||
assert captured["tool_choice"] == "auto"
|
||||
assert captured["stream"] is True
|
||||
assert captured["stream_options"] == {"include_usage": True}
|
||||
|
||||
|
||||
# ── Text-only (output_modalities=["text"]) ────────────────────────────────────
|
||||
|
||||
|
||||
def test_text_only_streaming_preserves_raw_deltas():
|
||||
"""With output_modalities=["text"], deltas are forwarded verbatim: no
|
||||
remove_unspeechable (emoji/markdown survive) and no sentence batching."""
|
||||
h = _make_handler(stream=True)
|
||||
h.client.chat.completions.create = lambda **k: _FakeStream(
|
||||
[
|
||||
_chunk(content="# Title 🎉\n"),
|
||||
_chunk(content="- one\n- two 😀\n"),
|
||||
_chunk(usage=SimpleNamespace(prompt_tokens=3, completion_tokens=4)),
|
||||
]
|
||||
)
|
||||
text, tools, usage, chat, end = _drive(h, response=RealtimeResponseCreateParams(output_modalities=["text"]))
|
||||
# Raw markdown layout and emoji preserved end-to-end.
|
||||
assert text == "# Title 🎉\n- one\n- two 😀\n"
|
||||
assert tools == []
|
||||
assert usage == (3, 4)
|
||||
# Raw assistant text is committed to history (not the filtered TTS string).
|
||||
assert any(getattr(i, "role", None) == "assistant" for i in chat.buffer), "assistant turn should be stored"
|
||||
|
||||
|
||||
def test_text_only_tool_call_in_same_delta_not_dropped():
|
||||
"""In text-only mode a delta can carry both content and a tool_call fragment;
|
||||
the tool_call must still be accumulated despite the verbatim-forward `continue`."""
|
||||
h = _make_handler(stream=True)
|
||||
h.client.chat.completions.create = lambda **k: _FakeStream(
|
||||
[
|
||||
SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
delta=SimpleNamespace(
|
||||
content="Looking it up. ",
|
||||
tool_calls=[_tc_delta(0, id="srv_1", name="search", arguments='{"q":"x"}')],
|
||||
),
|
||||
finish_reason=None,
|
||||
)
|
||||
],
|
||||
usage=None,
|
||||
),
|
||||
_chunk(usage=SimpleNamespace(prompt_tokens=5, completion_tokens=5)),
|
||||
]
|
||||
)
|
||||
text, tools, usage, chat, _end = _drive(
|
||||
h,
|
||||
tools=[{"type": "function", "name": "search", "parameters": {"type": "object"}}],
|
||||
response=RealtimeResponseCreateParams(output_modalities=["text"]),
|
||||
)
|
||||
assert "Looking it up." in text
|
||||
assert len(tools) == 1 and tools[0].name == "search" # not dropped by the text-only continue
|
||||
assert json.loads(tools[0].arguments) == {"q": "x"}
|
||||
|
||||
|
||||
def test_non_streaming_text_only_preserves_symbols():
|
||||
h = _make_handler(stream=False)
|
||||
h.client.chat.completions.create = lambda **k: SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content="**bold** 🎉", tool_calls=[]))],
|
||||
usage=SimpleNamespace(prompt_tokens=2, completion_tokens=2),
|
||||
)
|
||||
text, tools, usage, chat, end = _drive(h, response=RealtimeResponseCreateParams(output_modalities=["text"]))
|
||||
assert text == "**bold** 🎉" # symbols not stripped
|
||||
|
||||
|
||||
# ── tool_choice decoupled from tools ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_tool_choice_sent_without_tools():
|
||||
"""A session-level tool_choice must reach the server even when no tools list
|
||||
is supplied (e.g. tool_choice="none" to suppress tool use)."""
|
||||
h = _make_handler(stream=True)
|
||||
captured = {}
|
||||
|
||||
def fake_create(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return _FakeStream([_chunk(content="ok.")])
|
||||
|
||||
h.client.chat.completions.create = fake_create
|
||||
_drive(h, tool_choice="none")
|
||||
assert "tools" not in captured
|
||||
assert captured["tool_choice"] == "none"
|
||||
|
||||
|
||||
# ── Error propagation ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_empty_input_emits_failed_end_of_response():
|
||||
"""No instructions and no conversation input → terminating EndOfResponse with
|
||||
an error, instead of an opaque provider 400."""
|
||||
h = _make_handler(stream=True)
|
||||
called = {"n": 0}
|
||||
|
||||
def fake_create(**kwargs):
|
||||
called["n"] += 1
|
||||
return _FakeStream([_chunk(content="should not happen")])
|
||||
|
||||
h.client.chat.completions.create = fake_create
|
||||
# Empty chat + empty instructions => nothing to send.
|
||||
text, tools, usage, chat, end = _drive(h, user="", instructions="", chat=Chat(10))
|
||||
assert called["n"] == 0, "no API call should be made when there is nothing to send"
|
||||
assert end is not None and end.error is not None
|
||||
assert text == ""
|
||||
|
||||
|
||||
def test_generation_error_emits_failed_end_of_response():
|
||||
"""An exception during generation is caught and surfaced on EndOfResponse.error
|
||||
so the response is closed instead of leaving the pipeline stuck."""
|
||||
h = _make_handler(stream=True)
|
||||
|
||||
def boom(**kwargs):
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
h.client.chat.completions.create = boom
|
||||
text, tools, usage, chat, end = _drive(h)
|
||||
assert end is not None and end.error is not None
|
||||
assert "kaboom" in end.error
|
||||
|
||||
|
||||
# ── Out-of-band (conversation="none") responses ───────────────────────────────
|
||||
|
||||
|
||||
def test_out_of_band_does_not_commit_to_default_conversation():
|
||||
"""Out-of-band output is emitted but never written back to the default chat."""
|
||||
h = _make_handler(stream=True)
|
||||
h.client.chat.completions.create = lambda **k: _FakeStream(
|
||||
[_chunk(content="Background note."), _chunk(usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1))]
|
||||
)
|
||||
chat = Chat(10)
|
||||
text, tools, usage, chat, end = _drive(
|
||||
h, chat=chat, response=RealtimeResponseCreateParams(conversation="none", output_modalities=["text"])
|
||||
)
|
||||
assert "Background note." in text
|
||||
# Default conversation keeps only the seeded user turn — no assistant commit.
|
||||
assert not any(getattr(i, "role", None) == "assistant" for i in chat.buffer)
|
||||
|
||||
|
||||
# ── Standalone runner (no pytest required) ────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||||
failed = 0
|
||||
for t in tests:
|
||||
try:
|
||||
t()
|
||||
print(f"PASS {t.__name__}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed += 1
|
||||
print(f"FAIL {t.__name__}: {type(e).__name__}: {e}")
|
||||
print(f"\n{len(tests) - failed}/{len(tests)} passed")
|
||||
raise SystemExit(1 if failed else 0)
|
||||
Reference in New Issue
Block a user