mirror of https://github.com/razor-ai/soup.git
258 lines
8.4 KiB
Python
258 lines
8.4 KiB
Python
"""Tests for soup serve — inference server command."""
|
|
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
class TestServeValidation:
|
|
"""Test serve command argument validation."""
|
|
|
|
def test_model_path_not_found(self):
|
|
"""serve should fail if model path doesn't exist."""
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.cli import app
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["serve", "--model", "/nonexistent/path"])
|
|
assert result.exit_code != 0
|
|
assert "not found" in result.output.lower() or "not installed" in result.output.lower()
|
|
|
|
def test_fastapi_import_error(self):
|
|
"""serve should fail gracefully if FastAPI not installed."""
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.cli import app
|
|
|
|
runner = CliRunner()
|
|
with patch.dict("sys.modules", {"fastapi": None}):
|
|
result = runner.invoke(app, ["serve", "--model", "."])
|
|
# Either import error or model not found
|
|
assert result.exit_code != 0
|
|
|
|
|
|
class TestCreateApp:
|
|
"""Test the FastAPI app creation."""
|
|
|
|
def test_create_app_returns_fastapi_instance(self):
|
|
"""_create_app should return a FastAPI app with correct endpoints."""
|
|
try:
|
|
from fastapi import FastAPI
|
|
except ImportError:
|
|
pytest.skip("FastAPI not installed")
|
|
|
|
from soup_cli.commands.serve import _create_app
|
|
|
|
mock_model = MagicMock()
|
|
mock_tokenizer = MagicMock()
|
|
|
|
app = _create_app(
|
|
model_obj=mock_model,
|
|
tokenizer=mock_tokenizer,
|
|
device="cpu",
|
|
model_name="test-model",
|
|
max_tokens_default=256,
|
|
)
|
|
|
|
assert isinstance(app, FastAPI)
|
|
|
|
# Check routes exist
|
|
routes = [route.path for route in app.routes]
|
|
assert "/health" in routes
|
|
assert "/v1/models" in routes
|
|
assert "/v1/chat/completions" in routes
|
|
|
|
def test_health_endpoint(self):
|
|
"""Health endpoint should return ok status."""
|
|
try:
|
|
from fastapi.testclient import TestClient
|
|
except ImportError:
|
|
pytest.skip("FastAPI not installed")
|
|
|
|
from soup_cli.commands.serve import _create_app
|
|
|
|
mock_model = MagicMock()
|
|
mock_tokenizer = MagicMock()
|
|
|
|
app = _create_app(
|
|
model_obj=mock_model,
|
|
tokenizer=mock_tokenizer,
|
|
device="cpu",
|
|
model_name="test-model",
|
|
max_tokens_default=256,
|
|
)
|
|
|
|
client = TestClient(app)
|
|
response = client.get("/health")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "ok"
|
|
assert data["model"] == "test-model"
|
|
assert data["device"] == "cpu"
|
|
|
|
def test_models_endpoint(self):
|
|
"""Models endpoint should list the loaded model."""
|
|
try:
|
|
from fastapi.testclient import TestClient
|
|
except ImportError:
|
|
pytest.skip("FastAPI not installed")
|
|
|
|
from soup_cli.commands.serve import _create_app
|
|
|
|
app = _create_app(
|
|
model_obj=MagicMock(),
|
|
tokenizer=MagicMock(),
|
|
device="cpu",
|
|
model_name="my-model",
|
|
max_tokens_default=256,
|
|
)
|
|
|
|
client = TestClient(app)
|
|
response = client.get("/v1/models")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["object"] == "list"
|
|
assert len(data["data"]) == 1
|
|
assert data["data"][0]["id"] == "my-model"
|
|
|
|
def test_chat_completions_endpoint(self):
|
|
"""Chat completions should return OpenAI-compatible response."""
|
|
try:
|
|
from fastapi.testclient import TestClient
|
|
except ImportError:
|
|
pytest.skip("FastAPI not installed")
|
|
|
|
from soup_cli.commands.serve import _create_app
|
|
|
|
with patch("soup_cli.commands.serve._generate_response") as mock_gen:
|
|
mock_gen.return_value = ("Hello there!", 10, 5)
|
|
|
|
app = _create_app(
|
|
model_obj=MagicMock(),
|
|
tokenizer=MagicMock(),
|
|
device="cpu",
|
|
model_name="test-model",
|
|
max_tokens_default=256,
|
|
)
|
|
|
|
client = TestClient(app)
|
|
response = client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "test-model",
|
|
"messages": [{"role": "user", "content": "Hi"}],
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["object"] == "chat.completion"
|
|
assert data["choices"][0]["message"]["role"] == "assistant"
|
|
assert data["choices"][0]["message"]["content"] == "Hello there!"
|
|
assert data["choices"][0]["finish_reason"] == "stop"
|
|
assert data["usage"]["prompt_tokens"] == 10
|
|
assert data["usage"]["completion_tokens"] == 5
|
|
|
|
def test_chat_completions_streaming(self):
|
|
"""Streaming should return SSE events."""
|
|
try:
|
|
from fastapi.testclient import TestClient
|
|
except ImportError:
|
|
pytest.skip("FastAPI not installed")
|
|
|
|
from soup_cli.commands.serve import _create_app
|
|
|
|
with patch("soup_cli.commands.serve._generate_response") as mock_gen:
|
|
mock_gen.return_value = ("Hello world", 10, 5)
|
|
|
|
app = _create_app(
|
|
model_obj=MagicMock(),
|
|
tokenizer=MagicMock(),
|
|
device="cpu",
|
|
model_name="test-model",
|
|
max_tokens_default=256,
|
|
)
|
|
|
|
client = TestClient(app)
|
|
response = client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "test-model",
|
|
"messages": [{"role": "user", "content": "Hi"}],
|
|
"stream": True,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
text = response.text
|
|
assert "data:" in text
|
|
assert "[DONE]" in text
|
|
|
|
|
|
class TestStreamResponse:
|
|
"""Test SSE streaming."""
|
|
|
|
def test_stream_response_yields_chunks(self):
|
|
"""_stream_response should yield SSE events."""
|
|
from soup_cli.commands.serve import _stream_response
|
|
|
|
with patch("soup_cli.commands.serve._generate_response") as mock_gen:
|
|
mock_gen.return_value = ("Hello world", 5, 2)
|
|
|
|
chunks = list(_stream_response(
|
|
model=MagicMock(),
|
|
tokenizer=MagicMock(),
|
|
messages=[{"role": "user", "content": "test"}],
|
|
max_tokens=256,
|
|
temperature=0.7,
|
|
top_p=0.9,
|
|
model_name="test",
|
|
))
|
|
|
|
# Should have word chunks + final chunk + [DONE]
|
|
assert len(chunks) >= 3
|
|
assert chunks[-1] == "data: [DONE]\n\n"
|
|
|
|
# First chunk should be valid SSE JSON
|
|
first_data = json.loads(chunks[0].replace("data: ", "").strip())
|
|
assert first_data["object"] == "chat.completion.chunk"
|
|
assert first_data["choices"][0]["delta"]["content"] == "Hello"
|
|
|
|
|
|
class TestDetectBaseModel:
|
|
"""Test base model detection from adapter config."""
|
|
|
|
def test_detect_base_model_from_adapter(self, tmp_path):
|
|
"""Should read base model from adapter_config.json."""
|
|
from soup_cli.commands.serve import _detect_base_model
|
|
|
|
config_file = tmp_path / "adapter_config.json"
|
|
config_file.write_text(json.dumps({
|
|
"base_model_name_or_path": "meta-llama/Llama-3.1-8B"
|
|
}))
|
|
|
|
result = _detect_base_model(config_file)
|
|
assert result == "meta-llama/Llama-3.1-8B"
|
|
|
|
def test_detect_base_model_missing_key(self, tmp_path):
|
|
"""Should return None if key is missing."""
|
|
from soup_cli.commands.serve import _detect_base_model
|
|
|
|
config_file = tmp_path / "adapter_config.json"
|
|
config_file.write_text(json.dumps({"other_key": "value"}))
|
|
|
|
result = _detect_base_model(config_file)
|
|
assert result is None
|
|
|
|
def test_detect_base_model_invalid_json(self, tmp_path):
|
|
"""Should return None for invalid JSON."""
|
|
from soup_cli.commands.serve import _detect_base_model
|
|
|
|
config_file = tmp_path / "adapter_config.json"
|
|
config_file.write_text("not json")
|
|
|
|
result = _detect_base_model(config_file)
|
|
assert result is None
|