Skip to content

Commit cdda2f7

Browse files
polinabinder1claude
andcommitted
evo2-sae serve: API under /api + optional static frontend mount
Move the API routes under an /api prefix (one APIRouter + include_router) and, when a built frontend is configured (build_app(static_dir=...) or DASHBOARD_DIST env), mount it at / via StaticFiles(html=True). This lets a single container serve both the dashboard and the API on one origin: the frontend always calls /api/* (in dev via the Vite proxy, in prod from the same server). The static mount is generic — it serves whatever dir it's pointed at and knows nothing about the dashboard; the dashboard recipe (#1623) supplies the dir + the Docker build. With no frontend configured the server is API-only and / 404s (never crashes). Startup already tolerates a load failure (stays not-ready -> 503), so a frontend+API smoke needs no GPU/checkpoints. Tests: re-point existing contract tests to /api/*, add SPA-index/asset served, API-reachable-under- prefix, unknown-/api-is-404-not-SPA, and API-only-when-no-frontend. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Polina Binder <pbinder@nvidia.com>
1 parent dc46ad5 commit cdda2f7

2 files changed

Lines changed: 128 additions & 31 deletions

File tree

interpretability/sparse_autoencoders/recipes/evo2/src/evo2_sae/server.py

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,25 @@
1515

1616
"""FastAPI server over the Evo2SAE engine — the live backend the viz talks to.
1717
18-
Endpoints: /health, /features, /annotate (per-base activations for a pasted
19-
sequence), /generate (autoregressive generation + optional SAE-feature clamp).
20-
This is a thin layer; all model work lives in `core.Evo2SAE`.
18+
Endpoints (under /api): /api/health, /api/features, /api/annotate (per-base activations for a
19+
pasted sequence), /api/generate (autoregressive generation + optional SAE-feature clamp). The
20+
/api prefix lets a prebuilt frontend be served from "/" on the same origin (single-container
21+
deploy); when no frontend is configured the server is API-only. This is a thin layer; all model
22+
work lives in `core.Evo2SAE`.
2123
"""
2224

2325
from __future__ import annotations
2426

2527
import logging
2628
import os
2729
from contextlib import asynccontextmanager
30+
from pathlib import Path
2831
from typing import Optional
2932

3033
import anyio
31-
from fastapi import FastAPI, HTTPException, Response
34+
from fastapi import APIRouter, FastAPI, HTTPException, Response
3235
from fastapi.responses import JSONResponse
36+
from fastapi.staticfiles import StaticFiles
3337
from pydantic import BaseModel
3438

3539
from . import core
@@ -39,6 +43,19 @@
3943
logger = logging.getLogger("evo2_sae_infer.server")
4044

4145

46+
def _resolve_static_dir(static_dir: Optional[str]) -> Optional[str]:
47+
"""Directory of a prebuilt frontend to serve at ``/``, or None for an API-only server.
48+
49+
Explicit ``static_dir`` arg wins, else the ``DASHBOARD_DIST`` env var. Returns None unless the
50+
path is an existing directory — so a server with no built frontend (dev, or an image built
51+
without one) just serves the API and ``/`` 404s, instead of crashing. This layer is generic:
52+
it serves whatever static dir it's pointed at and knows nothing about the dashboard (the
53+
dashboard recipe supplies the dir via DASHBOARD_DIST / the Docker build).
54+
"""
55+
cand = static_dir or os.getenv("DASHBOARD_DIST")
56+
return cand if (cand and Path(cand).is_dir()) else None
57+
58+
4259
class AnnotateRequest(BaseModel):
4360
"""Request body for /annotate (top-k feature scan or an explicit feature pick)."""
4461

@@ -70,8 +87,14 @@ class GenerateRequest(BaseModel):
7087
compare_baseline: bool = False
7188

7289

73-
def build_app(engine: Evo2SAE) -> FastAPI:
74-
"""Build the FastAPI app; the engine is loaded once in the lifespan handler."""
90+
def build_app(engine: Evo2SAE, static_dir: Optional[str] = None) -> FastAPI:
91+
"""Build the FastAPI app; the engine is loaded once in the lifespan handler.
92+
93+
API routes live under ``/api`` (so the dashboard and the API can be served from one origin:
94+
the frontend always calls ``/api/*``, in dev via the Vite proxy and in production from the
95+
same server). If a built frontend is found (``static_dir`` / ``DASHBOARD_DIST``), it is mounted
96+
at ``/`` so a single container serves both the UI and the API; otherwise the server is API-only.
97+
"""
7598
# One GPU (the engine serializes model calls with a lock), so cap how many sync requests run
7699
# at once: excess requests wait for a worker instead of piling up dozens of parked threads.
77100
# NOTE: generation is bounded only by the context window now, so a single /generate can run
@@ -113,7 +136,10 @@ def _require_ready():
113136
if not engine.ready:
114137
raise HTTPException(503, "Backend not ready")
115138

116-
@app.get("/health")
139+
# All endpoints under /api (mounted below) so the SPA at "/" never collides with them.
140+
api = APIRouter()
141+
142+
@api.get("/health")
117143
def health(response: Response):
118144
if not engine.ready:
119145
response.status_code = 503 # readiness probes shed this pod until load finishes (body still informative)
@@ -128,7 +154,7 @@ def health(response: Response):
128154
"max_seq_len": engine.max_seq_len, # context budget — UI caps generation length to this
129155
}
130156

131-
@app.get("/features")
157+
@api.get("/features")
132158
def features():
133159
_require_ready()
134160
rows = [
@@ -137,7 +163,7 @@ def features():
137163
rows.sort(key=lambda r: r["id"])
138164
return rows
139165

140-
@app.post("/annotate")
166+
@api.post("/annotate")
141167
def annotate(req: AnnotateRequest):
142168
_require_ready()
143169
try:
@@ -183,7 +209,7 @@ def annotate(req: AnnotateRequest):
183209
"features": feats,
184210
}
185211

186-
@app.post("/generate")
212+
@api.post("/generate")
187213
def generate(req: GenerateRequest):
188214
_require_ready()
189215
try:
@@ -200,4 +226,16 @@ def generate(req: GenerateRequest):
200226
except ValueError as e:
201227
raise HTTPException(413 if "too long" in str(e) else 400, str(e))
202228

229+
app.include_router(api, prefix="/api")
230+
231+
# Serve a prebuilt frontend at "/" when present, so one container serves UI + API. Mounted
232+
# AFTER the API router, so /api/* always resolves to the API; unknown /api/* paths 404 here
233+
# (StaticFiles only serves index.html for "/", not as a SPA catch-all — the UI is tabs at "/").
234+
resolved = _resolve_static_dir(static_dir)
235+
if resolved:
236+
app.mount("/", StaticFiles(directory=resolved, html=True), name="dashboard")
237+
logger.info("serving dashboard from %s", resolved)
238+
else:
239+
logger.info("no frontend mounted (API-only)")
240+
203241
return app

interpretability/sparse_autoencoders/recipes/evo2/tests/test_server.py

Lines changed: 80 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,25 @@ def client(fake_engine):
3434
yield c
3535

3636

37+
@pytest.fixture
38+
def dist_dir(tmp_path):
39+
"""A minimal stand-in for a built frontend (feature_explorer/dist) — index + one asset."""
40+
d = tmp_path / "dist"
41+
(d / "assets").mkdir(parents=True)
42+
(d / "index.html").write_text('<!doctype html><html><body><div id="root"></div></body></html>')
43+
(d / "assets" / "app.js").write_text("console.log('dashboard')")
44+
return d
45+
46+
47+
@pytest.fixture
48+
def static_client(fake_engine, dist_dir):
49+
"""A client for the single-container shape: API under /api + the frontend served at /."""
50+
with TestClient(build_app(fake_engine, static_dir=str(dist_dir))) as c:
51+
yield c
52+
53+
3754
def test_health(client):
38-
r = client.get("/health")
55+
r = client.get("/api/health")
3956
assert r.status_code == 200 # 200 only when ready
4057
b = r.json()
4158
assert b["ready"] is True and b["layer"] == 19
@@ -44,72 +61,74 @@ def test_health(client):
4461

4562
def test_annotate_rejects_too_long(client, fake_engine):
4663
seq = "A" * (fake_engine.max_seq_len + 1) # exceeds the context budget
47-
assert client.post("/annotate", json={"sequence": seq}).status_code == 413
64+
assert client.post("/api/annotate", json={"sequence": seq}).status_code == 413
4865

4966

5067
def test_features(client):
51-
rows = client.get("/features").json()
68+
rows = client.get("/api/features").json()
5269
assert {"id", "label", "natural_peak"} <= set(rows[0])
5370

5471

5572
def test_annotate_returns_per_base_activations(client):
56-
b = client.post("/annotate", json={"sequence": "ACGTACGT", "organism": "None (raw DNA)"}).json()
73+
b = client.post("/api/annotate", json={"sequence": "ACGTACGT", "organism": "None (raw DNA)"}).json()
5774
assert {"sequence", "features", "bases", "tag_len", "layer", "n_tokens"} <= set(b)
5875
assert b["features"][0]["activations"] # the per-base track the viz plots
5976

6077

6178
def test_annotate_rejects_non_dna(client):
62-
assert client.post("/annotate", json={"sequence": "ZZZZ"}).status_code == 400
79+
assert client.post("/api/annotate", json={"sequence": "ZZZZ"}).status_code == 400
6380

6481

6582
def test_annotate_pick_mode(client):
66-
b = client.post("/annotate", json={"sequence": "ACGTACGT", "mode": "pick", "feature_ids": [1]}).json()
83+
b = client.post("/api/annotate", json={"sequence": "ACGTACGT", "mode": "pick", "feature_ids": [1]}).json()
6784
assert [f["feature_id"] for f in b["features"]] == [1]
6885
assert b["features"][0]["activations"] # per-base track returned for the picked feature
6986

7087

7188
def test_annotate_pick_requires_ids(client):
72-
assert client.post("/annotate", json={"sequence": "ACGT", "mode": "pick"}).status_code == 400
89+
assert client.post("/api/annotate", json={"sequence": "ACGT", "mode": "pick"}).status_code == 400
7390

7491

7592
def test_annotate_pick_rejects_out_of_range_id(client, fake_engine):
7693
# user-supplied pick ids: an over-range id must 400 (not 500/IndexError), a negative one must
7794
# 400 (not silently index the wrong feature via torch negative-indexing).
78-
over = client.post("/annotate", json={"sequence": "ACGT", "mode": "pick", "feature_ids": [fake_engine.n_features]})
95+
over = client.post(
96+
"/api/annotate", json={"sequence": "ACGT", "mode": "pick", "feature_ids": [fake_engine.n_features]}
97+
)
7998
assert over.status_code == 400
80-
neg = client.post("/annotate", json={"sequence": "ACGT", "mode": "pick", "feature_ids": [-1]})
99+
neg = client.post("/api/annotate", json={"sequence": "ACGT", "mode": "pick", "feature_ids": [-1]})
81100
assert neg.status_code == 400
82101

83102

84103
def test_annotate_rejects_invalid_mode(client):
85-
assert client.post("/annotate", json={"sequence": "ACGT", "mode": "bogus"}).status_code == 400
104+
assert client.post("/api/annotate", json={"sequence": "ACGT", "mode": "bogus"}).status_code == 400
86105

87106

88107
def test_annotate_clamps_k_into_range(client, fake_engine):
89-
client.post("/annotate", json={"sequence": "ACGT", "k": 999})
108+
client.post("/api/annotate", json={"sequence": "ACGT", "k": 999})
90109
assert fake_engine.last_k == 64 # upper bound
91-
client.post("/annotate", json={"sequence": "ACGT", "k": 0})
110+
client.post("/api/annotate", json={"sequence": "ACGT", "k": 0})
92111
assert fake_engine.last_k == 1 # lower bound
93112

94113

95114
def test_generate_returns_sequence(client):
96-
b = client.post("/generate", json={"prompt": "ACGT", "organism": "None (raw DNA)"}).json()
115+
b = client.post("/api/generate", json={"prompt": "ACGT", "organism": "None (raw DNA)"}).json()
97116
assert b["generation"]["sequence"]
98117

99118

100119
def test_generate_rejects_out_of_range_feature(client):
101-
r = client.post("/generate", json={"prompt": "ACGT", "features": [{"feature_id": 999}]})
120+
r = client.post("/api/generate", json={"prompt": "ACGT", "features": [{"feature_id": 999}]})
102121
assert r.status_code == 400 # the wedge guard, surfaced to the client
103122

104123

105124
def test_generate_rejects_too_long(client, fake_engine):
106125
seq = "A" * (fake_engine.max_seq_len + 1) # exceeds the context budget -> 413 (parity w/ annotate)
107-
assert client.post("/generate", json={"prompt": seq}).status_code == 413
126+
assert client.post("/api/generate", json={"prompt": seq}).status_code == 413
108127

109128

110129
def test_generate_compare_baseline(client):
111130
b = client.post(
112-
"/generate",
131+
"/api/generate",
113132
json={"prompt": "ACGT", "features": [{"feature_id": 0, "strength": 5.0}], "compare_baseline": True},
114133
).json()
115134
assert b["baseline"]["sequence"] # unsteered comparison returned alongside the steered one
@@ -118,14 +137,54 @@ def test_generate_compare_baseline(client):
118137
def test_rejects_oversized_body(monkeypatch, fake_engine):
119138
monkeypatch.setenv("MAX_BODY_BYTES", "100")
120139
with TestClient(build_app(fake_engine)) as c:
121-
assert c.post("/annotate", json={"sequence": "ACGT" * 100}).status_code == 413
140+
assert c.post("/api/annotate", json={"sequence": "ACGT" * 100}).status_code == 413
122141

123142

124143
def test_endpoints_503_until_ready(fake_engine):
125144
fake_engine.ready = False
126145
fake_engine.load = lambda: None # startup leaves it not-ready
127146
with TestClient(build_app(fake_engine)) as c:
128-
assert c.get("/health").status_code == 503 # readiness probe sheds the pod
129-
assert c.get("/features").status_code == 503
130-
assert c.post("/annotate", json={"sequence": "ACGT"}).status_code == 503
131-
assert c.post("/generate", json={"prompt": "ACGT", "organism": "None (raw DNA)"}).status_code == 503
147+
assert c.get("/api/health").status_code == 503 # readiness probe sheds the pod
148+
assert c.get("/api/features").status_code == 503
149+
assert c.post("/api/annotate", json={"sequence": "ACGT"}).status_code == 503
150+
assert c.post("/api/generate", json={"prompt": "ACGT", "organism": "None (raw DNA)"}).status_code == 503
151+
152+
153+
# ---------------------------------------------------------- single-container: SPA at / + API at /api
154+
def test_serves_spa_index(static_client):
155+
"""With a built frontend mounted, GET / returns the SPA index (HTML), not an API response."""
156+
r = static_client.get("/")
157+
assert r.status_code == 200
158+
assert "text/html" in r.headers["content-type"]
159+
assert 'id="root"' in r.text
160+
161+
162+
def test_serves_static_asset(static_client):
163+
"""Frontend assets are served from the mount (so the SPA's JS/CSS load same-origin)."""
164+
r = static_client.get("/assets/app.js")
165+
assert r.status_code == 200
166+
assert "dashboard" in r.text
167+
168+
169+
def test_api_reachable_under_prefix_with_frontend(static_client):
170+
"""The API stays fully reachable under /api even with the SPA mounted at / (no shadowing)."""
171+
b = static_client.get("/api/health").json()
172+
assert b["ready"] is True and b["layer"] == 19
173+
174+
175+
def test_unknown_api_path_is_404_not_spa(static_client):
176+
"""An unknown /api/* path 404s — the /api namespace never falls through to the SPA index."""
177+
r = static_client.get("/api/does-not-exist")
178+
assert r.status_code == 404
179+
assert 'id="root"' not in r.text
180+
181+
182+
def test_api_only_when_no_frontend(fake_engine, monkeypatch):
183+
"""No static_dir and no DASHBOARD_DIST -> API-only: / 404s (nothing mounted), /api still works.
184+
185+
Guards dev / a frontend-less image: a missing build degrades to API-only, never a crash.
186+
"""
187+
monkeypatch.delenv("DASHBOARD_DIST", raising=False)
188+
with TestClient(build_app(fake_engine)) as c:
189+
assert c.get("/").status_code == 404
190+
assert c.get("/api/health").status_code == 200

0 commit comments

Comments
 (0)