Metadata-Version: 2.5
Name: gmi-agentbox-sdk
Version: 0.1.0b8
Summary: GMI AgentBox SDK for Python.
Project-URL: Homepage, https://www.gmicloud.ai/
Author: GMI
License: MIT
License-File: LICENSE
Keywords: agentbox,gmi,sdk
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.9
Requires-Dist: certifi>=2024.0.0
Requires-Dist: websocket-client>=1.8
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: twine>=5.0; extra == 'dev'
Description-Content-Type: text/markdown

# Usage

GMI AgentBox SDK (`gmi-agentbox-sdk`) is a thin Python client for AgentBox.

## Install

```bash
python -m pip install gmi-agentbox-sdk
```

Requires Python 3.9+. TLS uses [certifi](https://pypi.org/project/certifi/).

## Authenticate

To create an AgentBox API key, open
[API keys](https://console.gmicloud.ai/user-setting/ce/access/api-keys), then
click **Create API key**. Store the key securely; it
authenticates requests for your organization.

```bash
export GMI_AGENTBOX_API_KEY="your-api-key"
```

`GMI_AGENTBOX_API_KEY` is required when constructing `AgentBoxClient()` with no
`api_key=` argument.

Or pass it to the client:

```python
from agentbox_sdk import AgentBoxClient

client = AgentBoxClient(api_key="your-api-key")
```

The default API root is production (used when `GMI_AGENTBOX_BASE_URL` is unset):

`https://api.gmi-serving.com/v1/agents`

Resource paths such as `/deployments` and `/tasks` are joined onto that root.
Override the full root with `GMI_AGENTBOX_BASE_URL` or `AgentBoxClient(base_url=...)`.

## Quickstart

```python
import time

from agentbox_sdk import (
    AgentBoxClient,
    ConflictError,
    NotFoundError,
    SandboxFailed,
    SandboxWaitTimeout,
)

client = AgentBoxClient()

# Available values vary by organization. Use the catalogue to select an IDC
# and a SKU for the Sandbox runtime.
idcs = client.idcs.list(runtime="sandbox")
# Choose an `idc_id` returned above. This is the production example value.
idc_id = "us-central-iowa16"
products = client.products.list(idc_name=idc_id, runtime="sandbox")
# Choose an `instance_type` returned above.
instance_type = "gmi.sandbox.small"


def wait_for_template(agent, timeout=600, poll_interval=3):
    deadline = time.monotonic() + timeout
    while True:
        agent.refresh()
        status = agent.template_build_status
        if status == "ready" or status is None:
            return
        if status == "error":
            raise RuntimeError(agent.template_build_error or "Sandbox image build failed")
        if time.monotonic() >= deadline:
            raise TimeoutError(f"Sandbox image is still {status!r} after {timeout}s")
        print("...")
        time.sleep(poll_interval)


def delete_agent(agent, timeout=120, poll_interval=5):
    """Delete an Agent, retrying the 409 it returns while sandboxes remain."""
    deadline = time.monotonic() + timeout
    while True:
        try:
            agent.delete()
            return
        except NotFoundError:
            return
        except ConflictError:  # sandbox_template_in_use: sandboxes not gone yet
            if time.monotonic() >= deadline:
                raise
            time.sleep(poll_interval)

agent = None
sandbox = None
try:
    agent = client.agents.create(
        title="agentbox-demo",  # choose a unique name
        image_url="docker.io/library/alpine:3.20",
        idc=idc_id,
        instance_type=instance_type,
        runtime="sandbox",
    )
    print("Building Sandbox image; a new image can take several minutes.")
    wait_for_template(agent)
    sandbox = agent.launch(instance_type=instance_type)
    sandbox.wait_until_running()  # default timeout 300s
    print(sandbox.status, sandbox.endpoint_url)
    execution = sandbox.execute("echo hello")
    print(execution.status, execution.exit_code, execution.data.get("stdout"))
except SandboxFailed as exc:
    print("failed", exc.status, exc.message)
except SandboxWaitTimeout:
    print("still not running", sandbox.status, sandbox.last_error)
finally:
    if sandbox is not None:
        sandbox.delete()
    if agent is not None:
        delete_agent(agent)  # sandbox first; the Agent returns 409 until it is gone
```

Use `client.idcs.list(runtime="sandbox")` and
`client.products.list(idc_name=..., runtime="sandbox")` to discover available
Sandbox data centers and SKUs.

## Register an Agent

```python
agent = client.agents.create(
    title="agentbox-demo",
    image_url="docker.io/library/alpine:3.20",
    idc=idc_id,
    runtime="sandbox",
    instance_type=instance_type,
    env=[{"name": "GMI_MODELS", "value": "llama", "secret": False}],
)
print(agent.slug, agent.launchable)
```

- `generated_api_key` is returned only by create, and only when create generates a MaaS key: an Agent without `GMI_MODELS` gets `None` on both the sandbox and container runtimes. The SDK keeps it on the same `Agent` object across `refresh()` / `update()`, but `client.agents.get()` or a new object will not have it, so store it at create time.
- `agent.launchable` is false when the upstream template is missing.

Later:

```python
agent = client.agents.get("agentbox-demo")
page = client.agents.list(page=1, page_size=20)
agent.update(title="renamed")
agent.delete()
```

## Launch a Sandbox

```python
sandbox = agent.launch(instance_type=instance_type)
# or: client.sandboxes.launch(agent.slug, instance_type=..., idc_name=agent.idc)

sandbox.wait_until_running(timeout=300, poll_interval=2.0)
sandbox.refresh()
print(sandbox.status, sandbox.last_error, sandbox.endpoint_url)
```

`wait_until_running` polls until `running`. It raises:

- `SandboxFailed` if status is `error`, `deleted`, `stopped`, or `stopping`
- `SandboxWaitTimeout` if the timeout elapses first

`endpoint_url` is `None` until the sandbox is running. On the sandbox runtime it
holds the data-plane URL once the sandbox is `running`.

## Metadata and expiry

Both are sandbox-runtime only. `metadata` is your own labels.
`timeout_seconds` is the lifetime in seconds from now. Omit it to keep the
platform TTL and auto-renew. A positive value pins the expiry. Read the
effective timestamp from `sandbox.expires_at` after `refresh()` or
`set_timeout()`.

```python
sandbox = agent.launch(
    instance_type=instance_type,
    metadata={"session": "abc"},
    timeout_seconds=3600,
)
sandbox.refresh()
print(sandbox.metadata, sandbox.expires_at, sandbox.expiry_pinned)

sandbox.set_timeout(6 * 3600)
page = client.sandboxes.list(metadata={"session": "abc"})
```

`set_timeout` works while the sandbox is running or paused. It can shorten or
extend the remaining lifetime, and it pins the sandbox so later reads do not
auto-renew it.

`pause()` pauses a running sandbox. Disk files are kept; memory and running
processes are not. A paused sandbox stops consuming run time. The call
returns when the pause is accepted; `status` is usually `pausing`. Poll with
`refresh()` until it is `paused`. `paused_expire_at` is the deadline to resume
before the sandbox and its disk are reclaimed.

```python
sandbox.pause()
while sandbox.status != "paused":
    time.sleep(2)
    sandbox.refresh()
print(sandbox.paused_expire_at)
```

`resume()` wakes a paused sandbox. Omit `timeout_seconds` to keep the run time
that was left at pause. Set it (>= 60) to reset that run time and pin the
expiry. The call returns when the resume is accepted; `status` is usually
`resuming`. Poll with `wait_until_running()`. Resume is a cold start: disk
files come back, processes are not restored, and the start command is not
re-run.

```python
sandbox.resume()
sandbox.wait_until_running()

sandbox.resume(timeout_seconds=3600)
```

## Snapshots

A snapshot saves a running sandbox so new sandboxes of the same Agent can
start from it. Creation is asynchronous: `create_snapshot()` returns the status
at creation time (`creating`) and the object is not refreshed, so call
`wait_until_ready()` before launching. Launching a snapshot that is not
`ready` is HTTP 409 `snapshot_not_ready` (`ConflictError`); whether a launch
right after create is accepted depends on timing. Launching from a snapshot
inherits its sizing and IDC, so `instance_type` can be omitted.

```python
snapshot = sandbox.create_snapshot(name="base")
snapshot.wait_until_ready(timeout=600)

clone = agent.launch(snapshot_id=snapshot.id)
clone.wait_until_running()

snapshots = agent.list_snapshots()  # newest first
snapshot.rename("base-v2")          # only when ready or error
snapshot.delete()                   # async; status becomes deleting
```

`wait_until_ready()` raises `SnapshotFailed` if the status becomes `error`,
`deleting` or `delete_failed`, and `SnapshotWaitTimeout` if the timeout
elapses first. A snapshot from `client.sandboxes.create_snapshot()` does not
know its Agent; poll it with `client.agents.list_snapshots()` instead.

## Run commands and transfer files

```python
# Finished command
result = sandbox.execute("echo hello")
print(result.status, result.exit_code, result.data.get("stdout"))

# Start a long-running command, then cancel it.
execution = sandbox.execute("sleep 120", wait=False)
if execution.accepted:
    sandbox.cancel_execution(execution.id)

# File round trip
sandbox.upload_file(path="/home/user/input.txt", file=b"hello\n")
download = sandbox.download_file(path="/home/user/input.txt")
print(download.filename, download.content)
```

`wait=False` returns an accepted execution; retrieve its latest state with
`sandbox.get_execution(execution.id)`. `wait_timeout_seconds` accepts values
from 1 through 25 and is omitted when `wait=False`. File paths must be absolute
and cannot contain `.` or `..` segments.

## Interactive shell (Beta)

**Beta**: the shell API and its message protocol may change in a later release.

`sandbox.shell()` returns a connected `websocket-client` WebSocket. Every
message is a text frame holding one JSON object: send input as `stdin` and the
terminal size as `resize`, and read `status`, `stdout` and `error` back.

```python
import json

ws = sandbox.shell(timeout=20)
try:
    print(json.loads(ws.recv()))  # {"type": "status", "message": "connected"}
    ws.send(json.dumps({"type": "resize", "cols": 120, "rows": 32}))
    ws.send(json.dumps({"type": "stdin", "data": "cd /tmp && pwd\n"}))

    output = ""
    while "\n/tmp" not in output:  # skip the echo of the typed command
        message = json.loads(ws.recv())
        if message["type"] == "stdout":
            output += message["data"]
        elif message["type"] in ("error", "status"):
            print(message)
            break
    print(output)  # "cd /tmp && pwd\r\n/tmp\r\n/tmp $ "
finally:
    ws.close()  # close the connection; do not type `exit`
```

| Direction | Message |
|---|---|
| Client → service | `{"type": "stdin", "data": "pwd\n"}` types input into the shell; end a command with `\n` |
| Client → service | `{"type": "resize", "cols": 120, "rows": 32}` sets the terminal size |
| Service → client | `{"type": "status", "message": "connected"}` first message after the handshake |
| Service → client | `{"type": "stdout", "data": "..."}` terminal output |
| Service → client | `{"type": "status", "message": "closed"}` the shell ended; the service closes the socket |
| Service → client | `{"type": "error", "message": "..."}` for example a second concurrent connection |

Plain text such as `ws.send("ls\n")`, binary frames and other message types
are ignored without an error, so nothing comes back. Other `type` values the
service may send should be passed over, not treated as errors. There is no
message helper: build input with `json.dumps` and read with
`json.loads(ws.recv())`.

Session behaviour:

- The shell runs as `root` with bash (`HOME=/root`); the `$` prompt is only
  the prompt string.
- `stdout` is raw terminal output: it echoes your input, uses `\r\n` and
  control sequences, and includes stderr. Use `sandbox.execute()` when you
  need clean output and an exit code.
- Closing the connection keeps the shell; connecting again resumes the same
  session (working directory and environment are kept).
- A sandbox has one shell connection at a time. A second concurrent connection
  completes the handshake, receives `connected` and then an `error` message,
  and gets no output; the first connection keeps working.
- Typing `exit` ends the shell: the service sends `status` `closed` and closes
  the socket, and later connections to that sandbox are closed right after
  `connected`. Close the WebSocket instead of typing `exit`.
- Latency is not guaranteed; do not depend on timing.
- The WebSocket is opened with `enable_multithread=True` (websocket-client
  1.8 or later): each `send` and each `recv` holds a lock for one whole
  message, so the WebSocket can be shared between threads, for example one
  reading while another writes.

A shell handshake HTTP error raises the mapped `APIError` (401 is
`AuthenticationError`); redirects are not followed.

## List and filter sandboxes

```python
page = client.sandboxes.list()  # omits stopped and deleted
page = client.sandboxes.list(status=["running", "creating", "error"])
page = client.sandboxes.list(agent_id=agent.id, status="running")
page = client.sandboxes.list(metadata={"session": "abc"})
page = agent.sandboxes(metadata={"session": "abc"})
```

The default Sandbox list **excludes** `stopped` and `deleted`.
Pass `status` when you need those.

## Delete

```python
sandbox.delete()  # one-way; the sandbox cannot be restarted
agent.delete()     # raises ConflictError (409) while the Agent still has sandboxes
```

`agent.delete()` raises `ConflictError` (HTTP 409, `sandbox_template_in_use`)
while the Agent still has sandboxes; it never stops or deletes them. Delete the
sandboxes first. Right after the last one is deleted it can still raise
`ConflictError` for a few seconds, so retry (see `delete_agent` in the
Quickstart).

Always delete sandboxes you launched in tests. They bill the Console account.

## Log stream

The SDK supports log streaming when the Sandbox provides the `logs_stream`
capability:

```python
if sandbox.capabilities.get("logs_stream"):
    for event in sandbox.stream():
        print(event.event, event.data)
        if event.event == "status" and isinstance(event.data, dict):
            break
```

When unavailable, this call raises an unsupported-runtime error. A backend
can enable the capability without requiring an SDK update.

## Template build logs

Sandbox Agents expose paged template build logs. Container Agents raise
`NotSupportedError` (HTTP 501). GMI does not persist these logs.

```python
import time

from agentbox_sdk import GoneError

builds = agent.list_builds()
build_id = builds[0].id
offset = 0
while True:
    try:
        page = agent.build_logs(build_id, offset=offset)
    except GoneError:
        print("logs no longer available")
        break
    for entry in page.entries:
        print(entry.level, entry.message)
    offset = page.next_offset
    if page.has_more:
        continue
    build = agent.get_build(build_id)
    if build.status in ("ready", "error"):
        try:
            final = agent.build_logs(build_id, offset=offset)
        except GoneError:
            print("logs no longer available")
            break
        for entry in final.entries:
            print(entry.level, entry.message)
        if build.status == "error" and build.failure is not None:
            raise RuntimeError(build.failure.message)
        break
    time.sleep(2)
```

`has_more` false only means nothing more is available right now. Keep polling
from `next_offset` while the build is `waiting` or `building`, then read one
last page after it reaches `ready` or `error`. `410` means the logs are gone,
including for a successful build.

## Eligibility and metrics

```python
entitlement = client.eligibility()
print(entitlement.eligible, entitlement.data_centers)

if sandbox.capabilities.get("metrics"):
    batch = sandbox.metrics(start=start, end=end, kinds=["cpu", "memory"], step=60)
    series = sandbox.metrics_timeseries(kind="cpu", start=start, end=end)
    print(series.empty_reason, series.points)
```

`kinds` omitted means the API default (all 8 charts): `gpu_util`, `gpu_mem`,
`cpu`, `memory`, `disk_read`, `disk_write`, `net_rx`, `net_tx`. `start` /
`end` are unix seconds. Metrics are callable only when the Sandbox provides
the `metrics` capability; otherwise the service reports that the runtime does
not support them.

## Concepts

| Name | Meaning |
|---|---|
| `idc` / `idc_name` | Data center identifier, not a region label. |
| `instance_type` | Billing SKU, not the product object name. |

## Errors

```python
from agentbox_sdk import (
    AuthenticationError,
    SandboxFailed,
    SandboxWaitTimeout,
    NotFoundError,
    UnprocessableError,
)

try:
    client.agents.get("missing")
except NotFoundError as exc:
    print(exc.status_code, exc.code, exc.message)
```

`APIError.code` is the gateway's `error` value (for example
`snapshot_not_ready`) and `message` is its human text; switch on `exc.code`.
With a body that has only `error`, both hold that value. `str(exc)` reads
`<code>: <message>`, or just `<message>` when the code is empty or equals the
message. Before 0.1.0b8, `message` held the `error` value and `code` was set
only from a `code` field, which the gateway does not send.

`401` is `AuthenticationError` (for example, when the API key is invalid).
`422` is `UnprocessableError` (locked field / template mismatch). `410` is
`GoneError` (template build logs reclaimed). `501` is `NotSupportedError`
(template build APIs on a container Agent); it is a service answer, not a
transient failure. The SDK never retries requests. Network failures,
truncated responses, malformed URLs, and shell connection failures (other
than a failed handshake) raise `TransportError`. A shell handshake HTTP error
raises the mapped `APIError` (for example `AuthenticationError` for `401`, or
`APIError` for a `3xx`). You only need to catch `AgentBoxSDKError`.
Redirects are followed safely: `Authorization` is not sent to a different
origin, and a redirect that cannot be followed raises `APIError`. See
[Reference](https://docs.gmicloud.ai/api-reference/agentbox-sdk/reference).
