Misbahuddin Claude Opus 4.8 (1M context) commited on
Commit
09213ce
·
1 Parent(s): 04a389a

Phase 5 prep: bound /api/triage spend by construction (PUBLIC_DEMO)

Browse files

A 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>

Files changed (5) hide show
  1. Dockerfile +3 -0
  2. STATUS.md +18 -4
  3. api.py +10 -1
  4. scripts/test_api.py +17 -0
  5. src/config.py +5 -0
Dockerfile CHANGED
@@ -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}
STATUS.md CHANGED
@@ -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
- **Remaining (needs Docker Desktop running / an HF account — user actions):** local `docker build` +
342
- `docker run` smoke; create the HF **Docker** Space; set `ANTHROPIC_API_KEY` + `LLM_PROVIDER=anthropic`
343
- as Space secrets; add the Space git remote and push. Recipe in `PLAN.md` §5b.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
api.py CHANGED
@@ -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 req.force and cached["provider"] == provider:
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
scripts/test_api.py CHANGED
@@ -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
  ]:
src/config.py CHANGED
@@ -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()