217 lines
7.2 KiB
Python
217 lines
7.2 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib
|
|
import sys
|
|
from collections.abc import Iterator
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from types import ModuleType
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
APP_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class FakeVector:
|
|
values: tuple[float, ...]
|
|
|
|
def tolist(self) -> list[float]:
|
|
return list(self.values)
|
|
|
|
def dot(self, other: FakeVector) -> float:
|
|
return sum(left * right for left, right in zip(self.values, other.values))
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class FakeScores:
|
|
values: tuple[float, ...]
|
|
|
|
def __neg__(self) -> FakeScores:
|
|
return FakeScores(tuple(-value for value in self.values))
|
|
|
|
def __getitem__(self, index: int) -> float:
|
|
return self.values[index]
|
|
|
|
|
|
class FakeMatrix:
|
|
def __init__(self, rows: tuple[tuple[float, ...], ...]) -> None:
|
|
self._vectors = tuple(FakeVector(row) for row in rows)
|
|
|
|
def __iter__(self) -> Iterator[FakeVector]:
|
|
return iter(self._vectors)
|
|
|
|
def __getitem__(self, index: int) -> FakeVector:
|
|
return self._vectors[index]
|
|
|
|
def __matmul__(self, other: FakeVector) -> FakeScores:
|
|
return FakeScores(tuple(vector.dot(other) for vector in self._vectors))
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class EncodeCall:
|
|
texts: tuple[str, ...]
|
|
task: str | None
|
|
instruction: str | None
|
|
|
|
|
|
class FailingSentenceTransformer:
|
|
def __init__(self, model_name: str, *, device: str, trust_remote_code: bool) -> None:
|
|
raise AssertionError(f"real model constructor must not run for {model_name} on {device}")
|
|
|
|
|
|
class FakeCuda:
|
|
@staticmethod
|
|
def is_available() -> bool:
|
|
return False
|
|
|
|
|
|
def build_fake_torch_module() -> ModuleType:
|
|
module = ModuleType("torch")
|
|
setattr(module, "cuda", FakeCuda())
|
|
return module
|
|
|
|
|
|
def build_fake_sentence_transformers_module() -> ModuleType:
|
|
module = ModuleType("sentence_transformers")
|
|
setattr(module, "SentenceTransformer", FailingSentenceTransformer)
|
|
return module
|
|
|
|
|
|
def build_fake_numpy_module() -> ModuleType:
|
|
module = ModuleType("numpy")
|
|
setattr(module, "ndarray", FakeMatrix)
|
|
|
|
def argsort(scores: FakeScores) -> list[int]:
|
|
return sorted(range(len(scores.values)), key=lambda index: scores.values[index])
|
|
|
|
setattr(module, "argsort", argsort)
|
|
return module
|
|
|
|
|
|
@pytest.fixture()
|
|
def server_module(monkeypatch: pytest.MonkeyPatch) -> ModuleType:
|
|
monkeypatch.syspath_prepend(str(APP_ROOT))
|
|
monkeypatch.setitem(sys.modules, "torch", build_fake_torch_module())
|
|
monkeypatch.setitem(sys.modules, "sentence_transformers", build_fake_sentence_transformers_module())
|
|
monkeypatch.setitem(sys.modules, "numpy", build_fake_numpy_module())
|
|
sys.modules.pop("server", None)
|
|
return importlib.import_module("server")
|
|
|
|
|
|
def test_model_loading_disables_remote_code_TASK_8c19d6a7(
|
|
server_module: ModuleType, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# Given: a recording model constructor at the real loader boundary.
|
|
constructor_calls: list[tuple[str, str, bool]] = []
|
|
|
|
class RecordingSentenceTransformer:
|
|
def __init__(self, model_name: str, *, device: str, trust_remote_code: bool) -> None:
|
|
constructor_calls.append((model_name, device, trust_remote_code))
|
|
|
|
monkeypatch.setattr(server_module, "SentenceTransformer", RecordingSentenceTransformer)
|
|
setattr(server_module, "_model", None)
|
|
|
|
# When: the runtime lazily loads its configured model.
|
|
server_module._load_model()
|
|
|
|
# Then: repository-supplied model code is never executed.
|
|
assert constructor_calls == [(server_module.MODEL_NAME, server_module.DEVICE, False)]
|
|
|
|
|
|
def test_health_remains_available_without_loading_model_SPEC_KSERVE_002_SC_KSERVE_002(
|
|
server_module: ModuleType,
|
|
) -> None:
|
|
client = TestClient(server_module.app)
|
|
|
|
response = client.get("/health")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "ok", "device": "cpu", "model": "/mnt/models"}
|
|
|
|
|
|
def test_embeddings_accept_string_and_return_openai_list_shape_SPEC_KSERVE_002_SC_KSERVE_002(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
server_module: ModuleType,
|
|
) -> None:
|
|
calls: list[EncodeCall] = []
|
|
|
|
def encode(texts: list[str], task: str | None, instruction: str | None) -> FakeMatrix:
|
|
calls.append(EncodeCall(tuple(texts), task, instruction))
|
|
return FakeMatrix(((0.25, 0.75),))
|
|
|
|
monkeypatch.setattr(server_module, "_encode", encode)
|
|
client = TestClient(server_module.app)
|
|
|
|
response = client.post("/v1/embeddings", json={"input": "hello world", "model": "test-model"})
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["object"] == "list"
|
|
assert payload["model"] == "test-model"
|
|
assert payload["data"] == [{"object": "embedding", "index": 0, "embedding": [0.25, 0.75]}]
|
|
assert payload["usage"] == {"prompt_tokens": 2, "total_tokens": 2}
|
|
assert calls == [EncodeCall(("hello world",), None, None)]
|
|
|
|
|
|
def test_embeddings_accept_list_input_and_preserve_indices_SPEC_KSERVE_002_SC_KSERVE_002(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
server_module: ModuleType,
|
|
) -> None:
|
|
calls: list[EncodeCall] = []
|
|
|
|
def encode(texts: list[str], task: str | None, instruction: str | None) -> FakeMatrix:
|
|
calls.append(EncodeCall(tuple(texts), task, instruction))
|
|
return FakeMatrix(((1.0, 0.0), (0.0, 1.0)))
|
|
|
|
monkeypatch.setattr(server_module, "_encode", encode)
|
|
client = TestClient(server_module.app)
|
|
|
|
response = client.post(
|
|
"/v1/embeddings",
|
|
json={"input": ["first text", "second"], "task": "classification"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["object"] == "list"
|
|
assert payload["data"] == [
|
|
{"object": "embedding", "index": 0, "embedding": [1.0, 0.0]},
|
|
{"object": "embedding", "index": 1, "embedding": [0.0, 1.0]},
|
|
]
|
|
assert payload["usage"] == {"prompt_tokens": 3, "total_tokens": 3}
|
|
assert calls == [EncodeCall(("first text", "second"), "classification", None)]
|
|
|
|
|
|
def test_rerank_returns_documents_ordered_by_score_SPEC_KSERVE_002_SC_KSERVE_002(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
server_module: ModuleType,
|
|
) -> None:
|
|
calls: list[EncodeCall] = []
|
|
|
|
def encode(texts: list[str], task: str | None, instruction: str | None) -> FakeMatrix:
|
|
calls.append(EncodeCall(tuple(texts), task, instruction))
|
|
if texts == ["needle"]:
|
|
return FakeMatrix(((1.0, 0.0),))
|
|
return FakeMatrix(((0.20, 0.0), (0.95, 0.0), (0.50, 0.0)))
|
|
|
|
monkeypatch.setattr(server_module, "_encode", encode)
|
|
client = TestClient(server_module.app)
|
|
|
|
response = client.post(
|
|
"/v1/rerank",
|
|
json={"query": "needle", "documents": ["low", "high", "middle"], "top_n": 2},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["model"] == "/mnt/models"
|
|
assert payload["results"] == [
|
|
{"index": 1, "relevance_score": 0.95, "document": "high"},
|
|
{"index": 2, "relevance_score": 0.5, "document": "middle"},
|
|
]
|
|
assert calls == [
|
|
EncodeCall(("needle",), "retrieval.query", None),
|
|
EncodeCall(("low", "high", "middle"), "retrieval.passage", None),
|
|
]
|