feat(compute): RunPod as an alternative compute provider to Modal - #145
feat(compute): RunPod as an alternative compute provider to Modal#145lucastononro wants to merge 163 commits into
Conversation
Adds `GET /api/sessions/{sid}/download` and `GET /api/projects/{pid}/download`
that stream a self-contained zip of the agent's workspace: every file under
`/sessions/{sid}/` plus a synthetic `trainable_local.py` shim, a filtered
`requirements.txt`, and a runbook README. The shim lets downloaded scripts
that import `from trainable import log, log_image, ...` run on a vanilla
Python install — calls land in `./trainable_out/` instead of the Modal
volume.
The zip is streamed via `StreamingResponse` over an in-memory buffer
drained per write, so multi-hundred-MB sessions don't materialize on
disk or in RAM. A 2 GB uncompressed cap with a trailing `__truncated.txt`
marker keeps a runaway walk bounded.
Frontend ships two entry points:
- Download icon in the WorkspaceSidebar header → session zip
- Download icon on every Project row in the sidebar → project zip
Closes #79.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
…erving Introduce services/compute with per-concern interfaces (SandboxProvider, KernelTransport, StorageBackend, ServingBackend) selected by the new COMPUTE_PROVIDER setting. sandbox.py/volume.py/kernel_manager.py/deploy.py become facades that keep their public signatures; Modal mechanics move to services/compute/modal_provider/*. Billing rows now record the actual provider, sandbox.yml gains runpod rates, and deployments get provider/provider_endpoint_id columns. Refs #130 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GrGFBtdZv7qtgn8KQEQEB
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
- Serverless code-runner endpoints per GPU tier (scale-to-zero, 60s idle, FlashBoot) streaming stdout/stderr via /stream; provider-neutral SandboxTimeoutError mapped from TIMED_OUT jobs - Notebook kernels on long-lived pods behind an HTTP kernel gateway (cursor-based ≤25s long-poll under the proxy's 100s cap, token auth) - Workspace storage on a RunPod network volume via its S3-compatible API (multipart above 256MB, Modal-shaped listdir entries, live reads) - Model serving as one serverless endpoint per model: generated handler.py on the volume + shared serving image, pure REST deploys, per-model template env carries HANDLER_PATH/API_KEY - Canonical GPU label → RunPod pool mapping with ordered fallbacks (no T4/A10G/A100-40GB SKUs; documented) - docker/runpod-worker image (runner/kernel/serving roles), Makefile target, CLI wizard compute-provider step, .env.example + docs - 44 new unit tests with a faked RunPod client / in-memory S3 Refs #130 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GrGFBtdZv7qtgn8KQEQEB
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
| return _volume | ||
|
|
||
|
|
||
| def _storage(): | ||
| from services.compute import get_storage | ||
|
|
There was a problem hiding this comment.
read_volume_file is Modal-only but not visibly guarded at call sites
The function still calls get_volume().read_file(path) (a raw modal.Volume method) instead of routing through _storage(). Any call site that invokes this path when COMPUTE_PROVIDER=runpod will either fail if Modal is not configured or silently read from the wrong volume. The async twin read_volume_file_async was correctly migrated to _storage().read_file(), but the sync version was deliberately left behind. Consider raising NotImplementedError or logging a clear warning when the active provider is not Modal.
HomePage.connectSSE and useNotebookSSE each opened their own EventSource
to the identical /api/sessions/{id}/stream endpoint, doubling server
fan-out and parsing every event twice. Add SSEStreamContext to expose
the single page-level stream via a publish/subscribe bus; the notebook
now subscribes instead of opening a second connection.
Closes #100
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
…wn on every token renderChatItem was a plain function, so every agent_token/agent_message setChatItems re-ran the whole list and re-parsed markdown for every prior message. Extract a memo-wrapped ChatItemView keyed by item.id (only the streaming bubble's item reference changes, so React bails out on the rest) and hoist the assistant bubble's remark-plugins array to a module constant so it doesn't defeat the memoization with a fresh array each render. Closes #98 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
…aming
bottomRef.scrollIntoView({behavior:'smooth'}) fired on every chatItems
change, including every agent_token during streaming, yanking the view
back to the bottom and making it impossible to read earlier output.
Track whether the user is pinned near the bottom via a scroll listener +
threshold and only auto-scroll while pinned; use behavior:'auto' while a
bubble is actively streaming, and re-pin when the user sends a message.
Closes #99
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
…ptile P1 on #159) FileViewer is memoized and keeps its component instance across file switches, so a markdown render crash left the boundary stuck in its error fallback for every subsequent file. key={filePath} remounts the boundary per file.
…he app (closes #102)
Reconciliation with the #148 SSE-bus chain: - DROPPED useNotebookSSE.test.ts (stale — it tested the hook's own EventSource, which #148 replaced with the SSEStreamContext bus; #148's bus-based useNotebookSSE.test.tsx is the live suite). - api.test.ts add/add: combined both suites — #158's fetch-mocked fetchJSON/FormData helper tests AND staging's (#133) relative-URL builder tests — 15 tests total. - vitest.config.ts add/add: kept one config — staging's (#148) plus #158's globals + setupFiles (vitest.setup.ts / @testing-library/jest-dom). - package.json: union of devDeps (@testing-library/dom + jest-dom), single test script; package-lock.json: took staging's, regenerated via npm install. - ci.yml: #158's frontend-test job kept as-is (staging had none — #133 only wired the npm script); all 10 jobs intact, YAML valid.
# Conflicts: # frontend/src/app/page.tsx
…chat/workspace components (closes #97)
- resume.py: prior-run wording no longer claims "stopped early" for done/*_done sessions (resumed as re-runs of finished work) — new _prior_run_outcome helper used by both build_resume_context and build_resume_prompt. - resume.py: widen _clip annotation to str | None to match its runtime None guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts: # backend/services/agent/runner.py # frontend/src/components/chat/ChatPane.tsx # frontend/src/lib/useSessionStream.ts
- ApprovalCard: surface verdict-submission failures inline instead of only console.error — the buttons re-enable on failure but the user previously had no indication their Approve/Edit never reached the agent (expired approval, backend restart, ...). - ApprovalCard: named ErrorBoundary import per components/ style guide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- EdaFindingsPanel: stable content-derived card key (was the sorted-array index, which shifts when new findings arrive — resetting each card's applied state and allowing duplicate "Apply in prep" appends). - EdaFindingsPanel: named export per components/ style guide. - report-eda-findings handler: report items truncated by the 50-finding cap separately from schema-invalid drops, so the agent doesn't mistake the cap for a formatting problem and retry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts: # backend/routers/sessions.py
…ive stages (closes #108)
# Conflicts: # frontend/src/lib/useSessionStream.ts
… stream retry - ensure_runner_endpoint no longer holds the shared non-reentrant lock across ensure_network_volume()/ensure_runner_template() (deadlock on the first execution of a fresh setup) + regression test - worker runner stages job code in a temp file and execs it via a -c bootstrap instead of putting the whole script on argv (execve ARG_MAX ~2 MB killed Popen for multi-MB jobs the backend accepts) - /stream poller tolerates a few consecutive transient failures before condemning a job to FAILED/returncode -9 + tests
# Conflicts: # backend/services/volume.py # docker-compose.yml
|
Too many files changed for review. ( Bypass the limit by tagging |
|
Merged into integration branch |
Closes #130
What
Adds RunPod as a full-parity alternative compute provider to Modal, selected globally via
COMPUTE_PROVIDER=modal|runpod. Modal stays the default and its behavior is unchanged (the whole pre-existing suite passes untouched after the refactor commit, before any RunPod code exists).Commit 1 — provider abstraction (pure refactor)
New
backend/services/compute/package with per-concern interfaces selected bysettings.compute_provider:SandboxProvider(one-shot exec, streamed stdout/stderr)modal.Sandbox.createservices/sandbox.py:run_code(signature unchanged)KernelTransport(newline-JSON cmd/event channel)services/kernel_manager.pyStorageBackend(workspace volume, 1:1 withvolume.pyhelpers)services/volume.py(all functions keep their names)ServingBackend(codegen/deploy/stop/secret)_serving_app_code/modal deployCLI helpersservices/deploy.py(orchestration + DB rows stay)Also: billing rows now record the actual provider (
record_sandbox_usage(provider=...)replaces the hardcoded"modal"),sandbox.ymlgains arunpod:rate key, anddeploymentsgainsprovider/provider_endpoint_idcolumns (ALTER-TABLE migration indb.py).The Modal adapters deliberately resolve
modal.Sandbox/get_app/get_image/get_volumethrough the legacy module namespaces so every existing test monkeypatch keeps working.Commit 2 — RunPod implementation
workersMin=0, 60 s idle keeps workers warm across consecutive agent calls, FlashBoot). The worker generator-handler subprocess-runs the code and yields stdout/stderr lines; the backend polls/stream/{job}and re-exposes them as the same async-iterator handle shape Modal has. Job timeout viapolicy.executionTimeout;TIMED_OUTmaps to a provider-neutralSandboxTimeoutErrorthat the execute-code skill handles like Modal's.AsyncKernelManagerlogic as the Modal stdin/stdout proxy):POST /cmd, cursor-basedGET /eventslong-poll ≤25 s (the pod proxy kills connections at 100 s), per-pod token auth, SDK preamble via env. Ready timeout is provider-aware (600 s vs 120 s — first boot pulls the image). The existing idle reaper bounds pod cost.modal.Volume's client API plays today). Multipart above 256 MB (500 MB single-PUT cap), Modal-shapedlistdirentries (.path,.type.name),FileNotFoundErrorparity, auto-created volume with pinnable id.create-serving-appwrites a readablehandler.pyto the volume; a shared serving image loads it viaHANDLER_PATHenv (deploy = metadata, no image build — same property as Modal). Pure REST, no CLI subprocess. Per-model key enforced in-handler (input.api_key); rotation updates the template env (rolls on next cold start).endpoint_url = https://api.runpod.ai/v2/{id}/runsync.runpod_provider/gpu.pymaps each to an ordered RunPod pool (T4→RTX A4000-class,A10G→RTX A5000/A40,A100-40GBschedules and bills as 80 GB — surfaced in/deploy/compute-optionsnotes and docs).RUNPOD_*settings with startup validation,trainable initcompute-provider step (RunPod branch collects API key + S3 key pair + datacenter),.env.exampleblock,docker/runpod-worker/image +make runpod-image,docs/compute-providers.md.✅ Verified automatically (no live account needed)
COMPUTE_PROVIDER=modalbehavior is byte-for-byte preserved, including every existingmodal.Sandbox.create/get_volumemonkeypatch.TIMED_OUT→SandboxTimeoutError(rc 124), FAILED→nonzero rc, cancel-on-terminate,policy.executionTimeout+ workdir in the run payload, lazy endpoint creation, oversized-payload guard, and a fullrun_code()pass on the runpod provider asserting usage is recorded withprovider="runpod"+ the actual GPUFileNotFoundErrorparity for files and dirs, recursive + delimiter listings with synthesized directory entries, prefix-recursive remove, session-workspace bootstrap, live-reload no-opast.parse+ api-key gate presence/absence, no/data/datadouble-prefix, xgboost-native loader selection, first-deploy creates template+endpoint (env, volume, DC, GPU pool asserted), CPU deploys usecomputeType=CPU, redeploy PATCHes instead of re-creating, stop scales to 0 then deletes, missing-endpoint-id error/cmdPOST shape, pod-delete on terminate, pod-create payload (mount path, ports, env, dockerStartCmd), worker scripts parse + long-poll stays under the 100 s proxy capprovider='modal', idempotent)_has_compute_credstruth table,.envwrite→read round-trip for the RunPod block, modal-only configs emit no RunPod keys.entrypoint.shshell syntax (sh -n);ruff check+ruff formatclean; frontend production build green; full suite at HEAD: 343 passed, 8 skipped.🧑💻 Requires a human with a live RunPod account
These touch real RunPod infrastructure and cannot be exercised by CI. In order:
make runpod-image RUNPOD_IMAGE=<your-registry>/trainable-runpod-worker:latest— needs docker buildx + a registry; verify the image is pullable by RunPod (public, or registry creds configured in the RunPod console). Nothing below works without this.trainable init→ pick RunPod → enter API key, S3 key pair, datacenter →trainable up. Confirm backend starts with noCOMPUTE_PROVIDERvalidation errors and the network volume is auto-created (check the log line, then pinRUNPOD_NETWORK_VOLUME_IDin.env).trainable.log(...)metrics render on the dashboard. Expect minutes of cold start on the very first call per GPU tier; a second call within ~60 s should be near-instant (warm worker).time.sleep(9999)with a short project timeout — the agent should receive the sandbox-timeout tool error, not a crash.create-serving-app→ Deploy from/models→curl -X POST <endpoint_url> -H "Authorization: Bearer $RUNPOD_API_KEY" -d '{"input": {"records": [...], "api_key": "<model key>"}}'→ predictions returned; wrongapi_key→ 401-shaped error; Rotate key → old key rejected after next cold start; Stop → endpoint gone from the RunPod console.provider=runpod.T4, run aheavy=truecall, confirmnvidia-smi/torch.cuda.is_available()sees a GPU and the endpoint created in the console targets the RTX A4000 pool..envback toCOMPUTE_PROVIDER=modal, restart, rerun 3 + 7 briefly.Known limitations / follow-ups
{"input": ...}/{"output": ...}) — documented with curl examples.gpuTypeIdsfallbacks mitigate).🤖 Generated with Claude Code
https://claude.ai/code/session_012GrGFBtdZv7qtgn8KQEQEB
Greptile Summary
This PR adds RunPod as a full-parity alternative compute provider to Modal, selected via
COMPUTE_PROVIDER=modal|runpod. It introduces a clean four-interface abstraction (SandboxProvider,KernelTransport,StorageBackend,ServingBackend) with Modal and RunPod implementations, while keeping all existing Modal call-sites and test patch points unchanged.backend/services/compute/package with provider factory; allservices/volume.py,sandbox.py,kernel_manager.py, anddeploy.pycall sites route through the abstraction with no signature changes. DB migration addsprovider/provider_endpoint_idto deployments; billing rows now record the actual provider.Confidence Score: 3/5
RunPod code execution fails for scripts exceeding ~2 MB (Linux ARG_MAX) despite the backend accepting up to 5 MB, and large model artifacts written via write_to_volume bypass multipart and hit RunPod's 500 MB single-PUT cap.
Two concrete defects on the RunPod hot path: the worker subprocess rejects code via -c above ~2 MB while the backend permits 5 MB; and RunPodStorage.write() uses put_object without multipart, capping in-memory writes at RunPod's documented 500 MB S3 limit. The Modal path is untouched and its full suite passes. The abstraction is well-designed.
docker/runpod-worker/worker/runner_handler.py (subprocess ARG_MAX), backend/services/compute/runpod_provider/storage.py (write() missing multipart), backend/services/compute/runpod_provider/bootstrap.py (template/lock issues).
Important Files Changed
write()uses put_object directly, bypassing multipart and RunPod's 500 MB single-PUT cap.rotate_keymay not propagate the new API key if RunPod caches template env at endpoint-creation time.Reviews (2): Last reviewed commit: "test(db): cover the deployments provider..." | Re-trigger Greptile