mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
feat(harness): add sandbox.prized(), a remote microVM sandbox for litellm.agent()
This commit is contained in:
parent
9a38ff4cb5
commit
a5f8110957
7 changed files with 630 additions and 27 deletions
|
|
@ -307,6 +307,8 @@ print(result.text, result.cost, [f.path for f in result.files])
|
|||
|
||||
Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call the agent makes goes through your AI Gateway, tagged `harness,claude_code`. Drop the `litellm_proxy/` prefix to call a provider directly. Install `starlette uvicorn` plus the agent's CLI (`claude`, `codex`, `opencode` or `pi`), or `deepagents langchain-litellm` for Deep Agents.
|
||||
|
||||
To keep the agent's commands and file edits off your machine, pass `sandbox=sandbox.docker(image)`, or `sandbox=sandbox.prized()` for a remote [Prized](https://prized.dev/docs/sandboxes?via=litellm) microVM (set `PRIZED_API_KEY`, works with Deep Agents).
|
||||
|
||||
[**Docs: Agent Harnesses**](https://docs.litellm.ai/docs/harness)
|
||||
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from litellm.harness.sandbox.base import CompletedRun, Process, Sandbox
|
||||
from litellm.harness.sandbox.docker import DockerSandbox, docker
|
||||
from litellm.harness.sandbox.local import LocalSandbox, local
|
||||
from litellm.harness.sandbox.prized import PrizedSandbox, prized
|
||||
from litellm.harness.sandbox.snapshot import (
|
||||
build_file_changes,
|
||||
capture_text_contents,
|
||||
|
|
@ -14,6 +15,7 @@ __all__ = (
|
|||
"CompletedRun",
|
||||
"DockerSandbox",
|
||||
"LocalSandbox",
|
||||
"PrizedSandbox",
|
||||
"Process",
|
||||
"Sandbox",
|
||||
"build_file_changes",
|
||||
|
|
@ -21,5 +23,6 @@ __all__ = (
|
|||
"diff_snapshots",
|
||||
"docker",
|
||||
"local",
|
||||
"prized",
|
||||
"snapshot_local",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,40 +10,16 @@ from collections.abc import Mapping, Sequence
|
|||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from litellm.constants import HARNESS_SNAPSHOT_SKIP_DIRS
|
||||
from litellm.harness.errors import SandboxError
|
||||
from litellm.harness.sandbox.base import CompletedRun
|
||||
from litellm.harness.sandbox.local import SubprocessHandle, collect_output
|
||||
from litellm.harness.sandbox.snapshot import HARNESS_SNAPSHOT_MAX_FILE_BYTES
|
||||
from litellm.harness.sandbox.snapshot import parse_sha256sum, remote_snapshot_script
|
||||
|
||||
DOCKER_HOST_ALIAS: Final = "host.docker.internal"
|
||||
_SHA256_HEX_LEN: Final = 64
|
||||
_WRITE_SCRIPT: Final = 'mkdir -p "$(dirname "$1")" && cat > "$1"'
|
||||
_WHICH_SCRIPT: Final = 'command -v "$1"'
|
||||
|
||||
|
||||
def _snapshot_script() -> str:
|
||||
prune = " -o ".join(f"-name '{name}'" for name in sorted(HARNESS_SNAPSHOT_SKIP_DIRS))
|
||||
return (
|
||||
'cd "$1" && find . -type d \\( '
|
||||
+ prune
|
||||
+ " \\) -prune -o -type f -size -"
|
||||
+ f"{HARNESS_SNAPSHOT_MAX_FILE_BYTES + 1}c"
|
||||
+ " -exec sha256sum {} +"
|
||||
)
|
||||
|
||||
|
||||
def parse_sha256sum(output: str) -> Mapping[str, str]:
|
||||
"""Parse `sha256sum` lines ("<hex> ./rel/path") into {rel/path: hex}."""
|
||||
return MappingProxyType(
|
||||
{
|
||||
line[_SHA256_HEX_LEN + 2 :].removeprefix("./"): line[:_SHA256_HEX_LEN]
|
||||
for line in output.splitlines()
|
||||
if len(line) > _SHA256_HEX_LEN + 2
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class DockerSandbox:
|
||||
"""Sandbox backed by a long-lived `sleep infinity` container."""
|
||||
|
||||
|
|
@ -243,7 +219,7 @@ class DockerSandbox:
|
|||
return path
|
||||
|
||||
async def snapshot(self) -> Mapping[str, str]:
|
||||
code, out, err = await self._exec_capture(("sh", "-c", _snapshot_script(), "sh", self.workdir))
|
||||
code, out, err = await self._exec_capture(("sh", "-c", remote_snapshot_script(), "sh", self.workdir))
|
||||
if code != 0:
|
||||
raise SandboxError(f"snapshot failed: {err.decode(errors='replace').strip()}")
|
||||
return parse_sha256sum(out.decode("utf-8", errors="replace"))
|
||||
|
|
|
|||
441
litellm/harness/sandbox/prized.py
Normal file
441
litellm/harness/sandbox/prized.py
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
"""PrizedSandbox: run harness commands and files in a Prized sandbox (a Firecracker microVM) over its HTTP API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import posixpath
|
||||
import shlex
|
||||
import uuid
|
||||
from collections.abc import Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from litellm.harness.errors import CapabilityUnsupported, SandboxError
|
||||
from litellm.harness.sandbox.base import CompletedRun
|
||||
from litellm.harness.sandbox.snapshot import parse_sha256sum, remote_snapshot_script
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
|
||||
PRIZED_API_BASE: Final = "https://api.prized.dev/api/v1"
|
||||
PRIZED_DEFAULT_TEMPLATE: Final = "prized-base-v1"
|
||||
PRIZED_HOME: Final = "/home/prized"
|
||||
PRIZED_WORKDIR: Final = "/home/prized/work"
|
||||
PRIZED_EXEC_MAX_SECONDS: Final = 3600
|
||||
_START_TIMEOUT_SECONDS: Final = 180.0
|
||||
_READY_TIMEOUT_SECONDS: Final = 30.0
|
||||
_POLL_SECONDS: Final = 0.5
|
||||
_ANSWER_SLACK_SECONDS: Final = 30.0
|
||||
_STOP_TIMEOUT_SECONDS: Final = 15.0
|
||||
_RETRYABLE_EDGE_STATUSES: Final = frozenset({502, 503})
|
||||
_RUN_DIR: Final = "/tmp/litellm-harness-runs"
|
||||
_WHICH_SCRIPT: Final = 'command -v "$1"'
|
||||
_STOP_SCRIPT: Final = (
|
||||
'f="$1"; i=0; while [ ! -s "$f" ] && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done; '
|
||||
'[ -s "$f" ] || exit 0; pg=$(cat "$f"); kill -TERM -- "-$pg" 2>/dev/null; i=0; '
|
||||
'while kill -0 -- "-$pg" 2>/dev/null && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done; '
|
||||
'kill -KILL -- "-$pg" 2>/dev/null; rm -f "$f"'
|
||||
)
|
||||
|
||||
|
||||
class _SandboxDoc(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore", populate_by_name=True)
|
||||
|
||||
id: str
|
||||
state: str
|
||||
toolbox_base: str | None = Field(default=None, alias="toolboxBase")
|
||||
error_reason: str | None = Field(default=None, alias="errorReason")
|
||||
|
||||
|
||||
class _SandboxEnvelope(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
|
||||
sandbox: _SandboxDoc
|
||||
|
||||
|
||||
class _ExecAnswer(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore", populate_by_name=True)
|
||||
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
exit_code: int = Field(alias="exitCode")
|
||||
timed_out: bool = Field(default=False, alias="timedOut")
|
||||
|
||||
|
||||
class _PrizedProcess:
|
||||
"""A command run to completion in the sandbox; its output arrives on the readers when it exits."""
|
||||
|
||||
def __init__(self, run: asyncio.Task[CompletedRun]) -> None:
|
||||
self.stdin: asyncio.StreamWriter | None = None
|
||||
self.stdout: Final = asyncio.StreamReader()
|
||||
self.stderr: Final = asyncio.StreamReader()
|
||||
self._run: Final = run
|
||||
self.done: Final = run
|
||||
run.add_done_callback(self._deliver)
|
||||
|
||||
def _deliver(self, run: asyncio.Task[CompletedRun]) -> None:
|
||||
if not run.cancelled() and run.exception() is None:
|
||||
self.stdout.feed_data(run.result().stdout.encode())
|
||||
self.stderr.feed_data(run.result().stderr.encode())
|
||||
self.stdout.feed_eof()
|
||||
self.stderr.feed_eof()
|
||||
|
||||
async def wait(self) -> int:
|
||||
try:
|
||||
return (await asyncio.shield(self._run)).exit_code
|
||||
except asyncio.CancelledError:
|
||||
if self._run.cancelled():
|
||||
return -9
|
||||
raise
|
||||
|
||||
async def kill(self) -> None:
|
||||
self._run.cancel()
|
||||
await asyncio.gather(self._run, return_exceptions=True)
|
||||
|
||||
|
||||
class PrizedSandbox:
|
||||
"""Sandbox in a Prized microVM, created lazily on first use and deleted on close().
|
||||
|
||||
Pass `sandbox_id` to attach to a sandbox you already have instead; close() then leaves it running.
|
||||
Commands run under `bash` as the user `prized`, with stdin at EOF and output returned when they exit.
|
||||
Files move through the sandbox's file API, so `workdir` and every path must be under /home/prized.
|
||||
The sandbox cannot reach this machine, so CLI harnesses that call back to a local model endpoint
|
||||
(Claude Code, Codex, OpenCode) are not supported; Deep Agents and Tool Loop are.
|
||||
"""
|
||||
|
||||
is_container = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
template: str = PRIZED_DEFAULT_TEMPLATE,
|
||||
sandbox_id: str | None = None,
|
||||
workdir: str = PRIZED_WORKDIR,
|
||||
env: Mapping[str, str] | None = None,
|
||||
labels: Mapping[str, str] | None = None,
|
||||
vcpu: int | None = None,
|
||||
memory_mb: int | None = None,
|
||||
max_runtime_min: int | None = None,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
normalized: Final = posixpath.normpath(workdir)
|
||||
if not posixpath.isabs(normalized) or not _under_home(normalized):
|
||||
raise SandboxError(f"prized workdir must be an absolute path under {PRIZED_HOME}: {workdir}")
|
||||
self.workdir: str = normalized
|
||||
self.template = template
|
||||
self.env: Mapping[str, str] = MappingProxyType(dict(env or ()))
|
||||
self.labels: Mapping[str, str] = MappingProxyType(dict(labels or ()))
|
||||
self.sandbox_id: str | None = sandbox_id
|
||||
self._owns_sandbox = sandbox_id is None
|
||||
self._resources: Final = MappingProxyType(
|
||||
{k: v for k, v in (("vcpu", vcpu), ("memoryMb", memory_mb)) if v is not None}
|
||||
)
|
||||
self._max_runtime_min = max_runtime_min
|
||||
self._api_key = api_key
|
||||
self._api_base = (api_base or PRIZED_API_BASE).rstrip("/")
|
||||
self._client = client
|
||||
self._owns_client = False
|
||||
self._toolbox: str | None = None
|
||||
self._start_lock: Final = asyncio.Lock()
|
||||
self._processes: Final[set[_PrizedProcess]] = set() # mutable-ok: live-process registry, killed on close()
|
||||
self._closed = False
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"PrizedSandbox({self.sandbox_id or self.template!r}, workdir={self.workdir!r})"
|
||||
|
||||
def _http(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient()
|
||||
self._owns_client = True
|
||||
return self._client
|
||||
|
||||
def _headers(self) -> Mapping[str, str]:
|
||||
key: Final = self._api_key or get_secret_str("PRIZED_API_KEY")
|
||||
if not key:
|
||||
raise SandboxError("Prized API key not set. Set PRIZED_API_KEY or pass api_key=...")
|
||||
return {"Authorization": f"Bearer {key}"}
|
||||
|
||||
def sandbox_path(self, path: str) -> str:
|
||||
"""Absolute sandbox path; relative paths resolve against workdir."""
|
||||
return posixpath.normpath(path if posixpath.isabs(path) else posixpath.join(self.workdir, path))
|
||||
|
||||
async def start(self) -> str:
|
||||
"""Create (or attach to) the sandbox if needed, wait until it answers commands, and return its id."""
|
||||
if self._closed:
|
||||
raise SandboxError("sandbox is closed")
|
||||
async with self._start_lock:
|
||||
if self.sandbox_id is not None and self._toolbox is not None:
|
||||
return self.sandbox_id
|
||||
doc: Final = await (self._get(self.sandbox_id) if self.sandbox_id else self._create())
|
||||
self.sandbox_id = doc.id
|
||||
running: Final = await self._wait_running(doc)
|
||||
if running.toolbox_base is None:
|
||||
raise SandboxError(f"prized sandbox {doc.id} has no command endpoint")
|
||||
self._toolbox = running.toolbox_base.rstrip("/")
|
||||
await self._wait_ready()
|
||||
if self._owns_sandbox:
|
||||
await self._run_line(f"mkdir -p -- {shlex.quote(self.workdir)}", cwd=PRIZED_HOME, timeout=60)
|
||||
return doc.id
|
||||
|
||||
async def _create(self) -> _SandboxDoc:
|
||||
body: Final = {
|
||||
"template": self.template,
|
||||
"labels": {"created-by": "litellm-harness", **self.labels},
|
||||
**({"envVars": dict(self.env)} if self.env else {}),
|
||||
**({"resources": dict(self._resources)} if self._resources else {}),
|
||||
**({"maxRuntimeMin": self._max_runtime_min} if self._max_runtime_min else {}),
|
||||
}
|
||||
response: Final = await self._http().post(
|
||||
f"{self._api_base}/sandboxes",
|
||||
params={"wait": "running"},
|
||||
json=body,
|
||||
headers={**self._headers(), "Idempotency-Key": f"litellm-{uuid.uuid4().hex}"},
|
||||
timeout=_START_TIMEOUT_SECONDS,
|
||||
)
|
||||
return _sandbox_doc(response, "create")
|
||||
|
||||
async def _get(self, sandbox_id: str) -> _SandboxDoc:
|
||||
response: Final = await self._http().get(
|
||||
f"{self._api_base}/sandboxes/{sandbox_id}", headers=self._headers(), timeout=_READY_TIMEOUT_SECONDS
|
||||
)
|
||||
return _sandbox_doc(response, f"get {sandbox_id}")
|
||||
|
||||
async def _wait_running(self, doc: _SandboxDoc) -> _SandboxDoc:
|
||||
deadline: Final = asyncio.get_running_loop().time() + _START_TIMEOUT_SECONDS
|
||||
current = doc # rebind-ok: polled until the sandbox settles
|
||||
while current.state != "running":
|
||||
if current.state in ("deleting", "deleted", "error"):
|
||||
raise SandboxError(f"prized sandbox {doc.id} is {current.state}: {current.error_reason or ''}".strip())
|
||||
if current.state == "paused":
|
||||
return current
|
||||
if asyncio.get_running_loop().time() > deadline:
|
||||
raise SandboxError(f"prized sandbox {doc.id} did not start within {_START_TIMEOUT_SECONDS:g}s")
|
||||
await asyncio.sleep(_POLL_SECONDS)
|
||||
current = await self._get(doc.id)
|
||||
return current
|
||||
|
||||
async def _wait_ready(self) -> None:
|
||||
"""The edge can answer 502 for a moment after a sandbox reaches running; a harmless `true` waits it out."""
|
||||
deadline: Final = asyncio.get_running_loop().time() + _READY_TIMEOUT_SECONDS
|
||||
while True:
|
||||
response = await self._post_execute({"command": "true", "timeout": 10}, timeout=40.0)
|
||||
if response.status_code not in _RETRYABLE_EDGE_STATUSES:
|
||||
_exec_answer(response)
|
||||
return
|
||||
if asyncio.get_running_loop().time() > deadline:
|
||||
raise SandboxError(f"prized sandbox {self.sandbox_id} did not answer commands ({response.status_code})")
|
||||
await asyncio.sleep(_POLL_SECONDS)
|
||||
|
||||
async def _post_execute(self, body: Mapping[str, object], *, timeout: float) -> httpx.Response:
|
||||
return await self._http().post(
|
||||
f"{self._toolbox}/process/execute", json=dict(body), headers=self._headers(), timeout=timeout
|
||||
)
|
||||
|
||||
async def _run_line(
|
||||
self, line: str, *, cwd: str, env: Mapping[str, str] | None = None, timeout: int = PRIZED_EXEC_MAX_SECONDS
|
||||
) -> _ExecAnswer:
|
||||
body: Final = {"command": line, "cwd": cwd, "timeout": timeout, **({"env": dict(env)} if env else {})}
|
||||
return _exec_answer(await self._post_execute(body, timeout=timeout + _ANSWER_SLACK_SECONDS))
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
cmd: Sequence[str],
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
) -> _PrizedProcess:
|
||||
if not cmd:
|
||||
raise SandboxError("exec() needs a non-empty command")
|
||||
await self.start()
|
||||
process: Final = _PrizedProcess(asyncio.ensure_future(self.run(cmd, env=env, cwd=cwd)))
|
||||
self._processes.add(process)
|
||||
process.done.add_done_callback(lambda _: self._processes.discard(process))
|
||||
return process
|
||||
|
||||
async def run(
|
||||
self,
|
||||
cmd: Sequence[str],
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> CompletedRun:
|
||||
if not cmd:
|
||||
raise SandboxError("run() needs a non-empty command")
|
||||
await self.start()
|
||||
limit: Final = PRIZED_EXEC_MAX_SECONDS if timeout is None else max(1, math.ceil(timeout))
|
||||
if limit > PRIZED_EXEC_MAX_SECONDS:
|
||||
raise SandboxError(f"prized runs one command for at most {PRIZED_EXEC_MAX_SECONDS}s")
|
||||
pid_file: Final = f"{_RUN_DIR}/{uuid.uuid4().hex}.pid"
|
||||
line: Final = (
|
||||
f'mkdir -p {_RUN_DIR} && printf %s "$$" > {pid_file}; '
|
||||
f"{shlex.join(cmd)}; status=$?; rm -f {pid_file}; exit $status"
|
||||
)
|
||||
try:
|
||||
answer: Final = await self._run_line(
|
||||
line, cwd=self.sandbox_path(cwd or self.workdir), env={**self.env, **(env or {})}, timeout=limit
|
||||
)
|
||||
except BaseException:
|
||||
await asyncio.shield(self._stop(pid_file))
|
||||
raise
|
||||
if answer.timed_out:
|
||||
raise SandboxError(f"command timed out after {timeout}s: {cmd[0]}")
|
||||
return CompletedRun(stdout=answer.stdout, stderr=answer.stderr, exit_code=answer.exit_code)
|
||||
|
||||
async def _stop(self, pid_file: str) -> None:
|
||||
"""Kill the process group of a command whose call was cancelled or lost; best effort."""
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._run_line(shlex.join(("sh", "-c", _STOP_SCRIPT, "sh", pid_file)), cwd=PRIZED_HOME, timeout=10),
|
||||
_STOP_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (SandboxError, httpx.HTTPError, asyncio.TimeoutError):
|
||||
return
|
||||
|
||||
async def read(self, path: str) -> bytes:
|
||||
await self.start()
|
||||
target: Final = self.sandbox_path(path)
|
||||
response: Final = await self._http().get(
|
||||
f"{self._toolbox}/files/download", params={"path": target}, headers=self._headers(), timeout=600.0
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise SandboxError(f"could not read {target}: {_edge_error(response)}")
|
||||
return response.content
|
||||
|
||||
async def write(self, path: str, data: bytes) -> None:
|
||||
await self.start()
|
||||
target: Final = self.sandbox_path(path)
|
||||
response: Final = await self._http().post(
|
||||
f"{self._toolbox}/files/upload",
|
||||
params={"path": target},
|
||||
content=data,
|
||||
headers={**self._headers(), "Content-Type": "application/octet-stream"},
|
||||
timeout=600.0,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise SandboxError(f"could not write {target}: {_edge_error(response)}")
|
||||
|
||||
def host_url(self, port: int) -> str:
|
||||
raise CapabilityUnsupported(
|
||||
"a Prized sandbox cannot reach this machine, so CLI harnesses (Claude Code, Codex, OpenCode) "
|
||||
"cannot call the local model endpoint from it; use Harness.DEEPAGENTS or Harness.TOOL_LOOP"
|
||||
)
|
||||
|
||||
async def which(self, binary: str) -> str | None:
|
||||
found: Final = await self.run(("sh", "-lc", _WHICH_SCRIPT, "sh", binary), timeout=30)
|
||||
path: Final = found.stdout.strip()
|
||||
return path if found.exit_code == 0 and path else None
|
||||
|
||||
async def tempdir(self) -> str:
|
||||
"""A fresh directory under /home/prized, so the file API reaches it."""
|
||||
made: Final = await self.run(("mktemp", "-d", "-p", PRIZED_HOME, "litellm-harness-XXXXXX"), timeout=30)
|
||||
path: Final = made.stdout.strip()
|
||||
if made.exit_code != 0 or not path:
|
||||
raise SandboxError(f"mktemp -d failed: {made.stderr.strip()}")
|
||||
return path
|
||||
|
||||
async def snapshot(self) -> Mapping[str, str]:
|
||||
listed: Final = await self.run(("sh", "-c", remote_snapshot_script(), "sh", self.workdir), timeout=300)
|
||||
if listed.exit_code != 0:
|
||||
raise SandboxError(f"snapshot failed: {listed.stderr.strip()}")
|
||||
return parse_sha256sum(listed.stdout)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Stop running commands and delete the sandbox this object created; an attached one keeps running."""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
await asyncio.gather(*(process.kill() for process in self._processes), return_exceptions=True)
|
||||
self._processes.clear()
|
||||
try:
|
||||
if self._owns_sandbox and self.sandbox_id is not None:
|
||||
await self._delete(self.sandbox_id)
|
||||
finally:
|
||||
await self._close_client()
|
||||
|
||||
async def _delete(self, sandbox_id: str) -> None:
|
||||
try:
|
||||
response: Final = await self._http().delete(
|
||||
f"{self._api_base}/sandboxes/{sandbox_id}", headers=self._headers(), timeout=30.0
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise SandboxError(f"could not delete prized sandbox {sandbox_id}: {exc}") from exc
|
||||
if response.status_code >= 400 and response.status_code != 404:
|
||||
raise SandboxError(f"could not delete prized sandbox {sandbox_id}: {_edge_error(response)}")
|
||||
|
||||
async def _close_client(self) -> None:
|
||||
if self._owns_client and self._client is not None:
|
||||
await self._client.aclose()
|
||||
|
||||
async def __aenter__(self) -> PrizedSandbox:
|
||||
await self.start()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
await self.close()
|
||||
|
||||
|
||||
def _under_home(path: str) -> bool:
|
||||
return path == PRIZED_HOME or path.startswith(PRIZED_HOME + "/")
|
||||
|
||||
|
||||
class _ErrorBody(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
|
||||
error: str = ""
|
||||
|
||||
|
||||
def _edge_error(response: httpx.Response) -> str:
|
||||
try:
|
||||
code: Final = _ErrorBody.model_validate_json(response.content).error
|
||||
except ValidationError:
|
||||
return f"HTTP {response.status_code}"
|
||||
return f"HTTP {response.status_code} {code}".strip()
|
||||
|
||||
|
||||
def _sandbox_doc(response: httpx.Response, action: str) -> _SandboxDoc:
|
||||
if response.status_code >= 400:
|
||||
raise SandboxError(f"prized sandbox {action} failed: {_edge_error(response)}")
|
||||
try:
|
||||
return _SandboxEnvelope.model_validate_json(response.content).sandbox
|
||||
except ValidationError as exc:
|
||||
raise SandboxError(f"prized sandbox {action} returned an unexpected body") from exc
|
||||
|
||||
|
||||
def _exec_answer(response: httpx.Response) -> _ExecAnswer:
|
||||
if response.status_code != 200:
|
||||
raise SandboxError(f"prized command failed: {_edge_error(response)}")
|
||||
try:
|
||||
return _ExecAnswer.model_validate_json(response.content)
|
||||
except ValidationError as exc:
|
||||
raise SandboxError("prized command returned an unexpected body") from exc
|
||||
|
||||
|
||||
def prized(
|
||||
template: str = PRIZED_DEFAULT_TEMPLATE,
|
||||
*,
|
||||
sandbox_id: str | None = None,
|
||||
workdir: str = PRIZED_WORKDIR,
|
||||
env: Mapping[str, str] | None = None,
|
||||
labels: Mapping[str, str] | None = None,
|
||||
vcpu: int | None = None,
|
||||
memory_mb: int | None = None,
|
||||
max_runtime_min: int | None = None,
|
||||
api_key: str | None = None,
|
||||
) -> PrizedSandbox:
|
||||
"""Sandbox in a new Prized microVM from `template` (or the existing `sandbox_id`), started lazily on first use."""
|
||||
return PrizedSandbox(
|
||||
template=template,
|
||||
sandbox_id=sandbox_id,
|
||||
workdir=workdir,
|
||||
env=env,
|
||||
labels=labels,
|
||||
vcpu=vcpu,
|
||||
memory_mb=memory_mb,
|
||||
max_runtime_min=max_runtime_min,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
|
@ -32,6 +32,32 @@ _HASH_CHUNK_BYTES: Final = 1024 * 1024
|
|||
_NO_NEWLINE_MARKER: Final = "\\ No newline at end of file\n"
|
||||
|
||||
|
||||
_SHA256_HEX_LEN: Final = 64
|
||||
|
||||
|
||||
def remote_snapshot_script() -> str:
|
||||
"""`sh -c` script (workdir as $1) printing `sha256sum` lines for the files a snapshot covers."""
|
||||
prune: Final = " -o ".join(f"-name '{name}'" for name in sorted(HARNESS_SNAPSHOT_SKIP_DIRS))
|
||||
return (
|
||||
'cd "$1" && find . -type d \\( '
|
||||
+ prune
|
||||
+ " \\) -prune -o -type f -size -"
|
||||
+ f"{HARNESS_SNAPSHOT_MAX_FILE_BYTES + 1}c"
|
||||
+ " -exec sha256sum {} +"
|
||||
)
|
||||
|
||||
|
||||
def parse_sha256sum(output: str) -> Mapping[str, str]:
|
||||
"""Parse `sha256sum` lines ("<hex> ./rel/path") into {rel/path: hex}."""
|
||||
return MappingProxyType(
|
||||
{
|
||||
line[_SHA256_HEX_LEN + 2 :].removeprefix("./"): line[:_SHA256_HEX_LEN]
|
||||
for line in output.splitlines()
|
||||
if len(line) > _SHA256_HEX_LEN + 2
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _hash_file(path: str) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""litellm.sandbox: code-interpreter providers (see main.py) plus harness sandboxes.
|
||||
|
||||
`sandbox.local(path)` and `sandbox.docker(image, ...)` re-export litellm.harness.sandbox.
|
||||
`sandbox.local(path)`, `sandbox.docker(image, ...)` and `sandbox.prized(...)` re-export litellm.harness.sandbox.
|
||||
They resolve lazily so `import litellm` does not pull in litellm.harness.
|
||||
"""
|
||||
|
||||
|
|
@ -15,8 +15,10 @@ _HARNESS_EXPORTS: Final = frozenset(
|
|||
{
|
||||
"local",
|
||||
"docker",
|
||||
"prized",
|
||||
"LocalSandbox",
|
||||
"DockerSandbox",
|
||||
"PrizedSandbox",
|
||||
"Sandbox",
|
||||
"Process",
|
||||
"CompletedRun",
|
||||
|
|
|
|||
153
tests/unit/harness/sandbox/test_prized.py
Normal file
153
tests/unit/harness/sandbox/test_prized.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import asyncio
|
||||
import json
|
||||
import shlex
|
||||
from collections.abc import Callable
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from litellm import sandbox
|
||||
from litellm.harness.errors import CapabilityUnsupported, SandboxError
|
||||
from litellm.harness.sandbox import PrizedSandbox, Sandbox
|
||||
|
||||
API: Final = "https://api.prized.dev/api/v1"
|
||||
EDGE: Final = "https://term.prized.dev/v1/sandbox/sbx_1"
|
||||
ExecReply = Callable[[dict[str, object]], httpx.Response]
|
||||
|
||||
|
||||
class FakePrized:
|
||||
"""In-memory Prized API: one sandbox, a file store, and scripted command answers."""
|
||||
|
||||
def __init__(self, exec_reply: ExecReply | None = None, *, unready_answers: int = 0) -> None:
|
||||
self.creates: list[dict[str, object]] = []
|
||||
self.commands: list[dict[str, object]] = []
|
||||
self.files: dict[str, bytes] = {}
|
||||
self.deleted: list[str] = []
|
||||
self.unready_answers = unready_answers
|
||||
self.exec_reply = exec_reply or (lambda body: httpx.Response(200, json={"stdout": "", "exitCode": 0}))
|
||||
|
||||
def _sandbox(self, state: str = "running") -> dict[str, object]:
|
||||
return {"sandbox": {"id": "sbx_1", "state": state, "toolboxBase": EDGE}}
|
||||
|
||||
def handler(self, request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["authorization"] == "Bearer dcp_test"
|
||||
path = request.url.path
|
||||
if request.method == "POST" and path == "/api/v1/sandboxes":
|
||||
self.creates.append(json.loads(request.content))
|
||||
return httpx.Response(201, json=self._sandbox())
|
||||
if request.method == "GET" and path == "/api/v1/sandboxes/sbx_1":
|
||||
return httpx.Response(200, json=self._sandbox())
|
||||
if request.method == "DELETE" and path == "/api/v1/sandboxes/sbx_1":
|
||||
self.deleted.append("sbx_1")
|
||||
return httpx.Response(202, json=self._sandbox("deleting"))
|
||||
if path.endswith("/process/execute"):
|
||||
body = json.loads(request.content)
|
||||
if body["command"] == "true" and self.unready_answers:
|
||||
self.unready_answers -= 1
|
||||
return httpx.Response(502, json={"error": "sandbox_unreachable"})
|
||||
self.commands.append(body)
|
||||
return self.exec_reply(body)
|
||||
if path.endswith("/files/upload"):
|
||||
self.files[request.url.params["path"]] = request.content
|
||||
return httpx.Response(200, json={"path": request.url.params["path"], "size": len(request.content)})
|
||||
if path.endswith("/files/download"):
|
||||
data = self.files.get(request.url.params["path"])
|
||||
return (
|
||||
httpx.Response(404, json={"error": "not_found"}) if data is None else httpx.Response(200, content=data)
|
||||
)
|
||||
return httpx.Response(404, json={"error": "not_found"})
|
||||
|
||||
|
||||
def make_box(fake: FakePrized, **kwargs: object) -> PrizedSandbox:
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(fake.handler))
|
||||
return PrizedSandbox(api_key="dcp_test", client=client, **kwargs) # pyright: ignore[reportArgumentType] # test kwargs
|
||||
|
||||
|
||||
def user_commands(fake: FakePrized) -> list[str]:
|
||||
return [
|
||||
str(c["command"]) for c in fake.commands if c["command"] != "true" and "mkdir -p --" not in str(c["command"])
|
||||
]
|
||||
|
||||
|
||||
def test_prized_satisfies_protocol_and_is_exported():
|
||||
box = sandbox.prized()
|
||||
assert isinstance(box, Sandbox)
|
||||
assert box.workdir == "/home/prized/work"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("workdir", ["/workspace", "work", "/home/prized/../etc"])
|
||||
def test_workdir_must_be_under_the_sandbox_home(workdir):
|
||||
with pytest.raises(SandboxError):
|
||||
sandbox.prized(workdir=workdir)
|
||||
|
||||
|
||||
async def test_concurrent_first_use_creates_one_sandbox_and_runs_argv_literally():
|
||||
fake = FakePrized(lambda body: httpx.Response(200, json={"stdout": "out", "stderr": "err", "exitCode": 7}))
|
||||
box = make_box(fake, env={"A": "1"})
|
||||
payload = "a b; $(rm -rf /) 'q'"
|
||||
results = await asyncio.gather(*(box.run(["printf", "%s", payload], env={"B": "2"}) for _ in range(3)))
|
||||
assert len(fake.creates) == 1
|
||||
assert {(r.exit_code, r.stdout, r.stderr) for r in results} == {(7, "out", "err")}
|
||||
command = user_commands(fake)[0]
|
||||
assert shlex.join(["printf", "%s", payload]) in command
|
||||
assert fake.commands[-1]["env"] == {"A": "1", "B": "2"}
|
||||
assert fake.commands[-1]["cwd"] == "/home/prized/work"
|
||||
|
||||
|
||||
async def test_first_command_waits_out_an_edge_that_is_not_ready_yet():
|
||||
fake = FakePrized(unready_answers=1)
|
||||
box = make_box(fake)
|
||||
assert (await box.run(["true"])).exit_code == 0
|
||||
|
||||
|
||||
async def test_a_command_past_its_timeout_raises():
|
||||
fake = FakePrized(lambda body: httpx.Response(200, json={"exitCode": -1, "timedOut": True}))
|
||||
box = make_box(fake)
|
||||
with pytest.raises(SandboxError, match="timed out"):
|
||||
await box.run(["sleep", "10"], timeout=1)
|
||||
assert fake.commands[-1]["timeout"] == 1
|
||||
|
||||
|
||||
async def test_files_round_trip_through_the_file_api_with_absolute_paths():
|
||||
fake = FakePrized()
|
||||
box = make_box(fake)
|
||||
await box.write("src/a.py", b"\x00data\xff")
|
||||
assert fake.files == {"/home/prized/work/src/a.py": b"\x00data\xff"}
|
||||
assert await box.read("/home/prized/work/src/a.py") == b"\x00data\xff"
|
||||
with pytest.raises(SandboxError):
|
||||
await box.read("missing.txt")
|
||||
|
||||
|
||||
async def test_snapshot_maps_relative_paths_to_hashes():
|
||||
digest = "a" * 64
|
||||
fake = FakePrized(lambda body: httpx.Response(200, json={"stdout": f"{digest} ./src/a.py\n", "exitCode": 0}))
|
||||
box = make_box(fake)
|
||||
assert dict(await box.snapshot()) == {"src/a.py": digest}
|
||||
|
||||
|
||||
async def test_exec_delivers_output_on_the_readers():
|
||||
fake = FakePrized(lambda body: httpx.Response(200, json={"stdout": "hello\n", "stderr": "", "exitCode": 3}))
|
||||
box = make_box(fake)
|
||||
process = await box.exec(["echo", "hello"])
|
||||
assert await process.wait() == 3
|
||||
assert await process.stdout.read() == b"hello\n"
|
||||
|
||||
|
||||
async def test_close_deletes_a_created_sandbox_but_not_an_attached_one():
|
||||
created = FakePrized()
|
||||
box = make_box(created)
|
||||
await box.run(["true"])
|
||||
await box.close()
|
||||
assert created.deleted == ["sbx_1"]
|
||||
|
||||
attached = FakePrized()
|
||||
other = make_box(attached, sandbox_id="sbx_1")
|
||||
await other.run(["true"])
|
||||
await other.close()
|
||||
assert attached.creates == [] and attached.deleted == []
|
||||
|
||||
|
||||
def test_cli_harnesses_cannot_reach_this_machine():
|
||||
with pytest.raises(CapabilityUnsupported):
|
||||
sandbox.prized().host_url(4000)
|
||||
Loading…
Add table
Reference in a new issue