286 lines
9.4 KiB
Python
Executable File
286 lines
9.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Local API validation tests for llmfit serve.
|
|
|
|
Usage:
|
|
# Test an already-running server
|
|
python3 scripts/test_api.py --base-url http://127.0.0.1:8787
|
|
|
|
# Spawn server automatically (from repo root)
|
|
python3 scripts/test_api.py --spawn
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
|
|
def _http_json(url: str, timeout: float = 10.0) -> Tuple[int, Dict[str, Any]]:
|
|
req = urllib.request.Request(url, method="GET")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
code = resp.getcode()
|
|
body = resp.read().decode("utf-8")
|
|
data = json.loads(body) if body else {}
|
|
return code, data
|
|
except urllib.error.HTTPError as exc:
|
|
body = exc.read().decode("utf-8") if exc.fp else ""
|
|
try:
|
|
data = json.loads(body) if body else {}
|
|
except json.JSONDecodeError:
|
|
data = {"raw": body}
|
|
return exc.code, data
|
|
|
|
|
|
def _assert(condition: bool, message: str) -> None:
|
|
if not condition:
|
|
raise AssertionError(message)
|
|
|
|
|
|
def _expect_keys(obj: Dict[str, Any], keys: List[str], path: str) -> None:
|
|
for key in keys:
|
|
_assert(key in obj, f"missing key '{key}' in {path}")
|
|
|
|
|
|
def test_health(base_url: str) -> None:
|
|
code, data = _http_json(f"{base_url}/health")
|
|
_assert(code == 200, f"/health expected 200, got {code}")
|
|
_expect_keys(data, ["status", "node"], "/health")
|
|
_assert(data["status"] == "ok", "health status must be 'ok'")
|
|
_assert(isinstance(data["node"], dict), "health node must be object")
|
|
_expect_keys(data["node"], ["name", "os"], "/health.node")
|
|
|
|
|
|
def test_system(base_url: str) -> None:
|
|
code, data = _http_json(f"{base_url}/api/v1/system")
|
|
_assert(code == 200, f"/api/v1/system expected 200, got {code}")
|
|
_expect_keys(data, ["node", "system"], "/api/v1/system")
|
|
_expect_keys(
|
|
data["system"],
|
|
["total_ram_gb", "available_ram_gb", "cpu_cores", "cpu_name", "has_gpu", "backend", "gpus"],
|
|
"/api/v1/system.system",
|
|
)
|
|
|
|
|
|
def test_models_envelope_and_limit(base_url: str) -> None:
|
|
code, data = _http_json(f"{base_url}/api/v1/models?limit=3&sort=score")
|
|
_assert(code == 200, f"/api/v1/models expected 200, got {code}")
|
|
_expect_keys(data, ["node", "system", "total_models", "returned_models", "filters", "models"], "/api/v1/models")
|
|
_assert(isinstance(data["models"], list), "models must be a list")
|
|
_assert(data["returned_models"] <= 3, "returned_models must respect limit")
|
|
_assert(len(data["models"]) == data["returned_models"], "returned_models must equal models length")
|
|
|
|
|
|
def test_top_endpoint_excludes_too_tight(base_url: str) -> None:
|
|
code, data = _http_json(f"{base_url}/api/v1/models/top?limit=10&min_fit=marginal")
|
|
_assert(code == 200, f"/api/v1/models/top expected 200, got {code}")
|
|
models = data.get("models", [])
|
|
for row in models:
|
|
_assert(row.get("fit_level") != "too_tight", "/models/top should not include too_tight fits")
|
|
|
|
|
|
def test_filters_runtime_and_use_case(base_url: str) -> None:
|
|
code, data = _http_json(f"{base_url}/api/v1/models?limit=10&runtime=any&use_case=general")
|
|
_assert(code == 200, f"runtime/use_case filter query expected 200, got {code}")
|
|
models = data.get("models", [])
|
|
for row in models:
|
|
category = str(row.get("category", "")).lower()
|
|
_assert(category == "general", "use_case=general should only return General category")
|
|
|
|
|
|
def test_models_shape(base_url: str) -> None:
|
|
code, data = _http_json(f"{base_url}/api/v1/models?limit=5")
|
|
_assert(code == 200, f"/api/v1/models shape query expected 200, got {code}")
|
|
models = data.get("models", [])
|
|
if not models:
|
|
return
|
|
|
|
sample = models[0]
|
|
_expect_keys(
|
|
sample,
|
|
[
|
|
"name",
|
|
"provider",
|
|
"fit_level",
|
|
"run_mode",
|
|
"score",
|
|
"estimated_tps",
|
|
"runtime",
|
|
"best_quant",
|
|
"memory_required_gb",
|
|
"memory_available_gb",
|
|
"utilization_pct",
|
|
"score_components",
|
|
],
|
|
"/api/v1/models.models[0]",
|
|
)
|
|
_expect_keys(sample["score_components"], ["quality", "speed", "fit", "context"], "/score_components")
|
|
|
|
|
|
def test_name_lookup(base_url: str) -> None:
|
|
code, data = _http_json(f"{base_url}/api/v1/models?limit=1")
|
|
_assert(code == 200, f"seed query expected 200, got {code}")
|
|
models = data.get("models", [])
|
|
if not models:
|
|
return
|
|
|
|
raw_name = str(models[0].get("name", "")).strip()
|
|
_assert(raw_name, "expected at least one model name")
|
|
|
|
token = raw_name.split("/")[-1].split("-")[0] or raw_name[:8]
|
|
path_name = urllib.parse.quote(token, safe="")
|
|
|
|
code2, data2 = _http_json(f"{base_url}/api/v1/models/{path_name}?limit=10")
|
|
_assert(code2 == 200, f"/api/v1/models/{{name}} expected 200, got {code2}")
|
|
_expect_keys(data2, ["models"], "/api/v1/models/{name}")
|
|
result_models = data2.get("models", [])
|
|
|
|
if result_models:
|
|
lower_token = token.lower()
|
|
matched = any(lower_token in str(row.get("name", "")).lower() for row in result_models)
|
|
_assert(matched, "name lookup should return at least one model matching token")
|
|
|
|
|
|
def test_invalid_filter_returns_400(base_url: str) -> None:
|
|
code, data = _http_json(f"{base_url}/api/v1/models?min_fit=nope")
|
|
_assert(code == 400, f"invalid min_fit expected 400, got {code}")
|
|
_expect_keys(data, ["error"], "error response")
|
|
|
|
|
|
def test_sort_score_desc(base_url: str) -> None:
|
|
code, data = _http_json(f"{base_url}/api/v1/models?limit=25&sort=score")
|
|
_assert(code == 200, f"sort=score query expected 200, got {code}")
|
|
|
|
scores: List[float] = []
|
|
for row in data.get("models", []):
|
|
fit_level = row.get("fit_level")
|
|
if fit_level == "too_tight":
|
|
continue
|
|
score = row.get("score")
|
|
if isinstance(score, (int, float)):
|
|
scores.append(float(score))
|
|
|
|
for i in range(1, len(scores)):
|
|
_assert(scores[i - 1] >= scores[i] - 1e-9, "scores should be non-increasing for sort=score")
|
|
|
|
|
|
def wait_for_health(base_url: str, timeout_s: float = 30.0) -> None:
|
|
deadline = time.time() + timeout_s
|
|
while time.time() < deadline:
|
|
try:
|
|
code, data = _http_json(f"{base_url}/health", timeout=2.0)
|
|
if code == 200 and data.get("status") == "ok":
|
|
return
|
|
except Exception:
|
|
pass
|
|
time.sleep(0.5)
|
|
raise RuntimeError(f"server did not become healthy at {base_url} within {timeout_s}s")
|
|
|
|
|
|
def spawn_server(base_url: str, project_root: str) -> subprocess.Popen:
|
|
parsed = urllib.parse.urlparse(base_url)
|
|
host = parsed.hostname or "127.0.0.1"
|
|
port = parsed.port or 8787
|
|
|
|
cmd = [
|
|
"cargo",
|
|
"run",
|
|
"-p",
|
|
"llmfit",
|
|
"--",
|
|
"serve",
|
|
"--host",
|
|
host,
|
|
"--port",
|
|
str(port),
|
|
]
|
|
|
|
proc = subprocess.Popen(
|
|
cmd,
|
|
cwd=project_root,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
)
|
|
return proc
|
|
|
|
|
|
def run_all_tests(base_url: str) -> None:
|
|
tests = [
|
|
("health", test_health),
|
|
("system", test_system),
|
|
("models envelope+limit", test_models_envelope_and_limit),
|
|
("top excludes too_tight", test_top_endpoint_excludes_too_tight),
|
|
("filters runtime/use_case", test_filters_runtime_and_use_case),
|
|
("model row shape", test_models_shape),
|
|
("name lookup", test_name_lookup),
|
|
("invalid filter 400", test_invalid_filter_returns_400),
|
|
("sort score desc", test_sort_score_desc),
|
|
]
|
|
|
|
for name, fn in tests:
|
|
fn(base_url)
|
|
print(f"✓ {name}")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Run llmfit REST API validation tests")
|
|
parser.add_argument("--base-url", default="http://127.0.0.1:8787", help="API base URL")
|
|
parser.add_argument(
|
|
"--spawn",
|
|
action="store_true",
|
|
help="Spawn llmfit serve automatically (requires cargo in PATH)",
|
|
)
|
|
parser.add_argument(
|
|
"--project-root",
|
|
default=os.path.abspath(os.path.join(os.path.dirname(__file__), "..")),
|
|
help="Project root used when --spawn is set",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
proc: Optional[subprocess.Popen] = None
|
|
|
|
try:
|
|
if args.spawn:
|
|
print(f"Spawning server at {args.base_url} ...")
|
|
proc = spawn_server(args.base_url, args.project_root)
|
|
wait_for_health(args.base_url, timeout_s=45.0)
|
|
|
|
print(f"Running API tests against {args.base_url}")
|
|
run_all_tests(args.base_url)
|
|
print("\nAll API tests passed.")
|
|
return 0
|
|
|
|
except Exception as exc:
|
|
print(f"\nAPI tests failed: {exc}", file=sys.stderr)
|
|
if proc and proc.stdout:
|
|
try:
|
|
output = proc.stdout.read(4000)
|
|
if output:
|
|
print("\nServer output:", file=sys.stderr)
|
|
print(output, file=sys.stderr)
|
|
except Exception:
|
|
pass
|
|
return 1
|
|
|
|
finally:
|
|
if proc is not None:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|