Metadata-Version: 2.4
Name: ai-sandbox-platform
Version: 0.1.0
Summary: Python SDK for the AI Sandbox Pilot platform (Section 8.3 / Build Order 41)
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: httpx<1.0,>=0.27
Provides-Extra: test
Requires-Dist: pytest>=8.0; extra == "test"

# ai-sandbox (Python SDK)

Section 8.3 / Build Order 41.

```python
from ai_sandbox import Client

client = Client()  # reads credentials written by `ai-sandbox login`
sandbox = client.launch(template="llama-3-8b")  # gpu/provider/hours auto-defaulted
print(sandbox.jupyter_url)
```

## Install

```bash
pip install -e .
```

## Auth

`Client()` resolves credentials in this order: an explicit `api_key=` argument,
the `AI_SANDBOX_API_KEY` environment variable, then `~/.ai-sandbox/credentials.json`
(written by `ai-sandbox login` — see the `cli/` package — or by calling
`client.device_login()` directly). There is never a manual API-key
copy/paste step; login is always the browser-based device-authorization flow
described in Section 8.2. `base_url` resolves the same way, via
`AI_SANDBOX_API_URL` or the credentials file, falling back to
`http://localhost:8000`.

### Token refresh

Access tokens are deliberately short-lived. `device_login()` and the CLI's
`ai-sandbox login` both also store a `refresh_token` alongside the access
token. When any request gets a 401, `Client` transparently exchanges the
refresh token for a new access token (`POST /auth/refresh`) and retries the
request exactly once — this happens automatically, with no code required on
your end. The backend rotates the refresh token on every use, so the new one
is persisted back to `~/.ai-sandbox/credentials.json` immediately.

If the retry also 401s, the refresh token itself is gone (revoked by
`ai-sandbox logout`, or expired) and the original `NotAuthenticatedError`
propagates — that's a real "please log in again", not a transient failure to
retry around.

This exists because a live launch on 2026-07-27 had its access token expire
mid-poll and died with "Not logged in" while the GPU it had provisioned kept
billing, with no way to recover the instance id. Long-running calls
(`launch()`'s poll loop in particular) are safe across a token expiry now.

## Live progress

`client.launch(...)` blocks and streams progress via an `on_progress`
callback (defaults to printing `[ai-sandbox] <stage>` lines) while the
request is in flight, and continues polling `GET /sandbox/instances/{id}`
if the server hands the launch off to background provisioning (rare — only
when the synchronous wait exceeds its own patience window, controlled by
`poll_timeout_seconds` / `poll_interval_seconds`). See
`client.py`'s module docstring for the full design and its limits.

## Errors

Every exception this SDK raises inherits from `ai_sandbox.AiSandboxError`, so
`except AiSandboxError` is always a safe catch-all. Beyond that, catch the
specific type when you need to react differently:

| Exception | Cause | Notes |
|---|---|---|
| `NotAuthenticatedError` | No credentials, or refresh also failed | Run `ai-sandbox login`, pass `api_key=`, or set `AI_SANDBOX_API_KEY` |
| `QuotaExceededError` | HTTP 402 — Gate A rejection | Projected cost exceeds remaining balance |
| `TemplateNotFoundError` | HTTP 404 on launch | Unknown `template_id` |
| `MissingExplicitGpuError` | HTTP 422 | custom-docker/benchmarking launches require an explicit `gpu=` |
| `InvalidLaunchRequestError` | HTTP 400 | Malformed request — e.g. neither/both of `template`/`free_text_request` set |
| `ProvisioningFailedError` | HTTP 503, or a definite `RUNTIME_FAILED` | Every provider in the routing order failed |
| `FreeTextReviewQueuedError` | Section 7.4 Tier 2 | Not an HTTP error (still a 202) — no instance was created; the request is queued for admin review. Carries `review_queue_id` |
| `ProvisioningTimeoutError` | Client-side poll budget exhausted after a 202 handoff | The instance is real and still provisioning server-side — this is *your* poll timeout expiring, not a server failure. Carries `sandbox_instance_id`; keep polling with `client.get_instance(...)` yourself |
| `DeviceLoginTimeoutError` | `device_login()`'s device code expired (10 minute window) | |
| `DeviceLoginDeniedError` | The user explicitly denied the device authorization request | |
| `AiSandboxAPIError` | Catch-all for any other non-2xx response | Carries `.status_code` and `.body` so nothing is silently swallowed |

`QuotaExceededError`, `TemplateNotFoundError`, `MissingExplicitGpuError`,
`InvalidLaunchRequestError`, and `ProvisioningFailedError` are all
`AiSandboxAPIError` subclasses, so they carry `.status_code` and `.body` too.
The mapping from HTTP status to exception type lives in
`client.py`'s `_raise_for_status()` — see `exceptions.py` for the full
hierarchy and each type's docstring.

```python
from ai_sandbox import Client, QuotaExceededError, ProvisioningTimeoutError

client = Client()
try:
    sandbox = client.launch(template="llama-3-8b")
except QuotaExceededError as e:
    print(f"Over budget: {e}")
except ProvisioningTimeoutError as e:
    # The instance kept provisioning past our poll budget — it isn't lost.
    sandbox = client.get_instance(e.sandbox_instance_id)
```
