File size: 22,514 Bytes
0623ff0
a4508e8
 
 
9f8362f
 
 
 
a4508e8
9f8362f
a4508e8
 
 
9f8362f
 
 
a4508e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b4a2e6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4508e8
 
 
 
 
 
 
 
0623ff0
a4508e8
 
 
 
 
 
 
 
 
 
0623ff0
a4508e8
 
 
 
 
 
 
 
 
 
 
 
 
0623ff0
a4508e8
 
 
9f8362f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4508e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c35b12
0623ff0
3c35b12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4508e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c35b12
0623ff0
3c35b12
 
 
 
 
 
 
 
a4508e8
 
 
 
 
 
 
 
 
 
 
3c35b12
0623ff0
3c35b12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4508e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0623ff0
a4508e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0623ff0
a4508e8
 
 
 
 
3c35b12
 
 
 
 
 
 
 
 
 
 
 
 
0623ff0
3c35b12
 
 
 
 
 
0623ff0
3c35b12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4508e8
0623ff0
a4508e8
 
3c35b12
 
 
a4508e8
 
 
 
0623ff0
a4508e8
 
 
0623ff0
a4508e8
 
 
3c35b12
0623ff0
3c35b12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4508e8
 
 
3c35b12
 
0623ff0
3c35b12
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
"""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"]