Spaces:
Sleeping
Phase 5 prep: bound /api/triage spend by construction (PUBLIC_DEMO)
Browse filesA public Space with a billed /api/triage is a potential spend faucet. Close it
structurally rather than with after-the-fact limits.
The endpoint already only triages the 18 fixed campaigns (it takes a
campaign_id, never free-form text — arbitrary input can't reach the model). The
one remaining leak was force=true re-running past the per-campaign cache.
- New PUBLIC_DEMO config flag (src/config.py). When on, /api/triage ignores
force (no cache bypass) and locks the provider to Anthropic (also neutralizes
the Ollama toggle, which can't run on a Space anyway).
- ENV PUBLIC_DEMO=1 baked into the Dockerfile, so the deployed image is locked
with no extra Space secret. Local dev (no flag) keeps force + provider toggle.
- Result: max spend = 18 Haiku triages per container restart, then cache hits.
Pairs with the operator-side backstop (a hard monthly cap on a dedicated key in
the Anthropic Console) — documented in STATUS as the first deploy step.
Tested: scripts.test_api 9/9 (new PUBLIC_DEMO-caps-spend case proves force +
ollama are both neutralized — served from cache, which a live call would not be).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Dockerfile +3 -0
- STATUS.md +18 -4
- api.py +10 -1
- scripts/test_api.py +17 -0
- src/config.py +5 -0
|
@@ -31,6 +31,9 @@ COPY --from=frontend /app/frontend/dist ./frontend/dist
|
|
| 31 |
RUN chmod -R a+rwX /app/data
|
| 32 |
|
| 33 |
ENV LLM_PROVIDER=anthropic
|
|
|
|
|
|
|
|
|
|
| 34 |
EXPOSE 7860
|
| 35 |
# Shell form so $PORT (if the platform injects one) is honoured; defaults to 7860.
|
| 36 |
CMD uvicorn api:app --host 0.0.0.0 --port ${PORT:-7860}
|
|
|
|
| 31 |
RUN chmod -R a+rwX /app/data
|
| 32 |
|
| 33 |
ENV LLM_PROVIDER=anthropic
|
| 34 |
+
# Public-demo spend cap: lock /api/triage to Anthropic + no cache-bypass so a public Space can't be
|
| 35 |
+
# used as a billing faucet (max spend = the 18 fixed campaigns once each per restart). See api.py.
|
| 36 |
+
ENV PUBLIC_DEMO=1
|
| 37 |
EXPOSE 7860
|
| 38 |
# Shell form so $PORT (if the platform injects one) is honoured; defaults to 7860.
|
| 39 |
CMD uvicorn api:app --host 0.0.0.0 --port ${PORT:-7860}
|
|
@@ -337,10 +337,24 @@ container will actually build, and hardening the one thing that has ever flaked.
|
|
| 337 |
→ *same* `all-MiniLM-L6-v2` download. A 429 during the HF Space build would fail the build the same
|
| 338 |
way — it's a one-shot build, so **just rebuild the Space** if it hits a transient 429 (or build the
|
| 339 |
image locally first, where the model layer caches). Not a code bug.
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
|
| 345 |
## AWAITING — user action
|
| 346 |
- **Build/run the container** (start Docker Desktop): `docker build -t amana .` then
|
|
|
|
| 337 |
→ *same* `all-MiniLM-L6-v2` download. A 429 during the HF Space build would fail the build the same
|
| 338 |
way — it's a one-shot build, so **just rebuild the Space** if it hits a transient 429 (or build the
|
| 339 |
image locally first, where the model layer caches). Not a code bug.
|
| 340 |
+
- **Spend protection on the public endpoint** — a public Space with a billed `/api/triage` is a
|
| 341 |
+
potential spend faucet. Closed structurally: the endpoint **already only triages the 18 fixed
|
| 342 |
+
campaigns** (it takes a `campaign_id`, never free-form text — arbitrary input can't reach the
|
| 343 |
+
model). The one leak was `force=true` re-running past the cache. New **`PUBLIC_DEMO`** mode
|
| 344 |
+
(`src/config.py`; `ENV PUBLIC_DEMO=1` baked into the `Dockerfile`, so no Space secret needed):
|
| 345 |
+
`/api/triage` **ignores `force`** and **locks the provider to Anthropic** (also neutralizes the
|
| 346 |
+
Ollama toggle on the Space). Result: **max spend = 18 Haiku triages per container restart, then
|
| 347 |
+
cache hits** — bounded by construction. Local dev (no `PUBLIC_DEMO`) keeps `force` + the provider
|
| 348 |
+
toggle. Tested offline: `scripts.test_api` **9/9** (new `PUBLIC_DEMO caps spend` case).
|
| 349 |
+
|
| 350 |
+
**Remaining (needs Docker Desktop running / an HF account — user actions):**
|
| 351 |
+
- **Set a hard billing cap (do first — the backstop only you can set):** in the Anthropic Console,
|
| 352 |
+
create a **dedicated** API key for the Space and set a low monthly usage limit on it, so even a
|
| 353 |
+
total failure of every other layer is bounded. Use that key (not your dev key) so it's revocable.
|
| 354 |
+
- Local `docker build -t amana .` + `docker run` smoke (the image bakes `PUBLIC_DEMO=1`).
|
| 355 |
+
- Create the HF **Docker** Space; set `ANTHROPIC_API_KEY` (the dedicated key) + `LLM_PROVIDER=anthropic`
|
| 356 |
+
as Space secrets; add the Space git remote and push. Recipe in `PLAN.md` §5b.
|
| 357 |
+
- *(optional)* make the Space private, or rely on the spend cap and keep it public for evaluators.
|
| 358 |
|
| 359 |
## AWAITING — user action
|
| 360 |
- **Build/run the container** (start Docker Desktop): `docker build -t amana .` then
|
|
@@ -135,12 +135,21 @@ def get_campaign(cid: str) -> dict:
|
|
| 135 |
@app.post("/api/triage")
|
| 136 |
def run_triage(req: TriageRequest) -> dict:
|
| 137 |
provider = req.provider.lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
if provider not in _PROVIDERS:
|
| 139 |
raise HTTPException(status_code=400, detail=f"provider must be one of {_PROVIDERS}")
|
| 140 |
campaign = _campaign_or_404(req.campaign_id)
|
| 141 |
|
| 142 |
cached = _triage_cache.get(req.campaign_id)
|
| 143 |
-
if cached and not
|
| 144 |
gated = cached["gated"]
|
| 145 |
else:
|
| 146 |
from src.agent import triage, _resolve_model # lazy: pulls in pydantic-ai
|
|
|
|
| 135 |
@app.post("/api/triage")
|
| 136 |
def run_triage(req: TriageRequest) -> dict:
|
| 137 |
provider = req.provider.lower()
|
| 138 |
+
force = req.force
|
| 139 |
+
# Public-demo spend cap (PUBLIC_DEMO=1, set on the deployed Space): bound billing BY CONSTRUCTION.
|
| 140 |
+
# The endpoint already only triages the 18 fixed campaigns (arbitrary text can't reach the model),
|
| 141 |
+
# so the only spend faucet is force-rerunning the cache — disabled here — and a non-Anthropic
|
| 142 |
+
# provider (Ollama can't run on the Space anyway). With both shut, max spend is 18 calls/restart,
|
| 143 |
+
# then cache hits. Local dev (no PUBLIC_DEMO) keeps force + the provider toggle.
|
| 144 |
+
if CONFIG.public_demo:
|
| 145 |
+
provider = "anthropic"
|
| 146 |
+
force = False
|
| 147 |
if provider not in _PROVIDERS:
|
| 148 |
raise HTTPException(status_code=400, detail=f"provider must be one of {_PROVIDERS}")
|
| 149 |
campaign = _campaign_or_404(req.campaign_id)
|
| 150 |
|
| 151 |
cached = _triage_cache.get(req.campaign_id)
|
| 152 |
+
if cached and not force and cached["provider"] == provider:
|
| 153 |
gated = cached["gated"]
|
| 154 |
else:
|
| 155 |
from src.agent import triage, _resolve_model # lazy: pulls in pydantic-ai
|
|
@@ -95,6 +95,22 @@ def t_triage_returns_enriched_rule_text():
|
|
| 95 |
assert rv["rule_text"] and "interest" in rv["rule_text"].lower(), "PROH-3 rule text should be enriched in"
|
| 96 |
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
def t_decision_agree_and_override_governance():
|
| 99 |
_seed_cache("camp-005")
|
| 100 |
# Agreeing with the AI (REJECT) needs no reason.
|
|
@@ -124,6 +140,7 @@ def main() -> int:
|
|
| 124 |
("GET /api/policy (5 sections, all rules)", t_policy_sections),
|
| 125 |
("POST /api/decisions 409 before triage", t_decision_requires_triage_first),
|
| 126 |
("POST /api/triage enriches rule text", t_triage_returns_enriched_rule_text),
|
|
|
|
| 127 |
("POST /api/decisions override governance", t_decision_agree_and_override_governance),
|
| 128 |
("GET /api/decisions round-trip", t_decisions_log_roundtrip),
|
| 129 |
]:
|
|
|
|
| 95 |
assert rv["rule_text"] and "interest" in rv["rule_text"].lower(), "PROH-3 rule text should be enriched in"
|
| 96 |
|
| 97 |
|
| 98 |
+
def t_public_demo_caps_spend():
|
| 99 |
+
# With PUBLIC_DEMO on, the billed endpoint must neutralize the two spend faucets: force-rerun
|
| 100 |
+
# (cache bypass) and a non-Anthropic provider. A force+ollama request should therefore be served
|
| 101 |
+
# from the seeded cache — a live call would 502 here (no key), so a 200 proves both were ignored.
|
| 102 |
+
_seed_cache("camp-005") # provider "anthropic"
|
| 103 |
+
object.__setattr__(api.CONFIG, "public_demo", True)
|
| 104 |
+
try:
|
| 105 |
+
r = client.post("/api/triage", json={"campaign_id": "camp-005", "provider": "ollama", "force": True})
|
| 106 |
+
assert r.status_code == 200, f"lockdown should serve cache, got {r.status_code}: {r.text}"
|
| 107 |
+
body = r.json()
|
| 108 |
+
assert body["provider"] == "anthropic", "lockdown forces the Anthropic provider"
|
| 109 |
+
assert body["decision"]["rule_violations"][0]["rule_id"] == "PROH-3", "served the cached decision"
|
| 110 |
+
finally:
|
| 111 |
+
object.__setattr__(api.CONFIG, "public_demo", False)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
def t_decision_agree_and_override_governance():
|
| 115 |
_seed_cache("camp-005")
|
| 116 |
# Agreeing with the AI (REJECT) needs no reason.
|
|
|
|
| 140 |
("GET /api/policy (5 sections, all rules)", t_policy_sections),
|
| 141 |
("POST /api/decisions 409 before triage", t_decision_requires_triage_first),
|
| 142 |
("POST /api/triage enriches rule text", t_triage_returns_enriched_rule_text),
|
| 143 |
+
("POST /api/triage PUBLIC_DEMO caps spend", t_public_demo_caps_spend),
|
| 144 |
("POST /api/decisions override governance", t_decision_agree_and_override_governance),
|
| 145 |
("GET /api/decisions round-trip", t_decisions_log_roundtrip),
|
| 146 |
]:
|
|
@@ -46,5 +46,10 @@ class Config:
|
|
| 46 |
policy_collection: str = os.getenv("POLICY_COLLECTION", "policy_rules")
|
| 47 |
cases_collection: str = os.getenv("CASES_COLLECTION", "past_cases")
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
CONFIG = Config()
|
|
|
|
| 46 |
policy_collection: str = os.getenv("POLICY_COLLECTION", "policy_rules")
|
| 47 |
cases_collection: str = os.getenv("CASES_COLLECTION", "past_cases")
|
| 48 |
|
| 49 |
+
# Public-demo lockdown (set PUBLIC_DEMO=1 on the deployed Space). When on, the billed
|
| 50 |
+
# /api/triage endpoint refuses to bypass its per-campaign cache and serves Anthropic only, so
|
| 51 |
+
# total spend is bounded by construction to the 18 fixed campaigns once each (then cache hits).
|
| 52 |
+
public_demo: bool = os.getenv("PUBLIC_DEMO", "").strip().lower() in ("1", "true", "yes", "on")
|
| 53 |
+
|
| 54 |
|
| 55 |
CONFIG = Config()
|