Enforce per-project cost budgets with a hard-stop guardrail - #165
Enforce per-project cost budgets with a hard-stop guardrail#165lucastononro wants to merge 98 commits into
Conversation
The cli/README.md was stale against v0.0.4: it described a single-provider (Anthropic + Modal) setup, claimed the wizard downloaded the compose file from raw.githubusercontent.com, and omitted `trainable reconfigure`. Update it to match the current implementation: - multi-provider selection (Claude API key / subscription OAuth, OpenAI, Gemini, LiteLLM-routed backends) as a flat picker, with a Providers table - compose file bundled inside the wheel (offline installs) - version-pinned images on `trainable up` - `trainable reconfigure` command and the existing-config merge flow Assisted-by: Claude Code
…#123) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
…t:8000 (#96) Replace getSSEBase()/getBackendUrl() (which returned http://host:8000) with relative /api paths at all EventSource and raw file/image/PDF fetch sites, routing through the existing Next rewrite proxy so HTTPS deploys no longer hit mixed-content blocking or an unexposed port. Both helpers are deleted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
…tores (#90) docker-compose.prod.yml shipped hardcoded default credentials (trainable/trainable, minioadmin/minioadmin) and published Postgres (5432), MinIO (9000/9001) and the auth-less Jaeger UI (16686) on 0.0.0.0 — direct data-store compromise with well-known creds on any networked host, and Jaeger traces leak prompts/paths/token counts. - Replace every hardcoded secret with required env-var interpolation that fails loudly if unset: POSTGRES_USER/PASSWORD/DB and MINIO_ROOT_USER/PASSWORD via `${VAR:?set VAR in .env}`. The backend's DATABASE_URL and AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY are now derived from those same vars, so app creds always match the datastores (var names match what backend/config.py reads). - Bind Postgres, MinIO API+console and the Jaeger UI to 127.0.0.1 so they are reachable only from the host / internal compose network, never from another machine. Drop the Jaeger OTLP host publishes (4317/4318) — the backend reaches them by service name internally. frontend (3000) and backend (8000) stay published as before. - Document the newly-required vars in .env.example with placeholders and a note that prod has no defaults. Validated: `docker compose -f docker-compose.prod.yml config` parses and interpolates with a filled .env, and fails fast with a clear message when the secrets are unset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Add an opt-in shared-secret gate for the API. When API_AUTH_TOKEN is
unset (the default) nothing changes — every endpoint stays open, so
local dev and the frontend are unaffected. When set, all /api/*
requests must send `Authorization: Bearer <token>`.
- backend/auth.py: pure-ASGI BearerTokenAuthMiddleware (no
BaseHTTPMiddleware wrapping, so SSE streaming is untouched);
constant-time token comparison; 401 + WWW-Authenticate on failure.
- Exempt: /api/health, /api/readyz, CORS preflight (OPTIONS).
- SSE: EventSource cannot send headers, so the stream endpoint
(/api/sessions/{id}/stream) additionally accepts ?token=<token>;
the query param is stream-only and does not unlock other routes.
- config.py: api_auth_token setting (env API_AUTH_TOKEN, default None).
- Middleware added before CORSMiddleware so CORS stays outermost and
answers preflights before auth runs.
- .env.example: new "Optional — security" section documenting it.
- tests/test_api_auth.py: middleware unit tests (open-by-default,
401/200 matrix, exemptions, stream query token, preflight).
Closes #88
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
…edentials Replace the `cors_origins=["*"]` + `allow_credentials=True` default (any web page could script credentialed cross-origin requests against localhost:8000) with a safe, configurable policy: - config.py: default origins are the local frontend (http://localhost:3000, http://127.0.0.1:3000); overridable via CORS_ORIGINS as a comma-separated env var (NoDecode + validator so plain CSV works, not just JSON). - main.py: if `*` is configured it is honored but credentials are disabled (allow_credentials=False) with a startup warning — the wildcard+credentials combination is now impossible. - .env.example: document CORS_ORIGINS. No impact on the standard setup: the frontend proxies /api through Next.js rewrites (same-origin), so CORS is not involved there. Closes #89 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
- Stream uploads to S3 in bounded 8 MB chunks (single put_object for small bodies, multipart upload for larger ones) instead of reading the whole body into RAM; abort the multipart upload on any failure. - Enforce the configured max_upload_size_bytes cap incrementally while reading (413 without buffering the oversize body). - Validate bucket against the app-provisioned allowlist (new settings.s3_allowed_buckets, also used by startup bucket init) and reject traversal/absolute/backslash keys; write endpoints (upload + put-presign) are scoped to the datasets/projects/ prefix. - Authentication is intentionally NOT added here — it lands globally via issue #88. Closes #91 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
A stalled provider HTTP call had no wall-clock limit: the runner's outer asyncio.timeout was deliberately removed (the Modal sandbox timeout only bounds tool execution), and every provider accepted timeout_seconds but discarded it. A hung network call left the session's background task alive forever — spinner never cleared, task-registry entry never freed. Thread the existing timeout_seconds into each provider around the HTTP call only, so tool-execution time is never counted: - base.py: new enforce_wall_clock() helper — asyncio.wait_for around a single provider call, raising builtin TimeoutError on expiry. - openai: per-request SDK timeout via client.with_options(timeout=...) plus the hard wall-clock cap (SDK retries can't stretch past it). - gemini: hard cap around generate_content (google-genai has no default request timeout at all). - litellm: keep the existing per-attempt timeout= knob, add the hard cap so backend retries/fallbacks can't exceed the budget. - claude: query() runs the whole tool loop internally, so it must not be wrapped wholesale; bound each underlying API HTTP request via Claude Code's API_TIMEOUT_MS env instead (caller-supplied value wins). On expiry the providers let TimeoutError propagate: run_agent's existing TimeoutError handler publishes agent_timeout, moves the session to timed_out, and the finally block frees the task registry — the run ends cleanly instead of hanging. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Every boto3 call ran synchronously inside async def handlers/services, stalling the event loop for each S3 round-trip. Wrap them all in asyncio.to_thread, behavior-identical: - services/s3_sync.py: put_object in the per-file sync loop - routers/s3_browser.py: list_buckets, list_objects_v2, presign generation, and the whole upload path (put_object + multipart create/upload_part/complete/abort) - routers/experiments.py: put_object in create_experiment/attach_data; the four get_object(...).read() sites now stream to a temp file in bounded 1 MB chunks inside a worker thread (_download_to_tempfile) instead of reading whole bodies into memory on the loop Test mock: conftest get_object now returns a fresh BytesIO body per call so chunked reads hit EOF like a real StreamingBody. Closes #92 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
/api/health stays a cheap static liveness check. New /api/readyz pings the DB (SELECT 1 on the async engine) and S3 (list_buckets via asyncio.to_thread so sync boto3 doesn't block the event loop) and returns 503 with per-check detail when either is down, so an orchestrator no longer routes traffic to a backend whose Postgres pool or object store is dead. Compose wiring (both docker-compose.yml and docker-compose.prod.yml): - backend healthcheck: curl -fsS /api/health (curl is already in the backend image), 5s interval, 15s start_period - frontend depends_on backend with condition: service_healthy, so the frontend waits for a responsive backend instead of racing it tests/test_readyz.py covers ready, S3-down, DB-down, and health-stays- static; verified live in-process (real app, TestClient): 200 when both up, 503 EndpointConnectionError when S3 unreachable, 503 when DB connect fails. Both compose files pass `docker compose config -q`. Closes #119 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
create_experiment accumulated every file fully in RAM (content += chunk) and then retained all bytes again in the staged tuples, so a folder upload of many large files (500 MB per-file cap) could OOM the worker. - Stream each multipart body straight to its temp file in bounded 1 MB chunks; the size cap is enforced on a running counter and the content hash for dataset versioning is computed incrementally (sha256) while streaming — the full bytes are never materialized. - Staged tuples are now (tmp_path, remote_path) only; the temp path is registered before streaming so the finally block also cleans up partially-written files on mid-stream failure. - The S3 push now uses s3.upload_file from the temp file (streams from disk, off-loop) instead of put_object(Body=<whole bytes>). - record_upload accepts precomputed content_hash/size_bytes so streaming callers don't need the full bytes; content-based callers unchanged. Closes #94 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Adds pytest-cov to backend/requirements.txt and runs the suite with --cov=. --cov-report=xml --cov-report=term-missing, uploading the coverage.xml as a build artifact. Sets --cov-fail-under=60 as a floor (measured baseline is ~66%), catching future regressions in untested modules while leaving headroom. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
) generic_exception_handler only logged unhandled request exceptions and never reported them to Sentry. Background agent work spawned via asyncio.create_task in routers/sessions.py (send_message's _run_followup) runs outside the request lifecycle, so FastAPI's exception handler never saw those errors either — they were silently swallowed into a "failed" session state with no logging or tracking. Add errors.capture_exception(), a thin no-op-safe wrapper around sentry_sdk.capture_exception (no-ops with no DSN and never lets a Sentry-side failure mask the original error), call it from the generic handler, and call it (plus add the missing logger.exception) from the background-task except block. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
…grations) (#121) Scaffolds backend/alembic/ wired to the app's async engine (settings.database_url) and Base.metadata, with a single autogenerated initial revision reflecting today's full schema. init_db() now runs `alembic upgrade head` on boot (in a worker thread, since env.py drives its own asyncio.run()); a DB that already has the full schema but no alembic_version table gets `stamp head` instead, so existing deployments don't get destructive re-creation attempts. _run_migrations is kept in place but unused, documented as dead code for rollback reference. Verified schema-equivalence of `alembic upgrade head` (fresh DB) vs `create_all + _run_migrations` (legacy path) on both SQLite and Postgres: only difference found is two redundant duplicate indexes the legacy code created under typo'd names, no logical schema divergence. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
backend-test now spins up a postgres:16-alpine service container and points TEST_DATABASE_URL at it; conftest.py respects TEST_DATABASE_URL (falling back to DATABASE_URL, then the sqlite default for local dev) instead of unconditionally forcing sqlite+aiosqlite. This exercises the Column(JSON) fields, FK ON DELETE CASCADE behavior, and the hand-rolled db._run_migrations against the engine we actually ship. Also pins asyncio_default_fixture_loop_scope/asyncio_default_test_loop_scope to "session" in a new backend/pytest.ini. Without it, pytest-asyncio 1.x's per-test event loop (fine for aiosqlite) breaks the module-level asyncpg engine with "Future attached to a different loop" once a second test needs the DB. Pinning both to session fixes this for both SQLite and Postgres. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Adds focused unit tests for the two most critical, near-untested modules in the agent runtime, with the Modal boundary faked throughout: - test_sandbox.py: run_code's success/timeout/exception paths against a faked modal.Sandbox (create/stdout/stderr/wait) — asserts span attributes, metric/log-event dispatch routing, and usage recording per path. - test_runner.py: run_agent's reachable orchestration state machine (spawn -> run -> complete/error/cancel) and its interplay with the real task registry (register_task/abort_agent/is_agent_running/ cleanup_session), using real asyncio Tasks for the registry tests so cancellation semantics are genuine. _drive_provider is faked — its internals are already covered by test_drive_provider.py. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
CI's only security step was bandit (Python SAST) — no dependency
scanning and no image scanning, despite requirements.txt already
carrying a manual note pinning litellm to dodge a CVE that a scanner
would enforce automatically.
Adds three new jobs, all advisory (continue-on-error: true on the
scan step) so pre-existing advisories don't break CI:
- backend-vuln-scan: pip-audit against backend/requirements.txt
- frontend-vuln-scan: npm audit against the frontend lockfile
- image-scan: builds trainable-backend/trainable-frontend locally
(not pushed) and scans them with Trivy (CRITICAL/HIGH)
Each can be flipped to blocking later by dropping continue-on-error
once its current advisories are triaged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
The backend /compare endpoint already aggregates metrics, feature overlap, and cost totals across up to 8 sessions, but nothing in the frontend consumed it. This adds the leaderboard UI: - api.ts: typed api.compare(sessionIds) client fn; types.ts: Compare* types mirroring routers/compare.py (incl. the feature_overlap list-when-empty quirk) - /compare page: sortable leaderboard table (per-metric latest values with best-value highlight, total cost, created-at), overlaid per-metric charts reusing the MetricsTab chart stack (palette, tooltip, formatters now exported), session legend toggles, and a feature-overlap panel (common + per-session extras) - experiments page: checkbox column + "Compare selected (n/8)" header action that navigates to /compare with the selected session ids (project-scoped when the selection stays within one project) Closes #105 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
#112) Snapshots were a passive record (dataset/code hashes + manifest). This adds an active reproduce path: - services/reproduce.py: load the snapshot manifest, verify current workspace files against the captured hashes (input drift), replay the captured .py scripts in a Modal sandbox (stage=None so the replay never writes into the original run's metric history), parse the metrics the replay emits, and diff final values vs the recorded run with a configurable tolerance — flagging drift/missing/new metrics. Report is also written to the volume as reproduce_report.json (best-effort). - POST /api/sessions/{id}/snapshot/reproduce with Pydantic request/response schemas (404 no snapshot, 409 manifest unreadable / no scripts). - Frontend: SnapshotReproduce component (button + status pill + metric diff table + input-drift warning) mounted in the experiment detail snapshot section; api.reproduceSnapshot + ReproduceReport types. - Tests: pure diff/verify/select logic + route-level tests with the sandbox call faked (drift, match, failed replay, 404). Closes #112 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Adds a "Test" panel to live model cards on /models so the
train -> deploy -> validate loop closes without leaving the app:
- Backend proxy POST /api/models/{id}/predict forwards the batch to the
live Modal endpoint with the stored X-API-Key, so the browser never
holds the key and never fights Modal CORS. Upstream 4xx (e.g. key
drift 401) passes through; network errors and upstream 5xx surface as
502. Batches capped at 200 records.
- GET /api/models/{id}/predict-schema resolves the trained
feature_columns/target_column from the training dataset's metadata
(same lookup generate_serving_app uses, now extracted into
_resolve_training_metadata).
- Frontend Test panel renders a per-feature input form from the schema,
or accepts a small CSV upload (client-side parser, header row +
quoted-field support) when metadata is missing or batch testing is
wanted; predictions render inline in a table.
- 10 new backend tests covering proxy success/auth-forwarding, 404/409/
400 paths, upstream 401/500 mapping, schema resolution, and a
regression guard that generate_serving_app still embeds the resolved
feature columns.
Closes #109
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
…, trial budget, caps Give users say over the trainer agent before it runs (closes #104). Backend: - New TrainingConfig schema (optimization_metric, model_families, max_trials, max_wallclock_minutes, max_cost_usd) stored as project.training_config (JSON column + startup migration), editable via POST/PATCH /api/projects. - Agent runner injects a "## User training constraints" prompt block into any agent that can call start-training (orchestrator, trainer, chat), and clamps the training sandbox profile's per-call timeout to the wall-clock cap before sandbox_config flows into execute-code. - start-training skill now accepts optional optimization_metric and max_trials, and enforces the project's constraints at the skill boundary: disallowed framework, conflicting metric, or over-budget trial plan are rejected before the training window opens. Compliant calls echo the active constraints back to the agent. - trainer.yaml: user constraints override the default 30-50-trial strategy. Frontend: - ProjectSettingsModal grows a "Training Controls" section (metric input with suggestions, model-family chips, trial/wall-clock/cost caps); Sidebar saves sandbox_config + training_config together. No page.tsx changes. Projects with no training_config behave exactly as before (empty config renders no prompt block and skips all skill-boundary checks). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
…rail
Cost was fully observable (usage_events, CostBadge) but nothing capped it.
This adds a per-project USD budget that hard-stops agents when exceeded:
- projects.budget_usd column (nullable = uncapped) + dev-DB migration,
exposed via ProjectCreate/ProjectUpdate (explicit null clears the cap)
- services/budget.py: BudgetStatus (project-wide spend summed over
usage_events) + check_budget() raising BudgetExceededError
- runner: pre-run check (an over-budget project never starts an agent)
and a check after every recorded usage event (a running agent halts at
its next LLM call once the cap is crossed); run_agent lands the session
in a clean `budget_exceeded` terminal state (not `failed`) with a
budget_exceeded event carrying spent/cap amounts
- usage endpoints (/sessions/{id}/usage, /projects/{id}/usage) now return
a `budget` block for the UI
- frontend: budget field in Project Settings, CostBadge shows spend vs.
cap with an "over budget" state, chat surfaces the halt message and
stops the running indicator
Closes #107
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
| async with async_session() as db: | ||
| pid = project_id | ||
| if pid is None and session_id: | ||
| pid = await _project_id_for_session(db, session_id) | ||
| if not pid: | ||
| return None | ||
|
|
||
| row = await db.execute(select(Project.budget_usd).where(Project.id == pid)) | ||
| budget = row.scalar_one_or_none() | ||
|
|
||
| row = await db.execute( | ||
| select(func.coalesce(func.sum(UsageEvent.cost_usd), 0.0)).where( | ||
| UsageEvent.project_id == pid | ||
| ) | ||
| ) | ||
| spent = float(row.scalar_one() or 0.0) |
There was a problem hiding this comment.
Two separate queries, no atomicity guarantee
get_budget_status runs two independent SELECTs — one for projects.budget_usd and one for SUM(usage_events.cost_usd) — within the same async with async_session() block but not inside a single database transaction. A concurrent agent that records a usage event between these two queries could make the spend sum appear slightly lower than reality, potentially allowing one more LLM round before the cap fires.
| async with async_session() as db: | |
| pid = project_id | |
| if pid is None and session_id: | |
| pid = await _project_id_for_session(db, session_id) | |
| if not pid: | |
| return None | |
| row = await db.execute(select(Project.budget_usd).where(Project.id == pid)) | |
| budget = row.scalar_one_or_none() | |
| row = await db.execute( | |
| select(func.coalesce(func.sum(UsageEvent.cost_usd), 0.0)).where( | |
| UsageEvent.project_id == pid | |
| ) | |
| ) | |
| spent = float(row.scalar_one() or 0.0) | |
| async with async_session() as db: | |
| pid = project_id | |
| if pid is None and session_id: | |
| pid = await _project_id_for_session(db, session_id) | |
| if not pid: | |
| return None | |
| # Read both values inside an explicit transaction to minimise the | |
| # window where a concurrent usage event could be recorded between | |
| # the two queries and make the spend appear lower than reality. | |
| await db.execute(text("BEGIN")) | |
| row = await db.execute(select(Project.budget_usd).where(Project.id == pid)) | |
| budget = row.scalar_one_or_none() | |
| row = await db.execute( | |
| select(func.coalesce(func.sum(UsageEvent.cost_usd), 0.0)).where( | |
| UsageEvent.project_id == pid | |
| ) | |
| ) | |
| spent = float(row.scalar_one() or 0.0) |
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
After uploading a CSV/Parquet the user talked to the agent blind — the
only structured preview (/sessions/{id}/prep/preview) required processed
data that doesn't exist until after prep. Practitioners want to eyeball
the raw data before spending tokens.
Backend: GET /api/projects/{project_id}/datasets/preview runs a fast
DuckDB scan (read_csv_auto / read_parquet + SUMMARIZE) on the raw file
in the volume, returning head rows, dtypes, row/col counts, and
per-column missing %/cardinality. The blocking scan is offloaded via
asyncio.to_thread (issue #93 convention), external access is disabled
before any query runs, and the path is confined to the project's
datasets root.
Frontend: CSV/TSV/Parquet rows in ProjectDataModal are now clickable
and open a DatasetPreviewPanel (head-rows table + per-column profile)
with a back button; files that never reached the sandbox stay
non-clickable since there's nothing on the volume to scan.
Closes #103
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
New users landed on an empty experiments gallery with no obvious first move, even though the repo ships seven demo datasets in sample-data/. Backend: samples.yml is the one-source-of-truth catalog (tile copy, files, suggested prompt per sample). GET /api/samples lists the tiles; POST /api/projects/from-sample creates a project + experiment + session and copies the sample files through the normal dataset-upload path (S3 + Modal Volume batch + dataset versioning), so the result is indistinguishable from a hand-uploaded project. Sample data location is probed (repo checkout / /app/sample-data mount) and overridable via SAMPLE_DATA_DIR; deployments without it degrade gracefully (tiles report available=false, create returns 503). Dev compose mounts ./sample-data read-only since it sits outside the backend build context. Frontend: the empty gallery now renders one-click "Try a sample dataset" tiles (task badge, description, size) plus a three-step inline tour of the chat/canvas split. Clicking a tile creates the pre-loaded project, activates it, and drops the user into the studio with the sample's suggested prompt pre-filled in the chat input (sessionStorage hand-off keyed to the new session, consumed once). Closes #110 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Resolve s3_browser.py conflict keeping both intents: #141's bounded chunked upload, bucket allowlist, key-traversal validation and typed UploadResponse, plus #143's asyncio.to_thread offloading and shielded multipart-abort-on-cancellation. Also restore the loop's original default executor in the cancellation-race regression test (Greptile P1 on #143): the test replaced the session-scoped loop's default executor with a single-worker pool and shut it down without restoring it, breaking any later asyncio.to_thread on the same loop.
The files branch of attach_data still buffered each upload body in memory (content = await f.read()); apply the same bounded 1 MB chunked temp-file streaming as create_experiment (issue #94), with the S3 put switched to upload_file from the temp path.
- render_as_batch=True in both offline and online alembic contexts so future ALTER-style migrations work on SQLite (Greptile 4/5 note); no-op on Postgres. - ruff format (0.15.22) the generated initial-schema revision file, unblocking Backend Lint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
) Stack wave 5, PR 4/4. Greptile P2 stamp-vs-upgrade atomicity addressed on the PR branch by author fixup 7dfaf43 (documented single-instance assumption + regression tests + fileConfig logger fix). Outstanding 4/5 render_as_batch note fixed in 7fe06d7, plus ruff format of the initial-schema revision unblocking Backend Lint.
…f metrics (closes #112) # Conflicts: # backend/schemas.py
- Move raw-file profiling into services/dataset_preview.py (thin routers) - Map duckdb.Error to 400; let server-side failures surface as 500 - Build preview query string with URLSearchParams (api.ts) - Add type="button" to the preview back button - prettier format frontend/src/components/ProjectDataModal.tsx
…pload (closes #103) # Conflicts: # backend/schemas.py # frontend/src/lib/api.ts
- Typed response models: SampleDatasetEntry, SampleProjectSummary, SampleExperimentSummary, ProjectFromSampleResponse in schemas.py - Move catalog scan + project-from-sample flow into services/samples.py (thin routers) - Move shared dataset path helpers into services/datasets.py as public API (no more cross-router underscore imports) - Offload sync file I/O (read_bytes, stat, is_file) via asyncio.to_thread - ruff format backend/routers/samples.py (+ reformatted service files) - prettier format frontend/src/app/experiments/page.tsx
…c, model families, trial budget, caps (closes #104) # Conflicts: # backend/schemas.py
# Conflicts: # backend/models.py # backend/routers/projects.py # backend/schemas.py # backend/services/agent/runner.py # frontend/src/components/ProjectSettingsModal.tsx # frontend/src/components/Sidebar.tsx # frontend/src/lib/api.ts # frontend/src/lib/types.ts
|
Too many files changed for review. ( Bypass the limit by tagging |
…p guardrail (closes #107)
Conflicts/semantic reconciliation: - MetricsTab.tsx: #157 typed ChartTooltip props (dropped `any`); staging's #161 compare page imports it — kept the typed props AND the export. - SSE union extended with events other staging PRs added/consume: `budget_exceeded` (#165, new BudgetExceededSSEData) and the `notebook.kernel.state`/`notebook.structure.changed`/`notebook.cell.*` events dispatched by useNotebookSSE (#148's typed bus requires them). StructureChangedEvent moved to lib/notebook/types.ts (re-exported from useNotebookSSE) so lib/types.ts can reference it. - page.tsx budget_exceeded case adapted to #157's per-case `const data = event.data` block pattern. - useNotebookSSE.test.tsx: cell.completed payload gained exec_count: null to satisfy the now-typed CellCompletedEvent.
|
Merged into integration branch |
Summary
Cost was fully observable after the fact (
services/usage.py, liveCostBadge) but nothing capped it — an orchestrator fanning out to specialists could silently rack up spend. This adds a per-project USD budget that the runner checks against accumulatedUsageEventrows and hard-stops the agent when exceeded, landing the session in a cleanbudget_exceededterminal state (notfailed) with a clear message telling the user how to raise or clear the cap.The budget is project-scoped on purpose: sub-agents spawned via
delegate-taskshare the session/project, so the whole fan-out is capped as one unit (spend includes both LLM tokens and sandbox compute).Changes
Backend
models.py/db.py: new nullableprojects.budget_usdcolumn (NULL = uncapped) + idempotent dev-DB migration; surfaced inProject.to_dict().schemas.py/routers/projects.py:budget_usdonProjectCreate/ProjectUpdate(0 ≤ x ≤ 1M). PATCH distinguishes "omitted" from "explicit null clears the cap" viamodel_fields_set.services/budget.py(new):BudgetStatus(budget vs. project-wide spend summed overusage_events.cost_usd),get_budget_status()(by project or session), andcheck_budget()which raisesBudgetExceededError.services/agent/runner.py:_record_usage).run_agentcatchesBudgetExceededErrorand publishes abudget_exceededevent (message + spent/cap amounts) plus astate_changeto the cleanbudget_exceededterminal state.routers/usage.py:/sessions/{id}/usageand/projects/{id}/usagenow return abudgetblock (budget_usd,spent_usd,remaining_usd,exceeded) for the UI.Frontend (minimal)
PATCH /projects/{id}path inSidebar.CostBadge: shows project spend vs. cap with a progress bar; flips to a red "over budget" state when exceeded.page.tsx(kept tiny — 5 small edits):budgetInfostate hydrated from session usage, abudget_exceededSSE case (error message + stop running indicator),budget_exceededadded to the terminal-state list, prop pass-through toCostBadge.Tests —
backend/tests/test_budget.py(8 new):UsageEvents over the cap never drives the provider and persistsbudget_exceeded(and is not markedfailed); a run that crosses the cap mid-flight is halted at the next usage event (events after the tripping usage event are never consumed).Test plan
budget_usdNULL) →check_budgetis a no-op, runs behave exactly as today, cost still displays inCostBadge. Full backend suite: 304 passed, 8 skipped (was 296 before; +8 new).ruff check+ruff format --checkclean.npm ci+next lint+tsc --noEmit+prettier --check+next buildall green.Follow-ups (out of scope)
services/budget.pymakes this a small extension).Closes #107
🤖 Generated with Claude Code
https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Greptile Summary
This PR introduces a per-project USD budget guardrail that hard-stops agents when accumulated spend (
usage_events.cost_usd, LLM + sandbox compute) crosses a configurable cap. The backend adds a nullablebudget_usdcolumn onprojects, a newservices/budget.pymodule withBudgetStatus/check_budget, and hook points in the agent runner (pre-run and post-usage-event). The frontend adds a spend-cap field in Project Settings and updatesCostBadgewith a progress bar and an "over budget" state.services/budget.pyresolves a session → project via the legacy experiment back-pointer and sums allUsageEvent.cost_usdrows;check_budgetraisesBudgetExceededErrorwhen spend meets or exceeds the cap._check_budget_failopen(new wrapper) is called twice — once before the provider loop starts and once after every recorded usage event.BudgetExceededErroris caught inrun_agentand lands the session in a cleanbudget_exceededterminal state with a descriptive user-facing message.Confidence Score: 4/5
Safe to merge with one fix: a $0 budget silently blocks all agents immediately, before any spend occurs.
The only new defect introduced is the
>=comparison inBudgetStatus.exceededcombined with the schema allowingbudget_usd=0. A user who enters0in the settings field gets all agents hard-stopped with a "spent $0.00 of $0.00" message before any work happens. Everything else — the fail-open wrapper, the pre-run and mid-run check hooks, the clean terminal state, the frontend SSE handling — is implemented correctly and covered by tests.backend/services/budget.py (the
exceededproperty) and backend/schemas.py (thege=0.0constraint) should be reviewed together for the $0-budget edge case.Important Files Changed
exceededuses>=which means abudget_usd=0project is immediately exceeded before any spend._check_budget_failopenwrapper; pre-run and per-usage-event hooks both propagate onlyBudgetExceededError, swallowing other exceptions (fail-open).BudgetExceededErrorhandled cleanly before the genericExceptioncatch.budget_usd=0.0viage=0.0; combined with the>=comparison inBudgetStatus.exceeded, a $0 budget is immediately exceeded even with $0 spend.parseFloat. Negative input silently sends null (already flagged in previous thread).Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant UI as Frontend participant API as projects / usage routers participant Runner as agent runner participant Budget as services/budget.py participant DB as SQLite (usage_events, projects) UI->>API: "PATCH /projects/{id} {budget_usd: 5.0}" API->>DB: "UPDATE projects SET budget_usd=5.0" API-->>UI: "200 {budget_usd: 5.0}" UI->>Runner: run_agent(session_id) Runner->>Budget: _check_budget_failopen(session_id) [pre-run] Budget->>DB: "SELECT budget_usd FROM projects WHERE id=pid" Budget->>DB: "SELECT SUM(cost_usd) FROM usage_events WHERE project_id=pid" DB-->>Budget: "budget=5.0, spent=2.0" Budget-->>Runner: OK (not exceeded) Runner->>Runner: _drive_provider() — LLM call Runner->>DB: record_llm_usage (new UsageEvent) Runner->>Budget: _check_budget_failopen(session_id) [post-usage] Budget->>DB: SELECT budget_usd / SUM(cost_usd) DB-->>Budget: "budget=5.0, spent=6.1" Budget-->>Runner: raises BudgetExceededError Runner->>UI: "SSE budget_exceeded {error, spent_usd, budget_usd}" Runner->>UI: "SSE state_change {state: budget_exceeded}" UI->>API: "GET /sessions/{id}/usage" API->>Budget: get_budget_status(session_id) Budget->>DB: SELECT budget_usd / SUM(cost_usd) API-->>UI: "{summary, budget: {exceeded: true, spent_usd: 6.1, budget_usd: 5.0}}"%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant UI as Frontend participant API as projects / usage routers participant Runner as agent runner participant Budget as services/budget.py participant DB as SQLite (usage_events, projects) UI->>API: "PATCH /projects/{id} {budget_usd: 5.0}" API->>DB: "UPDATE projects SET budget_usd=5.0" API-->>UI: "200 {budget_usd: 5.0}" UI->>Runner: run_agent(session_id) Runner->>Budget: _check_budget_failopen(session_id) [pre-run] Budget->>DB: "SELECT budget_usd FROM projects WHERE id=pid" Budget->>DB: "SELECT SUM(cost_usd) FROM usage_events WHERE project_id=pid" DB-->>Budget: "budget=5.0, spent=2.0" Budget-->>Runner: OK (not exceeded) Runner->>Runner: _drive_provider() — LLM call Runner->>DB: record_llm_usage (new UsageEvent) Runner->>Budget: _check_budget_failopen(session_id) [post-usage] Budget->>DB: SELECT budget_usd / SUM(cost_usd) DB-->>Budget: "budget=5.0, spent=6.1" Budget-->>Runner: raises BudgetExceededError Runner->>UI: "SSE budget_exceeded {error, spent_usd, budget_usd}" Runner->>UI: "SSE state_change {state: budget_exceeded}" UI->>API: "GET /sessions/{id}/usage" API->>Budget: get_budget_status(session_id) Budget->>DB: SELECT budget_usd / SUM(cost_usd) API-->>UI: "{summary, budget: {exceeded: true, spent_usd: 6.1, budget_usd: 5.0}}"Comments Outside Diff (1)
frontend/src/components/ProjectSettingsModal.tsx, line 881-888 (link)Number.isFinite(parsed) && parsed >= 0correctly rejects negative numbers, but the fallback isnull(no cap), not an error. A user who accidentally types-5would see no validation message — the cap would simply be cleared. The backend validator (ge=0.0) will also reject negatives, but only via a 422 response that is swallowed by thecatchblock inhandleSaveSandboxConfig. Consider either showing an inline validation message when the parsed value is negative, or clamping withMath.max(0, parsed)and a visible hint.Reviews (2): Last reviewed commit: "fix(review): address Greptile findings o..." | Re-trigger Greptile