dakshtaneja commited on
Commit
98d4633
·
1 Parent(s): 5c3aa37

deploy hardening: access gate, rate limiting, spend guard, env CORS

Browse files

Split-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 CHANGED
@@ -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
- ## Hosting (Hugging Face Spaces)
65
 
66
- This repo is a single Docker Space: the multi-stage `Dockerfile` builds the
67
- Next.js frontend as a static export and FastAPI serves it alongside the API on
68
- port 7860.
 
69
 
70
- ### Deploy
71
 
72
- 1. Create a **Docker** Space and push this repo to it:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  ```bash
74
- git remote add hf https://huggingface.co/spaces/<your-username>/AuctionRouter
75
- git push hf master:main
76
  ```
77
- 2. In **Settings → Variables and secrets**, add:
78
- - `OPENROUTER_API_KEY` (secret, required)
79
- - `MONGODB_URI` (secret, optional MongoDB Atlas M0; without it the app
80
- uses an in-memory store that resets on restart)
81
- - `MONGODB_DB` (variable, optional, default `auctionrouter`)
82
- - `LANGCHAIN_TRACING_V2` / `LANGCHAIN_API_KEY` (optional, LangSmith)
83
- 3. If using Atlas, allow `0.0.0.0/0` in its Network Access list Space IPs
84
- are not static.
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
  Notes:
87
- - Spaces restart on every push and wipe local disk; persistent history needs
88
- the Mongo URI.
89
- - CPU Basic hardware is sufficient (all inference happens on OpenRouter).
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
 
backend/app/config.py CHANGED
@@ -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
 
backend/app/main.py CHANGED
@@ -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=["http://localhost:3000"],
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
- async def query(req: QueryRequest):
 
 
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
- async def query_stream(req: QueryRequest):
 
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
 
backend/app/pipeline.py CHANGED
@@ -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
 
backend/app/security.py ADDED
@@ -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()
backend/pyproject.toml CHANGED
@@ -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]
backend/uv.lock CHANGED
@@ -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"
frontend/app/page.tsx CHANGED
@@ -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">
frontend/components/AccessGate.tsx ADDED
@@ -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
+ }
frontend/lib/api.ts CHANGED
@@ -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}`, init);
 
 
 
 
 
 
 
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
  }