From 1cf9c1d0da6b510a76ae22a4989d2ea3d309a559 Mon Sep 17 00:00:00 2001 From: MohammedPathariya Date: Mon, 27 Jul 2026 12:49:43 -0400 Subject: [PATCH] prepare public deployment --- .env.example | 17 ++++++++- README.md | 13 ++++++- backend/config.py | 5 +++ backend/main.py | 87 ++++++++++++++++++++++++++++++++++++------ backend/run_manager.py | 48 ++++++++++++++++++++++- docs/DEPLOYMENT.md | 64 +++++++++++++++++++++++++++++++ docs/STATUS.md | 50 +++++++++++++++++------- frontend/vercel.json | 6 +++ render.yaml | 29 ++++++++++++++ tests/test_api.py | 77 +++++++++++++++++++++++++++++++++++++ tests/test_config.py | 16 ++++++++ 11 files changed, 384 insertions(+), 28 deletions(-) create mode 100644 docs/DEPLOYMENT.md create mode 100644 frontend/vercel.json create mode 100644 render.yaml diff --git a/.env.example b/.env.example index 1b0584a..8cc3f85 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,21 @@ -# Rename this file to `.env` and replace placeholders with your actual values. -OPENAI_API_KEY=your_openai_api_key_here +OPENAI_API_KEY= OPENAI_MODEL_NAME=gpt-4o-mini HOST=0.0.0.0 PORT=8000 + CORS_ORIGINS=["http://localhost:3000","http://localhost:8501"] MAX_REQUEST_CHARACTERS=20000 MAX_ATTEMPTS=3 +MAX_ACTIVE_RUNS=1 +MAX_DAILY_MODEL_RUNS=20 +RATE_LIMIT_REQUESTS=10 +RATE_LIMIT_WINDOW_SECONDS=60 +RUN_TIMEOUT_SECONDS=300 + +SANDBOX_BACKEND=docker +DOCKER_SANDBOX_IMAGE=digital-forge-sandbox:py311 +MODAL_SANDBOX_APP=digital-forge-sandbox +SANDBOX_TIMEOUT_SECONDS=10 +SANDBOX_MEMORY_MIB=256 +SANDBOX_CPU_CORES=1 +SANDBOX_PROCESS_LIMIT=64 diff --git a/README.md b/README.md index 34c767f..fd6ae77 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,15 @@ npm run dev The frontend defaults to `http://localhost:3000` and calls the backend at `http://localhost:8000`. Override that with `NEXT_PUBLIC_BACKEND_URL` if needed. +## Deployment + +Day 7 deployment is configured but not performed by this branch. See +`docs/DEPLOYMENT.md` for the Vercel, Render, and Modal checklist. + +The hosted backend should run with `SANDBOX_BACKEND=modal`, one active public run, +per-client rate limits, a daily process-local model-run budget, and a workflow-boundary +timeout. Set `NEXT_PUBLIC_BACKEND_URL` in Vercel to the deployed Render backend URL. + ## Running Benchmarks Zero-shot baseline: @@ -166,13 +175,15 @@ cd frontend && npm run lint && npm run typecheck && npm run build - Run snapshots are process-local and disappear when the backend restarts. - Cancellation is cooperative and stops at workflow boundaries. -- Public deployment rate limits, spend controls, durable run storage, and one-run concurrency enforcement remain deployment work. +- Public deployment controls are process-local. They protect the free demo from casual + overuse but reset when the Render process restarts or scales. - Per-agent token and cost telemetry is not yet captured by the benchmark runner. - The RAG layer is evaluated separately from the active 20-task algorithm benchmark. ## Project Docs - `docs/STATUS.md`: current implementation status, verification, risks, and handoff notes. +- `docs/DEPLOYMENT.md`: public deployment configuration and smoke-test checklist. - `docs/ARCHITECTURE.md`: system architecture and deployment model. - `docs/DECISIONS.md`: accepted design decisions. - `docs/WEEK_PLAN.md`: phased revamp plan. diff --git a/backend/config.py b/backend/config.py index c7da20a..d86cd1e 100644 --- a/backend/config.py +++ b/backend/config.py @@ -22,6 +22,11 @@ class Settings(BaseSettings): cors_origins: list[str] = ["http://localhost:3000", "http://localhost:8501"] max_request_characters: int = Field(default=20_000, ge=1) max_attempts: int = Field(default=3, ge=1, le=3) + max_active_runs: int = Field(default=1, ge=1, le=1) + max_daily_model_runs: int = Field(default=20, ge=1, le=100) + rate_limit_requests: int = Field(default=10, ge=1, le=100) + rate_limit_window_seconds: float = Field(default=60.0, gt=0, le=3600) + run_timeout_seconds: float = Field(default=300.0, gt=0, le=900) sandbox_backend: Literal["docker", "modal"] = "docker" docker_sandbox_image: str = "digital-forge-sandbox:py311" modal_sandbox_app: str = "digital-forge-sandbox" diff --git a/backend/main.py b/backend/main.py index f21122a..037a909 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,10 +2,12 @@ import argparse from collections.abc import Sequence +from threading import RLock +from time import monotonic from uuid import UUID, uuid4 import uvicorn -from fastapi import FastAPI, HTTPException +from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from benchmark.models import BenchmarkReport @@ -14,7 +16,9 @@ from .config import Settings, get_settings from .models import RunRequest, RunResponse, RunSnapshot from .run_manager import ( + ActiveRunLimitExceeded, CancellationCheck, + DailyRunLimitExceeded, RunManager, Runner, RunnerFactory, @@ -22,6 +26,32 @@ ) +class RateLimiter: + """Small process-local limiter for the public demo API.""" + + def __init__(self, request_limit: int, window_seconds: float): + self.request_limit = request_limit + self.window_seconds = window_seconds + self._requests: dict[str, list[float]] = {} + self._lock = RLock() + + def allow(self, key: str) -> bool: + now = monotonic() + window_start = now - self.window_seconds + with self._lock: + timestamps = [ + timestamp + for timestamp in self._requests.get(key, []) + if timestamp >= window_start + ] + if len(timestamps) >= self.request_limit: + self._requests[key] = timestamps + return False + timestamps.append(now) + self._requests[key] = timestamps + return True + + def _default_runner( request: str, settings: Settings, @@ -49,6 +79,10 @@ def create_app( app = FastAPI(title="The Digital Forge", version="0.1.0") run_manager = RunManager(app_settings, create_runner) app.state.run_manager = run_manager + app.state.rate_limiter = RateLimiter( + app_settings.rate_limit_requests, + app_settings.rate_limit_window_seconds, + ) app.add_middleware( CORSMiddleware, allow_origins=app_settings.cors_origins, @@ -61,23 +95,54 @@ def create_app( def health() -> dict[str, str]: return {"status": "ok"} + def enforce_rate_limit(request: Request) -> None: + client = request.client.host if request.client else "unknown" + if not app.state.rate_limiter.allow(client): + raise HTTPException(status_code=429, detail="Rate limit exceeded.") + @app.post("/run", response_model=RunResponse) - def run_pipeline(payload: RunRequest) -> RunResponse: + def run_pipeline( + payload: RunRequest, _rate_limit: None = Depends(enforce_rate_limit) + ) -> RunResponse: if len(payload.request) > app_settings.max_request_characters: raise HTTPException(status_code=413, detail="Request is too large.") - return create_runner( - payload.request, - app_settings, - uuid4(), - lambda _state: None, - lambda: False, - ).run() + try: + run_manager.reserve_external_run() + except ActiveRunLimitExceeded: + raise HTTPException( + status_code=409, detail="Another run is already active." + ) from None + except DailyRunLimitExceeded: + raise HTTPException( + status_code=429, detail="Daily model run limit exceeded." + ) from None + try: + return create_runner( + payload.request, + app_settings, + uuid4(), + lambda _state: None, + lambda: False, + ).run() + finally: + run_manager.release_external_run() @app.post("/runs", response_model=RunSnapshot, status_code=202) - def start_run(payload: RunRequest) -> RunSnapshot: + def start_run( + payload: RunRequest, _rate_limit: None = Depends(enforce_rate_limit) + ) -> RunSnapshot: if len(payload.request) > app_settings.max_request_characters: raise HTTPException(status_code=413, detail="Request is too large.") - return run_manager.start(payload.request) + try: + return run_manager.start(payload.request) + except ActiveRunLimitExceeded: + raise HTTPException( + status_code=409, detail="Another run is already active." + ) from None + except DailyRunLimitExceeded: + raise HTTPException( + status_code=429, detail="Daily model run limit exceeded." + ) from None @app.get("/runs/{run_id}", response_model=RunSnapshot) def get_run(run_id: UUID) -> RunSnapshot: diff --git a/backend/run_manager.py b/backend/run_manager.py index 98897b7..16e50cc 100644 --- a/backend/run_manager.py +++ b/backend/run_manager.py @@ -1,7 +1,8 @@ """Thread-safe in-memory run coordination for the polling API.""" from collections.abc import Callable -from threading import Event, RLock, Thread +from datetime import date +from threading import Event, RLock, Thread, Timer from typing import Protocol from uuid import UUID, uuid4 @@ -30,6 +31,14 @@ def run(self) -> RunResponse: ... ] +class ActiveRunLimitExceeded(Exception): + """Raised when the public demo already has a run in progress.""" + + +class DailyRunLimitExceeded(Exception): + """Raised when process-local model-backed run budget is exhausted.""" + + class RunManager: """Own process-local run snapshots and execute pipelines in worker threads.""" @@ -38,6 +47,9 @@ def __init__(self, settings: Settings, runner_factory: RunnerFactory): self.runner_factory = runner_factory self._runs: dict[UUID, RunSnapshot] = {} self._cancellations: dict[UUID, Event] = {} + self._external_active_runs = 0 + self._daily_run_count = 0 + self._daily_run_date = date.today() self._lock = RLock() def start(self, request: str) -> RunSnapshot: @@ -56,6 +68,7 @@ def start(self, request: str) -> RunSnapshot: ) cancellation = Event() with self._lock: + self._reserve_run_locked() self._runs[run_id] = snapshot self._cancellations[run_id] = cancellation Thread( @@ -66,6 +79,15 @@ def start(self, request: str) -> RunSnapshot: ).start() return snapshot.model_copy(deep=True) + def reserve_external_run(self) -> None: + with self._lock: + self._reserve_run_locked() + self._external_active_runs += 1 + + def release_external_run(self) -> None: + with self._lock: + self._external_active_runs = max(0, self._external_active_runs - 1) + def get(self, run_id: UUID) -> RunSnapshot | None: with self._lock: snapshot = self._runs.get(run_id) @@ -90,6 +112,9 @@ def cancel(self, run_id: UUID) -> RunSnapshot | None: return snapshot.model_copy(deep=True) def _execute(self, run_id: UUID, request: str, cancellation: Event) -> None: + timer = Timer(self.settings.run_timeout_seconds, cancellation.set) + timer.daemon = True + timer.start() try: runner = self.runner_factory( request, @@ -133,6 +158,8 @@ def _execute(self, run_id: UUID, request: str, cancellation: Event) -> None: ), } ) + finally: + timer.cancel() def _update(self, run_id: UUID, state: RunState) -> None: artifacts: list[RunArtifact] = [] @@ -174,3 +201,22 @@ def _update(self, run_id: UUID, state: RunState) -> None: "updated_at": utc_now(), } ) + + def _reset_daily_count_if_needed(self) -> None: + today = date.today() + if today != self._daily_run_date: + self._daily_run_date = today + self._daily_run_count = 0 + + def _reserve_run_locked(self) -> None: + self._reset_daily_count_if_needed() + active_runs = self._external_active_runs + sum( + 1 + for existing in self._runs.values() + if existing.status in {RunStatus.pending, RunStatus.running} + ) + if active_runs >= self.settings.max_active_runs: + raise ActiveRunLimitExceeded + if self._daily_run_count >= self.settings.max_daily_model_runs: + raise DailyRunLimitExceeded + self._daily_run_count += 1 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..c3419fe --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,64 @@ +# Deployment + +Day 7 deployment uses Vercel for the Next.js frontend, Render Free for the FastAPI +backend, and Modal for hosted sandbox execution. Do not run a public benchmark from the +hosted demo; benchmark execution remains local or CI-only. + +## Backend on Render + +Use `render.yaml` as the backend blueprint. Set these environment variables in Render: + +```text +OPENAI_API_KEY= +OPENAI_MODEL_NAME=gpt-4o-mini +CORS_ORIGINS=["https://.vercel.app"] +SANDBOX_BACKEND=modal +MODAL_SANDBOX_APP=digital-forge-sandbox +MAX_ACTIVE_RUNS=1 +MAX_DAILY_MODEL_RUNS=20 +RATE_LIMIT_REQUESTS=10 +RATE_LIMIT_WINDOW_SECONDS=60 +RUN_TIMEOUT_SECONDS=300 +``` + +The public backend is intentionally process-local: it allows one active run, applies a +small per-client rate limit, cancels at workflow boundaries after the configured timeout, +and stops accepting new model-backed runs after the daily process-local run budget is +exhausted. + +## Frontend on Vercel + +Deploy `frontend/` as the Vercel project root. Set: + +```text +NEXT_PUBLIC_BACKEND_URL=https://.onrender.com +``` + +The frontend build should run `npm ci` and `npm run build`. + +## Modal Sandbox + +Render must use `SANDBOX_BACKEND=modal` because Render Free is not a Docker host. The +Modal path builds the sandbox image from the same pinned offline capability set used by +Docker. + +Authenticate Modal in the Render environment before live runs. Without Modal credentials, +the backend health check can pass but generated-code execution will fail as an +infrastructure configuration error. + +## Smoke Tests + +After deployment, verify: + +```bash +curl -fsS https://.onrender.com/health +curl -fsS https://.onrender.com/benchmarks +``` + +Then open the Vercel URL and verify: + +- Backend health shows connected after Render cold start. +- The benchmark dashboard loads tracked reports. +- A second submitted run while one is active returns `409`. +- Excess repeated submissions return `429`. +- One small paid live run reaches a terminal state through Modal before sharing the demo. diff --git a/docs/STATUS.md b/docs/STATUS.md index 878ab9e..16720a5 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,9 +2,10 @@ ## Current phase -Day 6, Rebuild the frontend, is complete on `mjp/revamp-digital-forge`. -Days 1 through 6 remain consistent with the current week plan, architecture, and -accepted decisions. Day 7 deployment work has not started. +Day 7, Deploy and verify, is locally complete on `mjp/revamp-digital-forge`. +Days 1 through 7 remain consistent with the current week plan, architecture, and +accepted decisions. Live Vercel, Render, and Modal deployment has not been performed +because it requires the user's hosted accounts, credentials, and explicit approval. ## Completed work @@ -116,6 +117,14 @@ accepted decisions. Day 7 deployment work has not started. - Added benchmark checkpointing and guarded early-stop support. Each completed task now writes a task-level checkpoint before the aggregate report is finalized, and benchmark CLI runs can stop after a configured consecutive-failure streak unless the suite is already near completion. +- Added Day 7 deployment readiness for the public demo. The backend now enforces one active + model-backed run per process, applies a small per-client rate limit to run-submission + endpoints, enforces a process-local daily model-run budget, and requests cooperative + cancellation after the configured run timeout. +- Added deployment configuration and handoff docs: `render.yaml` for the Render Free FastAPI + service, `frontend/vercel.json` for the Vercel frontend project, root `.env.example` settings + for backend deployment controls, and `docs/DEPLOYMENT.md` with the Vercel, Render, Modal, and + smoke-test checklist. ## Verification performed @@ -186,6 +195,18 @@ accepted decisions. Day 7 deployment work has not started. the issue was corrected and covered by a deterministic test, and another paid run was intentionally not started. - `git diff --check` passed after implementation. +- `.venv/bin/python -m pytest tests/test_api.py tests/test_config.py -q` passed with 18 tests. +- `.venv/bin/python -m ruff check backend tests` passed. +- `.venv/bin/python -m ruff format --check backend tests` passed. +- `.venv/bin/python -m mypy backend tests` passed with 33 source files checked. +- `.venv/bin/python -m pytest -q` passed with 98 tests and five environment-dependent tests + skipped. +- `npm run lint` passed for the Next.js frontend. +- `npm run typecheck` passed with strict TypeScript checking. +- `npm run build` produced static frontend routes for `/`, `/benchmark`, and `/icon.svg`. +- `.venv/bin/python -m ruff check .` passed. +- `.venv/bin/python -m ruff format --check .` passed with 48 files checked. +- `.venv/bin/python -m mypy backend benchmark rag tests` passed with 48 source files checked. ## Known risks and deferred work @@ -193,8 +214,9 @@ accepted decisions. Day 7 deployment work has not started. shared run storage is deferred until deployment requirements are finalized. - Cancellation is cooperative. A request stops at the next workflow boundary but cannot interrupt a CrewAI or model request already in progress. -- The public one-run concurrency limit, rate limits, request timeouts, and model spending - controls remain Day 7 work. The local Day 6 run manager does not claim those protections. +- Public deployment controls are process-local. The one-run gate, rate limit, timeout, and daily + run budget protect a single Render Free process from casual demo overuse, but they reset on + process restart and are not durable account-level spending controls. - The zero-shot `gpt-4` algorithm benchmark is now measured at 18/20 overall, with 9/10 easy and 9/10 medium tasks passing on historical benchmark v1.0.0. That suite overemphasized single-function algorithm tasks and is no longer the @@ -233,8 +255,8 @@ accepted decisions. Day 7 deployment work has not started. cover normalization and workflow boundaries, but final live-model output quality remains to be reverified deliberately. - Modal capability construction remains verified against the installed SDK signature and - contract-level fakes, not an authenticated cloud sandbox build. Hosted verification remains - part of Day 7 deployment work. + contract-level fakes, not an authenticated cloud sandbox build. Hosted Modal execution still + must be verified during the live deployment smoke test. - Existing local Docker images must be rebuilt after sandbox capability changes; otherwise the pipeline now reports the stale image as a non-retryable system failure without consuming candidate attempts. @@ -254,12 +276,14 @@ accepted decisions. Day 7 deployment work has not started. D008 now defines the shared offline sandbox capability contract and failure ownership for missing modules. Day 6 continues to implement D005 through Next.js, typed background run -state, polling, and cancellation. The benchmark dashboard continues to follow D001 and D007 -by displaying only precomputed, measured artifacts and never triggering or inventing results. +state, polling, and cancellation. Day 7 keeps D001's split between the bounded hosted demo and +local or CI benchmark execution. The benchmark dashboard continues to follow D001 and D007 by +displaying only precomputed, measured artifacts and never triggering or inventing results. ## Exact next task -Add an independent final contract audit that cannot treat generated tests as the source of truth. -It must compare the final application directly with the immutable request and block a successful -status when repaired code drops or contradicts a requirement. Then finish benchmark checkpointing, -usage telemetry, and spending limits before another paid pilot or any resume claim update. +Deploy with the user present: create or connect the Vercel frontend, Render backend, and Modal +sandbox credentials; set `NEXT_PUBLIC_BACKEND_URL`, `CORS_ORIGINS`, `OPENAI_API_KEY`, and Modal +auth; run the smoke tests in `docs/DEPLOYMENT.md`; then record the live URLs and hosted +verification result. After live deployment, add the independent final contract audit before +another paid pilot or any resume claim update. diff --git a/frontend/vercel.json b/frontend/vercel.json new file mode 100644 index 0000000..010a330 --- /dev/null +++ b/frontend/vercel.json @@ -0,0 +1,6 @@ +{ + "framework": "nextjs", + "installCommand": "npm ci", + "buildCommand": "npm run build", + "outputDirectory": ".next" +} diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..f6ed2aa --- /dev/null +++ b/render.yaml @@ -0,0 +1,29 @@ +services: + - type: web + name: digital-forge-api + runtime: python + plan: free + buildCommand: pip install -r requirements.txt + startCommand: python -m uvicorn backend.main:app --host 0.0.0.0 --port $PORT + healthCheckPath: /health + envVars: + - key: OPENAI_API_KEY + sync: false + - key: OPENAI_MODEL_NAME + value: gpt-4o-mini + - key: CORS_ORIGINS + sync: false + - key: SANDBOX_BACKEND + value: modal + - key: MODAL_SANDBOX_APP + value: digital-forge-sandbox + - key: MAX_ACTIVE_RUNS + value: "1" + - key: MAX_DAILY_MODEL_RUNS + value: "20" + - key: RATE_LIMIT_REQUESTS + value: "10" + - key: RATE_LIMIT_WINDOW_SECONDS + value: "60" + - key: RUN_TIMEOUT_SECONDS + value: "300" diff --git a/tests/test_api.py b/tests/test_api.py index 094bb2f..940aba3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -69,6 +69,17 @@ def run(self) -> RunResponse: raise TimeoutError("Cancellation was not requested.") +class SlowRunner(FakeRunner): + def run(self) -> RunResponse: + while not self.is_cancel_requested(): + time.sleep(0.001) + return RunResponse( + run_id=self.run_id, + status=RunStatus.cancelled, + report="Run cancelled by timeout.", + ) + + def test_health() -> None: client = TestClient(create_app(Settings(), runner_factory=FakeRunner)) @@ -118,6 +129,17 @@ def test_run_rejects_oversized_request() -> None: assert response.status_code == 413 +def test_run_endpoint_rate_limits_by_client() -> None: + settings = Settings(rate_limit_requests=1, rate_limit_window_seconds=60) + client = TestClient(create_app(settings, runner_factory=FakeRunner)) + + assert client.post("/run", json={"request": "build a parser"}).status_code == 200 + response = client.post("/run", json={"request": "build another parser"}) + + assert response.status_code == 429 + assert response.json()["detail"] == "Rate limit exceeded." + + def test_polling_run_api_starts_and_reaches_a_terminal_state() -> None: client = TestClient(create_app(Settings(), runner_factory=FakeRunner)) @@ -137,6 +159,61 @@ def test_polling_run_api_starts_and_reaches_a_terminal_state() -> None: assert snapshot.json()["report"] == "Completed: build a parser" +def test_polling_run_api_rejects_concurrent_public_runs() -> None: + client = TestClient(create_app(Settings(), runner_factory=CancellableRunner)) + + first = client.post("/runs", json={"request": "build a parser"}) + second = client.post("/runs", json={"request": "build another parser"}) + + assert first.status_code == 202 + assert second.status_code == 409 + assert second.json()["detail"] == "Another run is already active." + + +def test_polling_run_api_enforces_daily_model_run_budget() -> None: + settings = Settings(max_daily_model_runs=1) + client = TestClient(create_app(settings, runner_factory=FakeRunner)) + + first = client.post("/runs", json={"request": "build a parser"}) + assert first.status_code == 202 + run_id = first.json()["run_id"] + for _ in range(100): + snapshot = client.get(f"/runs/{run_id}") + if snapshot.json()["status"] == "completed": + break + time.sleep(0.001) + response = client.post("/runs", json={"request": "build another parser"}) + + assert response.status_code == 429 + assert response.json()["detail"] == "Daily model run limit exceeded." + + +def test_sync_run_endpoint_uses_public_run_budget() -> None: + settings = Settings(max_daily_model_runs=1) + client = TestClient(create_app(settings, runner_factory=FakeRunner)) + + assert client.post("/run", json={"request": "build a parser"}).status_code == 200 + response = client.post("/run", json={"request": "build another parser"}) + + assert response.status_code == 429 + assert response.json()["detail"] == "Daily model run limit exceeded." + + +def test_polling_run_api_cancels_after_configured_timeout() -> None: + settings = Settings(run_timeout_seconds=0.01) + client = TestClient(create_app(settings, runner_factory=SlowRunner)) + run_id = client.post("/runs", json={"request": "build a parser"}).json()["run_id"] + + for _ in range(100): + snapshot = client.get(f"/runs/{run_id}") + if snapshot.json()["status"] == "cancelled": + break + time.sleep(0.01) + + assert snapshot.json()["status"] == "cancelled" + assert snapshot.json()["report"] == "Run cancelled by timeout." + + def test_polling_run_api_returns_not_found() -> None: client = TestClient(create_app(Settings(), runner_factory=FakeRunner)) diff --git a/tests/test_config.py b/tests/test_config.py index b969804..0040d02 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -23,3 +23,19 @@ def test_sandbox_limits_are_typed_and_bounded() -> None: assert settings.sandbox_memory_mib == 512 with pytest.raises(ValueError): Settings(sandbox_process_limit=2) + + +def test_public_demo_controls_are_typed_and_bounded() -> None: + settings = Settings( + max_daily_model_runs=5, + rate_limit_requests=3, + rate_limit_window_seconds=30, + run_timeout_seconds=120, + ) + + assert settings.max_active_runs == 1 + assert settings.max_daily_model_runs == 5 + with pytest.raises(ValueError): + Settings(max_active_runs=2) + with pytest.raises(ValueError): + Settings(run_timeout_seconds=0)