gpu-perf-prophet / tests /test_api.py
vxa8502's picture
Add provenance fields to /health response
b4a2e6b
Raw
History Blame Contribute Delete
22.5 kB
"""Integration tests for src/api/main.py via FastAPI TestClient."""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import pytest
import yaml
from fastapi.testclient import TestClient
from src.api.main import app
from src.api.middleware import RateLimiter
_REPO_ROOT = Path(__file__).parent.parent
@pytest.fixture(scope="module")
def client() -> TestClient:
with TestClient(app) as c:
yield c
# /health
class TestHealth:
def test_returns_ok(self, client):
r = client.get("/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"
def test_model_loaded(self, client):
r = client.get("/health")
assert r.json()["model_loaded"] is True
def test_returns_provenance_fields_matching_version_endpoint(self, client):
# Checked against /version's own response, not re-derived from source files again — TestVersion.test_returns_provenance_fields already independently verifies /version against the raw files; this test only confirms /health doesn't drift from it.
health_body = client.get("/health").json()
version_body = client.get("/version").json()
for field in ("model_artifact_version", "model_artifact_sha256", "pricing_snapshot_date", "gpu_spec_db_version"):
assert health_body[field] == version_body[field]
def test_degrades_gracefully_without_provenance_when_model_not_loaded(self, client, monkeypatch):
# /version raises 503 when _predictor is None; /health must not, since it exists to report exactly that "not ready yet" state.
import src.api.main as main_module
monkeypatch.setattr(main_module, "_predictor", None)
r = client.get("/health")
assert r.status_code == 200
body = r.json()
assert body == {"status": "ok", "model_loaded": False}
# /gpus
class TestListGpus:
def test_returns_gpu_list(self, client):
r = client.get("/gpus")
assert r.status_code == 200
gpus = r.json()["gpus"]
# The spec DB has 8 required in-scope GPUs; len > 0 would pass even if all but one were dropped.
assert len(gpus) >= 8
def test_mi300x_present(self, client):
r = client.get("/gpus")
ids = [g["id"] for g in r.json()["gpus"]]
assert "mi300x" in ids
def test_gpu_fields_present(self, client):
r = client.get("/gpus")
gpu = r.json()["gpus"][0]
# Verify keys exist AND values are populated with correct types — a dict full of None values would pass a bare "in gpu" key-presence check.
assert isinstance(gpu["id"], str) and gpu["id"]
assert gpu["vendor"] in ("amd", "nvidia")
assert isinstance(gpu["vram_gb"], (int, float)) and gpu["vram_gb"] > 0
assert isinstance(gpu["in_model_scope"], bool)
# /models
class TestListModels:
def test_returns_model_list(self, client):
from src.models.predictor import VALID_MODELS
r = client.get("/models")
assert r.status_code == 200
# Compare the complete set — spot-checking 2 models misses regressions that remove other models (e.g. mixtral-8x7b, llama3.1-405b).
assert set(r.json()["models"]) == VALID_MODELS
# GET /version
class TestVersion:
def test_returns_provenance_fields(self, client):
# Each expected value is independently recomputed from the raw source file — not imported from src.models.predictor/src.data.gpu_spec_db/src.recommend.recommender — so this fails if any of those computations silently drift, not just if the field goes missing.
r = client.get("/version")
assert r.status_code == 200
body = r.json()
model_bytes = (_REPO_ROOT / "data" / "models" / "prophet_v1.json").read_bytes()
assert body["model_artifact_sha256"] == hashlib.sha256(model_bytes).hexdigest()
meta = json.loads((_REPO_ROOT / "data" / "models" / "feature_metadata.json").read_text())
assert body["model_artifact_version"] == meta["model_version"]
specs = yaml.safe_load((_REPO_ROOT / "data" / "gpu_specs.yaml").read_text())
assert body["gpu_spec_db_version"] == str(specs["schema_version"])
pricing = yaml.safe_load((_REPO_ROOT / "data" / "pricing.yaml").read_text())
assert body["pricing_snapshot_date"] == pricing["source_date"]
# Observability middleware (request-id, access log, rate limit)
class TestObservability:
def test_request_id_header_present_and_unique(self, client):
r1 = client.get("/health")
r2 = client.get("/health")
assert "x-request-id" in r1.headers
assert r1.headers["x-request-id"] != r2.headers["x-request-id"]
def test_meta_block_on_predict(self, client):
r = client.post("/predict", json={
"gpu_id": "h100_sxm",
"model_name": "llama2-70b",
"accuracy_tier": "99",
})
assert r.status_code == 200
meta = r.json()["meta"]
assert meta["request_id"] == r.headers["x-request-id"]
assert meta["model_artifact_sha256"]
assert meta["gpu_spec_db_version"]
def test_meta_block_on_recommend(self, client):
r = client.post("/recommend", json={"model_name": "llama2-70b", "accuracy_tier": "99"})
assert r.status_code == 200
meta = r.json()["meta"]
assert meta["request_id"] == r.headers["x-request-id"]
def test_meta_matches_version_provenance(self, client):
# /version and every response's meta block draw their 4 provenance fields from one shared helper (_provenance_fields) rather than two independently-maintained copies.
version = client.get("/version").json()
meta = client.post("/predict", json={
"gpu_id": "h100_sxm",
"model_name": "llama2-70b",
"accuracy_tier": "99",
}).json()["meta"]
for key in version:
assert meta[key] == version[key], f"{key}: meta={meta[key]!r} version={version[key]!r}"
def test_meta_present_on_value_error_422(self, client):
# A ValueError raised from inside predict()/recommend() (after the predictor is already loaded) becomes a 422 via `except ValueError -> HTTPException(422, ...)` — this response must still carry meta, unlike a Pydantic-level RequestValidationError (a different exception type, not covered).
r = client.post("/predict", json={
"gpu_id": "not-a-real-gpu",
"model_name": "llama2-70b",
"accuracy_tier": "99",
})
assert r.status_code == 422
body = r.json()
assert body["meta"]["request_id"] == r.headers["x-request-id"]
assert body["meta"]["model_artifact_sha256"]
def test_access_log_line_emitted(self, client, caplog):
# One structured JSON log line per request is expected; this asserts log.info() is actually called with the right shape, independent of whether logging.basicConfig gives it a visible handler under uvicorn.
import json as _json
with caplog.at_level("INFO", logger="gpp.access"):
r = client.get("/health")
lines = [rec.message for rec in caplog.records if rec.name == "gpp.access"]
assert len(lines) == 1
payload = _json.loads(lines[0])
assert payload["request_id"] == r.headers["x-request-id"]
assert payload["route"] == "/health"
assert payload["status"] == 200
assert "latency_ms" in payload
def test_rate_limit_returns_429_above_burst(self, client, monkeypatch):
# RATE_LIMIT_RPM=60 tokens, refilled at 1/s; a tight burst of 61 requests on a fresh bucket must trip the limiter on the 61st. `_rate_limiter` is a module-level singleton shared by every request, so monkeypatching a fresh instance keeps this burst from draining the bucket other tests rely on.
import src.api.main as main_module
monkeypatch.setattr(main_module, "_rate_limiter", RateLimiter())
statuses = [client.get("/health").status_code for _ in range(61)]
assert statuses.count(429) >= 1
# POST /predict
class TestPredict:
def test_response_has_throughput(self, client):
r = client.post("/predict", json={
"gpu_id": "h100_sxm",
"model_name": "llama2-70b",
"scenario": "Offline",
"accuracy_tier": "99",
"framework": "tensorrt",
})
assert r.status_code == 200
data = r.json()
assert data["pred_throughput_tok_per_sec"] >= 0.05 * data["roofline_tput_tok_per_sec"]
def test_throughput_not_exceeds_roofline(self, client):
r = client.post("/predict", json={
"gpu_id": "mi300x",
"model_name": "llama2-70b",
})
assert r.status_code == 200
data = r.json()
assert data["pred_throughput_tok_per_sec"] <= data["roofline_tput_tok_per_sec"] + 1e-2
def test_has_training_data_survives_http_round_trip(self, client):
# FastAPI's response_model silently drops any dict key not declared on the Pydantic schema (it would vanish over HTTP without raising) — assert the actual JSON, not the Python dict predictor.predict() returns.
r = client.post("/predict", json={"gpu_id": "mi300x", "model_name": "gptj"})
assert r.status_code == 200
assert r.json()["has_training_data"] is True
# mi300x has real rows but sits under this project's 100-row-per-GPU floor.
assert r.json()["training_data_tier"] == "below_floor"
r = client.post("/predict", json={"gpu_id": "rtx4090", "model_name": "gptj"})
assert r.status_code == 200
assert r.json()["has_training_data"] is False
assert r.json()["training_data_tier"] == "none"
r = client.post("/predict", json={"gpu_id": "h100_sxm", "model_name": "gptj"})
assert r.status_code == 200
assert r.json()["has_training_data"] is True
assert r.json()["training_data_tier"] == "sufficient"
def test_memory_fit_fields_survive_http_round_trip(self, client):
r = client.post("/predict", json={"gpu_id": "mi300x", "model_name": "llama2-70b"})
assert r.status_code == 200
data = r.json()
for key in ("memory_fit_verdict", "kv_cache_gb", "memory_total_gb", "vram_utilization"):
assert key in data, f"{key} missing from HTTP response"
assert data["memory_fit_verdict"] in ("fits", "tight", "does_not_fit")
def test_batch_size_out_of_range_returns_422(self, client):
r = client.post("/predict", json={
"gpu_id": "mi300x", "model_name": "gptj", "batch_size": 0,
})
assert r.status_code == 422
def test_input_tokens_out_of_range_returns_422(self, client):
r = client.post("/predict", json={
"gpu_id": "mi300x", "model_name": "gptj", "input_tokens": 100_000,
})
assert r.status_code == 422
def test_unknown_gpu_returns_422(self, client):
r = client.post("/predict", json={
"gpu_id": "titan_v",
"model_name": "llama2-70b",
})
assert r.status_code == 422
def test_unknown_model_returns_422(self, client):
r = client.post("/predict", json={
"gpu_id": "mi300x",
"model_name": "gpt5",
})
assert r.status_code == 422
def test_invalid_scenario_returns_422(self, client):
r = client.post("/predict", json={
"gpu_id": "mi300x",
"model_name": "gptj",
"scenario": "Interactive",
})
assert r.status_code == 422
def test_unsupported_precision_returns_422(self, client):
# a100_sxm_80gb (Ampere) has no native FP8; accuracy_tier="99" selects fp8 and must return a structured 422, not a 200 with a silently substituted (and physically inconsistent) fp16-ceiling prediction.
r = client.post("/predict", json={
"gpu_id": "a100_sxm_80gb",
"model_name": "gptj",
"accuracy_tier": "99",
})
assert r.status_code == 422
assert "fp8" in r.json()["detail"]
def test_defaults_filled(self, client):
r = client.post("/predict", json={
"gpu_id": "h200_sxm",
"model_name": "gptj",
})
assert r.status_code == 200
data = r.json()
assert data["scenario"] == "Offline"
assert data["accuracy_tier"] == "99"
assert data["framework"] == "vllm"
def test_memory_fit_defaults_filled(self, client):
# `memory_fit_verdict in (three valid strings)` is true unconditionally and wouldn't prove DEFAULT_* was actually applied, so instead prove it two ways: (1) omitting the fields must byte-for-byte match passing DEFAULT_BATCH_SIZE/DEFAULT_INPUT_TOKENS/DEFAULT_OUTPUT_TOKENS explicitly; (2) a different batch/token combo must change the numbers, ruling out a stub that ignores the fields but coincidentally satisfies (1).
from src.features.build_features import (
DEFAULT_BATCH_SIZE, DEFAULT_INPUT_TOKENS, DEFAULT_OUTPUT_TOKENS,
)
r_omitted = client.post("/predict", json={"gpu_id": "h200_sxm", "model_name": "gptj"})
r_explicit = client.post("/predict", json={
"gpu_id": "h200_sxm", "model_name": "gptj",
"batch_size": DEFAULT_BATCH_SIZE,
"input_tokens": DEFAULT_INPUT_TOKENS,
"output_tokens": DEFAULT_OUTPUT_TOKENS,
})
assert r_omitted.status_code == 200
assert r_explicit.status_code == 200
for key in ("memory_fit_verdict", "kv_cache_gb", "memory_total_gb", "vram_utilization"):
assert r_omitted.json()[key] == r_explicit.json()[key], (
f"{key}: omitting batch/token fields ({r_omitted.json()[key]!r}) diverged from "
f"passing DEFAULT_* explicitly ({r_explicit.json()[key]!r})"
)
r_different = client.post("/predict", json={
"gpu_id": "h200_sxm", "model_name": "gptj",
"batch_size": 1, "input_tokens": 64, "output_tokens": 1,
})
assert r_different.json()["kv_cache_gb"] != r_omitted.json()["kv_cache_gb"], (
"kv_cache_gb didn't change with a different batch/token combo — "
"batch_size/input_tokens/output_tokens may not actually be wired in"
)
# POST /predict/batch
class TestPredictBatch:
def test_batch_returns_correct_count(self, client):
r = client.post("/predict/batch", json=[
{"gpu_id": "mi300x", "model_name": "llama2-70b"},
{"gpu_id": "h100_sxm", "model_name": "gptj"},
])
assert r.status_code == 200
results = r.json()
assert len(results) == 2
# Verify identity, not just count — [mi300x, mi300x] would also have len 2.
assert results[0]["gpu_id"] == "mi300x" and results[0]["model_name"] == "llama2-70b"
assert results[1]["gpu_id"] == "h100_sxm" and results[1]["model_name"] == "gptj"
def test_empty_batch_returns_empty(self, client):
r = client.post("/predict/batch", json=[])
assert r.status_code == 200
assert r.json() == []
def test_batch_over_50_returns_422(self, client):
batch = [{"gpu_id": "mi300x", "model_name": "gptj"}] * 51
r = client.post("/predict/batch", json=batch)
assert r.status_code == 422
def test_body_too_large_returns_413(self, client):
# Send 1 MB + 1 byte — the limit_body_size middleware must intercept before Pydantic validation (which would return 422, not 413); the 51-item batch test above only exercises the Pydantic max_length path.
from src.api.main import _MAX_BODY_BYTES
r = client.post(
"/predict/batch",
content=b"x" * (_MAX_BODY_BYTES + 1),
headers={"Content-Type": "application/json"},
)
assert r.status_code == 413
# POST /recommend
class TestRecommend:
def test_workload_echoed(self, client):
r = client.post("/recommend", json={
"model_name": "mixtral-8x7b",
"scenario": "Server",
"accuracy_tier": "base",
"framework": "rocm_other",
})
assert r.status_code == 200
wl = r.json()["workload"]
# All four echoed fields must round-trip — previously only model_name and scenario were asserted; accuracy_tier and framework were silently unchecked.
assert wl["model_name"] == "mixtral-8x7b"
assert wl["scenario"] == "Server"
assert wl["accuracy_tier"] == "base"
assert wl["framework"] == "rocm_other"
def test_ranking_objective_echoed_and_defaults_to_tokens_per_dollar(self, client):
r = client.post("/recommend", json={"model_name": "gptj"})
assert r.status_code == 200
assert r.json()["workload"]["ranking_objective"] == "tokens_per_dollar"
def test_ranking_objective_override_echoed(self, client):
r = client.post("/recommend", json={
"model_name": "gptj", "ranking_objective": "tokens_per_watt",
})
assert r.status_code == 200
assert r.json()["workload"]["ranking_objective"] == "tokens_per_watt"
def test_invalid_ranking_objective_rejected_before_recommender(self, client):
# Pydantic's Literal type rejects this before GpuRecommender.recommend() is ever called — a clean 422, same convention as scenario/accuracy_tier.
r = client.post("/recommend", json={
"model_name": "gptj", "ranking_objective": "most_tokens_ever",
})
assert r.status_code == 422
def test_watts_tokens_per_watt_cost_per_million_present_over_http(self, client):
# watts/tokens_per_watt/cost_per_million_tokens are asserted over a real HTTP round-trip (same silent-field-drop risk as has_training_data) and are checked against an independent /predict call for mi300x (tdp_w=750) rather than this response's own throughput/price fields, because mutation testing found that reusing the response's own throughput to build "expected" fails to catch throughput being wired to the wrong value (e.g. roofline_tput_tok_per_sec) since both fields move together and the test stays green.
r = client.post("/recommend", json={"model_name": "gptj", "accuracy_tier": "99"})
assert r.status_code == 200
candidates = r.json()["frontier"] + r.json()["dominated"]
assert len(candidates) > 0
for gpu in candidates:
assert gpu["watts"] is not None and gpu["watts"] > 0
assert gpu["cost_per_million_tokens"] > 0
mi300x = next(g for g in candidates if g["gpu_id"] == "mi300x")
assert mi300x["watts"] == 750
standalone = client.post(
"/predict", json={"gpu_id": "mi300x", "model_name": "gptj", "accuracy_tier": "99"},
).json()
real_tput = standalone["pred_throughput_tok_per_sec"]
assert mi300x["throughput"] == pytest.approx(real_tput)
assert mi300x["tokens_per_watt"] == pytest.approx(real_tput / 750)
def test_memory_fit_inputs_echoed_in_workload(self, client):
r = client.post("/recommend", json={
"model_name": "gptj",
"batch_size": 8,
"input_tokens": 512,
"output_tokens": 128,
})
assert r.status_code == 200
wl = r.json()["workload"]
assert wl["batch_size"] == 8
assert wl["input_tokens"] == 512
assert wl["output_tokens"] == 128
def test_budget_filter_in_recommend(self, client):
# Light batch/context override keeps the KV cache small enough that L4/RTX4090 (24 GB) still fit gptj — the default batch=32/2048-token assumption needs ~25 GB and would exclude both.
r = client.post("/recommend", json={
"model_name": "gptj",
"batch_size": 1,
"input_tokens": 128,
"output_tokens": 64,
"budget_per_gpu_hr": 1.0,
})
assert r.status_code == 200
candidates = r.json()["frontier"] + r.json()["dominated"]
# L4 ($0.44) and RTX4090 ($0.39) are both under $1.00/hr and fit gptj (6 GB) — without this guard the loop below fires zero assertions if all GPUs exceed the budget (e.g. after a pricing update).
assert len(candidates) > 0, (
"Expected at least one GPU within $1.00/hr (L4 $0.44, RTX4090 $0.39)"
)
# All in-scope GPUs have pricing (validated at startup), so price_per_gpu_hr is never None here — the is-None arm was dead code.
for gpu in candidates:
assert gpu["price_per_gpu_hr"] <= 1.0
def test_cheap_budget_top_pick_flagged_over_http(self, client):
# Same silent-field-drop risk as /predict, plus this is the actual scenario that motivated the feature: a tight-budget query's top pick is a GPU with zero real training rows.
r = client.post("/recommend", json={
"model_name": "gptj",
"batch_size": 1,
"input_tokens": 128,
"output_tokens": 64,
"budget_per_gpu_hr": 1.0,
})
assert r.status_code == 200
body = r.json()
assert body["frontier"], "Expected at least one candidate under $1.00/hr"
assert body["frontier"][0]["gpu_id"] == "rtx4090"
assert body["frontier"][0]["has_training_data"] is False
assert body["frontier"][0]["training_data_tier"] == "none"
for gpu in body["frontier"] + body["dominated"] + body["filtered"]:
assert "has_training_data" in gpu
assert "training_data_tier" in gpu
def test_unknown_model_returns_422(self, client):
r = client.post("/recommend", json={"model_name": "invalid_model"})
assert r.status_code == 422
def test_unsupported_precision_gpu_filtered_not_500(self, client):
# Unsupported-precision handling in the recommend() multi-GPU path: a100_sxm_80gb (no native FP8) must be excluded with a reason, not cause a 500 by reaching predict_batch() (which now raises for this case).
r = client.post("/recommend", json={
"model_name": "gptj",
"accuracy_tier": "99",
})
assert r.status_code == 200
body = r.json()
candidate_ids = {g["gpu_id"] for g in body["frontier"] + body["dominated"]}
assert "a100_sxm_80gb" not in candidate_ids
entry = next(f for f in body["filtered"] if f["gpu_id"] == "a100_sxm_80gb")
assert "fp8" in entry["reject_reason"]