Files
2026-07-13 12:24:33 +08:00

514 lines
18 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for bench engine request sender."""
# Standard
from unittest.mock import AsyncMock, MagicMock, patch
import os
# Third Party
from openai.types import Completion, CompletionUsage
from openai.types.chat import ChatCompletionChunk
from openai.types.chat.chat_completion_chunk import Choice, ChoiceDelta
from openai.types.completion_choice import CompletionChoice
import pytest
# First Party
from lmcache.cli.commands.bench.engine_bench.request_sender import (
RequestSender,
_extract_content,
_normalize_url,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_chat_chunk(
content: str = "",
usage: CompletionUsage = None,
) -> ChatCompletionChunk:
"""Build a minimal ``ChatCompletionChunk``."""
choices = []
if content:
choices.append(
Choice(
delta=ChoiceDelta(content=content),
index=0,
)
)
return ChatCompletionChunk(
id="chunk-1",
choices=choices,
created=0,
model="test-model",
object="chat.completion.chunk",
usage=usage,
)
def _make_completions_chunk(
text: str = "",
usage: CompletionUsage = None,
) -> Completion:
"""Build a minimal ``Completion`` chunk."""
choices = []
if text:
choices.append(
CompletionChoice(
text=text,
index=0,
finish_reason="stop",
)
)
return Completion(
id="cmpl-1",
choices=choices,
created=0,
model="test-model",
object="text_completion",
usage=usage,
)
async def _fake_stream(chunks):
"""Async generator yielding chunks."""
for chunk in chunks:
yield chunk
async def _error_stream(chunks, error_after: int = 1):
"""Async generator that raises after yielding some chunks."""
for i, chunk in enumerate(chunks):
if i >= error_after:
raise RuntimeError("stream interrupted")
yield chunk
def _usage(prompt: int = 100, completion: int = 2) -> CompletionUsage:
return CompletionUsage(
prompt_tokens=prompt,
completion_tokens=completion,
total_tokens=prompt + completion,
)
# ---------------------------------------------------------------------------
# _extract_content
# ---------------------------------------------------------------------------
class TestExtractContent:
def test_chat_content(self) -> None:
chunk = _make_chat_chunk(content="hello")
assert _extract_content(chunk, completions_mode=False) == "hello"
def test_chat_no_choices(self) -> None:
chunk = _make_chat_chunk() # no content → empty choices
assert _extract_content(chunk, completions_mode=False) == ""
def test_chat_none_content(self) -> None:
chunk = ChatCompletionChunk(
id="c1",
choices=[Choice(delta=ChoiceDelta(content=None), index=0)],
created=0,
model="m",
object="chat.completion.chunk",
)
assert _extract_content(chunk, completions_mode=False) == ""
def test_completions_text(self) -> None:
chunk = _make_completions_chunk(text="world")
assert _extract_content(chunk, completions_mode=True) == "world"
def test_completions_no_choices(self) -> None:
chunk = _make_completions_chunk() # no text → empty choices
assert _extract_content(chunk, completions_mode=True) == ""
# ---------------------------------------------------------------------------
# _normalize_url
# ---------------------------------------------------------------------------
class TestNormalizeUrl:
def test_appends_v1(self) -> None:
assert _normalize_url("http://localhost:8000") == ("http://localhost:8000/v1")
def test_keeps_existing_v1(self) -> None:
assert _normalize_url("http://localhost:8000/v1") == (
"http://localhost:8000/v1"
)
def test_strips_trailing_slash(self) -> None:
assert _normalize_url("http://localhost:8000/") == ("http://localhost:8000/v1")
# ---------------------------------------------------------------------------
# RequestSender — construction
# ---------------------------------------------------------------------------
class TestRequestSenderInit:
@patch(
"lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI",
)
def test_default_api_key(self, mock_openai_cls) -> None:
env = os.environ.copy()
env.pop("OPENAI_API_KEY", None)
with patch.dict(os.environ, env, clear=True):
RequestSender("http://localhost:8000", "test-model")
_, kwargs = mock_openai_cls.call_args
assert kwargs["api_key"] == "sk-dummy"
@patch(
"lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI",
)
def test_env_api_key(self, mock_openai_cls) -> None:
with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test"}):
RequestSender("http://localhost:8000", "test-model")
_, kwargs = mock_openai_cls.call_args
assert kwargs["api_key"] == "sk-test"
@patch(
"lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI",
)
def test_url_normalization(self, mock_openai_cls) -> None:
RequestSender("http://localhost:8000", "test-model")
_, kwargs = mock_openai_cls.call_args
assert kwargs["base_url"] == "http://localhost:8000/v1"
# ---------------------------------------------------------------------------
# RequestSender — send_request (chat mode)
# ---------------------------------------------------------------------------
class TestRequestSenderSendRequest:
@pytest.mark.asyncio
@patch(
"lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI",
)
async def test_successful_chat_request(self, mock_openai_cls) -> None:
chunks = [
_make_chat_chunk(content="Hello"),
_make_chat_chunk(content=" world"),
_make_chat_chunk(usage=_usage(prompt=100, completion=2)),
]
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_client.chat.completions.create = AsyncMock(
return_value=_fake_stream(chunks)
)
sender = RequestSender("http://localhost:8000", "test-model")
result = await sender.send_request("req_0", [{"role": "user", "content": "Hi"}])
assert result.successful is True
assert result.error == ""
assert result.ttft > 0
assert result.request_latency > 0
assert result.num_input_tokens == 100
assert result.num_output_tokens == 2
assert result.decode_speed > 0
assert result.submit_time < result.first_token_time < result.finish_time
@pytest.mark.asyncio
@patch(
"lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI",
)
async def test_ignore_eos_adds_extra_body(self, mock_openai_cls) -> None:
chunks = [_make_chat_chunk(usage=_usage(prompt=10, completion=1))]
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_client.chat.completions.create = AsyncMock(
return_value=_fake_stream(chunks)
)
sender = RequestSender("http://localhost:8000", "test-model", ignore_eos=True)
await sender.send_request("req_0", [{"role": "user", "content": "Hi"}])
_, kwargs = mock_client.chat.completions.create.call_args
assert kwargs["extra_body"] == {"ignore_eos": True}
@pytest.mark.asyncio
@patch(
"lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI",
)
async def test_default_omits_extra_body(self, mock_openai_cls) -> None:
chunks = [_make_chat_chunk(usage=_usage(prompt=10, completion=1))]
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_client.chat.completions.create = AsyncMock(
return_value=_fake_stream(chunks)
)
sender = RequestSender("http://localhost:8000", "test-model")
await sender.send_request("req_0", [{"role": "user", "content": "Hi"}])
_, kwargs = mock_client.chat.completions.create.call_args
assert "extra_body" not in kwargs
@pytest.mark.asyncio
@patch(
"lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI",
)
async def test_usage_extraction(self, mock_openai_cls) -> None:
chunks = [
_make_chat_chunk(content="tok"),
_make_chat_chunk(usage=_usage(prompt=500, completion=20)),
]
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_client.chat.completions.create = AsyncMock(
return_value=_fake_stream(chunks)
)
sender = RequestSender("http://localhost:8000", "test-model")
result = await sender.send_request("req_0", [{"role": "user", "content": "Hi"}])
assert result.num_input_tokens == 500
assert result.num_output_tokens == 20
@pytest.mark.asyncio
@patch(
"lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI",
)
async def test_failed_request_on_exception(self, mock_openai_cls) -> None:
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_client.chat.completions.create = AsyncMock(
side_effect=ConnectionError("refused")
)
sender = RequestSender("http://localhost:8000", "test-model")
result = await sender.send_request("req_0", [{"role": "user", "content": "Hi"}])
assert result.successful is False
assert result.ttft == -1.0
assert "refused" in result.error
assert result.num_input_tokens == 0
@pytest.mark.asyncio
@patch(
"lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI",
)
async def test_exception_during_streaming(self, mock_openai_cls) -> None:
chunks = [
_make_chat_chunk(content="Hello"),
_make_chat_chunk(content=" world"), # won't be reached
]
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_client.chat.completions.create = AsyncMock(
return_value=_error_stream(chunks, error_after=1)
)
sender = RequestSender("http://localhost:8000", "test-model")
result = await sender.send_request("req_0", [{"role": "user", "content": "Hi"}])
assert result.successful is False
assert "stream interrupted" in result.error
@pytest.mark.asyncio
@patch(
"lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI",
)
async def test_no_content_chunks(self, mock_openai_cls) -> None:
# Only usage chunk, no content
chunks = [
_make_chat_chunk(usage=_usage(prompt=100, completion=0)),
]
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_client.chat.completions.create = AsyncMock(
return_value=_fake_stream(chunks)
)
sender = RequestSender("http://localhost:8000", "test-model")
result = await sender.send_request("req_0", [{"role": "user", "content": "Hi"}])
assert result.successful is False
@pytest.mark.asyncio
@patch(
"lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI",
)
async def test_callbacks_called_on_success(self, mock_openai_cls) -> None:
chunks = [
_make_chat_chunk(content="Hello"),
_make_chat_chunk(content=" world"),
_make_chat_chunk(usage=_usage(prompt=100, completion=2)),
]
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_client.chat.completions.create = AsyncMock(
return_value=_fake_stream(chunks)
)
callback_args: list[tuple] = []
def on_finished(result, text):
callback_args.append((result, text))
sender = RequestSender(
"http://localhost:8000",
"test-model",
on_finished=[on_finished],
)
result = await sender.send_request("req_0", [{"role": "user", "content": "Hi"}])
assert len(callback_args) == 1
cb_result, cb_text = callback_args[0]
assert cb_result is result
assert cb_result.successful is True
assert cb_text == "Hello world"
@pytest.mark.asyncio
@patch(
"lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI",
)
async def test_callbacks_called_on_failure(self, mock_openai_cls) -> None:
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_client.chat.completions.create = AsyncMock(
side_effect=ConnectionError("refused")
)
callback_args: list[tuple] = []
def on_finished(result, text):
callback_args.append((result, text))
sender = RequestSender(
"http://localhost:8000",
"test-model",
on_finished=[on_finished],
)
await sender.send_request("req_0", [{"role": "user", "content": "Hi"}])
assert len(callback_args) == 1
cb_result, cb_text = callback_args[0]
assert cb_result.successful is False
assert cb_text == ""
# ---------------------------------------------------------------------------
# RequestSender — completions mode
# ---------------------------------------------------------------------------
class TestRequestSenderCompletionsMode:
@pytest.mark.asyncio
@patch(
"lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI",
)
async def test_uses_completions_api(self, mock_openai_cls) -> None:
chunks = [
_make_completions_chunk(text="Hello"),
_make_completions_chunk(usage=_usage(prompt=50, completion=1)),
]
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_client.completions.create = AsyncMock(return_value=_fake_stream(chunks))
sender = RequestSender(
"http://localhost:8000", "test-model", completions_mode=True
)
result = await sender.send_request(
"req_0", [{"role": "user", "content": "Test prompt"}]
)
# Verify completions API was called
mock_client.completions.create.assert_called_once()
call_kwargs = mock_client.completions.create.call_args[1]
assert call_kwargs["prompt"] == "Test prompt"
# Chat API should NOT have been called
mock_client.chat.completions.create.assert_not_called()
assert result.successful is True
@pytest.mark.asyncio
@patch(
"lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI",
)
async def test_completions_content_extraction(self, mock_openai_cls) -> None:
chunks = [
_make_completions_chunk(text="Hello"),
_make_completions_chunk(text=" there"),
_make_completions_chunk(usage=_usage(prompt=50, completion=2)),
]
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_client.completions.create = AsyncMock(return_value=_fake_stream(chunks))
callback_args: list[tuple] = []
sender = RequestSender(
"http://localhost:8000",
"test-model",
completions_mode=True,
on_finished=[lambda r, t: callback_args.append((r, t))],
)
result = await sender.send_request(
"req_0", [{"role": "user", "content": "Test"}]
)
assert result.successful is True
assert result.num_input_tokens == 50
assert result.num_output_tokens == 2
# Verify response text via callback
assert callback_args[0][1] == "Hello there"
# ---------------------------------------------------------------------------
# RequestSender — warmup
# ---------------------------------------------------------------------------
class TestRequestSenderWarmup:
@pytest.mark.asyncio
@patch(
"lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI",
)
async def test_warmup_defaults_max_tokens_1(self, mock_openai_cls) -> None:
chunks = [
_make_chat_chunk(content="X"),
_make_chat_chunk(usage=_usage(prompt=100, completion=1)),
]
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_client.chat.completions.create = AsyncMock(
return_value=_fake_stream(chunks)
)
sender = RequestSender("http://localhost:8000", "test-model")
await sender.send_warmup_request(
"warmup_0", [{"role": "user", "content": "Hi"}]
)
call_kwargs = mock_client.chat.completions.create.call_args[1]
assert call_kwargs["max_tokens"] == 1
# ---------------------------------------------------------------------------
# RequestSender — close
# ---------------------------------------------------------------------------
class TestRequestSenderClose:
@pytest.mark.asyncio
@patch(
"lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI",
)
async def test_close_calls_client_close(self, mock_openai_cls) -> None:
mock_client = MagicMock()
mock_client.close = AsyncMock()
mock_openai_cls.return_value = mock_client
sender = RequestSender("http://localhost:8000", "test-model")
await sender.close()
mock_client.close.assert_called_once()