"""Tests for speculative decoding — CLI flags, model loading, serve integration.""" import re from unittest.mock import MagicMock from unittest.mock import patch as mock_patch import pytest def _strip_ansi(text: str) -> str: """Remove ANSI escape codes from Rich-formatted output.""" return re.sub(r'\x1b\[[0-9;]*m', '', text) # ─── CLI Flag Tests ────────────────────────────────────────────────────── class TestSpeculativeDecodingCLI: """Test that --speculative-decoding flag is accepted by soup serve.""" def test_serve_speculative_flag_exists(self): """The serve command should accept --speculative-decoding flag.""" from typer.testing import CliRunner from soup_cli.cli import app runner = CliRunner() result = runner.invoke(app, ["serve", "--help"]) assert result.exit_code == 0 assert "--speculative-decoding" in _strip_ansi(result.output) def test_serve_spec_tokens_flag_exists(self): """The serve command should accept --num-speculative-tokens flag.""" from typer.testing import CliRunner from soup_cli.cli import app runner = CliRunner() result = runner.invoke(app, ["serve", "--help"]) assert result.exit_code == 0 assert "--num-speculative-tokens" in _strip_ansi(result.output) # ─── Draft Model Loading Tests ──────────────────────────────────────────── class TestDraftModelLoading: """Test draft model loading for speculative decoding.""" def test_load_draft_model_calls_from_pretrained(self): """_load_draft_model should call AutoModelForCausalLM.from_pretrained.""" mock_model = MagicMock() mock_model.eval = MagicMock() with mock_patch( "transformers.AutoModelForCausalLM.from_pretrained", return_value=mock_model, ): from soup_cli.commands.serve import _load_draft_model result = _load_draft_model("small-model", "cuda") mock_model.eval.assert_called_once() assert result == mock_model def test_load_draft_model_cpu_device(self): """CPU device should use device_map='cpu'.""" mock_model = MagicMock() mock_model.eval = MagicMock() with mock_patch( "transformers.AutoModelForCausalLM.from_pretrained", return_value=mock_model, ) as mock_load: from soup_cli.commands.serve import _load_draft_model _load_draft_model("small-model", "cpu") call_kwargs = mock_load.call_args[1] assert call_kwargs["device_map"] == "cpu" def test_load_draft_model_blocks_urls(self): """_load_draft_model should reject URL-based model paths (SSRF).""" import typer from soup_cli.commands.serve import _load_draft_model with pytest.raises(typer.Exit): _load_draft_model("https://evil.com/model", "cuda") def test_load_draft_model_blocks_http_urls(self): """_load_draft_model should reject http:// URLs.""" import typer from soup_cli.commands.serve import _load_draft_model with pytest.raises(typer.Exit): _load_draft_model("http://evil.com/model", "cpu") def test_load_draft_model_cuda_dtype(self): """CUDA device should use device_map='auto' and float16.""" import torch mock_model = MagicMock() mock_model.eval = MagicMock() with mock_patch( "transformers.AutoModelForCausalLM.from_pretrained", return_value=mock_model, ) as mock_load: from soup_cli.commands.serve import _load_draft_model _load_draft_model("small-model", "cuda") call_kwargs = mock_load.call_args[1] assert call_kwargs["device_map"] == "auto" assert call_kwargs["dtype"] == torch.float16 def test_load_draft_model_no_trust_remote_code(self): """_load_draft_model should NOT pass trust_remote_code.""" mock_model = MagicMock() mock_model.eval = MagicMock() with mock_patch( "transformers.AutoModelForCausalLM.from_pretrained", return_value=mock_model, ) as mock_load: from soup_cli.commands.serve import _load_draft_model _load_draft_model("small-model", "cuda") call_kwargs = mock_load.call_args[1] assert "trust_remote_code" not in call_kwargs # ─── Generate Response with Draft Model Tests ──────────────────────────── class TestGenerateWithDraftModel: """Test that _generate_response passes assistant_model correctly.""" def test_generate_passes_assistant_model(self): """When assistant_model is provided, it should be in generate kwargs.""" import torch mock_model = MagicMock() mock_model.device = "cpu" mock_outputs = torch.tensor([[1, 2, 3, 4, 5]]) mock_model.generate.return_value = mock_outputs mock_tokenizer = MagicMock() mock_tokenizer.chat_template = None mock_tokenizer.pad_token_id = 0 mock_tokenizer.return_value = { "input_ids": torch.tensor([[1, 2]]), "attention_mask": torch.tensor([[1, 1]]), } mock_tokenizer.decode.return_value = "response" mock_draft = MagicMock() from soup_cli.commands.serve import _generate_response _generate_response( mock_model, mock_tokenizer, [{"role": "user", "content": "hello"}], max_tokens=10, assistant_model=mock_draft, num_assistant_tokens=3, ) gen_call = mock_model.generate.call_args assert gen_call[1]["assistant_model"] == mock_draft assert gen_call[1]["num_assistant_tokens"] == 3 def test_generate_without_assistant_model(self): """When assistant_model is None, it should NOT be in generate kwargs.""" import torch mock_model = MagicMock() mock_model.device = "cpu" mock_outputs = torch.tensor([[1, 2, 3, 4, 5]]) mock_model.generate.return_value = mock_outputs mock_tokenizer = MagicMock() mock_tokenizer.chat_template = None mock_tokenizer.pad_token_id = 0 mock_tokenizer.return_value = { "input_ids": torch.tensor([[1, 2]]), "attention_mask": torch.tensor([[1, 1]]), } mock_tokenizer.decode.return_value = "response" from soup_cli.commands.serve import _generate_response _generate_response( mock_model, mock_tokenizer, [{"role": "user", "content": "hello"}], max_tokens=10, ) gen_call = mock_model.generate.call_args assert "assistant_model" not in gen_call[1] # ─── Create App with Draft Model Tests ────────────────────────────────── class TestCreateAppWithDraftModel: """Test that _create_app accepts and uses draft_model parameter.""" def test_create_app_accepts_draft_model(self): """_create_app should accept draft_model parameter.""" try: import fastapi # noqa: F401 except ImportError: pytest.skip("FastAPI not installed") mock_model = MagicMock() mock_tokenizer = MagicMock() mock_draft = MagicMock() from soup_cli.commands.serve import _create_app app = _create_app( model_obj=mock_model, tokenizer=mock_tokenizer, device="cpu", model_name="test", max_tokens_default=512, draft_model=mock_draft, num_speculative_tokens=5, ) assert app is not None def test_create_app_without_draft_model(self): """_create_app should work without draft_model.""" try: import fastapi # noqa: F401 except ImportError: pytest.skip("FastAPI not installed") mock_model = MagicMock() mock_tokenizer = MagicMock() from soup_cli.commands.serve import _create_app app = _create_app( model_obj=mock_model, tokenizer=mock_tokenizer, device="cpu", model_name="test", max_tokens_default=512, ) assert app is not None # ─── vLLM Speculative Decoding Tests ──────────────────────────────────── class TestVllmSpeculativeDecoding: """Test vLLM engine creation with speculative decoding params.""" def test_vllm_engine_spec_params_signature(self): """create_vllm_engine should accept speculative model params.""" import inspect from soup_cli.utils.vllm import create_vllm_engine sig = inspect.signature(create_vllm_engine) assert "speculative_model" in sig.parameters assert "num_speculative_tokens" in sig.parameters def test_vllm_engine_spec_params_defaults(self): """Default speculative params should be None and 5.""" import inspect from soup_cli.utils.vllm import create_vllm_engine sig = inspect.signature(create_vllm_engine) assert sig.parameters["speculative_model"].default is None assert sig.parameters["num_speculative_tokens"].default == 5