Files
2026-07-13 12:12:21 +08:00

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())