boxxkite_client/sandboxes.rs
1//! Sandbox lifecycle: `POST/GET/DELETE /v1/sandboxes*`. Mirrors
2//! `sdk-python`'s `create_sandbox`/`get_sandbox`/`list_sandboxes`/
3//! `destroy_sandbox`.
4
5use std::collections::HashMap;
6
7use reqwest::Method;
8use serde::{Deserialize, Serialize};
9
10use crate::client::Client;
11use crate::error::BoxxkiteError;
12
13/// CPU/memory size preset for a sandbox. Capped per-account by the
14/// deployment's `BOXXKITE_MAX_SANDBOX_SIZE` -- requesting a larger size than
15/// the account is allowed returns `429`.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "lowercase")]
18pub enum SandboxSize {
19 Small,
20 Medium,
21 Large,
22}
23
24/// Builder for `POST /v1/sandboxes`'s optional fields. All fields are
25/// unset (server defaults apply) unless set via the chained setters.
26///
27/// ```no_run
28/// use boxxkite_client::CreateSandboxOptions;
29///
30/// let options = CreateSandboxOptions::new()
31/// .label("demo")
32/// .storage_gb(20.0)
33/// .lifetime_minutes(120);
34/// ```
35#[derive(Debug, Clone, Default, Serialize)]
36pub struct CreateSandboxOptions {
37 #[serde(skip_serializing_if = "Option::is_none")]
38 label: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 size: Option<SandboxSize>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 storage_gb: Option<f64>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 lifetime_minutes: Option<i64>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 count: Option<u32>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 secret_names: Option<Vec<String>>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 image_id: Option<String>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 mcp_connection_names: Option<Vec<String>>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 volume_mounts: Option<HashMap<String, String>>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 gpu_count: Option<i64>,
57}
58
59impl CreateSandboxOptions {
60 pub fn new() -> Self {
61 Self::default()
62 }
63
64 /// Optional human-readable label for the sandbox.
65 pub fn label(mut self, label: impl Into<String>) -> Self {
66 self.label = Some(label.into());
67 self
68 }
69
70 /// CPU/memory size preset. Defaults to `Small` server-side when omitted.
71 pub fn size(mut self, size: SandboxSize) -> Self {
72 self.size = Some(size);
73 self
74 }
75
76 /// Requested persistent storage in GB.
77 pub fn storage_gb(mut self, storage_gb: f64) -> Self {
78 self.storage_gb = Some(storage_gb);
79 self
80 }
81
82 /// Maximum lifetime of the sandbox in minutes before automatic teardown.
83 pub fn lifetime_minutes(mut self, lifetime_minutes: i64) -> Self {
84 self.lifetime_minutes = Some(lifetime_minutes);
85 self
86 }
87
88 /// Number of sandboxes to create in this request (1-10). Use
89 /// [`Client::create_sandbox_batch`], not [`Client::create_sandbox`], to
90 /// create more than one -- the server returns a JSON array only when
91 /// `count` is greater than 1.
92 pub fn count(mut self, count: u32) -> Self {
93 self.count = Some(count);
94 self
95 }
96
97 /// Names of this account's secrets (see `docs/SECRETS-DESIGN.md`) this
98 /// session should be granted access to via the sidecar's secrets-broker
99 /// `http_request` tool. A name that doesn't exist for this account 404s
100 /// before any sandbox is created.
101 pub fn secret_names<I, S>(mut self, secret_names: I) -> Self
102 where
103 I: IntoIterator<Item = S>,
104 S: Into<String>,
105 {
106 self.secret_names = Some(secret_names.into_iter().map(Into::into).collect());
107 self
108 }
109
110 /// Id of a completed custom image built via [`Client::create_image`].
111 /// 404s if not owned by this account or not yet status `"completed"`.
112 pub fn image_id(mut self, image_id: impl Into<String>) -> Self {
113 self.image_id = Some(image_id.into());
114 self
115 }
116
117 /// Labels of this account's outbound-MCP connections (see
118 /// [`Client::create_mcp_connection`]) this session should be granted
119 /// network egress to.
120 pub fn mcp_connection_names<I, S>(mut self, names: I) -> Self
121 where
122 I: IntoIterator<Item = S>,
123 S: Into<String>,
124 {
125 self.mcp_connection_names = Some(names.into_iter().map(Into::into).collect());
126 self
127 }
128
129 /// `{volume_id: mount_path}` mapping of independent PVC-backed volumes
130 /// (see [`Client::create_volume`]) to mount into this sandbox.
131 pub fn volume_mounts(mut self, volume_mounts: HashMap<String, String>) -> Self {
132 self.volume_mounts = Some(volume_mounts);
133 self
134 }
135
136 /// Opt-in, experimental (`docs/GPU-SUPPORT-SCOPING.md`) -- requests this
137 /// many GPUs as a Kubernetes extended-resource limit on the sandbox
138 /// container. 422s (`gpu_support_disabled`) unless the deployment has
139 /// `BOXXKITE_GPU_ENABLED` set and a GPU-equipped node pool with a device
140 /// plugin provisioned; not verified against real GPU hardware in this
141 /// codebase. Bounded by `BOXXKITE_MAX_GPU_COUNT_PER_SESSION`
142 /// (422 `invalid_gpu_count` otherwise).
143 pub fn gpu_count(mut self, gpu_count: i64) -> Self {
144 self.gpu_count = Some(gpu_count);
145 self
146 }
147}
148
149/// How to reach the sandbox session -- an opaque handle for operators with
150/// cluster access; external callers operate on the session through this
151/// client's own `exec`/file-op methods instead (see `docs/API.md`).
152#[derive(Debug, Clone, Deserialize)]
153pub struct SandboxConnectInfo {
154 pub pod_name: Option<String>,
155 pub note: String,
156}
157
158/// Usage against this account's fair-use limits, included on
159/// [`Client::create_sandbox`]'s response.
160#[derive(Debug, Clone, Deserialize)]
161pub struct UsageSummary {
162 pub monthly_sandbox_hours_used: f64,
163 pub monthly_sandbox_hours_limit: f64,
164 pub concurrent_sandboxes: i64,
165 pub concurrent_sandboxes_limit: i64,
166}
167
168/// A sandbox session (`SandboxSessionOut`/`SandboxCreatedResponse`).
169/// `usage` is only present on [`Client::create_sandbox`]'s response, not on
170/// [`Client::get_sandbox`]/[`Client::list_sandboxes`].
171#[derive(Debug, Clone, Deserialize)]
172pub struct Sandbox {
173 pub id: String,
174 /// `"active"` or `"destroyed"`.
175 pub status: String,
176 pub label: Option<String>,
177 pub created_at: String,
178 pub destroyed_at: Option<String>,
179 pub expires_at: Option<String>,
180 pub connect: Option<SandboxConnectInfo>,
181 pub usage: Option<UsageSummary>,
182}
183
184impl Client {
185 /// `POST /v1/sandboxes` -- create a single sandbox. Use
186 /// [`Client::create_sandbox_batch`] instead if `options.count(n)` with
187 /// `n > 1` -- the server responds with a JSON array in that case, which
188 /// won't deserialize as a single [`Sandbox`].
189 pub async fn create_sandbox(
190 &self,
191 options: CreateSandboxOptions,
192 ) -> Result<Sandbox, BoxxkiteError> {
193 let builder = self.request(Method::POST, "/v1/sandboxes").json(&options);
194 self.send(builder).await
195 }
196
197 /// `POST /v1/sandboxes` with `options.count(n)`, `n > 1` -- creates a
198 /// batch of sandboxes in one call and returns all of them. Each session
199 /// in the batch is created and limit-checked one at a time, so a later
200 /// item can still fail the concurrent-sandbox or monthly-usage cap even
201 /// if earlier items succeeded (surfaces as a `BoxxkiteError::Api` for the
202 /// whole call, per the underlying REST contract).
203 pub async fn create_sandbox_batch(
204 &self,
205 options: CreateSandboxOptions,
206 ) -> Result<Vec<Sandbox>, BoxxkiteError> {
207 let builder = self.request(Method::POST, "/v1/sandboxes").json(&options);
208 self.send(builder).await
209 }
210
211 /// `GET /v1/sandboxes/{session_id}` -- fetch one sandbox session by id.
212 /// Resolves destroyed sessions too (a lookup, not an operational route
213 /// that requires a live pod).
214 pub async fn get_sandbox(&self, session_id: &str) -> Result<Sandbox, BoxxkiteError> {
215 let builder = self.request(Method::GET, &format!("/v1/sandboxes/{session_id}"));
216 self.send(builder).await
217 }
218
219 /// `GET /v1/sandboxes` -- list sandbox sessions owned by this account.
220 pub async fn list_sandboxes(&self, active_only: bool) -> Result<Vec<Sandbox>, BoxxkiteError> {
221 let builder = self
222 .request(Method::GET, "/v1/sandboxes")
223 .query(&[("active_only", active_only.to_string())]);
224 self.send_or_default(builder).await
225 }
226
227 /// `DELETE /v1/sandboxes/{session_id}` -- tear down a sandbox session.
228 pub async fn destroy_sandbox(&self, session_id: &str) -> Result<(), BoxxkiteError> {
229 let builder = self.request(Method::DELETE, &format!("/v1/sandboxes/{session_id}"));
230 self.send_no_content(builder).await
231 }
232}