Spaces:
Sleeping
deploy hardening: access gate, rate limiting, spend guard, env CORS
Browse filesSplit-deploy ready (Vercel frontend + HF backend). The API key is a
server-side secret; the threat is abuse of the endpoints that spend it,
so bound the worst case in layers:
- access code (X-Access-Code) required on all /api/* routes via one
dependency; /health stays open and reports access_required so the
frontend gate auto-skips in local dev (no ACCESS_CODE set)
- per-IP rate limiting (slowapi) on the query endpoints, real IP from
X-Forwarded-For
- in-memory daily spend guard -> 503 once DAILY_SPEND_LIMIT_USD hit;
fed by every run's cost at persistence time
- env-driven CORS allowlist (ALLOWED_ORIGINS)
- locking the code also closes the previously-open /api/runs and
/api/metrics global leaks
Frontend: AccessGate screen, X-Access-Code header on all requests,
401 bounces back to the gate. README gains the split-deploy runbook +
env matrix + security model. slowapi added.
Verified locally: 401 without code, 429 over rate limit, 503 over
spend cap, gate auto-disabled when no ACCESS_CODE.
- README.md +53 -20
- backend/app/config.py +14 -0
- backend/app/main.py +27 -8
- backend/app/pipeline.py +5 -0
- backend/app/security.py +79 -0
- backend/pyproject.toml +1 -0
- backend/uv.lock +115 -0
- frontend/app/page.tsx +30 -1
- frontend/components/AccessGate.tsx +61 -0
- frontend/lib/api.ts +51 -3
|
@@ -61,33 +61,66 @@ FRONTIER_MODEL_ID=deepseek/deepseek-r1 uv run python -m evals.run_evals
|
|
| 61 |
FRONTIER_MODEL_ID=deepseek/deepseek-r1 uv run python -m evals.run_evals --mode frontier
|
| 62 |
```
|
| 63 |
|
| 64 |
-
##
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
| 69 |
|
| 70 |
-
###
|
| 71 |
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
```bash
|
| 74 |
-
git remote add hf https://huggingface.co/spaces/<
|
| 75 |
-
git push hf
|
| 76 |
```
|
| 77 |
-
|
| 78 |
-
- `OPENROUTER_API_KEY` (secret,
|
| 79 |
-
- `
|
| 80 |
-
|
| 81 |
-
- `
|
| 82 |
-
- `
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
Notes:
|
| 87 |
-
-
|
| 88 |
-
|
| 89 |
-
- CPU Basic
|
| 90 |
-
Pro-tier upgraded hardware avoids the ~48h inactivity sleep.
|
| 91 |
|
| 92 |
## Local development
|
| 93 |
|
|
|
|
| 61 |
FRONTIER_MODEL_ID=deepseek/deepseek-r1 uv run python -m evals.run_evals --mode frontier
|
| 62 |
```
|
| 63 |
|
| 64 |
+
## Deployment (Vercel frontend + Hugging Face backend)
|
| 65 |
|
| 66 |
+
The frontend is a pure client-side SPA and the backend is a pure API, so they
|
| 67 |
+
deploy independently: **Vercel** serves the UI, a **Hugging Face Docker Space**
|
| 68 |
+
runs FastAPI. (The `Dockerfile` also bundles the UI, so the Space works
|
| 69 |
+
standalone as a fallback.)
|
| 70 |
|
| 71 |
+
### Security model
|
| 72 |
|
| 73 |
+
The API key is a server-side secret never sent to the browser — the threat is
|
| 74 |
+
*abuse of the endpoints that spend it*. Defense in depth, worst case bounded
|
| 75 |
+
by a number:
|
| 76 |
+
|
| 77 |
+
1. **Credit-capped OpenRouter key** — create a dedicated key with a hard
|
| 78 |
+
credit limit (e.g. $20). Provider-enforced backstop; survives any app bug.
|
| 79 |
+
2. **Daily spend guard** — `DAILY_SPEND_LIMIT_USD`; query endpoints return 503
|
| 80 |
+
once the day's total is exceeded (in-memory, resets on restart).
|
| 81 |
+
3. **Access code** — every `/api/*` route requires the `X-Access-Code` header
|
| 82 |
+
(`ACCESS_CODE`); share the code with viewers. `/health` stays open.
|
| 83 |
+
4. **Per-IP rate limiting** — `RATE_LIMIT_PER_MIN` / `_PER_DAY` on the query
|
| 84 |
+
endpoints (real IP read from `X-Forwarded-For`).
|
| 85 |
+
5. **CORS** — `ALLOWED_ORIGINS` allowlist (browsers only; not a security
|
| 86 |
+
boundary — layers 1–4 are).
|
| 87 |
+
|
| 88 |
+
Locking the access code also closes the run-history / metrics endpoints, which
|
| 89 |
+
otherwise expose every visitor's queries and total spend.
|
| 90 |
+
|
| 91 |
+
### Backend → Hugging Face Docker Space
|
| 92 |
+
|
| 93 |
+
1. Create the credit-capped OpenRouter key.
|
| 94 |
+
2. Push this repo to a **Docker** Space:
|
| 95 |
```bash
|
| 96 |
+
git remote add hf https://huggingface.co/spaces/<user>/AuctionRouter
|
| 97 |
+
git push hf main:main
|
| 98 |
```
|
| 99 |
+
3. **Settings → Variables and secrets:**
|
| 100 |
+
- `OPENROUTER_API_KEY` (secret, the credit-capped key)
|
| 101 |
+
- `ACCESS_CODE` (secret, the shared demo code)
|
| 102 |
+
- `ALLOWED_ORIGINS` (variable, your Vercel URL, comma-separated with any others)
|
| 103 |
+
- `DAILY_SPEND_LIMIT_USD` (variable, e.g. `20`)
|
| 104 |
+
- `MONGODB_URI` / `MONGODB_DB` (secret/variable, optional — Atlas M0; without
|
| 105 |
+
it an in-memory store is used that resets on restart)
|
| 106 |
+
- `FRONTIER_MODEL_ID` (variable, optional, default `openai/gpt-5`)
|
| 107 |
+
4. If using Atlas, allow `0.0.0.0/0` in its Network Access list (Space IPs
|
| 108 |
+
aren't static).
|
| 109 |
+
5. Confirm `<space-url>/health` returns `openrouter_key_set: true`.
|
| 110 |
+
|
| 111 |
+
### Frontend → Vercel
|
| 112 |
+
|
| 113 |
+
1. Import `frontend/` as a Vercel project (auto-detected Next.js).
|
| 114 |
+
2. Set env `NEXT_PUBLIC_API_BASE` = the HF Space URL. (Do **not** put the
|
| 115 |
+
access code here — it's entered at runtime.)
|
| 116 |
+
3. Deploy, then add the Vercel domain to the Space's `ALLOWED_ORIGINS` and
|
| 117 |
+
redeploy the Space.
|
| 118 |
+
4. Open the Vercel URL → enter the access code → run a query.
|
| 119 |
|
| 120 |
Notes:
|
| 121 |
+
- HF free tier sleeps after ~48h idle → first query cold-starts ~30s. HF Pro
|
| 122 |
+
or an always-on backend (Fly.io / Render, same Dockerfile) removes this.
|
| 123 |
+
- CPU Basic is sufficient — all inference happens on OpenRouter.
|
|
|
|
| 124 |
|
| 125 |
## Local development
|
| 126 |
|
|
@@ -100,6 +100,20 @@ class Settings(BaseSettings):
|
|
| 100 |
# empty string keeps OpenRouter's default (price)
|
| 101 |
openrouter_provider_sort: str = "latency"
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
mongodb_uri: str = "" # empty -> in-memory store
|
| 104 |
mongodb_db: str = "auctionrouter"
|
| 105 |
|
|
|
|
| 100 |
# empty string keeps OpenRouter's default (price)
|
| 101 |
openrouter_provider_sort: str = "latency"
|
| 102 |
|
| 103 |
+
# --- Deployment / abuse protection --------------------------------------
|
| 104 |
+
# Shared access code required on every /api/* request (X-Access-Code
|
| 105 |
+
# header). Empty string disables the gate (local dev convenience).
|
| 106 |
+
access_code: str = ""
|
| 107 |
+
# Comma-separated browser origins allowed by CORS (localhost + the
|
| 108 |
+
# deployed frontend). NOT a security boundary — curl ignores CORS.
|
| 109 |
+
allowed_origins: str = "http://localhost:3000"
|
| 110 |
+
# Hard daily spend ceiling (USD, UTC day). Query endpoints 503 once
|
| 111 |
+
# exceeded. The credit-capped OpenRouter key is the true backstop.
|
| 112 |
+
daily_spend_limit_usd: float = 20.0
|
| 113 |
+
# Per-IP rate limits on the query endpoints
|
| 114 |
+
rate_limit_per_min: int = 15
|
| 115 |
+
rate_limit_per_day: int = 150
|
| 116 |
+
|
| 117 |
mongodb_uri: str = "" # empty -> in-memory store
|
| 118 |
mongodb_db: str = "auctionrouter"
|
| 119 |
|
|
@@ -7,18 +7,21 @@ from dotenv import load_dotenv
|
|
| 7 |
|
| 8 |
load_dotenv()
|
| 9 |
|
| 10 |
-
from fastapi import FastAPI, HTTPException # noqa: E402
|
| 11 |
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
| 12 |
from fastapi.staticfiles import StaticFiles # noqa: E402
|
| 13 |
|
| 14 |
import json # noqa: E402
|
| 15 |
|
| 16 |
from fastapi.responses import StreamingResponse # noqa: E402
|
|
|
|
|
|
|
| 17 |
|
| 18 |
from .config import TIER1_MODELS, TIER2_MODEL, VERIFIER_MODEL, settings # noqa: E402
|
| 19 |
from .llm import close_client # noqa: E402
|
| 20 |
from .pipeline import run_query, run_query_stream # noqa: E402
|
| 21 |
from .schemas import MetricsSummary, QueryRequest, RunResult # noqa: E402
|
|
|
|
| 22 |
from .store import get_store # noqa: E402
|
| 23 |
|
| 24 |
|
|
@@ -30,9 +33,16 @@ async def lifespan(app: FastAPI):
|
|
| 30 |
|
| 31 |
app = FastAPI(title="AuctionRouter", version="0.1.0", lifespan=lifespan)
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
app.add_middleware(
|
| 34 |
CORSMiddleware,
|
| 35 |
-
allow_origins=["
|
| 36 |
allow_methods=["*"],
|
| 37 |
allow_headers=["*"],
|
| 38 |
)
|
|
@@ -40,9 +50,11 @@ app.add_middleware(
|
|
| 40 |
|
| 41 |
@app.get("/health")
|
| 42 |
async def health():
|
|
|
|
| 43 |
return {
|
| 44 |
"status": "ok",
|
| 45 |
"openrouter_key_set": bool(settings.openrouter_api_key),
|
|
|
|
| 46 |
"store": "mongodb" if settings.mongodb_uri else "memory",
|
| 47 |
"tier1_models": [m.openrouter_id for m in TIER1_MODELS.values()],
|
| 48 |
"verifier": VERIFIER_MODEL.openrouter_id,
|
|
@@ -50,18 +62,23 @@ async def health():
|
|
| 50 |
}
|
| 51 |
|
| 52 |
|
| 53 |
-
@app.post("/api/query", response_model=RunResult
|
| 54 |
-
|
|
|
|
|
|
|
| 55 |
if not settings.openrouter_api_key:
|
| 56 |
raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY is not set")
|
|
|
|
| 57 |
return await run_query(req.query, [t.model_dump() for t in req.history],
|
| 58 |
req.hint)
|
| 59 |
|
| 60 |
|
| 61 |
-
@app.post("/api/query/stream")
|
| 62 |
-
|
|
|
|
| 63 |
if not settings.openrouter_api_key:
|
| 64 |
raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY is not set")
|
|
|
|
| 65 |
|
| 66 |
history = [t.model_dump() for t in req.history]
|
| 67 |
|
|
@@ -75,12 +92,14 @@ async def query_stream(req: QueryRequest):
|
|
| 75 |
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
| 76 |
|
| 77 |
|
| 78 |
-
@app.get("/api/runs", response_model=list[RunResult]
|
|
|
|
| 79 |
async def runs(limit: int = 50):
|
| 80 |
return await get_store().list_runs(min(limit, 200))
|
| 81 |
|
| 82 |
|
| 83 |
-
@app.get("/api/metrics", response_model=MetricsSummary
|
|
|
|
| 84 |
async def metrics():
|
| 85 |
return await get_store().metrics()
|
| 86 |
|
|
|
|
| 7 |
|
| 8 |
load_dotenv()
|
| 9 |
|
| 10 |
+
from fastapi import Depends, FastAPI, HTTPException, Request # noqa: E402
|
| 11 |
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
| 12 |
from fastapi.staticfiles import StaticFiles # noqa: E402
|
| 13 |
|
| 14 |
import json # noqa: E402
|
| 15 |
|
| 16 |
from fastapi.responses import StreamingResponse # noqa: E402
|
| 17 |
+
from slowapi import _rate_limit_exceeded_handler # noqa: E402
|
| 18 |
+
from slowapi.errors import RateLimitExceeded # noqa: E402
|
| 19 |
|
| 20 |
from .config import TIER1_MODELS, TIER2_MODEL, VERIFIER_MODEL, settings # noqa: E402
|
| 21 |
from .llm import close_client # noqa: E402
|
| 22 |
from .pipeline import run_query, run_query_stream # noqa: E402
|
| 23 |
from .schemas import MetricsSummary, QueryRequest, RunResult # noqa: E402
|
| 24 |
+
from .security import RATE_LIMITS, limiter, require_access, spend_guard # noqa: E402
|
| 25 |
from .store import get_store # noqa: E402
|
| 26 |
|
| 27 |
|
|
|
|
| 33 |
|
| 34 |
app = FastAPI(title="AuctionRouter", version="0.1.0", lifespan=lifespan)
|
| 35 |
|
| 36 |
+
# Per-IP rate limiting (slowapi)
|
| 37 |
+
app.state.limiter = limiter
|
| 38 |
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 39 |
+
|
| 40 |
+
# Browser-origin allowlist. NOT a security boundary (curl ignores CORS) —
|
| 41 |
+
# the access code + rate limits + spend guard are. Just lets the deployed
|
| 42 |
+
# frontend call the API from its own origin.
|
| 43 |
app.add_middleware(
|
| 44 |
CORSMiddleware,
|
| 45 |
+
allow_origins=[o.strip() for o in settings.allowed_origins.split(",") if o.strip()],
|
| 46 |
allow_methods=["*"],
|
| 47 |
allow_headers=["*"],
|
| 48 |
)
|
|
|
|
| 50 |
|
| 51 |
@app.get("/health")
|
| 52 |
async def health():
|
| 53 |
+
# Open (no access code): HF Spaces healthcheck. Returns no secrets.
|
| 54 |
return {
|
| 55 |
"status": "ok",
|
| 56 |
"openrouter_key_set": bool(settings.openrouter_api_key),
|
| 57 |
+
"access_required": bool(settings.access_code),
|
| 58 |
"store": "mongodb" if settings.mongodb_uri else "memory",
|
| 59 |
"tier1_models": [m.openrouter_id for m in TIER1_MODELS.values()],
|
| 60 |
"verifier": VERIFIER_MODEL.openrouter_id,
|
|
|
|
| 62 |
}
|
| 63 |
|
| 64 |
|
| 65 |
+
@app.post("/api/query", response_model=RunResult,
|
| 66 |
+
dependencies=[Depends(require_access)])
|
| 67 |
+
@limiter.limit(RATE_LIMITS)
|
| 68 |
+
async def query(request: Request, req: QueryRequest):
|
| 69 |
if not settings.openrouter_api_key:
|
| 70 |
raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY is not set")
|
| 71 |
+
spend_guard.check()
|
| 72 |
return await run_query(req.query, [t.model_dump() for t in req.history],
|
| 73 |
req.hint)
|
| 74 |
|
| 75 |
|
| 76 |
+
@app.post("/api/query/stream", dependencies=[Depends(require_access)])
|
| 77 |
+
@limiter.limit(RATE_LIMITS)
|
| 78 |
+
async def query_stream(request: Request, req: QueryRequest):
|
| 79 |
if not settings.openrouter_api_key:
|
| 80 |
raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY is not set")
|
| 81 |
+
spend_guard.check()
|
| 82 |
|
| 83 |
history = [t.model_dump() for t in req.history]
|
| 84 |
|
|
|
|
| 92 |
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
| 93 |
|
| 94 |
|
| 95 |
+
@app.get("/api/runs", response_model=list[RunResult],
|
| 96 |
+
dependencies=[Depends(require_access)])
|
| 97 |
async def runs(limit: int = 50):
|
| 98 |
return await get_store().list_runs(min(limit, 200))
|
| 99 |
|
| 100 |
|
| 101 |
+
@app.get("/api/metrics", response_model=MetricsSummary,
|
| 102 |
+
dependencies=[Depends(require_access)])
|
| 103 |
async def metrics():
|
| 104 |
return await get_store().metrics()
|
| 105 |
|
|
@@ -479,6 +479,11 @@ _save_tasks: set[asyncio.Task] = set()
|
|
| 479 |
|
| 480 |
|
| 481 |
def _save_run_bg(run: RunResult) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
task = asyncio.ensure_future(get_store().save_run(run))
|
| 483 |
_save_tasks.add(task)
|
| 484 |
|
|
|
|
| 479 |
|
| 480 |
|
| 481 |
def _save_run_bg(run: RunResult) -> None:
|
| 482 |
+
# Count this run's cost toward the daily spend ceiling (both query and
|
| 483 |
+
# stream paths persist here, so the guard sees every run)
|
| 484 |
+
from .security import spend_guard
|
| 485 |
+
spend_guard.add(run.total_cost_usd)
|
| 486 |
+
|
| 487 |
task = asyncio.ensure_future(get_store().save_run(run))
|
| 488 |
_save_tasks.add(task)
|
| 489 |
|
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Abuse protection: access-code gate, per-IP rate limiting, spend guard.
|
| 2 |
+
|
| 3 |
+
The API key is a server-side secret and is never sent to the browser, so it
|
| 4 |
+
cannot be stolen — the threat is abuse of the endpoints that *spend* it. These
|
| 5 |
+
layers bound the worst case; the true backstop is a credit-capped OpenRouter
|
| 6 |
+
key (provider-enforced, outside this code).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import hmac
|
| 10 |
+
from datetime import datetime, timezone
|
| 11 |
+
|
| 12 |
+
from fastapi import Header, HTTPException, Request
|
| 13 |
+
from slowapi import Limiter
|
| 14 |
+
from slowapi.util import get_remote_address
|
| 15 |
+
|
| 16 |
+
from .config import settings
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _client_ip(request: Request) -> str:
|
| 20 |
+
"""Real client IP behind HF Spaces' / Vercel's proxy."""
|
| 21 |
+
fwd = request.headers.get("x-forwarded-for")
|
| 22 |
+
if fwd:
|
| 23 |
+
return fwd.split(",")[0].strip()
|
| 24 |
+
return get_remote_address(request)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
limiter = Limiter(key_func=_client_ip)
|
| 28 |
+
|
| 29 |
+
# Rate strings shared by the query endpoints' decorators
|
| 30 |
+
RATE_LIMITS = f"{settings.rate_limit_per_min}/minute;{settings.rate_limit_per_day}/day"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def require_access(x_access_code: str = Header(default="")) -> None:
|
| 34 |
+
"""Dependency: gate every /api/* route behind the shared access code.
|
| 35 |
+
|
| 36 |
+
Empty ACCESS_CODE disables the gate (local dev). Constant-time compare so
|
| 37 |
+
the check can't be timing-probed.
|
| 38 |
+
"""
|
| 39 |
+
expected = settings.access_code
|
| 40 |
+
if not expected:
|
| 41 |
+
return
|
| 42 |
+
if not hmac.compare_digest(x_access_code, expected):
|
| 43 |
+
raise HTTPException(status_code=401, detail="Invalid or missing access code")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class SpendGuard:
|
| 47 |
+
"""In-memory daily spend ceiling (UTC day). Resets on restart, which is
|
| 48 |
+
fine: the credit-capped OpenRouter key is the real backstop, this is just
|
| 49 |
+
the graceful early stop."""
|
| 50 |
+
|
| 51 |
+
def __init__(self) -> None:
|
| 52 |
+
self._day = ""
|
| 53 |
+
self._spent = 0.0
|
| 54 |
+
|
| 55 |
+
def _roll(self) -> None:
|
| 56 |
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
| 57 |
+
if today != self._day:
|
| 58 |
+
self._day, self._spent = today, 0.0
|
| 59 |
+
|
| 60 |
+
def check(self) -> None:
|
| 61 |
+
"""Raise 503 if today's spend is already over the limit."""
|
| 62 |
+
self._roll()
|
| 63 |
+
if self._spent >= settings.daily_spend_limit_usd:
|
| 64 |
+
raise HTTPException(
|
| 65 |
+
status_code=503,
|
| 66 |
+
detail="Daily budget reached — try again tomorrow.",
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
def add(self, cost_usd: float) -> None:
|
| 70 |
+
self._roll()
|
| 71 |
+
self._spent += max(cost_usd, 0.0)
|
| 72 |
+
|
| 73 |
+
@property
|
| 74 |
+
def spent_today(self) -> float:
|
| 75 |
+
self._roll()
|
| 76 |
+
return self._spent
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
spend_guard = SpendGuard()
|
|
@@ -12,6 +12,7 @@ dependencies = [
|
|
| 12 |
"langgraph>=0.2.60",
|
| 13 |
"motor>=3.5",
|
| 14 |
"python-dotenv>=1.0",
|
|
|
|
| 15 |
]
|
| 16 |
|
| 17 |
[dependency-groups]
|
|
|
|
| 12 |
"langgraph>=0.2.60",
|
| 13 |
"motor>=3.5",
|
| 14 |
"python-dotenv>=1.0",
|
| 15 |
+
"slowapi>=0.1.9",
|
| 16 |
]
|
| 17 |
|
| 18 |
[dependency-groups]
|
|
@@ -45,6 +45,7 @@ dependencies = [
|
|
| 45 |
{ name = "pydantic" },
|
| 46 |
{ name = "pydantic-settings" },
|
| 47 |
{ name = "python-dotenv" },
|
|
|
|
| 48 |
{ name = "uvicorn", extra = ["standard"] },
|
| 49 |
]
|
| 50 |
|
|
@@ -63,6 +64,7 @@ requires-dist = [
|
|
| 63 |
{ name = "pydantic", specifier = ">=2.7" },
|
| 64 |
{ name = "pydantic-settings", specifier = ">=2.3" },
|
| 65 |
{ name = "python-dotenv", specifier = ">=1.0" },
|
|
|
|
| 66 |
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
| 67 |
]
|
| 68 |
|
|
@@ -176,6 +178,18 @@ wheels = [
|
|
| 176 |
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
| 177 |
]
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
[[package]]
|
| 180 |
name = "distro"
|
| 181 |
version = "1.9.0"
|
|
@@ -445,6 +459,20 @@ wheels = [
|
|
| 445 |
{ url = "https://files.pythonhosted.org/packages/7e/62/6339eae6b8c9ec941b06dc09fe05e97f586e91d2af4378a428070bab8d5d/langsmith-0.10.3-py3-none-any.whl", hash = "sha256:40fe55aab588ba5eddd462c9710ac10754ed0530366f1605969e054cbe03f8ca", size = 654001, upload-time = "2026-07-14T09:12:24.671Z" },
|
| 446 |
]
|
| 447 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
[[package]]
|
| 449 |
name = "motor"
|
| 450 |
version = "3.7.1"
|
|
@@ -912,6 +940,18 @@ wheels = [
|
|
| 912 |
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
|
| 913 |
]
|
| 914 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 915 |
[[package]]
|
| 916 |
name = "sniffio"
|
| 917 |
version = "1.3.1"
|
|
@@ -1268,6 +1308,81 @@ wheels = [
|
|
| 1268 |
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
| 1269 |
]
|
| 1270 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1271 |
[[package]]
|
| 1272 |
name = "xxhash"
|
| 1273 |
version = "3.8.1"
|
|
|
|
| 45 |
{ name = "pydantic" },
|
| 46 |
{ name = "pydantic-settings" },
|
| 47 |
{ name = "python-dotenv" },
|
| 48 |
+
{ name = "slowapi" },
|
| 49 |
{ name = "uvicorn", extra = ["standard"] },
|
| 50 |
]
|
| 51 |
|
|
|
|
| 64 |
{ name = "pydantic", specifier = ">=2.7" },
|
| 65 |
{ name = "pydantic-settings", specifier = ">=2.3" },
|
| 66 |
{ name = "python-dotenv", specifier = ">=1.0" },
|
| 67 |
+
{ name = "slowapi", specifier = ">=0.1.9" },
|
| 68 |
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
| 69 |
]
|
| 70 |
|
|
|
|
| 178 |
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
| 179 |
]
|
| 180 |
|
| 181 |
+
[[package]]
|
| 182 |
+
name = "deprecated"
|
| 183 |
+
version = "1.3.1"
|
| 184 |
+
source = { registry = "https://pypi.org/simple" }
|
| 185 |
+
dependencies = [
|
| 186 |
+
{ name = "wrapt" },
|
| 187 |
+
]
|
| 188 |
+
sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
|
| 189 |
+
wheels = [
|
| 190 |
+
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
|
| 191 |
+
]
|
| 192 |
+
|
| 193 |
[[package]]
|
| 194 |
name = "distro"
|
| 195 |
version = "1.9.0"
|
|
|
|
| 459 |
{ url = "https://files.pythonhosted.org/packages/7e/62/6339eae6b8c9ec941b06dc09fe05e97f586e91d2af4378a428070bab8d5d/langsmith-0.10.3-py3-none-any.whl", hash = "sha256:40fe55aab588ba5eddd462c9710ac10754ed0530366f1605969e054cbe03f8ca", size = 654001, upload-time = "2026-07-14T09:12:24.671Z" },
|
| 460 |
]
|
| 461 |
|
| 462 |
+
[[package]]
|
| 463 |
+
name = "limits"
|
| 464 |
+
version = "5.8.0"
|
| 465 |
+
source = { registry = "https://pypi.org/simple" }
|
| 466 |
+
dependencies = [
|
| 467 |
+
{ name = "deprecated" },
|
| 468 |
+
{ name = "packaging" },
|
| 469 |
+
{ name = "typing-extensions" },
|
| 470 |
+
]
|
| 471 |
+
sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" }
|
| 472 |
+
wheels = [
|
| 473 |
+
{ url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" },
|
| 474 |
+
]
|
| 475 |
+
|
| 476 |
[[package]]
|
| 477 |
name = "motor"
|
| 478 |
version = "3.7.1"
|
|
|
|
| 940 |
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
|
| 941 |
]
|
| 942 |
|
| 943 |
+
[[package]]
|
| 944 |
+
name = "slowapi"
|
| 945 |
+
version = "0.1.10"
|
| 946 |
+
source = { registry = "https://pypi.org/simple" }
|
| 947 |
+
dependencies = [
|
| 948 |
+
{ name = "limits" },
|
| 949 |
+
]
|
| 950 |
+
sdist = { url = "https://files.pythonhosted.org/packages/b9/52/24527cf25a8b508926aff53350b0136561dfe86c7125f61526653666e1b2/slowapi-0.1.10.tar.gz", hash = "sha256:d320d5bc04d9f171a77fb16700faf3036d85b00f420f22924c8a225f95bd14f9", size = 13841, upload-time = "2026-06-13T11:59:31.571Z" }
|
| 951 |
+
wheels = [
|
| 952 |
+
{ url = "https://files.pythonhosted.org/packages/c4/8b/1d359f38706b4097d9a943bf8bd22599f537de4cbaff1e622d3e3936e164/slowapi-0.1.10-py3-none-any.whl", hash = "sha256:3acb61561dc9d687e3d3669362ff6a439de9ba44e2fed3a9c165da26b4b83e28", size = 14921, upload-time = "2026-06-13T11:59:30.485Z" },
|
| 953 |
+
]
|
| 954 |
+
|
| 955 |
[[package]]
|
| 956 |
name = "sniffio"
|
| 957 |
version = "1.3.1"
|
|
|
|
| 1308 |
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
| 1309 |
]
|
| 1310 |
|
| 1311 |
+
[[package]]
|
| 1312 |
+
name = "wrapt"
|
| 1313 |
+
version = "2.2.2"
|
| 1314 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1315 |
+
sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" }
|
| 1316 |
+
wheels = [
|
| 1317 |
+
{ url = "https://files.pythonhosted.org/packages/27/15/0c2d55168707465abfc41f33c0b23d792a5fa9b65c26983606940900a120/wrapt-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f1a2ff355ece6a111ca7a20dc86df6659c9205d3fcee674ca34f2a2854fd4e73", size = 80782, upload-time = "2026-06-20T23:47:44.367Z" },
|
| 1318 |
+
{ url = "https://files.pythonhosted.org/packages/7d/b5/5c0b093eb48f8a062ef6267d3cb36e9bb1b88440181f6545a383c60efdf8/wrapt-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55b9a899e6fff5444f229d30aa6e9ac92d2216d9d60f33c771b5d76a760d5f8e", size = 81678, upload-time = "2026-06-20T23:47:45.857Z" },
|
| 1319 |
+
{ url = "https://files.pythonhosted.org/packages/34/f3/de70937472dd3e8a4e6811192f9c6075efdffd4a2cd9b4596bf160f89668/wrapt-2.2.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a2d78c363f97d8bd718ee40432c66395685e9e98528ccaa423c3355d1715a26d", size = 159671, upload-time = "2026-06-20T23:47:47.345Z" },
|
| 1320 |
+
{ url = "https://files.pythonhosted.org/packages/a5/ec/40aed2330e7f02ecf74386ffcfef9ccb7108c6a430f15b6a252b663b1bed/wrapt-2.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d619e1eed9bd4f6ed9f24cd61971aa086fa86505289628d464bcf8a2c2e3f328", size = 160785, upload-time = "2026-06-20T23:47:48.759Z" },
|
| 1321 |
+
{ url = "https://files.pythonhosted.org/packages/45/04/aa5309beed5344b00220ae6b3b24055852192656194c27947bee1736306a/wrapt-2.2.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:518b0c5e323511ec56a38894802ddd5e1222626484e68efe63f201854ad788e5", size = 153699, upload-time = "2026-06-20T23:47:50.177Z" },
|
| 1322 |
+
{ url = "https://files.pythonhosted.org/packages/01/df/2def7e99d1fe87eea413f95f671924cdddcb08823b1ffd212748dfa6d062/wrapt-2.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4bccea5cdecffa9dd70e343741f0e41e0a16619313d04b72f78bb525162ebcd0", size = 159695, upload-time = "2026-06-20T23:47:51.602Z" },
|
| 1323 |
+
{ url = "https://files.pythonhosted.org/packages/c7/f6/a906d01a2ce12157bad2404957b3e2140da354b8a70b2fa48bbf282871c0/wrapt-2.2.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:209112cafd963710a05d199aae431d79a28bc76eb8e6d1bbbb8ad24340722cae", size = 152813, upload-time = "2026-06-20T23:47:53.03Z" },
|
| 1324 |
+
{ url = "https://files.pythonhosted.org/packages/02/49/bc0086292d239575b4c08f4cf8a4079fa58abbad58ec23abf84833a283ed/wrapt-2.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5a5290e4bf2f332fc29ce72ffb9a2fff678aaac047e2e9f5f7165cd7792e099", size = 158809, upload-time = "2026-06-20T23:47:54.391Z" },
|
| 1325 |
+
{ url = "https://files.pythonhosted.org/packages/55/83/8fbd034de1f3e907edaa18786d5dd8f6932874edee0826c7cecb5cab03a1/wrapt-2.2.2-cp311-cp311-win32.whl", hash = "sha256:5499236ad1dc116012e2a5dd943f3f31af12fce452128e2bbcbd55a7d3d4d14c", size = 77414, upload-time = "2026-06-20T23:47:55.882Z" },
|
| 1326 |
+
{ url = "https://files.pythonhosted.org/packages/7e/9c/23695baa331c6de4e874c3d78b8e0bed92e1d2a274e665b29858f6841672/wrapt-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:8636809939152be6ae20a6cef0fed9fe60f411b47847d0426a826884b469e971", size = 80368, upload-time = "2026-06-20T23:47:57.237Z" },
|
| 1327 |
+
{ url = "https://files.pythonhosted.org/packages/08/49/40cefc342bf89b234a4490d741290fce781774b831aefb39c25471da96c9/wrapt-2.2.2-cp311-cp311-win_arm64.whl", hash = "sha256:5d0a142f7af07caeb5e5da87493162a7b8efa19ba919e550a746f7446e13fb30", size = 79489, upload-time = "2026-06-20T23:47:58.56Z" },
|
| 1328 |
+
{ url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" },
|
| 1329 |
+
{ url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" },
|
| 1330 |
+
{ url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" },
|
| 1331 |
+
{ url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" },
|
| 1332 |
+
{ url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" },
|
| 1333 |
+
{ url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" },
|
| 1334 |
+
{ url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" },
|
| 1335 |
+
{ url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" },
|
| 1336 |
+
{ url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" },
|
| 1337 |
+
{ url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" },
|
| 1338 |
+
{ url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" },
|
| 1339 |
+
{ url = "https://files.pythonhosted.org/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69", size = 81296, upload-time = "2026-06-20T23:48:15.881Z" },
|
| 1340 |
+
{ url = "https://files.pythonhosted.org/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617", size = 81841, upload-time = "2026-06-20T23:48:17.262Z" },
|
| 1341 |
+
{ url = "https://files.pythonhosted.org/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e", size = 167882, upload-time = "2026-06-20T23:48:18.764Z" },
|
| 1342 |
+
{ url = "https://files.pythonhosted.org/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d", size = 167411, upload-time = "2026-06-20T23:48:20.315Z" },
|
| 1343 |
+
{ url = "https://files.pythonhosted.org/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b", size = 158607, upload-time = "2026-06-20T23:48:21.728Z" },
|
| 1344 |
+
{ url = "https://files.pythonhosted.org/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c", size = 166367, upload-time = "2026-06-20T23:48:23.177Z" },
|
| 1345 |
+
{ url = "https://files.pythonhosted.org/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f", size = 157176, upload-time = "2026-06-20T23:48:24.711Z" },
|
| 1346 |
+
{ url = "https://files.pythonhosted.org/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94", size = 167025, upload-time = "2026-06-20T23:48:26.118Z" },
|
| 1347 |
+
{ url = "https://files.pythonhosted.org/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d", size = 77605, upload-time = "2026-06-20T23:48:27.643Z" },
|
| 1348 |
+
{ url = "https://files.pythonhosted.org/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4", size = 80508, upload-time = "2026-06-20T23:48:29.013Z" },
|
| 1349 |
+
{ url = "https://files.pythonhosted.org/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac", size = 79565, upload-time = "2026-06-20T23:48:30.429Z" },
|
| 1350 |
+
{ url = "https://files.pythonhosted.org/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5", size = 83264, upload-time = "2026-06-20T23:48:31.807Z" },
|
| 1351 |
+
{ url = "https://files.pythonhosted.org/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec", size = 83791, upload-time = "2026-06-20T23:48:33.482Z" },
|
| 1352 |
+
{ url = "https://files.pythonhosted.org/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9", size = 203399, upload-time = "2026-06-20T23:48:34.97Z" },
|
| 1353 |
+
{ url = "https://files.pythonhosted.org/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c", size = 210461, upload-time = "2026-06-20T23:48:36.569Z" },
|
| 1354 |
+
{ url = "https://files.pythonhosted.org/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194", size = 195313, upload-time = "2026-06-20T23:48:38.175Z" },
|
| 1355 |
+
{ url = "https://files.pythonhosted.org/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066", size = 206116, upload-time = "2026-06-20T23:48:39.804Z" },
|
| 1356 |
+
{ url = "https://files.pythonhosted.org/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20", size = 192668, upload-time = "2026-06-20T23:48:41.346Z" },
|
| 1357 |
+
{ url = "https://files.pythonhosted.org/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af", size = 198891, upload-time = "2026-06-20T23:48:43.056Z" },
|
| 1358 |
+
{ url = "https://files.pythonhosted.org/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9", size = 78537, upload-time = "2026-06-20T23:48:44.509Z" },
|
| 1359 |
+
{ url = "https://files.pythonhosted.org/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28", size = 82005, upload-time = "2026-06-20T23:48:46.391Z" },
|
| 1360 |
+
{ url = "https://files.pythonhosted.org/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0", size = 80762, upload-time = "2026-06-20T23:48:47.964Z" },
|
| 1361 |
+
{ url = "https://files.pythonhosted.org/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745", size = 81341, upload-time = "2026-06-20T23:48:49.39Z" },
|
| 1362 |
+
{ url = "https://files.pythonhosted.org/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00", size = 81921, upload-time = "2026-06-20T23:48:50.986Z" },
|
| 1363 |
+
{ url = "https://files.pythonhosted.org/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc", size = 167713, upload-time = "2026-06-20T23:48:52.598Z" },
|
| 1364 |
+
{ url = "https://files.pythonhosted.org/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95", size = 166779, upload-time = "2026-06-20T23:48:54.33Z" },
|
| 1365 |
+
{ url = "https://files.pythonhosted.org/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33", size = 158407, upload-time = "2026-06-20T23:48:55.944Z" },
|
| 1366 |
+
{ url = "https://files.pythonhosted.org/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab", size = 166594, upload-time = "2026-06-20T23:48:57.617Z" },
|
| 1367 |
+
{ url = "https://files.pythonhosted.org/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663", size = 157068, upload-time = "2026-06-20T23:48:59.171Z" },
|
| 1368 |
+
{ url = "https://files.pythonhosted.org/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d", size = 166470, upload-time = "2026-06-20T23:49:00.737Z" },
|
| 1369 |
+
{ url = "https://files.pythonhosted.org/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56", size = 78062, upload-time = "2026-06-20T23:49:02.327Z" },
|
| 1370 |
+
{ url = "https://files.pythonhosted.org/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406", size = 80832, upload-time = "2026-06-20T23:49:03.837Z" },
|
| 1371 |
+
{ url = "https://files.pythonhosted.org/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c", size = 80029, upload-time = "2026-06-20T23:49:05.237Z" },
|
| 1372 |
+
{ url = "https://files.pythonhosted.org/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a", size = 83357, upload-time = "2026-06-20T23:49:06.632Z" },
|
| 1373 |
+
{ url = "https://files.pythonhosted.org/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063", size = 83794, upload-time = "2026-06-20T23:49:08.098Z" },
|
| 1374 |
+
{ url = "https://files.pythonhosted.org/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f", size = 203362, upload-time = "2026-06-20T23:49:09.811Z" },
|
| 1375 |
+
{ url = "https://files.pythonhosted.org/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81", size = 210449, upload-time = "2026-06-20T23:49:11.521Z" },
|
| 1376 |
+
{ url = "https://files.pythonhosted.org/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c", size = 195349, upload-time = "2026-06-20T23:49:13.346Z" },
|
| 1377 |
+
{ url = "https://files.pythonhosted.org/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff", size = 206099, upload-time = "2026-06-20T23:49:15.051Z" },
|
| 1378 |
+
{ url = "https://files.pythonhosted.org/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5", size = 192728, upload-time = "2026-06-20T23:49:16.854Z" },
|
| 1379 |
+
{ url = "https://files.pythonhosted.org/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c", size = 198842, upload-time = "2026-06-20T23:49:18.504Z" },
|
| 1380 |
+
{ url = "https://files.pythonhosted.org/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca", size = 79059, upload-time = "2026-06-20T23:49:20.107Z" },
|
| 1381 |
+
{ url = "https://files.pythonhosted.org/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77", size = 82462, upload-time = "2026-06-20T23:49:21.631Z" },
|
| 1382 |
+
{ url = "https://files.pythonhosted.org/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347", size = 81182, upload-time = "2026-06-20T23:49:23.199Z" },
|
| 1383 |
+
{ url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" },
|
| 1384 |
+
]
|
| 1385 |
+
|
| 1386 |
[[package]]
|
| 1387 |
name = "xxhash"
|
| 1388 |
version = "3.8.1"
|
|
@@ -1,12 +1,14 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useState } from "react";
|
|
|
|
| 4 |
import { AuctionPanel } from "@/components/AuctionPanel";
|
| 5 |
import { BidArcade } from "@/components/BidArcade";
|
| 6 |
import { Chat } from "@/components/Chat";
|
| 7 |
import { MetricsDashboard } from "@/components/MetricsDashboard";
|
| 8 |
import { RoutingGraph } from "@/components/RoutingGraph";
|
| 9 |
import { VerificationPanel } from "@/components/VerificationPanel";
|
|
|
|
| 10 |
import type { RunResult } from "@/lib/types";
|
| 11 |
|
| 12 |
type Tab = "chat" | "metrics";
|
|
@@ -16,6 +18,33 @@ export default function Home() {
|
|
| 16 |
const [selectedRun, setSelectedRun] = useState<RunResult | null>(null);
|
| 17 |
const [runCount, setRunCount] = useState(0);
|
| 18 |
const [sideOpen, setSideOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
return (
|
| 21 |
<div className="mx-auto flex h-screen w-full flex-col px-6 py-4">
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useEffect, useState } from "react";
|
| 4 |
+
import { AccessGate } from "@/components/AccessGate";
|
| 5 |
import { AuctionPanel } from "@/components/AuctionPanel";
|
| 6 |
import { BidArcade } from "@/components/BidArcade";
|
| 7 |
import { Chat } from "@/components/Chat";
|
| 8 |
import { MetricsDashboard } from "@/components/MetricsDashboard";
|
| 9 |
import { RoutingGraph } from "@/components/RoutingGraph";
|
| 10 |
import { VerificationPanel } from "@/components/VerificationPanel";
|
| 11 |
+
import { fetchHealth, getAccessCode, onAuthFailure } from "@/lib/api";
|
| 12 |
import type { RunResult } from "@/lib/types";
|
| 13 |
|
| 14 |
type Tab = "chat" | "metrics";
|
|
|
|
| 18 |
const [selectedRun, setSelectedRun] = useState<RunResult | null>(null);
|
| 19 |
const [runCount, setRunCount] = useState(0);
|
| 20 |
const [sideOpen, setSideOpen] = useState(false);
|
| 21 |
+
// null until we've checked sessionStorage (avoids a gate flash on reload)
|
| 22 |
+
const [unlocked, setUnlocked] = useState<boolean | null>(null);
|
| 23 |
+
const [rejected, setRejected] = useState(false);
|
| 24 |
+
|
| 25 |
+
useEffect(() => {
|
| 26 |
+
// Skip the gate entirely when the backend has no access code (local dev)
|
| 27 |
+
fetchHealth()
|
| 28 |
+
.then((h) => setUnlocked(!h.access_required || !!getAccessCode()))
|
| 29 |
+
.catch(() => setUnlocked(!!getAccessCode()));
|
| 30 |
+
// A 401 mid-session (wrong/expired code) bounces back to the gate
|
| 31 |
+
onAuthFailure(() => {
|
| 32 |
+
setUnlocked(false);
|
| 33 |
+
setRejected(true);
|
| 34 |
+
});
|
| 35 |
+
}, []);
|
| 36 |
+
|
| 37 |
+
if (unlocked === null) return null;
|
| 38 |
+
if (!unlocked)
|
| 39 |
+
return (
|
| 40 |
+
<AccessGate
|
| 41 |
+
rejected={rejected}
|
| 42 |
+
onUnlock={() => {
|
| 43 |
+
setRejected(false);
|
| 44 |
+
setUnlocked(true);
|
| 45 |
+
}}
|
| 46 |
+
/>
|
| 47 |
+
);
|
| 48 |
|
| 49 |
return (
|
| 50 |
<div className="mx-auto flex h-screen w-full flex-col px-6 py-4">
|
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { setAccessCode } from "@/lib/api";
|
| 5 |
+
|
| 6 |
+
// Simple shared-code gate. Not real auth — one code shared with viewers —
|
| 7 |
+
// but it stops anonymous bots from spending the owner's API credits. The
|
| 8 |
+
// code is entered at runtime and kept in sessionStorage, never in the bundle.
|
| 9 |
+
export function AccessGate({
|
| 10 |
+
onUnlock,
|
| 11 |
+
rejected = false,
|
| 12 |
+
}: {
|
| 13 |
+
onUnlock: () => void;
|
| 14 |
+
rejected?: boolean;
|
| 15 |
+
}) {
|
| 16 |
+
const [code, setCode] = useState("");
|
| 17 |
+
|
| 18 |
+
function submit() {
|
| 19 |
+
const c = code.trim();
|
| 20 |
+
if (!c) return;
|
| 21 |
+
setAccessCode(c);
|
| 22 |
+
onUnlock();
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div className="flex h-screen items-center justify-center px-6">
|
| 27 |
+
<div className="w-full max-w-sm space-y-4 text-center">
|
| 28 |
+
<h1 className="font-[family-name:var(--font-pixel)] text-2xl text-stone-200">
|
| 29 |
+
AUCTION
|
| 30 |
+
<span className="text-orange-500">ROUTER</span>
|
| 31 |
+
<span className="blink text-orange-500">_</span>
|
| 32 |
+
</h1>
|
| 33 |
+
<p className="text-sm text-stone-500">
|
| 34 |
+
this demo is access-gated to protect API credits. enter the code you
|
| 35 |
+
were given.
|
| 36 |
+
</p>
|
| 37 |
+
<input
|
| 38 |
+
type="password"
|
| 39 |
+
value={code}
|
| 40 |
+
onChange={(e) => setCode(e.target.value)}
|
| 41 |
+
onKeyDown={(e) => e.key === "Enter" && submit()}
|
| 42 |
+
placeholder="access code"
|
| 43 |
+
autoFocus
|
| 44 |
+
className="w-full border-2 border-stone-700 bg-black px-3 py-2 text-center text-stone-200 outline-none placeholder:text-stone-600 focus:border-orange-500"
|
| 45 |
+
/>
|
| 46 |
+
{rejected && (
|
| 47 |
+
<p className="text-sm text-red-400">
|
| 48 |
+
that code was rejected — try again.
|
| 49 |
+
</p>
|
| 50 |
+
)}
|
| 51 |
+
<button
|
| 52 |
+
onClick={submit}
|
| 53 |
+
disabled={!code.trim()}
|
| 54 |
+
className="pixel-btn w-full bg-orange-950 py-2 font-[family-name:var(--font-pixel)] text-[12px] uppercase text-orange-400 disabled:text-stone-600"
|
| 55 |
+
>
|
| 56 |
+
enter
|
| 57 |
+
</button>
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
);
|
| 61 |
+
}
|
|
@@ -1,13 +1,50 @@
|
|
| 1 |
import type { ChatTurn, MetricsSummary, RunResult } from "./types";
|
| 2 |
|
| 3 |
// Same origin in production (FastAPI serves the static export);
|
| 4 |
-
// the local backend during `next dev`.
|
|
|
|
| 5 |
const API_BASE =
|
| 6 |
process.env.NEXT_PUBLIC_API_BASE ??
|
| 7 |
(process.env.NODE_ENV === "development" ? "http://localhost:8000" : "");
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
| 10 |
-
const res = await fetch(`${API_BASE}${path}`,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
if (!res.ok) {
|
| 12 |
const body = await res.text().catch(() => "");
|
| 13 |
throw new Error(`${res.status}: ${body.slice(0, 300)}`);
|
|
@@ -53,9 +90,13 @@ export async function streamQuery(
|
|
| 53 |
): Promise<void> {
|
| 54 |
const res = await fetch(`${API_BASE}/api/query/stream`, {
|
| 55 |
method: "POST",
|
| 56 |
-
headers: { "Content-Type": "application/json" },
|
| 57 |
body: JSON.stringify({ query, history, hint }),
|
| 58 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
if (!res.ok || !res.body) {
|
| 60 |
const body = await res.text().catch(() => "");
|
| 61 |
throw new Error(`${res.status}: ${body.slice(0, 300)}`);
|
|
@@ -76,6 +117,13 @@ export async function streamQuery(
|
|
| 76 |
}
|
| 77 |
}
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
export function fetchMetrics(): Promise<MetricsSummary> {
|
| 80 |
return request<MetricsSummary>("/api/metrics");
|
| 81 |
}
|
|
|
|
| 1 |
import type { ChatTurn, MetricsSummary, RunResult } from "./types";
|
| 2 |
|
| 3 |
// Same origin in production (FastAPI serves the static export);
|
| 4 |
+
// the local backend during `next dev`. On Vercel this MUST be set to the
|
| 5 |
+
// backend (HF Space) URL, since the frontend and API are different origins.
|
| 6 |
const API_BASE =
|
| 7 |
process.env.NEXT_PUBLIC_API_BASE ??
|
| 8 |
(process.env.NODE_ENV === "development" ? "http://localhost:8000" : "");
|
| 9 |
|
| 10 |
+
// --- Access code (shared demo gate) -----------------------------------------
|
| 11 |
+
// Entered at runtime, kept in sessionStorage — never baked into the bundle.
|
| 12 |
+
const CODE_KEY = "ar_access_code";
|
| 13 |
+
|
| 14 |
+
export function getAccessCode(): string {
|
| 15 |
+
if (typeof window === "undefined") return "";
|
| 16 |
+
return window.sessionStorage.getItem(CODE_KEY) ?? "";
|
| 17 |
+
}
|
| 18 |
+
export function setAccessCode(code: string): void {
|
| 19 |
+
window.sessionStorage.setItem(CODE_KEY, code);
|
| 20 |
+
}
|
| 21 |
+
export function clearAccessCode(): void {
|
| 22 |
+
window.sessionStorage.removeItem(CODE_KEY);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
let authFailureHandler: (() => void) | null = null;
|
| 26 |
+
export function onAuthFailure(fn: () => void): void {
|
| 27 |
+
authFailureHandler = fn;
|
| 28 |
+
}
|
| 29 |
+
function handle401(): void {
|
| 30 |
+
clearAccessCode();
|
| 31 |
+
authFailureHandler?.();
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function authHeaders(extra: Record<string, string> = {}): Record<string, string> {
|
| 35 |
+
const code = getAccessCode();
|
| 36 |
+
return code ? { ...extra, "X-Access-Code": code } : extra;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
| 40 |
+
const res = await fetch(`${API_BASE}${path}`, {
|
| 41 |
+
...init,
|
| 42 |
+
headers: authHeaders(init?.headers as Record<string, string>),
|
| 43 |
+
});
|
| 44 |
+
if (res.status === 401) {
|
| 45 |
+
handle401();
|
| 46 |
+
throw new Error("access code rejected");
|
| 47 |
+
}
|
| 48 |
if (!res.ok) {
|
| 49 |
const body = await res.text().catch(() => "");
|
| 50 |
throw new Error(`${res.status}: ${body.slice(0, 300)}`);
|
|
|
|
| 90 |
): Promise<void> {
|
| 91 |
const res = await fetch(`${API_BASE}/api/query/stream`, {
|
| 92 |
method: "POST",
|
| 93 |
+
headers: authHeaders({ "Content-Type": "application/json" }),
|
| 94 |
body: JSON.stringify({ query, history, hint }),
|
| 95 |
});
|
| 96 |
+
if (res.status === 401) {
|
| 97 |
+
handle401();
|
| 98 |
+
throw new Error("access code rejected");
|
| 99 |
+
}
|
| 100 |
if (!res.ok || !res.body) {
|
| 101 |
const body = await res.text().catch(() => "");
|
| 102 |
throw new Error(`${res.status}: ${body.slice(0, 300)}`);
|
|
|
|
| 117 |
}
|
| 118 |
}
|
| 119 |
|
| 120 |
+
// Open endpoint (no code) — tells the frontend whether to show the gate
|
| 121 |
+
export async function fetchHealth(): Promise<{ access_required: boolean }> {
|
| 122 |
+
const res = await fetch(`${API_BASE}/health`);
|
| 123 |
+
if (!res.ok) throw new Error(`${res.status}`);
|
| 124 |
+
return res.json();
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
export function fetchMetrics(): Promise<MetricsSummary> {
|
| 128 |
return request<MetricsSummary>("/api/metrics");
|
| 129 |
}
|