# SPDX-License-Identifier: Apache-2.0 """Tests for the BenchCommand CLI wiring and orchestrator.""" # Standard from unittest.mock import AsyncMock, MagicMock, patch import argparse import io import json import sys import time # Third Party import pytest # First Party from lmcache.cli.commands.bench import BenchCommand from lmcache.cli.commands.bench.engine_bench.command import ( _emit_final_metrics, _resolve_args, run_engine_bench, ) from lmcache.cli.commands.bench.engine_bench.config import EngineBenchConfig from lmcache.cli.commands.bench.engine_bench.stats import ( FinalStats, RequestResult, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_args(**overrides) -> argparse.Namespace: defaults = dict( bench_target="engine", engine_url="http://localhost:8000", lmcache_url=None, model="test-model", workload="long-doc-qa", kv_cache_volume=100.0, tokens_per_gb_kvcache=50000, seed=42, output_dir=".", no_csv=False, json=False, quiet=True, ignore_eos=False, ldqa_document_length=100, ldqa_query_per_document=1, ldqa_shuffle_policy="tile", ldqa_num_inflight_requests=1, ldqa_max_output_length=128, mrc_shared_prompt_length=2000, mrc_chat_history_length=10000, mrc_user_input_length=50, mrc_output_length=200, mrc_qps=1.0, mrc_duration=60.0, rp_request_length=10000, rp_num_requests=50, format=None, output=None, config=None, no_interactive=False, export_config=None, ) defaults.update(overrides) return argparse.Namespace(**defaults) def _make_config(**overrides) -> EngineBenchConfig: defaults = dict( engine_url="http://localhost:8000", model="test-model", workload="long-doc-qa", kv_cache_volume_gb=100.0, tokens_per_gb_kvcache=50000, seed=42, output_dir=".", export_csv=True, export_json=False, quiet=True, ) defaults.update(overrides) return EngineBenchConfig(**defaults) # type: ignore[arg-type] def _make_final_stats(**overrides) -> FinalStats: defaults: dict[str, int | float] = dict( total_requests=10, successful_requests=10, failed_requests=0, elapsed_time=5.0, mean_ttft_ms=300.0, mean_decode_speed=48.0, mean_request_latency_ms=2000.0, input_throughput=20000.0, output_throughput=256.0, total_input_tokens=100000, total_output_tokens=1280, p50_ttft_ms=280.0, p90_ttft_ms=450.0, p99_ttft_ms=600.0, p50_decode_speed=47.0, p90_decode_speed=42.0, p99_decode_speed=38.0, p50_request_latency_ms=1900.0, p90_request_latency_ms=2500.0, p99_request_latency_ms=3000.0, ) defaults.update(overrides) return FinalStats(**defaults) # type: ignore[arg-type] def _make_result(request_id: str = "req_0") -> RequestResult: now = time.time() return RequestResult( request_id=request_id, successful=True, ttft=0.3, request_latency=2.0, num_input_tokens=100, num_output_tokens=10, decode_speed=25.0, submit_time=now, first_token_time=now + 0.3, finish_time=now + 2.0, error="", ) # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- class TestBenchCommandRegistration: def test_bench_in_all_commands(self) -> None: # First Party from lmcache.cli.commands import ALL_COMMANDS names = [cmd.name() for cmd in ALL_COMMANDS] assert "bench" in names def test_engine_subparser_exists(self) -> None: parser = argparse.ArgumentParser() subparsers = parser.add_subparsers() cmd = BenchCommand() cmd.register(subparsers) args = parser.parse_args( [ "bench", "engine", "--engine-url", "http://localhost:8000", "--workload", "long-doc-qa", "--tokens-per-gb-kvcache", "50000", ] ) assert args.bench_target == "engine" assert args.engine_url == "http://localhost:8000" assert args.workload == "long-doc-qa" assert args.tokens_per_gb_kvcache == 50000 def test_optional_args_parse_without_engine_url(self) -> None: parser = argparse.ArgumentParser() subparsers = parser.add_subparsers() cmd = BenchCommand() cmd.register(subparsers) # engine-url and workload are now optional (for interactive mode) args = parser.parse_args(["bench", "engine"]) assert args.engine_url is None assert args.workload is None def test_config_flag_accepted(self) -> None: parser = argparse.ArgumentParser() subparsers = parser.add_subparsers() cmd = BenchCommand() cmd.register(subparsers) args = parser.parse_args(["bench", "engine", "--config", "my_config.json"]) assert args.config == "my_config.json" def test_default_values(self) -> None: parser = argparse.ArgumentParser() subparsers = parser.add_subparsers() cmd = BenchCommand() cmd.register(subparsers) args = parser.parse_args( [ "bench", "engine", "--engine-url", "http://localhost:8000", "--workload", "long-doc-qa", "--tokens-per-gb-kvcache", "50000", ] ) assert args.kv_cache_volume == 100.0 assert args.seed == 42 assert args.output_dir == "." assert args.ldqa_document_length == 10000 assert args.ldqa_query_per_document == 2 assert args.ldqa_shuffle_policy == "random" assert args.ldqa_num_inflight_requests == 3 assert args.quiet is False # --------------------------------------------------------------------------- # --no-interactive # --------------------------------------------------------------------------- class TestNoInteractive: def test_no_interactive_flag_accepted(self) -> None: parser = argparse.ArgumentParser() subparsers = parser.add_subparsers() cmd = BenchCommand() cmd.register(subparsers) args = parser.parse_args(["bench", "engine", "--no-interactive"]) assert args.no_interactive is True def test_no_interactive_missing_engine_url(self) -> None: args = _make_args( engine_url=None, workload="long-doc-qa", tokens_per_gb_kvcache=6553, no_interactive=True, ) with pytest.raises(SystemExit, match="--engine-url"): _resolve_args(args) def test_no_interactive_missing_workload(self) -> None: args = _make_args( engine_url="http://localhost:8000", workload=None, tokens_per_gb_kvcache=6553, no_interactive=True, ) with pytest.raises(SystemExit, match="--workload"): _resolve_args(args) def test_no_interactive_missing_tokens_and_lmcache(self) -> None: args = _make_args( engine_url="http://localhost:8000", workload="long-doc-qa", tokens_per_gb_kvcache=None, lmcache_url=None, no_interactive=True, ) with pytest.raises( SystemExit, match="--tokens-per-gb-kvcache or --lmcache-url" ): _resolve_args(args) def test_no_interactive_passes_with_all_args(self) -> None: args = _make_args(no_interactive=True) result = _resolve_args(args) assert result is args def test_no_interactive_passes_with_lmcache_url(self) -> None: args = _make_args( tokens_per_gb_kvcache=None, lmcache_url="http://localhost:8080", no_interactive=True, ) result = _resolve_args(args) assert result is args # --------------------------------------------------------------------------- # --export-config # --------------------------------------------------------------------------- class TestExportConfig: def test_export_config_flag_accepted(self) -> None: parser = argparse.ArgumentParser() subparsers = parser.add_subparsers() cmd = BenchCommand() cmd.register(subparsers) args = parser.parse_args( [ "bench", "engine", "--engine-url", "http://localhost:8000", "--workload", "long-doc-qa", "--tokens-per-gb-kvcache", "6553", "--export-config", "out.json", ] ) assert args.export_config == "out.json" def test_export_config_errors_when_missing_args(self) -> None: args = _make_args( engine_url=None, export_config="out.json", ) with pytest.raises(SystemExit, match="--engine-url"): _resolve_args(args) def test_export_config_writes_json(self, tmp_path) -> None: # type: ignore[no-untyped-def] export_path = str(tmp_path / "exported.json") args = _make_args( export_config=export_path, quiet=True, ) cmd = BenchCommand() old_stdout = sys.stdout sys.stdout = io.StringIO() try: run_engine_bench(cmd, args) finally: sys.stdout = old_stdout with open(export_path) as f: data = json.load(f) assert "engine_url" not in data assert data["workload"] == "long-doc-qa" assert data["tokens_per_gb_kvcache"] == 50000 assert "lmcache_url" not in data def test_max_output_length_rejected_for_unsupported_workload( self, tmp_path, # type: ignore[no-untyped-def] ) -> None: # Setting a non-default max output length for a workload without that # parameter is rejected. args = _make_args( workload="random-prefill", ldqa_max_output_length=512, export_config=str(tmp_path / "exported.json"), ) with pytest.raises(ValueError, match="max output length cannot be specified"): run_engine_bench(BenchCommand(), args) def test_export_config_excludes_lmcache_url( self, tmp_path, # type: ignore[no-untyped-def] ) -> None: export_path = str(tmp_path / "exported.json") args = _make_args( lmcache_url="http://localhost:8080", export_config=export_path, quiet=True, ) cmd = BenchCommand() old_stdout = sys.stdout sys.stdout = io.StringIO() try: run_engine_bench(cmd, args) finally: sys.stdout = old_stdout with open(export_path) as f: data = json.load(f) assert "lmcache_url" not in data assert data["tokens_per_gb_kvcache"] == 50000 def test_export_config_includes_workload_args( self, tmp_path, # type: ignore[no-untyped-def] ) -> None: export_path = str(tmp_path / "exported.json") args = _make_args( workload="long-doc-qa", ldqa_document_length=5000, ldqa_query_per_document=4, export_config=export_path, quiet=True, ) cmd = BenchCommand() old_stdout = sys.stdout sys.stdout = io.StringIO() try: run_engine_bench(cmd, args) finally: sys.stdout = old_stdout with open(export_path) as f: data = json.load(f) assert data["ldqa_document_length"] == 5000 assert data["ldqa_query_per_document"] == 4 # --------------------------------------------------------------------------- # Final metrics emission # --------------------------------------------------------------------------- class TestBenchCommandEmitMetrics: def test_emit_final_metrics_terminal(self) -> None: cmd = BenchCommand() config = _make_config() final = _make_final_stats() args = _make_args(quiet=False) old_stdout = sys.stdout sys.stdout = buf = io.StringIO() try: _emit_final_metrics(cmd, config, final, args) finally: sys.stdout = old_stdout output = buf.getvalue() assert "Engine Benchmark Result" in output assert "Configuration" in output assert "Time to First Token" in output assert "Decoding Speed" in output assert "test-model" in output def test_emit_final_metrics_json(self) -> None: cmd = BenchCommand() config = _make_config() final = _make_final_stats() args = _make_args(quiet=False, format="json") old_stdout = sys.stdout sys.stdout = buf = io.StringIO() try: _emit_final_metrics(cmd, config, final, args) finally: sys.stdout = old_stdout data = json.loads(buf.getvalue()) assert "metrics" in data assert "ttft" in data["metrics"] assert "decode" in data["metrics"] assert data["metrics"]["config"]["model"] == "test-model" assert data["metrics"]["results"]["successful"] == 10 # --------------------------------------------------------------------------- # Orchestrator # --------------------------------------------------------------------------- class TestBenchCommandOrchestrator: @patch( "lmcache.cli.commands.bench.engine_bench.config.resolve_tokens_per_gb", return_value=6553, ) @patch( "lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI", ) def test_lmcache_url_resolves( self, mock_openai_cls, mock_resolve, tmp_path, ) -> None: """When --lmcache-url is set, tokens_per_gb is resolved from server.""" # Third Party from openai.types import CompletionUsage from openai.types.chat import ChatCompletionChunk from openai.types.chat.chat_completion_chunk import ( Choice, ChoiceDelta, ) usage = CompletionUsage( prompt_tokens=100, completion_tokens=10, total_tokens=110, ) async def _fake_stream(*_args, **_kwargs): yield ChatCompletionChunk( id="c1", choices=[ Choice( delta=ChoiceDelta(content="Hi"), index=0, ) ], created=0, model="test-model", object="chat.completion.chunk", ) yield ChatCompletionChunk( id="c1", choices=[], created=0, model="test-model", object="chat.completion.chunk", usage=usage, ) mock_client = MagicMock() mock_openai_cls.return_value = mock_client mock_client.chat.completions.create = AsyncMock( side_effect=lambda **kw: _fake_stream(), ) mock_client.close = AsyncMock() args = _make_args( lmcache_url="http://localhost:8080", tokens_per_gb_kvcache=None, kv_cache_volume=0.001, ldqa_document_length=100, ldqa_query_per_document=1, ldqa_num_inflight_requests=1, output_dir=str(tmp_path), no_csv=True, ) cmd = BenchCommand() old_stdout = sys.stdout sys.stdout = io.StringIO() try: run_engine_bench(cmd, args) finally: sys.stdout = old_stdout mock_resolve.assert_called_once_with( "http://localhost:8080", "test-model", ) @patch( "lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI", ) def test_bench_engine_wiring( self, mock_openai_cls, tmp_path, ) -> None: """End-to-end orchestrator test with mocked OpenAI client.""" # Third Party from openai.types import CompletionUsage from openai.types.chat import ChatCompletionChunk from openai.types.chat.chat_completion_chunk import ( Choice, ChoiceDelta, ) def _make_chunk(content="", usage=None): choices = [] if content: choices.append( Choice( delta=ChoiceDelta(content=content), index=0, ) ) return ChatCompletionChunk( id="c1", choices=choices, created=0, model="test-model", object="chat.completion.chunk", usage=usage, ) usage = CompletionUsage( prompt_tokens=100, completion_tokens=10, total_tokens=110, ) async def _fake_stream(*_args, **_kwargs): yield _make_chunk(content="Hello") yield _make_chunk(content=" world") yield _make_chunk(usage=usage) mock_client = MagicMock() mock_openai_cls.return_value = mock_client mock_client.chat.completions.create = AsyncMock( side_effect=lambda **kw: _fake_stream(), ) mock_client.close = AsyncMock() output_dir = str(tmp_path) args = _make_args( kv_cache_volume=0.001, tokens_per_gb_kvcache=1000, ldqa_document_length=100, ldqa_query_per_document=1, ldqa_num_inflight_requests=1, output_dir=output_dir, no_csv=False, json=True, quiet=True, ) cmd = BenchCommand() # Suppress stdout for metrics emission old_stdout = sys.stdout sys.stdout = io.StringIO() try: run_engine_bench(cmd, args) finally: sys.stdout = old_stdout # Verify CSV was written csv_path = tmp_path / "bench_results.csv" assert csv_path.exists() # Verify JSON was written json_path = tmp_path / "bench_summary.json" assert json_path.exists() with open(json_path) as f: data = json.load(f) assert "config" in data assert "results" in data assert data["results"]["total_requests"] > 0 @patch( "lmcache.cli.commands.bench.engine_bench.request_sender.AsyncOpenAI", ) def test_config_file_loading( self, mock_openai_cls, tmp_path, ) -> None: """Benchmark runs correctly from a --config JSON file.""" # Third Party from openai.types import CompletionUsage from openai.types.chat import ChatCompletionChunk from openai.types.chat.chat_completion_chunk import ( Choice, ChoiceDelta, ) def _make_chunk(content="", usage=None): choices = [] if content: choices.append( Choice( delta=ChoiceDelta(content=content), index=0, ) ) return ChatCompletionChunk( id="c1", choices=choices, created=0, model="test-model", object="chat.completion.chunk", usage=usage, ) usage = CompletionUsage( prompt_tokens=100, completion_tokens=10, total_tokens=110, ) async def _fake_stream(*_args, **_kwargs): yield _make_chunk(content="Hello") yield _make_chunk(usage=usage) mock_client = MagicMock() mock_openai_cls.return_value = mock_client mock_client.chat.completions.create = AsyncMock( side_effect=lambda **kw: _fake_stream(), ) mock_client.close = AsyncMock() # Write a config JSON file config_data = { "engine_url": "http://localhost:8000", "model": "test-model", "workload": "long-doc-qa", "tokens_per_gb_kvcache": 1000, "kv_cache_volume": 0.001, "ldqa_document_length": 100, "ldqa_query_per_document": 1, "ldqa_shuffle_policy": "tile", "ldqa_num_inflight_requests": 1, } config_path = tmp_path / "test_config.json" with open(config_path, "w") as f: json.dump(config_data, f) args = _make_args( config=str(config_path), engine_url=None, workload=None, tokens_per_gb_kvcache=None, output_dir=str(tmp_path), no_csv=True, quiet=True, ) cmd = BenchCommand() old_stdout = sys.stdout sys.stdout = io.StringIO() try: run_engine_bench(cmd, args) finally: sys.stdout = old_stdout # Verify benchmark ran — sender was called assert mock_client.chat.completions.create.call_count > 0