Skip to content

Commit 8949001

Browse files
author
Your Name
committed
feat(api): expose safe model metadata
1 parent 29ab0bb commit 8949001

4 files changed

Lines changed: 80 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Added
66
- `quorabust-report` for generating Markdown model cards from saved artifacts and
77
optional labeled holdout CSVs.
8+
- `GET /models` serving endpoint for safe loaded-model metadata.
89

910
## 0.3.2
1011

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ quorabust-serve --host 0.0.0.0 --port 8000
9393
```
9494

9595
`GET /metrics` exposes Prometheus text; `POST /predict` accepts `{"question1":[...],"question2":[...]}` and optional header `X-Quorabust-Variant: b`. Interactive docs: **`/docs`**. Load testing: [docs/LOAD_TESTING.md](docs/LOAD_TESTING.md). Grafana: [docs/GRAFANA.md](docs/GRAFANA.md).
96+
`GET /models` returns allowlisted metadata for loaded variants without leaking local
97+
artifact paths or training CSV paths.
9698

9799
## Project layout
98100

src/quorabust/serve.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,31 @@ class PredictOut(BaseModel):
7777
variant: str = Field(..., description="Scoring variant (a or b) after A/B fallback rules.")
7878

7979

80+
_PUBLIC_META_KEYS = {
81+
"feature_backend",
82+
"feature_schema",
83+
"n_train",
84+
"n_eval",
85+
"seed",
86+
"quorabust_version",
87+
"git_revision",
88+
"csv_sha256",
89+
"reference_feature_means",
90+
}
91+
92+
93+
def _public_model_meta(meta: dict[str, Any]) -> dict[str, Any]:
94+
public = {k: meta[k] for k in _PUBLIC_META_KEYS if k in meta}
95+
eval_metrics = {
96+
k.removeprefix("eval_"): v
97+
for k, v in sorted(meta.items())
98+
if k.startswith("eval_") and isinstance(v, int | float)
99+
}
100+
if eval_metrics:
101+
public["eval_metrics"] = eval_metrics
102+
return public
103+
104+
80105
def create_app(
81106
model_path_a: str | None = None,
82107
model_path_b: str | None = None,
@@ -134,6 +159,25 @@ def ready() -> dict[str, str]:
134159
raise HTTPException(status_code=503, detail="model a not loaded")
135160
return {"status": "ready"}
136161

162+
@app.get(
163+
"/models",
164+
tags=["operations"],
165+
summary="List loaded model metadata",
166+
description=(
167+
"Returns allowlisted metadata for loaded variants. Local artifact paths and "
168+
"training CSV paths are intentionally omitted."
169+
),
170+
responses={503: {"description": "No models loaded"}},
171+
)
172+
def models() -> dict[str, dict[str, dict[str, Any]]]:
173+
if not state:
174+
raise HTTPException(status_code=503, detail="no models loaded")
175+
variants = {
176+
name: _public_model_meta(meta)
177+
for name, (_, _, meta) in state.items()
178+
}
179+
return {"variants": variants}
180+
137181
@app.get("/metrics", tags=["operations"])
138182
def metrics() -> PlainTextResponse:
139183
data = generate_latest(registry)

tests/test_serve.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,19 @@ def _tiny_pkl(path):
1515
}
1616
)
1717
b, clf = train_duplicate_classifier(df, xgb_params={"n_estimators": 12, "max_depth": 3})
18-
save_classifier(path, b, clf, meta={"id": "a"})
18+
save_classifier(
19+
path,
20+
b,
21+
clf,
22+
meta={
23+
"id": "a",
24+
"csv": "/private/path/train.csv",
25+
"feature_backend": "tfidf",
26+
"feature_schema": ["cos", "jaccard", "len_ratio", "abs_len_diff", "len_sum"],
27+
"n_train": len(df),
28+
"eval_accuracy": 0.8,
29+
},
30+
)
1931

2032

2133
def test_serve_health_ready_predict(tmp_path):
@@ -58,6 +70,26 @@ def test_openapi_includes_predict_examples(tmp_path):
5870
assert examples and "question1" in examples[0]
5971

6072

73+
def test_models_returns_safe_public_metadata(tmp_path):
74+
p = tmp_path / "m.pkl"
75+
_tiny_pkl(p)
76+
app = create_app(model_path_a=str(p))
77+
with TestClient(app) as client:
78+
r = client.get("/models")
79+
assert r.status_code == 200
80+
model = r.json()["variants"]["a"]
81+
assert model["feature_backend"] == "tfidf"
82+
assert model["eval_metrics"]["accuracy"] == 0.8
83+
assert "csv" not in model
84+
assert "id" not in model
85+
86+
87+
def test_models_without_loaded_artifacts_is_unavailable():
88+
app = create_app(model_path_a="/nonexistent/quorabust_missing.pkl")
89+
with TestClient(app) as client:
90+
assert client.get("/models").status_code == 503
91+
92+
6193
def test_serve_ab_variant_header(tmp_path):
6294
pa = tmp_path / "a.pkl"
6395
pb = tmp_path / "b.pkl"

0 commit comments

Comments
 (0)