Skip to content

Add baseten.sandbox package - #25

Merged
cretz merged 5 commits into
mainfrom
cretz/sandbox-sdk
Oct 7, 2026
Merged

cretz merged 5 commits into
mainfrom
cretz/sandbox-sdk

Conversation

@cretz

@cretz cretz commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #24

🚀 What

  • Adds baseten.sandbox, a hand-written package for Baseten sandboxes covering the control plane (/v1/sandboxes) and each sandbox's execution API, sync and async
  • Regenerates the management and sandbox clients from the latest specs
  • Generated clients take optional per-client base_url, auth, headers, timeout, so one httpx client can serve every API

💻 How

  • SandboxClient / AsyncSandboxClient: create, get, get_info, list, update, delete, sandbox_from_url, and images (ImageClient: push, wait_built, logs, get_info, list, list_tags, delete, delete_tag, cleanup, list_library)
  • Sandbox / AsyncSandbox: fs (text and byte read/write, list, mkdir, remove, find, grep, copy, write_tree, watch, multipart above 5MB) and process (exec, exec_stream, get, list, logs, stream_logs, wait, stop, kill, write_stdin, close_stdin)
  • ImageBuilder: immutable Dockerfile builder, pushed with images.push(builder=...)
  • API key exchanged at /v1/token for a cached, refreshed token used on both planes; token_provider overrides
  • HTTP/2 when h2 is installed (httpx[http2])
  • Generated clients reachable as raw_api
  • No new runtime dependencies
  • Generator: a success response with headers and no body becomes a raw-only method (head_filesystem). Temporary preprocess patch (TODO) makes SandboxImage.tags optional until the spec drops it, as the server already has

🔬 Testing

  • Unit: httpx.MockTransport per plane, sync and async, covering auth and revocation, retries and the transient classifier, multipart boundaries and concurrency (including a busy caller thread pool), zip, ignore files, the builder, sync/async signature parity, and field parity against the generated types
  • E2E (tests/sandbox/test_e2e.py, env-gated): every wrapped call against staging on both clients, including byte-exact 12MB and ~10MB text multipart writes, recursive watch, and a sandbox run from a builder-pushed image. One image push per run, skipped on the 3.11 CI job
  • CI: Linux, macOS, Windows on 3.14; Linux on 3.11

Important review notes and deviations from the previous SDK

  1. API shape
    1. Matches the Go and JS SDKs' behavior, with Python naming and types
    2. Sync and async both hand-written, matching ManagementClient/AsyncManagementClient. Models and pure logic are shared; only I/O, retry and poll loops, and streams are written twice. A test checks every sync/async pair has the same methods and signatures
    3. Keyword-only arguments on every call, required ones included (get_info(name=...)), instead of JS/Go's one request object. Adding a keyword never breaks callers. Builder methods are positional, as in JS/Go
    4. JS-style prefixed names (SandboxInfo, SandboxProcessInfo, ImageInfo), since Python callers import names unqualified
    5. get/create return a Sandbox handle (name, URL); get_info/list return data only. get takes name= or info= (no call). The previous SDK returned clients carrying the record
    6. Models are kw_only dataclasses, not pydantic: the generated models already validate the wire, and our types promise only fields. One type per concept for input and output, as the API uses one schema for both
    7. Open enums are Literal[...] | str aliases, so callers write string literals that still type-check
    8. None means unset on inputs and absent on outputs. update is one call; nothing can be cleared, since the API takes no nulls. labels, envs, expiration policies replace, not merge
    9. Durations are timedelta, dates timezone-aware datetime. Execution API timestamps parse against an explicit format list and fail clearly on anything else
    10. list, images.list, images.list_tags are lazy (async) generators; a repeated cursor raises
    11. Image list/get_info/delete return ImageInfo
    12. Unknown expiration policy types round-trip as SandboxExpirationPolicyUnknown
    13. Not implemented yet: drives, codegen, system, network. raw_api reaches them. Unlike JS, there are no placeholder accessors, since adding them later is additive
  2. Errors and retries
    1. SandboxError base for everything the SDK raises; SandboxAPIError on both planes, SandboxGatewayError for edge 502/503/504 on the execution API, typed errors for copy, process wait timeout, image upload, image build. httpx transport errors pass through after retries
    2. SandboxProcessWaitTimeoutError does not subclass builtin TimeoutError, which is an OSError
    3. Bad arguments raise TypeError (source combinations) or ValueError (content), before anything is sent
    4. Control plane calls are not retried. Execution API retries idempotent calls only (reads 5, gateway 2, uploads 3, settable via retry). All idempotent filesystem PUTs are retried, as the spec documents them idempotent; exec, stop, kill, write_stdin, remove are not
    5. The transient classifier matches Python exception types (ConnectionError, OS TimeoutError with an errno, httpx disconnect and HTTP/2 reset ec list; a caller's own read timeout is never retried
    6. A revoked token is re-minted and the request resent, the second resend after 1s. token_provider gets the revoked token
  3. Transport
    1. One pooled httpx client per SDK client, shared by both planes and every sandbox. http_client_override is transport only: requests carry absolute URLs and their own auth and headers
    2. HTTP/2 by default when h2 is installed (http2=None), required with True. The previous SDK never used HTTP/2 and made a new unpooled client
    3. No read timeout on the client we create: exec with wait_for_completion sends headers only when the command exits. httpx's default 5s would fail longer commands
    4. Sync calls cannot be cancelled, as with httpx; async uses ordinary task cancellation. No per-call timeout, since httpx's is per read, not per ca
    5. Streams (exec_stream, stream_logs, watch) return SandboxStream/AsyncSandboxStream: iterators and context managers whose close() releases the connection
    6. Multipart: 5MB threshold and parts, at most 2 parts in flight per Sandbox across its uploads. Sync uploads use the calling thread plus the cliExecutor`), or a 2-thread pool per sandbox, created on first use. Async uses tasks. The abort is sent even on an interrupt or cancellation, on its own10s timeout
  4. Process
    1. exec_stream requests and parses NDJSON. An error record raises with the server's message
    2. exec_stream events are SandboxProcessExecEventOutput (raw chunks, so prompts show at once) then SandboxProcessExecEventExit; stream_logss
    3. write_stdin takes str or bytes, not streams, so revocation resend stays possible
    4. Output is text only: the server replaces invalid UTF-8 with U+FFFD. Same in every SDK
    5. wait polls client-side and returns failed, killed, or stopped processes; an unknown status raises. timeout=None waits with no limit
  5. Filesystem
    1. Text/binary split kept. write_bytes sends permissions only when set, applied when the file is created. The multipart write_bytes is a raw request, since the generated client has no form for that endpoint
    2. No local-path upload or streaming download; contents are bytes, as before
    3. No fuzzy search or stat yet: the API only just added them
    4. copy runs cp -r with quoted paths and waits; anything but exit 0 raises SandboxFileSystemCopyError
    5. watch yields full paths, ops split from the server's |-joined ops, has a recursive option, and no with_content
    6. find/grep require path
  6. Images
    1. Push uses POST /v1/sandboxes/images plus upload, giving a reusable image (<name>:latest). The previous SDK's builder deployed a sandbox inst
    2. Exactly one source (builder, registry_image, zip, directory, files), one @overload per source plus a runtime check
    3. Zip via zipfile (ZIP64 automatic), modes kept, the same link rules as Go/JS. Local files are zipped to an anonymous temp file, which the OS rewith Content-Length (temp_file=False to opt out). Async zips and reads on a worker thread
    4. directory honors a root .dockerignore via ignore_file_processor, so no parser dependency; otherwise default_image_ignore_file (the previous CLI's defaults, Docker semantics). On the async client, the ignore function and processor may be coroutine functions
    5. logs() returns oldest first, capped at the 11,000 most recent lines
    6. list_library drops always-false fields and flattens creation options
  7. Image builder
    1. Immutable; ImageBuilder.from_registry starts one; nothing on it calls the server. dockerfile() matches JS's line for line, injected sandbox API copy and entrypoint included
    2. Invalid input raises ValueError at the call, as JS throws (Go records errors and continues)
    3. env/label keep mapping order, as JS (Go sorts keys)
    4. Package helpers take args only, shell-quoted; context entries use the basename, suffixed on collision

@cretz
cretz requested review from cploujoux and marius-baseten and a balanced review from Copilot October 7, 2026 18:29

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Signed uploads can leak shared-client credentials, alongside additional correctness and compatibility issues.

6 open findings
What changed in this PR

Adds a comprehensive sync/async sandbox SDK, including control-plane, filesystem, process, image, authentication, and retry APIs.

Changes:

  • Adds the handwritten baseten.sandbox package and documentation.
  • Regenerates API clients with per-client transport options and updated schemas.
  • Adds extensive unit and end-to-end coverage.
File(s) Description
baseten/​sandbox/​*.py Implements sandbox clients, models, auth, retries, filesystem, processes, images, and builders.
baseten/​client/​{managementapi,sandboxapi,inferenceapi}/​* Updates generated clients and models.
scripts/​apigen/​* Updates specifications, preprocessing, and client generation.
scripts/​docgen/​* Adds sandbox API documentation support.
tests/​sandbox/​* Adds sandbox unit and end-to-end coverage.
tests/​client/​*, tests/​__init__.py Updates generated-client and live API tests.
README.md, CONTRIBUTING.md Documents sandbox usage and testing.
pyproject.toml, uv.lock Adds HTTP/2 test tooling and lock data.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread baseten/sandbox/_auth.py
Comment thread baseten/sandbox/_images.py Outdated
Comment thread baseten/sandbox/_images_builder.py
Comment thread baseten/sandbox/_fs.py
Comment thread baseten/sandbox/_images.py Outdated
Comment thread scripts/apigen/specs/sandbox.yml
Arguments left ``None`` stay unchanged, and set ones replace their
previous values, including every entry of *envs*, *labels*, and the
lifecycle's expiration policies. Other lifecycle fields change only
when set. At least one argument besides *name* must be set. Other

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This documents "At least one argument besides name must be set", but nothing enforces it — update(name=...) with no other argument sends an empty PATCH. Either raise ValueError here (as push does for its source check) or drop the sentence.

@cretz cretz Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this came from server side or contract docs relay, but agreed can remove this, it may not be/stay true.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I forgot to do this before merging. Setting reminder to do it in a followup...

Base automatically changed from cretz/open-generated-enums to main October 7, 2026 22:20
cretz added 2 commits October 7, 2026 17:23
# Conflicts:
#	baseten/client/managementapi/__init__.py
#	baseten/client/managementapi/_models.py
#	baseten/client/modelconfig/_models.py
#	baseten/client/sandboxapi/_models.py
@cretz
cretz merged commit ca1be19 into main Oct 7, 2026
5 checks passed
@cretz
cretz deleted the cretz/sandbox-sdk branch October 7, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants