bladekit 4.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bladekit/_client.py ADDED
@@ -0,0 +1,1152 @@
1
+ """Thin HTTP client over the control plane ``/v1/*`` surface.
2
+
3
+ Responsibilities:
4
+ - attach the bearer token,
5
+ - decode the standard error envelope (conventions.md §2) into typed exceptions,
6
+ - expose the handful of endpoints the SDK/CLI use.
7
+
8
+ ``httpx`` is imported lazily so ``import bladekit`` stays cheap.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import threading
14
+ import time
15
+ from typing import Any, Mapping, Optional
16
+ from urllib.parse import quote
17
+
18
+ from . import exceptions as exc
19
+ from ._constants import DEFAULT_PAGE_LIMIT
20
+
21
+ # HTTP status -> envelope-aware exception class. The ``code`` in the envelope is
22
+ # authoritative; status only picks the broad family for unknown codes.
23
+ _STATUS_EXC = {
24
+ 400: exc.InvalidArgumentError,
25
+ 401: exc.AuthenticationError,
26
+ 403: exc.PermissionDeniedError,
27
+ 404: exc.NotFoundError,
28
+ 409: exc.ConflictError,
29
+ 413: exc.PayloadTooLargeError,
30
+ 503: exc.UnavailableError,
31
+ }
32
+
33
+ _CODE_EXC = {
34
+ "UNAUTHENTICATED": exc.AuthenticationError,
35
+ "RUN_TOKEN_EXPIRED": exc.AuthenticationError,
36
+ "PERMISSION_DENIED": exc.PermissionDeniedError,
37
+ "NOT_FOUND": exc.NotFoundError,
38
+ "RUN_NOT_FOUND": exc.NotFoundError,
39
+ "CALL_NOT_FOUND": exc.NotFoundError,
40
+ "BUNDLE_NOT_FOUND": exc.NotFoundError,
41
+ "NODE_NOT_FOUND": exc.NotFoundError,
42
+ "INVALID_ARGUMENT": exc.InvalidArgumentError,
43
+ "INVALID_CURSOR": exc.InvalidArgumentError,
44
+ "IMAGE_ALIAS_UNKNOWN": exc.InvalidArgumentError,
45
+ "BUNDLE_NOT_READY": exc.ConflictError,
46
+ "BUNDLE_CHECKSUM_MISMATCH": exc.ConflictError,
47
+ "RUN_NOT_CANCELLABLE": exc.ConflictError,
48
+ "RUN_NOT_ACCEPTING_CALLS": exc.ConflictError,
49
+ "CALL_ALREADY_FINISHED": exc.ConflictError,
50
+ "BUNDLE_TOO_LARGE": exc.PayloadTooLargeError,
51
+ "PAYLOAD_TOO_LARGE": exc.PayloadTooLargeError,
52
+ "UNAVAILABLE": exc.UnavailableError,
53
+ "IMAGE_BUILD_NOT_FOUND": exc.NotFoundError,
54
+ "VOLUME_NOT_FOUND": exc.NotFoundError,
55
+ "SECRET_NOT_FOUND": exc.NotFoundError,
56
+ "VOLUME_COMMIT_NOT_FOUND": exc.NotFoundError,
57
+ "VOLUME_FILE_NOT_FOUND": exc.NotFoundError,
58
+ "IMAGE_DEFINITION_INVALID": exc.InvalidArgumentError,
59
+ # Submit-time refusal of a run whose pickle would cross Python minors. The
60
+ # same verdict the runner would reach at boot (VersionMismatchError, run
61
+ # level), raised here as an API error because nothing was ever created.
62
+ "PYTHON_VERSION_INCOMPATIBLE": exc.InvalidArgumentError,
63
+ "MOUNT_PATH_INVALID": exc.InvalidArgumentError,
64
+ "SECRET_KEY_RESERVED": exc.InvalidArgumentError,
65
+ "IMAGE_BUILD_NOT_READY": exc.ConflictError,
66
+ "IMAGE_BUILD_FAILED": exc.ConflictError,
67
+ "VOLUME_IN_USE": exc.ConflictError,
68
+ "VOLUME_COMMIT_OBJECTS_MISSING": exc.ConflictError,
69
+ "VOLUME_COMMIT_NOT_PENDING": exc.ConflictError,
70
+ "RUN_NOT_UPDATABLE": exc.ConflictError,
71
+ "RUN_NOT_SERVING": exc.ConflictError,
72
+ "APP_NAME_INVALID": exc.InvalidArgumentError,
73
+ "APP_NOT_FOUND": exc.NotFoundError,
74
+ "DEPLOYMENT_NOT_FOUND": exc.NotFoundError,
75
+ "AUTOSCALE_LIMIT_INVALID": exc.InvalidArgumentError,
76
+ "ENDPOINT_ROUTE_CONFLICT": exc.ConflictError,
77
+ "DEPLOYMENT_NOT_READY": exc.ConflictError,
78
+ "DEPLOYMENT_NOT_ROLLBACKABLE": exc.ConflictError,
79
+ "APP_STOPPED": exc.UnavailableError,
80
+ "MAP_NOT_FOUND": exc.NotFoundError,
81
+ "MAP_INPUT_NOT_FOUND": exc.NotFoundError,
82
+ "CRON_NOT_FOUND": exc.NotFoundError,
83
+ "CRON_EXECUTION_NOT_FOUND": exc.NotFoundError,
84
+ "QUEUE_NOT_FOUND": exc.NotFoundError,
85
+ "DICT_NOT_FOUND": exc.NotFoundError,
86
+ "NAMESPACE_NOT_FOUND": exc.NotFoundError,
87
+ "MAP_NOT_ACCEPTING_INPUTS": exc.ConflictError,
88
+ "MAP_NOT_CANCELLABLE": exc.ConflictError,
89
+ "MAP_INPUTS_NOT_FINALIZED": exc.ConflictError,
90
+ "CRON_EXPRESSION_INVALID": exc.InvalidArgumentError,
91
+ "CRON_NOT_TRIGGERABLE": exc.ConflictError,
92
+ "QUEUE_NAME_INVALID": exc.InvalidArgumentError,
93
+ "DICT_NAME_INVALID": exc.InvalidArgumentError,
94
+ "QUEUE_ITEM_TOO_LARGE": exc.PayloadTooLargeError,
95
+ "DICT_VALUE_TOO_LARGE": exc.PayloadTooLargeError,
96
+ "DICT_KEY_TOO_LARGE": exc.InvalidArgumentError,
97
+ "PRIORITY_INVALID": exc.InvalidArgumentError,
98
+ "RETRY_POLICY_INVALID": exc.InvalidArgumentError,
99
+ "GPU_QUOTA_EXCEEDED": exc.GPUQuotaExceededError,
100
+ "ORG_GPU_QUOTA_EXCEEDED": exc.OrgGPUQuotaExceededError,
101
+ # --- sandbox errors ---
102
+ "SANDBOX_NOT_FOUND": exc.NotFoundError,
103
+ "SANDBOX_EXEC_NOT_FOUND": exc.NotFoundError,
104
+ "SANDBOX_TUNNEL_NOT_FOUND": exc.NotFoundError,
105
+ "SANDBOX_NOT_RUNNING": exc.SandboxNotRunningError,
106
+ "SANDBOX_EXEC_LIMIT": exc.SandboxExecLimitError,
107
+ "SANDBOX_FILE_NOT_FOUND": exc.SandboxFileNotFoundError,
108
+ "SANDBOX_FILE_TOO_LARGE": exc.SandboxFileTooLargeError,
109
+ "SANDBOX_PATH_INVALID": exc.SandboxPathInvalidError,
110
+ "SANDBOX_DISK_QUOTA_EXCEEDED": exc.SandboxDiskQuotaExceededError,
111
+ "SANDBOX_PORT_IN_USE": exc.SandboxPortInUseError,
112
+ "RUNTIME_INVALID": exc.InvalidArgumentError,
113
+ }
114
+
115
+
116
+ def _version_tuple(v: str) -> tuple:
117
+ """Best-effort numeric semver key: leading ``major.minor.patch`` ints.
118
+
119
+ Stops at the first non-numeric component (pre-release / build metadata), so
120
+ ``0.1.0+g1a2b3c4`` -> ``(0, 1, 0)``. Unparseable -> ``(0,)``.
121
+ """
122
+ head = v.strip().lstrip("vV").split("+", 1)[0].split("-", 1)[0]
123
+ parts: list[int] = []
124
+ for chunk in head.split("."):
125
+ if chunk.isdigit():
126
+ parts.append(int(chunk))
127
+ else:
128
+ break
129
+ return tuple(parts) if parts else (0,)
130
+
131
+
132
+ def _version_lt(current: str, minimum: str) -> bool:
133
+ """True iff ``current`` is strictly below ``minimum`` (numeric semver)."""
134
+ return _version_tuple(current) < _version_tuple(minimum)
135
+
136
+
137
+ def _raise_for_envelope(status: int, body: Any) -> None:
138
+ code = "INTERNAL"
139
+ message = f"HTTP {status}"
140
+ details: Mapping[str, Any] = {}
141
+ if isinstance(body, Mapping):
142
+ err = body.get("error")
143
+ if isinstance(err, Mapping):
144
+ code = str(err.get("code", code))
145
+ message = str(err.get("message", message))
146
+ details = err.get("details") or {}
147
+
148
+ # Prefer the machine code for the class; fall back to status family; then
149
+ # a generic APIError so unknown codes are still surfaced (conventions §2).
150
+ cls = _CODE_EXC.get(code) or _STATUS_EXC.get(status) or exc.APIError
151
+ raise cls(code, message, status_code=status, details=details)
152
+
153
+
154
+ def _could_not_connect(err: BaseException) -> bool:
155
+ """True when the request never reached a server.
156
+
157
+ `_request` collapses every httpx error into ConnectionFailed, read timeouts
158
+ included. Only a failed CONNECT may be retried against a different address: a read
159
+ timeout means the host accepted the request, and re-sending it elsewhere would run
160
+ the caller's work twice.
161
+ """
162
+ import httpx
163
+
164
+ return isinstance(err.__cause__, (httpx.ConnectError, httpx.ConnectTimeout))
165
+
166
+
167
+ class Client:
168
+ """Synchronous control-plane client."""
169
+
170
+ def __init__(
171
+ self,
172
+ base_url: str,
173
+ token: Optional[str],
174
+ *,
175
+ timeout: float = 30.0,
176
+ transport: Any = None,
177
+ ) -> None:
178
+ import httpx
179
+
180
+ self.base_url = base_url.rstrip("/")
181
+ self.token = token
182
+ headers = {"User-Agent": "forge-sdk/0.3.0"}
183
+ if token:
184
+ headers["Authorization"] = f"Bearer {token}"
185
+ # ``transport`` lets tests inject an in-process ASGI/WSGI transport.
186
+ kwargs: dict[str, Any] = {
187
+ "base_url": self.base_url,
188
+ "headers": headers,
189
+ "timeout": timeout,
190
+ }
191
+ if transport is not None:
192
+ kwargs["transport"] = transport
193
+ self._http = httpx.Client(**kwargs)
194
+ # Per-instance guard so the version handshake fires at most once per client
195
+ # *after a successful contact*. Per-instance (not process-global) so a 2nd
196
+ # Client — e.g. the App lifecycle creating its own — still checks; the lock
197
+ # makes the at-most-once promise safe if a worker thread also touches it.
198
+ self._version_checked = False
199
+ self._version_lock = threading.Lock()
200
+
201
+ # -- low level ----------------------------------------------------------
202
+
203
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
204
+ import httpx
205
+
206
+ try:
207
+ resp = self._http.request(method, path, **kwargs)
208
+ except httpx.HTTPError as e: # connect/read/timeout
209
+ raise exc.ConnectionFailed(
210
+ f"could not reach control plane at {self.base_url}: {e}"
211
+ ) from e
212
+
213
+ if resp.status_code >= 400:
214
+ try:
215
+ body = resp.json()
216
+ except ValueError:
217
+ body = None
218
+ _raise_for_envelope(resp.status_code, body)
219
+
220
+ if resp.content:
221
+ return resp.json()
222
+ return None
223
+
224
+ def close(self) -> None:
225
+ self._http.close()
226
+
227
+ def __enter__(self) -> "Client":
228
+ return self
229
+
230
+ def __exit__(self, *exc_info: Any) -> None:
231
+ self.close()
232
+
233
+ # -- meta ---------------------------------------------------------------
234
+
235
+ def get_version(self) -> dict:
236
+ return self._request("GET", "/v1/version")
237
+
238
+ def warn_if_outdated(self) -> None:
239
+ """Call ``/v1/version`` once and warn if the SDK is below ``min_sdk_version``.
240
+
241
+ Per openapi ``/v1/version``: the SDK calls this on first contact and shows
242
+ a warning if the server announces a ``min_sdk_version`` greater than the
243
+ SDK's own version. Best-effort: any network/parse failure is swallowed so a
244
+ version smoke-test never breaks a real command.
245
+
246
+ The HTTP call is made **outside** the lock so that a slow or stalled
247
+ control plane does not block a second thread that also calls this method
248
+ (e.g. the log-pump thread in blade serve). The lock only protects the
249
+ read/write of ``_version_checked``.
250
+ """
251
+ with self._version_lock:
252
+ if self._version_checked:
253
+ return
254
+ # Network call outside the lock.
255
+ try:
256
+ info = self.get_version()
257
+ except exc.FabricError:
258
+ # Transient failure (server not yet reachable): leave the guard
259
+ # unset so a later command re-attempts the smoke-test instead of
260
+ # silently disarming the compat warning for the process lifetime.
261
+ return
262
+ # Write back under the lock — another thread may have raced us.
263
+ with self._version_lock:
264
+ if self._version_checked:
265
+ # Other thread beat us; they already printed any warning.
266
+ return
267
+ self._version_checked = True
268
+ if not isinstance(info, Mapping):
269
+ return
270
+ min_sdk = info.get("min_sdk_version")
271
+ if not min_sdk:
272
+ return
273
+ from . import __version__
274
+
275
+ if _version_lt(__version__, str(min_sdk)):
276
+ from ._render import err_console
277
+
278
+ err_console.print(
279
+ f"[yellow]warning[/yellow]: bladekit {__version__} is older than the "
280
+ f"server's minimum ({min_sdk}). Upgrade with "
281
+ f"`pip install --upgrade bladekit`."
282
+ )
283
+
284
+ def healthz(self) -> dict:
285
+ return self._request("GET", "/healthz")
286
+
287
+ # -- bundles ------------------------------------------------------------
288
+
289
+ def create_bundle(self, size_bytes: int, sha256: str) -> dict:
290
+ return self._request(
291
+ "POST",
292
+ "/v1/bundles",
293
+ json={"size_bytes": size_bytes, "sha256": sha256},
294
+ )
295
+
296
+ def complete_bundle(self, bundle_id: str) -> dict:
297
+ return self._request("POST", f"/v1/bundles/{bundle_id}/complete")
298
+
299
+ def upload_to_presigned(self, url: str, data: bytes, content_type: str) -> None:
300
+ """PUT raw bytes to a presigned URL (bundle or payload)."""
301
+ import httpx
302
+
303
+ try:
304
+ resp = httpx.put(
305
+ url, content=data, headers={"Content-Type": content_type}, timeout=300.0
306
+ )
307
+ except httpx.HTTPError as e:
308
+ raise exc.ConnectionFailed(f"upload to object store failed: {e}") from e
309
+ if resp.status_code >= 400:
310
+ raise exc.FabricError(
311
+ f"object store rejected upload (HTTP {resp.status_code})"
312
+ )
313
+
314
+ def download_presigned(self, url: str) -> bytes:
315
+ import httpx
316
+
317
+ try:
318
+ resp = httpx.get(url, timeout=300.0)
319
+ except httpx.HTTPError as e:
320
+ raise exc.ConnectionFailed(f"download from object store failed: {e}") from e
321
+ if resp.status_code >= 400:
322
+ raise exc.FabricError(
323
+ f"object store rejected download (HTTP {resp.status_code})"
324
+ )
325
+ return resp.content
326
+
327
+ # -- payloads -----------------------------------------------------------
328
+
329
+ def create_payload(self, size_bytes: int, sha256: str) -> dict:
330
+ return self._request(
331
+ "POST",
332
+ "/v1/payloads",
333
+ json={"size_bytes": size_bytes, "sha256": sha256},
334
+ )
335
+
336
+ # -- runs ---------------------------------------------------------------
337
+
338
+ def create_run(self, body: dict) -> dict:
339
+ return self._request("POST", "/v1/runs", json=body)
340
+
341
+ def get_run(self, run_id: str) -> dict:
342
+ return self._request("GET", f"/v1/runs/{run_id}")
343
+
344
+ def list_runs(
345
+ self,
346
+ *,
347
+ state: Optional[list] = None,
348
+ limit: int = DEFAULT_PAGE_LIMIT,
349
+ cursor: Optional[str] = None,
350
+ ) -> dict:
351
+ params: dict[str, Any] = {"limit": limit}
352
+ if state:
353
+ params["state"] = state
354
+ if cursor:
355
+ params["cursor"] = cursor
356
+ return self._request("GET", "/v1/runs", params=params)
357
+
358
+ def cancel_run(self, run_id: str) -> dict:
359
+ return self._request("POST", f"/v1/runs/{run_id}/cancel")
360
+
361
+ # ---- admin (role=admin tokens only; PAAS-24 containment) ----
362
+
363
+ def admin_contain_user(self, user_id: str, reason: str = "") -> dict:
364
+ """Stop every workload of an account (graceful then hard). Idempotent.
365
+ The suspension itself is iam's (the backoffice)."""
366
+ return self._request(
367
+ "POST", f"/v1/admin/users/{user_id}/contain", json={"reason": reason}
368
+ )
369
+
370
+ def admin_force_stop(self, kind: str, ref: str) -> dict:
371
+ """Force-stop ANY tenant's workload (audited). kind is one of
372
+ app, deployment, run, sandbox, map."""
373
+ path = {
374
+ "app": f"/v1/admin/apps/{ref}/stop",
375
+ "deployment": f"/v1/admin/deployments/{ref}/stop",
376
+ "run": f"/v1/admin/runs/{ref}/cancel",
377
+ "sandbox": f"/v1/admin/sandboxes/{ref}/terminate",
378
+ "map": f"/v1/admin/maps/{ref}/cancel",
379
+ }[kind]
380
+ return self._request("POST", path)
381
+
382
+ def complete_run(self, run_id: str) -> dict:
383
+ return self._request("POST", f"/v1/runs/{run_id}/complete")
384
+
385
+ # -- calls --------------------------------------------------------------
386
+
387
+ def create_call(self, run_id: str, args_payload: dict) -> dict:
388
+ return self._request(
389
+ "POST", f"/v1/runs/{run_id}/calls", json={"args": args_payload}
390
+ )
391
+
392
+ def get_call(self, call_id: str) -> dict:
393
+ return self._request("GET", f"/v1/calls/{call_id}")
394
+
395
+ def spawn_async_endpoint(
396
+ self, app_name: str, path: str, *, input: Any, webhook: Optional[dict] = None
397
+ ) -> dict:
398
+ """POST /v1/serve-async/<app>/<path> — enqueue an async job on a deployed
399
+ endpoint; the control plane answers 202 with a call_id without holding the
400
+ connection (async-jobs-study.md)."""
401
+ body: dict[str, Any] = {"input": input}
402
+ if webhook:
403
+ body["webhook"] = webhook
404
+ app_seg = quote(app_name, safe="")
405
+ path_seg = quote(path.lstrip("/"), safe="/")
406
+ return self._request("POST", f"/v1/serve-async/{app_seg}/{path_seg}", json=body)
407
+
408
+ def cancel_call(self, call_id: str) -> dict:
409
+ return self._request("POST", f"/v1/calls/{call_id}/cancel")
410
+
411
+ # -- logs ---------------------------------------------------------------
412
+
413
+ def get_logs(
414
+ self,
415
+ run_id: str,
416
+ *,
417
+ cursor: Optional[int] = None,
418
+ limit: int = DEFAULT_PAGE_LIMIT,
419
+ stream: Optional[str] = None,
420
+ ) -> dict:
421
+ params: dict[str, Any] = {"limit": limit}
422
+ if cursor is not None:
423
+ params["cursor"] = cursor
424
+ if stream:
425
+ params["stream"] = stream
426
+ return self._request("GET", f"/v1/runs/{run_id}/logs", params=params)
427
+
428
+ def stream_logs(self, run_id: str, *, cursor: Optional[int] = None):
429
+ """Yield SSE events as ``(event, data_str, event_id)`` tuples.
430
+
431
+ Used by ``blade run`` / ``blade logs --follow``. Reconnection with a
432
+ resume cursor is handled by the caller (it re-invokes with the last seq).
433
+ """
434
+ import httpx
435
+
436
+ params: dict[str, Any] = {}
437
+ headers: dict[str, str] = {}
438
+ if cursor is not None:
439
+ # Resume via the standard SSE header only; the contract says the header
440
+ # wins over ?cursor= when both are present, so sending both is just
441
+ # redundancy that could mask a divergence bug later (Last-Event-ID is
442
+ # also what an EventSource-style reconnect would send natively).
443
+ headers["Last-Event-ID"] = str(cursor)
444
+
445
+ try:
446
+ with self._http.stream(
447
+ "GET",
448
+ f"/v1/runs/{run_id}/logs/stream",
449
+ params=params,
450
+ headers=headers,
451
+ # Finite read timeout: the server sends `event: ping` every 15 s
452
+ # (openapi /logs/stream). 60 s (4× ping) tolerates jitter but still
453
+ # bounds a silently-wedged socket so iter_lines() can't block
454
+ # forever — a ReadTimeout surfaces as ConnectionFailed and the
455
+ # caller reconnects from the last seq.
456
+ timeout=httpx.Timeout(connect=5.0, read=60.0, write=5.0, pool=5.0),
457
+ ) as resp:
458
+ if resp.status_code >= 400:
459
+ body = resp.read()
460
+ try:
461
+ import json
462
+
463
+ parsed = json.loads(body)
464
+ except ValueError:
465
+ parsed = None
466
+ _raise_for_envelope(resp.status_code, parsed)
467
+ yield from _warn_on_gap(_parse_sse(resp.iter_lines()))
468
+ except httpx.HTTPError as e:
469
+ raise exc.ConnectionFailed(f"log stream failed: {e}") from e
470
+
471
+ # -- image builds -------------------------------------------------------
472
+
473
+ def create_image_build(
474
+ self,
475
+ definition: dict,
476
+ definition_hash: Optional[str] = None,
477
+ *,
478
+ force: bool = False,
479
+ force_compression: bool = False,
480
+ context_bundle_id: Optional[str] = None,
481
+ ) -> dict:
482
+ body: dict[str, Any] = {"definition": definition}
483
+ if definition_hash:
484
+ body["definition_hash"] = definition_hash
485
+ if force:
486
+ body["force"] = True
487
+ if context_bundle_id:
488
+ # from_dockerfile build context (docs/specs/image-from-dockerfile.md):
489
+ # required iff definition.base.context_sha256 is set.
490
+ body["context_bundle_id"] = context_bundle_id
491
+ if force_compression:
492
+ # Admin-only operator knob (phase2-dogfood-catalog-build.md §4b/§4f.5):
493
+ # omit the field entirely by default so ordinary callers see no
494
+ # behavior change — only `--force-compression` sends it.
495
+ body["force_compression"] = True
496
+ return self._request("POST", "/v1/images/builds", json=body)
497
+
498
+ def get_image_build(self, build_id: str) -> dict:
499
+ return self._request("GET", f"/v1/images/builds/{build_id}")
500
+
501
+ def list_image_builds(
502
+ self,
503
+ *,
504
+ definition_hash: Optional[str] = None,
505
+ state: Optional[list] = None,
506
+ limit: int = DEFAULT_PAGE_LIMIT,
507
+ cursor: Optional[str] = None,
508
+ ) -> dict:
509
+ params: dict[str, Any] = {"limit": limit}
510
+ if definition_hash:
511
+ params["definition_hash"] = definition_hash
512
+ if state:
513
+ params["state"] = state
514
+ if cursor:
515
+ params["cursor"] = cursor
516
+ return self._request("GET", "/v1/images/builds", params=params)
517
+
518
+ def list_images(self, *, limit: int = DEFAULT_PAGE_LIMIT, cursor: Optional[str] = None) -> dict:
519
+ params: dict[str, Any] = {"limit": limit}
520
+ if cursor:
521
+ params["cursor"] = cursor
522
+ return self._request("GET", "/v1/images", params=params)
523
+
524
+ def stream_build_logs(self, build_id: str, *, cursor: Optional[int] = None):
525
+ """Yield SSE ``(event, data, id)`` tuples for a build's logs (same as runs)."""
526
+ import httpx
527
+
528
+ headers: dict[str, str] = {}
529
+ if cursor is not None:
530
+ headers["Last-Event-ID"] = str(cursor)
531
+ try:
532
+ with self._http.stream(
533
+ "GET",
534
+ f"/v1/images/builds/{build_id}/logs/stream",
535
+ headers=headers,
536
+ timeout=httpx.Timeout(connect=5.0, read=60.0, write=5.0, pool=5.0),
537
+ ) as resp:
538
+ if resp.status_code >= 400:
539
+ body = resp.read()
540
+ try:
541
+ import json
542
+
543
+ parsed = json.loads(body)
544
+ except ValueError:
545
+ parsed = None
546
+ _raise_for_envelope(resp.status_code, parsed)
547
+ yield from _parse_sse(resp.iter_lines())
548
+ except httpx.HTTPError as e:
549
+ raise exc.ConnectionFailed(f"build log stream failed: {e}") from e
550
+
551
+ def get_build_logs(
552
+ self, build_id: str, *, cursor: Optional[int] = None, limit: int = DEFAULT_PAGE_LIMIT
553
+ ) -> dict:
554
+ params: dict[str, Any] = {"limit": limit}
555
+ if cursor is not None:
556
+ params["cursor"] = cursor
557
+ return self._request("GET", f"/v1/images/builds/{build_id}/logs", params=params)
558
+
559
+ # -- volumes ------------------------------------------------------------
560
+
561
+ def create_volume(self, name: str) -> dict:
562
+ return self._request("POST", "/v1/volumes", json={"name": name})
563
+
564
+ def list_volumes(
565
+ self,
566
+ *,
567
+ name: Optional[str] = None,
568
+ limit: int = DEFAULT_PAGE_LIMIT,
569
+ cursor: Optional[str] = None,
570
+ ) -> dict:
571
+ params: dict[str, Any] = {"limit": limit}
572
+ if name:
573
+ params["name"] = name
574
+ if cursor:
575
+ params["cursor"] = cursor
576
+ return self._request("GET", "/v1/volumes", params=params)
577
+
578
+ def get_volume(self, volume_id: str) -> dict:
579
+ return self._request("GET", f"/v1/volumes/{volume_id}")
580
+
581
+ def delete_volume(self, volume_id: str) -> None:
582
+ self._request("DELETE", f"/v1/volumes/{volume_id}")
583
+
584
+ def list_volume_files(
585
+ self,
586
+ volume_id: str,
587
+ *,
588
+ path: str = "/",
589
+ generation: Optional[int] = None,
590
+ limit: int = DEFAULT_PAGE_LIMIT,
591
+ cursor: Optional[str] = None,
592
+ ) -> dict:
593
+ params: dict[str, Any] = {"path": path, "limit": limit}
594
+ if generation is not None:
595
+ params["generation"] = generation
596
+ if cursor:
597
+ params["cursor"] = cursor
598
+ return self._request("GET", f"/v1/volumes/{volume_id}/files", params=params)
599
+
600
+ def get_volume_file_content(
601
+ self, volume_id: str, path: str, *, generation: Optional[int] = None
602
+ ) -> dict:
603
+ params: dict[str, Any] = {"path": path}
604
+ if generation is not None:
605
+ params["generation"] = generation
606
+ return self._request(
607
+ "GET", f"/v1/volumes/{volume_id}/files/content", params=params
608
+ )
609
+
610
+ def create_volume_commit(self, volume_id: str, body: dict) -> dict:
611
+ return self._request("POST", f"/v1/volumes/{volume_id}/commits", json=body)
612
+
613
+ def finalize_volume_commit(self, volume_id: str, commit_id: str) -> dict:
614
+ return self._request(
615
+ "POST", f"/v1/volumes/{volume_id}/commits/{commit_id}/finalize"
616
+ )
617
+
618
+ def list_volume_commits(
619
+ self, volume_id: str, *, limit: int = DEFAULT_PAGE_LIMIT, cursor: Optional[str] = None
620
+ ) -> dict:
621
+ params: dict[str, Any] = {"limit": limit}
622
+ if cursor:
623
+ params["cursor"] = cursor
624
+ return self._request("GET", f"/v1/volumes/{volume_id}/commits", params=params)
625
+
626
+ # -- secrets ------------------------------------------------------------
627
+
628
+ def set_secret(self, name: str, values: dict) -> dict:
629
+ return self._request(
630
+ "POST", "/v1/secrets", json={"name": name, "values": values}
631
+ )
632
+
633
+ def list_secrets(
634
+ self,
635
+ *,
636
+ name: Optional[str] = None,
637
+ limit: int = DEFAULT_PAGE_LIMIT,
638
+ cursor: Optional[str] = None,
639
+ ) -> dict:
640
+ params: dict[str, Any] = {"limit": limit}
641
+ if name:
642
+ params["name"] = name
643
+ if cursor:
644
+ params["cursor"] = cursor
645
+ return self._request("GET", "/v1/secrets", params=params)
646
+
647
+ def delete_secret(self, secret_id: str) -> None:
648
+ self._request("DELETE", f"/v1/secrets/{secret_id}")
649
+
650
+ # -- gpu types ----------------------------------------------------------
651
+
652
+ def list_gpu_types(self) -> dict:
653
+ return self._request("GET", "/v1/gpu-types")
654
+
655
+ # -- hot bundle reload (blade serve) ------------------------------------
656
+
657
+ def update_run_bundle(self, run_id: str, bundle_id: str) -> dict:
658
+ return self._request(
659
+ "POST", f"/v1/runs/{run_id}/bundle", json={"bundle_id": bundle_id}
660
+ )
661
+
662
+ # -- apps / deployments -------------------------------------------------
663
+
664
+ def create_app(self, name: str) -> dict:
665
+ return self._request("POST", "/v1/apps", json={"name": name})
666
+
667
+ def list_apps(
668
+ self,
669
+ *,
670
+ name: Optional[str] = None,
671
+ state: Optional[str] = None,
672
+ limit: int = DEFAULT_PAGE_LIMIT,
673
+ cursor: Optional[str] = None,
674
+ ) -> dict:
675
+ params: dict[str, Any] = {"limit": limit}
676
+ if name:
677
+ params["name"] = name
678
+ if state:
679
+ params["state"] = state
680
+ if cursor:
681
+ params["cursor"] = cursor
682
+ return self._request("GET", "/v1/apps", params=params)
683
+
684
+ def get_app(self, app_id: str) -> dict:
685
+ return self._request("GET", f"/v1/apps/{app_id}")
686
+
687
+ def get_app_coldstarts(self, app_id: str, *, limit: Optional[int] = None) -> dict:
688
+ """Recent cold-start breakdowns for an app (newest first).
689
+
690
+ Recomputed on the fly from ready replicas' phase columns; returns
691
+ ``{"items": []}`` when no replica has gone ready yet (the common case
692
+ right after a ``min_replicas=0`` deploy), so callers must treat the
693
+ result as best-effort.
694
+ """
695
+ params: dict[str, Any] = {}
696
+ if limit is not None:
697
+ params["limit"] = limit
698
+ return self._request(
699
+ "GET", f"/v1/apps/{app_id}/coldstarts", params=params or None
700
+ )
701
+
702
+ def get_app_by_name(self, name: str) -> dict:
703
+ page = self.list_apps(name=name, limit=1)
704
+ for app in page.get("items", []):
705
+ if app.get("name") == name:
706
+ return app
707
+ raise exc.NotFoundError("APP_NOT_FOUND", f"app {name!r} not found")
708
+
709
+ def stop_app(self, app_id: str) -> dict:
710
+ return self._request("POST", f"/v1/apps/{app_id}/stop")
711
+
712
+ def scale_endpoint(
713
+ self,
714
+ app_id: str,
715
+ endpoint_name: str,
716
+ *,
717
+ min_replicas: Optional[int] = None,
718
+ max_replicas: Optional[int] = None,
719
+ max_concurrent_inputs: Optional[int] = None,
720
+ scale_down_idle_seconds: Optional[int] = None,
721
+ reserved_replicas: Optional[int] = None,
722
+ scaling_policy: Optional[dict] = None,
723
+ clear_scaling_policy: bool = False,
724
+ ) -> dict:
725
+ """Update a deployed endpoint's autoscaling bounds live (no redeploy).
726
+
727
+ Only the provided fields change; the autoscaler picks the new bounds up
728
+ on its next tick. Returns the updated app.
729
+
730
+ ``scaling_policy`` is a FULL REPLACEMENT of the endpoint's v2 policy
731
+ (the wire ``{"v": 1, ...}`` document) — the server never merges it
732
+ field by field. Pass ``clear_scaling_policy=True`` to send an explicit
733
+ ``null`` (back to default concurrency autoscaling).
734
+ """
735
+ body: dict[str, Any] = {}
736
+ if min_replicas is not None:
737
+ body["min_replicas"] = min_replicas
738
+ if max_replicas is not None:
739
+ body["max_replicas"] = max_replicas
740
+ if max_concurrent_inputs is not None:
741
+ body["max_concurrent_inputs"] = max_concurrent_inputs
742
+ if scale_down_idle_seconds is not None:
743
+ body["scale_down_idle_seconds"] = scale_down_idle_seconds
744
+ if reserved_replicas is not None:
745
+ body["reserved_replicas"] = reserved_replicas
746
+ if clear_scaling_policy:
747
+ body["scaling_policy"] = None
748
+ elif scaling_policy is not None:
749
+ body["scaling_policy"] = scaling_policy
750
+ return self._request(
751
+ "POST", f"/v1/apps/{app_id}/endpoints/{endpoint_name}/scale", json=body
752
+ )
753
+
754
+ def create_deployment(self, app_id: str, body: dict) -> dict:
755
+ return self._request("POST", f"/v1/apps/{app_id}/deployments", json=body)
756
+
757
+ def list_deployments(
758
+ self,
759
+ app_id: str,
760
+ *,
761
+ state: Optional[str] = None,
762
+ limit: int = DEFAULT_PAGE_LIMIT,
763
+ cursor: Optional[str] = None,
764
+ ) -> dict:
765
+ params: dict[str, Any] = {"limit": limit}
766
+ if state:
767
+ params["state"] = state
768
+ if cursor:
769
+ params["cursor"] = cursor
770
+ return self._request("GET", f"/v1/apps/{app_id}/deployments", params=params)
771
+
772
+ def get_deployment(self, deployment_id: str) -> dict:
773
+ return self._request("GET", f"/v1/deployments/{deployment_id}")
774
+
775
+ def get_active_deployment_for_app(self, app: dict) -> dict:
776
+ app_id = app.get("app_id")
777
+ active_id = app.get("active_deployment_id")
778
+ if active_id:
779
+ return self.get_deployment(str(active_id))
780
+ if not app_id:
781
+ raise exc.NotFoundError("DEPLOYMENT_NOT_FOUND", "app has no app_id")
782
+ page = self.list_deployments(str(app_id), state="active", limit=1)
783
+ items = page.get("items", [])
784
+ if items:
785
+ return items[0]
786
+ raise exc.NotFoundError(
787
+ "DEPLOYMENT_NOT_FOUND",
788
+ f"app {app.get('name')!r} has no active deployment",
789
+ )
790
+
791
+ def rollback_app(
792
+ self,
793
+ app_id: str,
794
+ *,
795
+ deployment_id: Optional[str] = None,
796
+ version: Optional[int] = None,
797
+ ) -> dict:
798
+ body: dict[str, Any] = {}
799
+ if deployment_id is not None:
800
+ body["deployment_id"] = deployment_id
801
+ if version is not None:
802
+ body["version"] = version
803
+ return self._request("POST", f"/v1/apps/{app_id}/rollback", json=body)
804
+
805
+ def invoke_web_endpoint(
806
+ self,
807
+ app_name: str,
808
+ endpoint_path: str,
809
+ *,
810
+ method: str = "POST",
811
+ json: Any = None,
812
+ headers: Optional[Mapping[str, str]] = None,
813
+ params: Optional[Mapping[str, Any]] = None,
814
+ url: Optional[str] = None,
815
+ ) -> Any:
816
+ import httpx
817
+
818
+ if not endpoint_path.startswith("/"):
819
+ endpoint_path = f"/{endpoint_path}"
820
+ app_segment = quote(app_name, safe="")
821
+ path_segment = quote(endpoint_path.lstrip("/"), safe="/")
822
+ path_url = f"/serve/{app_segment}/{path_segment}"
823
+ # The address the SERVER reported first, the path ingress behind it. A host
824
+ # route that cannot be resolved — a cell with no wildcard DNS, a stale record —
825
+ # must not be a dead end for a client that can still reach the control plane.
826
+ # Only a failure to CONNECT falls through: once the host has answered anything,
827
+ # including a 401 or a 503, that answer is the answer.
828
+ targets = [url, path_url] if url and url != path_url else [path_url]
829
+ # The ingress layer may block up to IngressWakeTimeout (server default: 60 s)
830
+ # while waiting for a scale-to-zero replica to start. The default client
831
+ # timeout of 30 s would fire before that window closes, surfacing a
832
+ # misleading ConnectionFailed instead of a successful cold-start response.
833
+ # Use a dedicated read timeout of 90 s (1.5× the server maximum) so a
834
+ # legitimate wake-from-zero always completes. The connect timeout remains
835
+ # short (5 s) to catch genuine network unreachability quickly.
836
+ # On UNAVAILABLE (503) the ingress polled past its wake window without
837
+ # getting a replica; retry up to 2 times with a short backoff
838
+ # (conventions.md §2: "le client peut retenter").
839
+ _INGRESS_TIMEOUT = httpx.Timeout(connect=5.0, read=90.0, write=10.0, pool=5.0)
840
+ _MAX_RETRIES = 2
841
+ _RETRY_INITIAL = 1.0
842
+ _RETRY_BACKOFF = 2.0
843
+
844
+ for index, target in enumerate(targets):
845
+ delay = _RETRY_INITIAL
846
+ for attempt in range(_MAX_RETRIES + 1):
847
+ try:
848
+ return self._request(
849
+ method.upper(),
850
+ target,
851
+ json=json,
852
+ headers=dict(headers or {}),
853
+ params=dict(params or {}),
854
+ timeout=_INGRESS_TIMEOUT,
855
+ )
856
+ except exc.UnavailableError:
857
+ if attempt >= _MAX_RETRIES:
858
+ raise
859
+ time.sleep(delay)
860
+ delay *= _RETRY_BACKOFF
861
+ except exc.ConnectionFailed as e:
862
+ # Never reached it at all, and something else to try: fall through
863
+ # WITHOUT consuming this target's remaining 503 retries.
864
+ if index + 1 < len(targets) and _could_not_connect(e):
865
+ break
866
+ raise
867
+ # Unreachable; satisfy type checkers.
868
+ raise exc.UnavailableError("UNAVAILABLE", "service unavailable after retries")
869
+
870
+
871
+ # -- maps (batch fan-out) -----------------------------------------------
872
+
873
+ def create_map(self, body: dict) -> dict:
874
+ return self._request("POST", "/v1/maps", json=body)
875
+
876
+ def get_map(self, map_id: str) -> dict:
877
+ return self._request("GET", f"/v1/maps/{map_id}")
878
+
879
+ def push_map_inputs(self, map_id: str, inputs: list) -> dict:
880
+ return self._request(
881
+ "POST", f"/v1/maps/{map_id}/inputs", json={"inputs": inputs}
882
+ )
883
+
884
+ def finalize_map(self, map_id: str) -> dict:
885
+ return self._request("POST", f"/v1/maps/{map_id}/finalize")
886
+
887
+ def cancel_map(self, map_id: str) -> dict:
888
+ return self._request("POST", f"/v1/maps/{map_id}/cancel")
889
+
890
+ def get_map_results(
891
+ self,
892
+ map_id: str,
893
+ *,
894
+ order: str = "index",
895
+ return_exceptions: bool = False,
896
+ cursor: Optional[str] = None,
897
+ limit: int = 200,
898
+ ) -> dict:
899
+ params: dict[str, Any] = {
900
+ "order": order,
901
+ "return_exceptions": "true" if return_exceptions else "false",
902
+ "limit": limit,
903
+ }
904
+ if cursor:
905
+ params["cursor"] = cursor
906
+ return self._request("GET", f"/v1/maps/{map_id}/results", params=params)
907
+
908
+ def list_maps(
909
+ self,
910
+ *,
911
+ limit: int = DEFAULT_PAGE_LIMIT,
912
+ cursor: Optional[str] = None,
913
+ ) -> dict:
914
+ params: dict[str, Any] = {"limit": limit}
915
+ if cursor:
916
+ params["cursor"] = cursor
917
+ return self._request("GET", "/v1/maps", params=params)
918
+
919
+ # -- crons --------------------------------------------------------------
920
+
921
+ def list_crons(self, app_id: str) -> dict:
922
+ return self._request("GET", f"/v1/apps/{app_id}/crons")
923
+
924
+ def get_cron(self, cron_id: str) -> dict:
925
+ return self._request("GET", f"/v1/crons/{cron_id}")
926
+
927
+ def list_cron_executions(
928
+ self,
929
+ cron_id: str,
930
+ *,
931
+ limit: int = DEFAULT_PAGE_LIMIT,
932
+ cursor: Optional[str] = None,
933
+ ) -> dict:
934
+ params: dict[str, Any] = {"limit": limit}
935
+ if cursor:
936
+ params["cursor"] = cursor
937
+ return self._request(
938
+ "GET", f"/v1/crons/{cron_id}/executions", params=params
939
+ )
940
+
941
+ def trigger_cron(self, cron_id: str) -> dict:
942
+ return self._request("POST", f"/v1/crons/{cron_id}/trigger")
943
+
944
+ def pause_cron(self, cron_id: str) -> dict:
945
+ return self._request("POST", f"/v1/crons/{cron_id}/pause")
946
+
947
+ def resume_cron(self, cron_id: str) -> dict:
948
+ return self._request("POST", f"/v1/crons/{cron_id}/resume")
949
+
950
+ # -- queues (name-keyed, get-or-create on use — openapi /v1/queues) -----
951
+
952
+ def get_queue(self, name: str) -> dict:
953
+ """GET /v1/queues/{name} — returns {queue_id, name, len, created_at}."""
954
+ return self._request("GET", f"/v1/queues/{_quote_key(name)}")
955
+
956
+ def delete_queue(self, name: str) -> None:
957
+ self._request("DELETE", f"/v1/queues/{_quote_key(name)}")
958
+
959
+ def queue_put(self, name: str, payload: dict, *, block: bool = False,
960
+ wait_seconds: int = 0) -> dict:
961
+ body: dict[str, Any] = {"value": payload}
962
+ if block:
963
+ body["block"] = True
964
+ body["wait_seconds"] = wait_seconds
965
+ return self._request("POST", f"/v1/queues/{_quote_key(name)}/put", json=body)
966
+
967
+ def queue_get(self, name: str, *, timeout: int = 0) -> dict:
968
+ """POST /v1/queues/{name}/get — claim-and-delete the head item.
969
+ Returns {item, item_id}; item is null when empty (never 404)."""
970
+ body: dict[str, Any] = {}
971
+ if timeout > 0:
972
+ body = {"block": True, "wait_seconds": timeout}
973
+ return self._request("POST", f"/v1/queues/{_quote_key(name)}/get", json=body)
974
+
975
+ # -- dicts (name-keyed, get-or-create on use — openapi /v1/dicts) -------
976
+
977
+ def get_dict(self, name: str) -> dict:
978
+ """GET /v1/dicts/{name} — returns {dict_id, name, created_at}."""
979
+ return self._request("GET", f"/v1/dicts/{_quote_key(name)}")
980
+
981
+ def delete_dict(self, name: str) -> None:
982
+ self._request("DELETE", f"/v1/dicts/{_quote_key(name)}")
983
+
984
+ def dict_get(self, name: str, key: str) -> dict:
985
+ """GET /v1/dicts/{name}/items/{key} — returns {found, value}."""
986
+ return self._request(
987
+ "GET", f"/v1/dicts/{_quote_key(name)}/items/{_quote_key(key)}"
988
+ )
989
+
990
+ def dict_put(self, name: str, key: str, payload: dict) -> None:
991
+ self._request(
992
+ "PUT", f"/v1/dicts/{_quote_key(name)}/items/{_quote_key(key)}",
993
+ json={"value": payload},
994
+ )
995
+
996
+ def dict_delete(self, name: str, key: str) -> None:
997
+ self._request(
998
+ "DELETE", f"/v1/dicts/{_quote_key(name)}/items/{_quote_key(key)}"
999
+ )
1000
+
1001
+ # -- namespaces ---------------------------------------------------------
1002
+
1003
+ def list_namespaces(
1004
+ self,
1005
+ *,
1006
+ limit: int = DEFAULT_PAGE_LIMIT,
1007
+ cursor: Optional[str] = None,
1008
+ ) -> dict:
1009
+ params: dict[str, Any] = {"limit": limit}
1010
+ if cursor:
1011
+ params["cursor"] = cursor
1012
+ return self._request("GET", "/v1/namespaces", params=params)
1013
+
1014
+
1015
+ # -- sandboxes ----------------------------------------------------------
1016
+
1017
+ def create_sandbox(self, body: dict) -> dict:
1018
+ return self._request("POST", "/v1/sandboxes", json=body)
1019
+
1020
+ def get_sandbox(self, sandbox_id: str) -> dict:
1021
+ return self._request("GET", f"/v1/sandboxes/{sandbox_id}")
1022
+
1023
+ def list_sandboxes(
1024
+ self,
1025
+ *,
1026
+ state: Optional[list] = None,
1027
+ limit: int = DEFAULT_PAGE_LIMIT,
1028
+ cursor: Optional[str] = None,
1029
+ ) -> dict:
1030
+ params: dict[str, Any] = {"limit": limit}
1031
+ if state:
1032
+ params["state"] = state
1033
+ if cursor:
1034
+ params["cursor"] = cursor
1035
+ return self._request("GET", "/v1/sandboxes", params=params)
1036
+
1037
+ def sandbox_terminate(self, sandbox_id: str) -> dict:
1038
+ return self._request("POST", f"/v1/sandboxes/{sandbox_id}/terminate")
1039
+
1040
+ def sandbox_exec(self, sandbox_id: str, body: dict) -> dict:
1041
+ return self._request("POST", f"/v1/sandboxes/{sandbox_id}/exec", json=body)
1042
+
1043
+ def get_sandbox_exec(self, sandbox_id: str, exec_id: str) -> dict:
1044
+ return self._request("GET", f"/v1/sandboxes/{sandbox_id}/execs/{exec_id}")
1045
+
1046
+ def sandbox_put_file(self, sandbox_id: str, body: dict) -> None:
1047
+ self._request("PUT", f"/v1/sandboxes/{sandbox_id}/files", json=body)
1048
+
1049
+ def sandbox_get_file(self, sandbox_id: str, path: str) -> dict:
1050
+ return self._request(
1051
+ "GET",
1052
+ f"/v1/sandboxes/{sandbox_id}/files/content",
1053
+ params={"path": path},
1054
+ )
1055
+
1056
+ def sandbox_ls(
1057
+ self,
1058
+ sandbox_id: str,
1059
+ path: str,
1060
+ *,
1061
+ limit: int = 200,
1062
+ cursor: Optional[str] = None,
1063
+ ) -> dict:
1064
+ params: dict[str, Any] = {"path": path, "limit": limit}
1065
+ if cursor:
1066
+ params["cursor"] = cursor
1067
+ return self._request("GET", f"/v1/sandboxes/{sandbox_id}/ls", params=params)
1068
+
1069
+ def sandbox_open_tunnel(self, sandbox_id: str, container_port: int) -> dict:
1070
+ return self._request(
1071
+ "POST",
1072
+ f"/v1/sandboxes/{sandbox_id}/tunnels",
1073
+ json={"container_port": container_port},
1074
+ )
1075
+
1076
+ def sandbox_close_tunnel(self, sandbox_id: str, tunnel_id: str) -> None:
1077
+ self._request("DELETE", f"/v1/sandboxes/{sandbox_id}/tunnels/{tunnel_id}")
1078
+
1079
+ def sandbox_list_tunnels(self, sandbox_id: str) -> dict:
1080
+ return self._request("GET", f"/v1/sandboxes/{sandbox_id}/tunnels")
1081
+
1082
+ def stream_sandbox_logs(self, sandbox_id: str, *, cursor: Optional[int] = None):
1083
+ """Yield SSE log events for a sandbox (same protocol as run logs)."""
1084
+ import httpx
1085
+
1086
+ headers: dict[str, str] = {}
1087
+ if cursor is not None:
1088
+ headers["Last-Event-ID"] = str(cursor)
1089
+ try:
1090
+ with self._http.stream(
1091
+ "GET",
1092
+ f"/v1/sandboxes/{sandbox_id}/logs/stream",
1093
+ headers=headers,
1094
+ timeout=httpx.Timeout(connect=5.0, read=60.0, write=5.0, pool=5.0),
1095
+ ) as resp:
1096
+ if resp.status_code >= 400:
1097
+ body = resp.read()
1098
+ try:
1099
+ import json as _json
1100
+ parsed = _json.loads(body)
1101
+ except ValueError:
1102
+ parsed = None
1103
+ _raise_for_envelope(resp.status_code, parsed)
1104
+ yield from _warn_on_gap(_parse_sse(resp.iter_lines()))
1105
+ except httpx.HTTPError as e:
1106
+ raise exc.ConnectionFailed(f"sandbox log stream failed: {e}") from e
1107
+
1108
+
1109
+ def _quote_key(key: str) -> str:
1110
+ """URL-encode a Dict key for use in a path segment."""
1111
+ return quote(key, safe="")
1112
+
1113
+
1114
+ def _warn_on_gap(events):
1115
+ """Pass SSE events through, warning on stderr at each ``gap`` (the stream stays open)."""
1116
+ for event in events:
1117
+ if event[0] == "gap":
1118
+ from ._render import warn_logs_incomplete
1119
+
1120
+ warn_logs_incomplete()
1121
+ yield event
1122
+
1123
+
1124
+ def _parse_sse(lines):
1125
+ """Parse an SSE byte/line iterator into ``(event, data, id)`` tuples."""
1126
+ event = "message"
1127
+ data_lines: list[str] = []
1128
+ last_id: Optional[str] = None
1129
+ for raw in lines:
1130
+ line = raw if isinstance(raw, str) else raw.decode("utf-8", "replace")
1131
+ if line == "":
1132
+ if data_lines:
1133
+ yield event, "\n".join(data_lines), last_id
1134
+ event = "message"
1135
+ data_lines = []
1136
+ continue
1137
+ if line.startswith(":"):
1138
+ continue # comment
1139
+ if line.startswith("event:"):
1140
+ event = line[len("event:"):].strip()
1141
+ elif line.startswith("data:"):
1142
+ # SSE spec: strip exactly one leading space after the colon, not all
1143
+ # whitespace — so a payload that begins with significant spaces/tabs
1144
+ # survives intact.
1145
+ value = line[len("data:"):]
1146
+ if value.startswith(" "):
1147
+ value = value[1:]
1148
+ data_lines.append(value)
1149
+ elif line.startswith("id:"):
1150
+ last_id = line[len("id:"):].strip()
1151
+ if data_lines:
1152
+ yield event, "\n".join(data_lines), last_id