Signed-off-by: Boao Shi <aoibosh@connect.hku.hk> Signed-off-by: bowieshi <aoibosh@connect.hku.hk> Signed-off-by: princepride <wangzhipeng628@gmail.com> Signed-off-by: R2-Y <ruiruyang2@gmail.com> Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com> Signed-off-by: Shi Boao Bowie <aoibosh@connect.hku.hk> Co-authored-by: 汪志鹏 <wangzhipeng628@gmail.com> Co-authored-by: ruirui(rein) yang <73573651+R2-Y@users.noreply.github.com> Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com> Co-authored-by: Gao Han <hgaoaf@connect.ust.hk> Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
613 lines
22 KiB
Python
613 lines
22 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Tests for multi-modal streaming finish_reason behavior (commit 44c799bc).
|
|
|
|
Verifies that the /v1/chat/completions streaming endpoint emits exactly one
|
|
finish_reason="stop" per choice when multiple output modalities (text, audio)
|
|
are active, complying with the OpenAI streaming spec.
|
|
|
|
Key invariants tested:
|
|
- Single modality (text only): last chunk carries finish_reason="stop"
|
|
- Multi-modality (text+audio): only the final modality chunk carries
|
|
finish_reason="stop"; earlier finishing modalities emit finish_reason=null
|
|
- n>1 with multi-modality: each choice independently tracks its own
|
|
modality state, so each choice gets exactly one "stop"
|
|
- Engine skips a declared modality: fallback stop chunk is emitted at
|
|
stream end so the client always receives finish_reason="stop"
|
|
- voice/speaker parameter compatibility in chat completions
|
|
"""
|
|
|
|
import json
|
|
import time
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from vllm.entrypoints.openai.chat_completion.protocol import (
|
|
ChatCompletionRequest,
|
|
ChatCompletionResponseStreamChoice,
|
|
)
|
|
from vllm.entrypoints.openai.engine.protocol import DeltaMessage
|
|
from vllm.entrypoints.openai.models.serving import OpenAIServingModels
|
|
from vllm.outputs import CompletionOutput, RequestOutput
|
|
|
|
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
|
|
from vllm_omni.outputs import OmniRequestOutput
|
|
|
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_text_omni_output(
|
|
request_id: str = "test-req",
|
|
text: str = "hello",
|
|
token_ids: list[int] | None = None,
|
|
finish_reason: str | None = None,
|
|
index: int = 0,
|
|
num_prompt_tokens: int = 3,
|
|
) -> OmniRequestOutput:
|
|
"""Build an OmniRequestOutput wrapping a text RequestOutput."""
|
|
if token_ids is None:
|
|
token_ids = [10, 11, 12]
|
|
res = RequestOutput(
|
|
request_id=request_id,
|
|
prompt="test",
|
|
prompt_token_ids=list(range(num_prompt_tokens)),
|
|
prompt_logprobs=None,
|
|
outputs=[
|
|
CompletionOutput(
|
|
index=index,
|
|
text=text,
|
|
token_ids=token_ids,
|
|
cumulative_logprob=0.0,
|
|
logprobs=None,
|
|
finish_reason=finish_reason,
|
|
stop_reason=None,
|
|
)
|
|
],
|
|
finished=finish_reason is not None,
|
|
)
|
|
return OmniRequestOutput.from_stage_output(
|
|
res,
|
|
request_id=request_id,
|
|
final_output_type="text",
|
|
finished=finish_reason is not None,
|
|
)
|
|
|
|
|
|
def _make_audio_omni_output(
|
|
request_id: str = "test-req",
|
|
index: int = 0,
|
|
num_prompt_tokens: int = 3,
|
|
stage_id: int | None = None,
|
|
replica_id: int | None = None,
|
|
audio_samples: int = 0,
|
|
) -> OmniRequestOutput:
|
|
"""Build an OmniRequestOutput for audio (no torch dependency)."""
|
|
completion = CompletionOutput(
|
|
index=index,
|
|
text="",
|
|
token_ids=[],
|
|
cumulative_logprob=0.0,
|
|
logprobs=None,
|
|
finish_reason="stop",
|
|
stop_reason=None,
|
|
)
|
|
if audio_samples > 0:
|
|
completion.multimodal_output = {
|
|
"audio": [MagicMock(numel=MagicMock(return_value=audio_samples))],
|
|
"sr": 24000,
|
|
}
|
|
res = RequestOutput(
|
|
request_id=request_id,
|
|
prompt="test",
|
|
prompt_token_ids=list(range(num_prompt_tokens)),
|
|
prompt_logprobs=None,
|
|
outputs=[completion],
|
|
finished=True,
|
|
)
|
|
return OmniRequestOutput.from_stage_output(
|
|
res,
|
|
request_id=request_id,
|
|
stage_id=stage_id,
|
|
replica_id=replica_id,
|
|
final_output_type="audio",
|
|
finished=True,
|
|
)
|
|
|
|
|
|
def _mock_audio_choices(index: int = 0, role: str = "assistant"):
|
|
return [
|
|
ChatCompletionResponseStreamChoice(
|
|
index=index,
|
|
delta=DeltaMessage(role=role, content="dGVzdA=="),
|
|
logprobs=None,
|
|
finish_reason="stop",
|
|
)
|
|
]
|
|
|
|
|
|
def _build_serving_chat():
|
|
"""Create a minimal OmniOpenAIServingChat for testing."""
|
|
mock_engine = MagicMock()
|
|
mock_engine.errored = False
|
|
|
|
models = OpenAIServingModels(
|
|
engine_client=mock_engine,
|
|
base_model_paths=[],
|
|
)
|
|
mock_render = MagicMock()
|
|
|
|
instance = OmniOpenAIServingChat(
|
|
engine_client=mock_engine,
|
|
models=models,
|
|
response_role="assistant",
|
|
online_renderer=mock_render,
|
|
request_logger=None,
|
|
chat_template=None,
|
|
chat_template_content_format="auto",
|
|
)
|
|
instance._create_audio_choice = MagicMock(
|
|
side_effect=lambda omni_res, role, request, stream=False: _mock_audio_choices(
|
|
index=omni_res.outputs[0].index,
|
|
role=role,
|
|
)
|
|
)
|
|
return instance
|
|
|
|
|
|
def _make_request(modalities: list[str], n: int = 1) -> ChatCompletionRequest:
|
|
req = ChatCompletionRequest(
|
|
model="test-model",
|
|
messages=[{"role": "user", "content": "hello"}],
|
|
n=n,
|
|
stream=True,
|
|
)
|
|
req.modalities = modalities # type: ignore[attr-defined]
|
|
return req
|
|
|
|
|
|
def _parse_sse_chunks(lines: list[str]) -> list[dict]:
|
|
"""Parse SSE lines into JSON dicts."""
|
|
prefix = "data: "
|
|
chunks = []
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line.startswith(prefix):
|
|
continue
|
|
payload = line[len(prefix) :].strip()
|
|
if payload == "[DONE]":
|
|
continue
|
|
try:
|
|
chunks.append(json.loads(payload))
|
|
except json.JSONDecodeError:
|
|
pass
|
|
return chunks
|
|
|
|
|
|
async def _collect_stream(gen):
|
|
result = []
|
|
async for item in gen:
|
|
result.append(item)
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: finish_reason correctness
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_modality_text_only_one_stop():
|
|
"""Text-only streaming: exactly one chunk has finish_reason='stop'."""
|
|
serving_chat = _build_serving_chat()
|
|
request = _make_request(modalities=["text"])
|
|
|
|
async def result_generator():
|
|
yield _make_text_omni_output(text="he", token_ids=[10, 11], finish_reason=None)
|
|
yield _make_text_omni_output(text="llo", token_ids=[12], finish_reason="stop")
|
|
|
|
raw_lines = await _collect_stream(
|
|
serving_chat.chat_completion_stream_generator(
|
|
request=request,
|
|
result_generator=result_generator(),
|
|
request_id="test-req",
|
|
model_name="test-model",
|
|
conversation=[],
|
|
tokenizer=MagicMock(),
|
|
request_metadata=MagicMock(),
|
|
)
|
|
)
|
|
|
|
chunks = _parse_sse_chunks(raw_lines)
|
|
finish_reasons = [c["choices"][0]["finish_reason"] for c in chunks if c.get("choices")]
|
|
|
|
assert finish_reasons[-1] == "stop"
|
|
assert finish_reasons.count("stop") == 1
|
|
for fr in finish_reasons[:-1]:
|
|
assert fr is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multi_modal_text_audio_only_last_stop():
|
|
"""text+audio: text finish sends finish_reason=null, audio sends stop."""
|
|
serving_chat = _build_serving_chat()
|
|
request = _make_request(modalities=["text", "audio"])
|
|
|
|
async def result_generator():
|
|
yield _make_text_omni_output(text="he", token_ids=[10, 11], finish_reason=None)
|
|
yield _make_text_omni_output(text="llo", token_ids=[12], finish_reason="stop")
|
|
yield _make_audio_omni_output()
|
|
|
|
raw_lines = await _collect_stream(
|
|
serving_chat.chat_completion_stream_generator(
|
|
request=request,
|
|
result_generator=result_generator(),
|
|
request_id="test-req",
|
|
model_name="test-model",
|
|
conversation=[],
|
|
tokenizer=MagicMock(),
|
|
request_metadata=MagicMock(),
|
|
)
|
|
)
|
|
|
|
chunks = _parse_sse_chunks(raw_lines)
|
|
finish_reasons = [ch["finish_reason"] for c in chunks for ch in c.get("choices", [])]
|
|
|
|
assert finish_reasons.count("stop") == 1
|
|
assert finish_reasons[-1] == "stop"
|
|
|
|
# The text finish chunk must have finish_reason=None
|
|
for idx, c in enumerate(chunks):
|
|
for ch in c.get("choices", []):
|
|
if c.get("modality") == "text" and ch.get("delta", {}).get("content") == "lo":
|
|
assert ch["finish_reason"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multi_modal_n2_independent_per_choice():
|
|
"""n=2 with text+audio: each choice gets exactly one stop, at the end."""
|
|
serving_chat = _build_serving_chat()
|
|
request = _make_request(modalities=["text", "audio"], n=2)
|
|
|
|
async def result_generator():
|
|
yield _make_text_omni_output(text="A", token_ids=[10], finish_reason=None, index=0)
|
|
yield _make_text_omni_output(text="B", token_ids=[20], finish_reason=None, index=1)
|
|
yield _make_text_omni_output(text="", token_ids=[11], finish_reason="stop", index=0)
|
|
yield _make_text_omni_output(text="", token_ids=[21], finish_reason="stop", index=1)
|
|
yield _make_audio_omni_output(index=0)
|
|
yield _make_audio_omni_output(index=1)
|
|
|
|
raw_lines = await _collect_stream(
|
|
serving_chat.chat_completion_stream_generator(
|
|
request=request,
|
|
result_generator=result_generator(),
|
|
request_id="test-req",
|
|
model_name="test-model",
|
|
conversation=[],
|
|
tokenizer=MagicMock(),
|
|
request_metadata=MagicMock(),
|
|
)
|
|
)
|
|
|
|
chunks = _parse_sse_chunks(raw_lines)
|
|
per_choice: dict[int, list] = {}
|
|
for c in chunks:
|
|
for ch in c.get("choices", []):
|
|
per_choice.setdefault(ch["index"], []).append(ch["finish_reason"])
|
|
|
|
for idx, reasons in per_choice.items():
|
|
assert reasons.count("stop") == 1, f"Choice {idx} has {reasons.count('stop')} stops"
|
|
assert reasons[-1] == "stop", f"Choice {idx} last reason is {reasons[-1]}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_modality_audio_only_one_stop():
|
|
"""Audio-only streaming: the audio chunk carries finish_reason='stop'."""
|
|
serving_chat = _build_serving_chat()
|
|
request = _make_request(modalities=["audio"])
|
|
|
|
async def result_generator():
|
|
yield _make_audio_omni_output()
|
|
|
|
raw_lines = await _collect_stream(
|
|
serving_chat.chat_completion_stream_generator(
|
|
request=request,
|
|
result_generator=result_generator(),
|
|
request_id="test-req",
|
|
model_name="test-model",
|
|
conversation=[],
|
|
tokenizer=MagicMock(),
|
|
request_metadata=MagicMock(),
|
|
)
|
|
)
|
|
|
|
chunks = _parse_sse_chunks(raw_lines)
|
|
finish_reasons = [ch["finish_reason"] for c in chunks for ch in c.get("choices", [])]
|
|
|
|
assert finish_reasons.count("stop") == 1
|
|
assert finish_reasons[-1] == "stop"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("output_replica_id", "pool_replica_id", "expected_replica_id", "pool_lookup"),
|
|
[
|
|
(7, None, 7, False),
|
|
(None, 5, 5, True),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_audio_metrics_resolve_replica_id(
|
|
monkeypatch,
|
|
output_replica_id,
|
|
pool_replica_id,
|
|
expected_replica_id,
|
|
pool_lookup,
|
|
):
|
|
import vllm_omni.entrypoints.openai.serving_chat as serving_chat_mod
|
|
from vllm_omni.entrypoints.client_request_state import ClientRequestState
|
|
|
|
serving_chat = _build_serving_chat()
|
|
request = _make_request(modalities=["audio"])
|
|
req_state = ClientRequestState(
|
|
request_id="internal-req",
|
|
external_request_id="test-req",
|
|
)
|
|
req_state.request_arrival_ts = time.time() - 1.0
|
|
serving_chat.engine_client.request_states = {"internal-req": req_state}
|
|
serving_chat.engine_client.mod_metrics = object()
|
|
serving_chat.engine_client.engine = MagicMock()
|
|
serving_chat.engine_client.engine.stage_pools = [MagicMock(), MagicMock(), MagicMock()]
|
|
serving_chat.engine_client.engine.stage_pools[2].get_bound_replica_id.return_value = pool_replica_id
|
|
|
|
first_packet_calls = []
|
|
finalize_calls = []
|
|
|
|
def fake_observe_audio_first_packet(*args, **kwargs):
|
|
first_packet_calls.append(kwargs)
|
|
|
|
def fake_observe_audio_streaming_finalize(*args, **kwargs):
|
|
finalize_calls.append(kwargs)
|
|
|
|
monkeypatch.setattr(serving_chat_mod, "observe_audio_first_packet", fake_observe_audio_first_packet)
|
|
monkeypatch.setattr(serving_chat_mod, "observe_audio_streaming_finalize", fake_observe_audio_streaming_finalize)
|
|
|
|
async def result_generator():
|
|
yield _make_audio_omni_output(stage_id=2, replica_id=output_replica_id, audio_samples=2400)
|
|
|
|
await _collect_stream(
|
|
serving_chat.chat_completion_stream_generator(
|
|
request=request,
|
|
result_generator=result_generator(),
|
|
request_id="test-req",
|
|
model_name="test-model",
|
|
conversation=[],
|
|
tokenizer=MagicMock(),
|
|
request_metadata=MagicMock(),
|
|
)
|
|
)
|
|
|
|
if pool_lookup:
|
|
serving_chat.engine_client.engine.stage_pools[2].get_bound_replica_id.assert_called_once_with("internal-req")
|
|
else:
|
|
serving_chat.engine_client.engine.stage_pools[2].get_bound_replica_id.assert_not_called()
|
|
assert first_packet_calls[0]["stage_id"] == 2
|
|
assert first_packet_calls[0]["replica_id"] == expected_replica_id
|
|
assert finalize_calls[0]["stage_id"] == 2
|
|
assert finalize_calls[0]["replica_id"] == expected_replica_id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: fallback stop chunk when declared modality is not produced
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_declared_modality_not_produced_emits_fallback_stop():
|
|
"""If request.modalities declares ["text","audio"] but engine only produces
|
|
text, a fallback stop chunk is emitted at stream end."""
|
|
serving_chat = _build_serving_chat()
|
|
request = _make_request(modalities=["text", "audio"])
|
|
|
|
async def result_generator():
|
|
# Engine only produces text, no audio output at all
|
|
yield _make_text_omni_output(text="hi", token_ids=[10], finish_reason=None)
|
|
yield _make_text_omni_output(text="!", token_ids=[11], finish_reason="stop")
|
|
|
|
raw_lines = await _collect_stream(
|
|
serving_chat.chat_completion_stream_generator(
|
|
request=request,
|
|
result_generator=result_generator(),
|
|
request_id="test-req",
|
|
model_name="test-model",
|
|
conversation=[],
|
|
tokenizer=MagicMock(),
|
|
request_metadata=MagicMock(),
|
|
)
|
|
)
|
|
|
|
chunks = _parse_sse_chunks(raw_lines)
|
|
finish_reasons = [ch["finish_reason"] for c in chunks for ch in c.get("choices", [])]
|
|
|
|
# Text finish is suppressed (audio not seen yet), but fallback stop
|
|
# chunk must appear at end.
|
|
assert finish_reasons.count("stop") == 1, f"Expected 1 stop, got {finish_reasons}"
|
|
assert finish_reasons[-1] == "stop"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_declared_modality_not_produced_text_finish_suppressed():
|
|
"""When text finishes but audio (declared in modalities) never appears,
|
|
the text finish chunk has finish_reason=null (suppressed)."""
|
|
serving_chat = _build_serving_chat()
|
|
request = _make_request(modalities=["text", "audio"])
|
|
|
|
async def result_generator():
|
|
yield _make_text_omni_output(text="hi", token_ids=[10], finish_reason=None)
|
|
yield _make_text_omni_output(text="!", token_ids=[11], finish_reason="stop")
|
|
# No audio output — stream ends
|
|
|
|
raw_lines = await _collect_stream(
|
|
serving_chat.chat_completion_stream_generator(
|
|
request=request,
|
|
result_generator=result_generator(),
|
|
request_id="test-req",
|
|
model_name="test-model",
|
|
conversation=[],
|
|
tokenizer=MagicMock(),
|
|
request_metadata=MagicMock(),
|
|
)
|
|
)
|
|
|
|
chunks = _parse_sse_chunks(raw_lines)
|
|
|
|
# Find the text finish chunk (content "!")
|
|
for c in chunks:
|
|
for ch in c.get("choices", []):
|
|
if c.get("modality") == "text" and ch.get("delta", {}).get("content") == "!":
|
|
# Text finish should be suppressed because audio hasn't appeared
|
|
assert ch["finish_reason"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: audio chunks that carry no waveform
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_audio_chunk_without_waveform_keeps_stream_alive():
|
|
"""An audio message with no PCM must not break the stream.
|
|
|
|
``_create_audio_choice`` used to return an ErrorResponse here, which the
|
|
stream generator then iterated as a list of choices; the pydantic model
|
|
yields (field, value) tuples, so the loop raised
|
|
``AttributeError: 'tuple' object has no attribute 'finish_reason'``.
|
|
"""
|
|
serving_chat = _build_serving_chat()
|
|
request = _make_request(modalities=["text", "audio"])
|
|
|
|
empty_audio = _make_audio_omni_output()
|
|
empty_audio.outputs[0].multimodal_output = {"audio": []}
|
|
|
|
def create_audio_choice(omni_res, role, request, stream=False):
|
|
return OmniOpenAIServingChat._create_audio_choice(serving_chat, omni_res, role, request, stream=stream)
|
|
|
|
serving_chat._create_audio_choice = create_audio_choice
|
|
|
|
async def result_generator():
|
|
yield _make_text_omni_output(text="hi", token_ids=[10], finish_reason=None)
|
|
yield _make_text_omni_output(text="!", token_ids=[11], finish_reason="stop")
|
|
yield empty_audio
|
|
|
|
raw_lines = await _collect_stream(
|
|
serving_chat.chat_completion_stream_generator(
|
|
request=request,
|
|
result_generator=result_generator(),
|
|
request_id="test-req",
|
|
model_name="test-model",
|
|
conversation=[],
|
|
tokenizer=MagicMock(),
|
|
request_metadata=MagicMock(),
|
|
)
|
|
)
|
|
|
|
assert not any("Error in chat completion stream generator" in line for line in raw_lines)
|
|
chunks = _parse_sse_chunks(raw_lines)
|
|
finish_reasons = [ch["finish_reason"] for c in chunks for ch in c.get("choices", [])]
|
|
assert finish_reasons.count("stop") == 1, f"Expected 1 stop, got {finish_reasons}"
|
|
assert finish_reasons[-1] == "stop"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_audio_choice_error_response_is_not_iterated_as_choices():
|
|
"""Any ErrorResponse from the audio path is skipped, not iterated."""
|
|
from vllm.entrypoints.openai.engine.protocol import ErrorResponse
|
|
|
|
serving_chat = _build_serving_chat()
|
|
request = _make_request(modalities=["text", "audio"])
|
|
|
|
serving_chat._create_audio_choice = MagicMock(
|
|
side_effect=lambda omni_res, role, request, stream=False: serving_chat._create_error_response("boom")
|
|
)
|
|
|
|
async def result_generator():
|
|
yield _make_text_omni_output(text="hi", token_ids=[10], finish_reason="stop")
|
|
yield _make_audio_omni_output()
|
|
|
|
raw_lines = await _collect_stream(
|
|
serving_chat.chat_completion_stream_generator(
|
|
request=request,
|
|
result_generator=result_generator(),
|
|
request_id="test-req",
|
|
model_name="test-model",
|
|
conversation=[],
|
|
tokenizer=MagicMock(),
|
|
request_metadata=MagicMock(),
|
|
)
|
|
)
|
|
|
|
assert isinstance(serving_chat._create_error_response("boom"), ErrorResponse)
|
|
assert not any("AttributeError" in line for line in raw_lines)
|
|
chunks = _parse_sse_chunks(raw_lines)
|
|
finish_reasons = [ch["finish_reason"] for c in chunks for ch in c.get("choices", [])]
|
|
assert finish_reasons.count("stop") == 1, f"Expected 1 stop, got {finish_reasons}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: voice/speaker parameter compatibility
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestVoiceSpeakerCompat:
|
|
"""Tests for voice/speaker parameter handling in chat completions."""
|
|
|
|
def test_voice_parameter_takes_priority(self):
|
|
"""When both voice and speaker are provided via extra_body, voice wins."""
|
|
req = ChatCompletionRequest(
|
|
model="test-model",
|
|
messages=[{"role": "user", "content": "hello"}],
|
|
)
|
|
# Pydantic model_extra collects unknown fields
|
|
req.voice = "alloy" # type: ignore[attr-defined]
|
|
req.speaker = "vivian" # type: ignore[attr-defined]
|
|
|
|
# voice takes priority: getattr(request, "voice", None) returns "alloy"
|
|
speaker = getattr(req, "voice", None) or getattr(req, "speaker", None)
|
|
assert speaker == "alloy"
|
|
|
|
def test_speaker_fallback_when_no_voice(self):
|
|
"""When only speaker is provided, it is used."""
|
|
req = ChatCompletionRequest(
|
|
model="test-model",
|
|
messages=[{"role": "user", "content": "hello"}],
|
|
)
|
|
req.speaker = "vivian" # type: ignore[attr-defined]
|
|
|
|
speaker = getattr(req, "voice", None) or getattr(req, "speaker", None)
|
|
assert speaker == "vivian"
|
|
|
|
def test_neither_voice_nor_speaker(self):
|
|
"""When neither is provided, result is None."""
|
|
req = ChatCompletionRequest(
|
|
model="test-model",
|
|
messages=[{"role": "user", "content": "hello"}],
|
|
)
|
|
|
|
speaker = getattr(req, "voice", None) or getattr(req, "speaker", None)
|
|
assert speaker is None
|
|
|
|
def test_empty_voice_falls_back_to_speaker(self):
|
|
"""Empty string voice falls back to speaker."""
|
|
req = ChatCompletionRequest(
|
|
model="test-model",
|
|
messages=[{"role": "user", "content": "hello"}],
|
|
)
|
|
req.voice = "" # type: ignore[attr-defined]
|
|
req.speaker = "vivian" # type: ignore[attr-defined]
|
|
|
|
# Empty string is falsy, so speaker is used
|
|
speaker = getattr(req, "voice", None) or getattr(req, "speaker", None)
|
|
assert speaker == "vivian"
|