hypercli-sdk 1.0.3__tar.gz → 2026.3.10__tar.gz

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.
Files changed (31) hide show
  1. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/.gitignore +1 -0
  2. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/PKG-INFO +1 -1
  3. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/__init__.py +1 -1
  4. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/agents.py +16 -8
  5. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/claw.py +5 -4
  6. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/gateway.py +86 -33
  7. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/jobs.py +28 -2
  8. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/pyproject.toml +1 -1
  9. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/tests/test_agents.py +7 -0
  10. hypercli_sdk-2026.3.10/tests/test_exec_shell_dryrun.py +151 -0
  11. hypercli_sdk-2026.3.10/tests/test_jobs.py +93 -0
  12. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/README.md +0 -0
  13. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/billing.py +0 -0
  14. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/client.py +0 -0
  15. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/config.py +0 -0
  16. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/files.py +0 -0
  17. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/http.py +0 -0
  18. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/instances.py +0 -0
  19. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/job/__init__.py +0 -0
  20. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/job/base.py +0 -0
  21. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/job/comfyui.py +0 -0
  22. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/job/gradio.py +0 -0
  23. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/keys.py +0 -0
  24. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/logs.py +0 -0
  25. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/renders.py +0 -0
  26. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/shell.py +0 -0
  27. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/user.py +0 -0
  28. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/x402.py +0 -0
  29. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/tests/test_apply_params.py +0 -0
  30. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/tests/test_claw.py +0 -0
  31. {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/tests/test_graph_to_api.py +0 -0
@@ -49,3 +49,4 @@ next-env.d.ts
49
49
 
50
50
  # Turbo
51
51
  .turbo
52
+ .netlify
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypercli-sdk
3
- Version: 1.0.3
3
+ Version: 2026.3.10
4
4
  Summary: Python SDK for HyperCLI - GPU orchestration and LLM API
5
5
  Project-URL: Homepage, https://hypercli.com
6
6
  Project-URL: Documentation, https://docs.hypercli.com
@@ -14,7 +14,7 @@ from .shell import ShellSession, shell_connect
14
14
  from .claw import Claw, ClawKey, ClawPlan, ClawModel
15
15
  from .gateway import GatewayClient, GatewayError, ChatEvent
16
16
 
17
- __version__ = "1.0.0"
17
+ __version__ = "2026.3.10"
18
18
  __all__ = [
19
19
  "HyperCLI",
20
20
  "configure",
@@ -9,7 +9,7 @@ runtime key generation, and DB persistence.
9
9
  """
10
10
  from __future__ import annotations
11
11
 
12
- from dataclasses import dataclass
12
+ from dataclasses import dataclass, field
13
13
  from datetime import datetime
14
14
  from typing import Optional, Any, AsyncIterator
15
15
 
@@ -41,6 +41,7 @@ class ReefPod:
41
41
  last_error: Optional[str] = None
42
42
  created_at: Optional[datetime] = None
43
43
  updated_at: Optional[datetime] = None
44
+ ports: list[dict] = field(default_factory=list)
44
45
 
45
46
  @classmethod
46
47
  def from_dict(cls, data: dict) -> ReefPod:
@@ -67,6 +68,7 @@ class ReefPod:
67
68
  last_error=data.get("last_error"),
68
69
  created_at=_parse_dt(data.get("created_at")),
69
70
  updated_at=_parse_dt(data.get("updated_at")),
71
+ ports=data.get("ports") or [],
70
72
  )
71
73
 
72
74
  @property
@@ -205,6 +207,8 @@ class Agents:
205
207
  cpu: int = None,
206
208
  memory: int = None,
207
209
  config: dict = None,
210
+ env: dict = None,
211
+ ports: list = None,
208
212
  start: bool = True,
209
213
  ) -> ReefPod:
210
214
  """Create a new agent (provisions a reef pod via the backend).
@@ -215,6 +219,8 @@ class Agents:
215
219
  cpu: Custom CPU in cores (overrides size).
216
220
  memory: Custom memory in GB (overrides size).
217
221
  config: Optional config overrides.
222
+ env: Optional environment variables to pass through to the pod.
223
+ ports: Optional exposed ports config.
218
224
  start: Start the agent immediately (default: True).
219
225
 
220
226
  Returns:
@@ -229,6 +235,10 @@ class Agents:
229
235
  body["cpu"] = cpu
230
236
  if memory is not None:
231
237
  body["memory"] = memory
238
+ if env is not None:
239
+ body["env"] = env
240
+ if ports is not None:
241
+ body["ports"] = ports
232
242
  data = self._post("/api/agents", json=body)
233
243
  return ReefPod.from_dict(data)
234
244
 
@@ -331,22 +341,20 @@ class Agents:
331
341
  return h
332
342
 
333
343
  def exec(self, pod: ReefPod, command: str, timeout: int = 30) -> ExecResult:
334
- """Execute a one-shot command on a reef pod via the executor API.
344
+ """Execute a one-shot command on a reef pod via lagoon exec API.
335
345
 
336
346
  Args:
337
- pod: ReefPod to execute on (needs jwt_token).
347
+ pod: ReefPod to execute on.
338
348
  command: Shell command to run.
339
349
  timeout: Command timeout in seconds.
340
350
 
341
351
  Returns:
342
352
  ExecResult with exit_code, stdout, stderr.
343
353
  """
344
- if not pod.executor_url:
345
- raise ValueError("Pod has no executor URL (missing hostname)")
346
- with httpx.Client(timeout=max(timeout + 5, 35)) as client:
354
+ with httpx.Client(timeout=max(timeout + 10, 35)) as client:
347
355
  resp = client.post(
348
- f"{pod.executor_url}/exec",
349
- headers=self._executor_headers(pod),
356
+ f"{self._api_base}/api/agents/{pod.id}/exec",
357
+ headers=self._headers,
350
358
  json={"command": command, "timeout": timeout},
351
359
  )
352
360
  if resp.status_code >= 400:
@@ -53,12 +53,13 @@ class ClawPlan:
53
53
 
54
54
  @classmethod
55
55
  def from_dict(cls, data: dict) -> "ClawPlan":
56
+ price = data.get("price_usd", data.get("price", 0))
56
57
  return cls(
57
58
  id=data["id"],
58
- name=data["name"],
59
- price_usd=data["price_usd"],
60
- tpm_limit=data["tpm_limit"],
61
- rpm_limit=data["rpm_limit"],
59
+ name=data.get("name", data["id"]),
60
+ price_usd=float(price or 0),
61
+ tpm_limit=int(data.get("tpm_limit", 0)),
62
+ rpm_limit=int(data.get("rpm_limit", 0)),
62
63
  )
63
64
 
64
65
 
@@ -88,7 +88,7 @@ class GatewayClient:
88
88
  gateway_token: str = "traefik-forwarded-auth-not-used",
89
89
  client_id: str = "openclaw-control-ui",
90
90
  client_mode: str = "webchat",
91
- origin: str = "https://hyperclaw.app",
91
+ origin: str = "https://sdk.hyperclaw.app",
92
92
  timeout: float = DEFAULT_TIMEOUT,
93
93
  ):
94
94
  self.url = url
@@ -270,22 +270,31 @@ class GatewayClient:
270
270
  self._pending.pop(req_id, None)
271
271
  raise GatewayError("TIMEOUT", f"Streaming {method} timed out")
272
272
 
273
- # Check if final response arrived
273
+ # Drain any queued events first (before checking done, to avoid dropping buffered content)
274
+ try:
275
+ event = await asyncio.wait_for(self._event_queue.get(), timeout=min(remaining, 0.1))
276
+ if event_filter is None or event.get("event", "").startswith(event_filter):
277
+ yield event
278
+ continue
279
+ except asyncio.TimeoutError:
280
+ pass
281
+
282
+ # Check if final response arrived (only after queue is drained for this tick)
274
283
  if fut.done():
284
+ # Drain any remaining events that arrived with the response
285
+ while not self._event_queue.empty():
286
+ try:
287
+ event = self._event_queue.get_nowait()
288
+ if event_filter is None or event.get("event", "").startswith(event_filter):
289
+ yield event
290
+ except asyncio.QueueEmpty:
291
+ break
275
292
  resp = fut.result()
276
293
  if not resp.get("ok"):
277
294
  err = resp.get("error", {})
278
295
  raise GatewayError(err.get("code", "RPC_ERROR"), err.get("message", ""))
279
296
  return
280
297
 
281
- # Drain events
282
- try:
283
- event = await asyncio.wait_for(self._event_queue.get(), timeout=min(remaining, 1.0))
284
- if event_filter is None or event.get("event", "").startswith(event_filter):
285
- yield event
286
- except asyncio.TimeoutError:
287
- continue
288
-
289
298
  # -----------------------------------------------------------------------
290
299
  # Config
291
300
  # -----------------------------------------------------------------------
@@ -299,12 +308,21 @@ class GatewayClient:
299
308
  """Get the JSON schema + uiHints for the config."""
300
309
  return await self.call("config.schema")
301
310
 
302
- async def config_patch(self, patch: dict) -> dict:
311
+ async def config_patch(self, patch: dict, base_hash: str = None) -> dict:
303
312
  """Patch the gateway configuration (merges with existing).
304
313
 
314
+ Fetches current config to get baseHash if not provided.
305
315
  The gateway will restart after applying the patch.
306
316
  """
307
- return await self.call("config.patch", {"patch": patch}, timeout=30)
317
+ import json as _json
318
+ if base_hash is None:
319
+ raw_result = await self.call("config.get")
320
+ base_hash = raw_result.get("hash") or raw_result.get("baseHash", "")
321
+ return await self.call(
322
+ "config.patch",
323
+ {"raw": _json.dumps(patch), "baseHash": base_hash},
324
+ timeout=30,
325
+ )
308
326
 
309
327
  async def config_apply(self, config: dict) -> dict:
310
328
  """Replace the entire gateway configuration.
@@ -397,40 +415,75 @@ class GatewayClient:
397
415
  result = await self.call("chat.history", params)
398
416
  return result.get("messages", [])
399
417
 
418
+ async def _listen_events(self, event_name: str, deadline: float) -> AsyncIterator[dict]:
419
+ """Yield broadcast events matching event_name until deadline."""
420
+ while asyncio.get_event_loop().time() < deadline:
421
+ remaining = deadline - asyncio.get_event_loop().time()
422
+ try:
423
+ event = await asyncio.wait_for(self._event_queue.get(), timeout=min(remaining, 1.0))
424
+ if event.get("event") == event_name:
425
+ yield event
426
+ except asyncio.TimeoutError:
427
+ continue
428
+
400
429
  async def chat_send(self, message: str, session_key: str = None, agent_id: str = None) -> AsyncIterator[ChatEvent]:
401
430
  """Send a chat message and stream the response.
402
431
 
403
- Yields ChatEvent objects as the agent responds.
432
+ Gateway chat events arrive as broadcast {"event": "chat"} messages with
433
+ payload.state = "delta" | "final" | "error" | "aborted".
434
+ The RPC response for chat.send is an immediate ACK; content follows as broadcasts.
404
435
  """
405
436
  import uuid as _uuid
406
- params: dict = {"message": message, "idempotencyKey": str(_uuid.uuid4())}
437
+ run_id = str(_uuid.uuid4())
438
+ params: dict = {"message": message, "idempotencyKey": run_id}
407
439
  if session_key:
408
440
  params["sessionKey"] = session_key
409
441
  if agent_id:
410
442
  params["agentId"] = agent_id
411
443
 
412
- async for event in self._call_streaming("chat.send", params, event_filter="chat."):
413
- evt = event.get("event", "")
444
+ # Send RPC and wait for ACK (gateway confirms message queued)
445
+ ack = await self.call("chat.send", params, timeout=30)
446
+ if not ack.get("ok", True) is False:
447
+ pass # ACK is just confirmation, not content
448
+
449
+ # Now listen for broadcast "chat" events matching our runId.
450
+ # The gateway may emit multiple "final" events for a single run:
451
+ # 1. An intermediate final with no text (tool-call turn, agent used exec/tools)
452
+ # 2. The real final with the assistant's text response after tool execution
453
+ # We keep listening until we get a final with actual text content, or an
454
+ # empty final that isn't followed by more events (simple text-only response).
455
+ deadline = asyncio.get_event_loop().time() + CHAT_TIMEOUT
456
+ got_text = False
457
+ async for event in self._listen_events(event_name="chat", deadline=deadline):
414
458
  payload = event.get("payload", {})
415
-
416
- if evt == "chat.content":
417
- yield ChatEvent(type="content", text=payload.get("text", ""))
418
- elif evt == "chat.thinking":
419
- yield ChatEvent(type="thinking", text=payload.get("text", ""))
420
- elif evt == "chat.tool_call":
459
+ if payload.get("runId") != run_id:
460
+ continue # Different chat run, skip
461
+
462
+ state = payload.get("state", "")
463
+ if state == "delta":
464
+ # Deltas are incremental streaming tokens — yield for low-latency consumers
465
+ msg = payload.get("message", {})
466
+ for part in (msg.get("content") or []):
467
+ if isinstance(part, dict) and part.get("type") == "text" and part.get("text"):
468
+ yield ChatEvent(type="delta", text=part["text"])
469
+ got_text = True
470
+ elif state == "final":
471
+ msg = payload.get("message", {})
472
+ has_text = False
473
+ for part in (msg.get("content") or []):
474
+ if isinstance(part, dict) and part.get("type") == "text" and part.get("text"):
475
+ yield ChatEvent(type="content", text=part["text"])
476
+ has_text = True
477
+
478
+ if has_text or got_text:
479
+ # Real final with content (or we already streamed deltas) — done
480
+ yield ChatEvent(type="done", data=payload)
481
+ return
482
+ # Empty final (tool-call turn) — keep listening for the real response
421
483
  yield ChatEvent(type="tool_call", data=payload)
422
- elif evt == "chat.tool_result":
423
- yield ChatEvent(type="tool_result", data=payload)
424
- elif evt == "chat.done":
425
- yield ChatEvent(type="done", data=payload)
426
- return
427
- elif evt == "chat.error":
428
- yield ChatEvent(type="error", text=payload.get("message", ""))
484
+ elif state in ("error", "aborted"):
485
+ yield ChatEvent(type="error", text=payload.get("errorMessage", state))
429
486
  return
430
- elif evt == "chat.status":
431
- yield ChatEvent(type="status", text=payload.get("status", ""))
432
- else:
433
- yield ChatEvent(type=evt, data=payload)
434
487
 
435
488
  async def chat_abort(self, session_key: str = None) -> dict:
436
489
  """Abort the current chat generation."""
@@ -20,6 +20,11 @@ class Job:
20
20
  price_per_second: float
21
21
  docker_image: str
22
22
  runtime: int
23
+ elapsed: int = 0
24
+ time_left: int = 0
25
+ command: str | None = None
26
+ env_vars: dict[str, str] | None = None
27
+ tags: dict[str, str] | None = None
23
28
  hostname: str | None = None
24
29
  cold_boot: bool = True
25
30
  created_at: float | None = None
@@ -28,6 +33,13 @@ class Job:
28
33
 
29
34
  @classmethod
30
35
  def from_dict(cls, data: dict) -> "Job":
36
+ command_b64 = data.get("command")
37
+ command = None
38
+ if isinstance(command_b64, str) and command_b64:
39
+ try:
40
+ command = base64.b64decode(command_b64).decode()
41
+ except Exception:
42
+ command = command_b64
31
43
  return cls(
32
44
  job_id=data.get("job_id", ""),
33
45
  job_key=data.get("job_key", ""),
@@ -39,7 +51,12 @@ class Job:
39
51
  price_per_hour=data.get("price_per_hour", 0),
40
52
  price_per_second=data.get("price_per_second", 0),
41
53
  docker_image=data.get("docker_image", ""),
54
+ command=command,
55
+ env_vars=data.get("env_vars"),
56
+ tags=data.get("tags"),
42
57
  runtime=data.get("runtime", 0),
58
+ elapsed=data.get("elapsed", 0),
59
+ time_left=data.get("time_left", 0),
43
60
  hostname=data.get("hostname"),
44
61
  cold_boot=data.get("cold_boot", True),
45
62
  created_at=data.get("created_at"),
@@ -127,9 +144,15 @@ class Jobs:
127
144
  def __init__(self, http: "HTTPClient"):
128
145
  self._http = http
129
146
 
130
- def list(self, state: str = None) -> list[Job]:
147
+ def list(self, state: str = None, tags: dict[str, str] | None = None) -> list[Job]:
131
148
  """List all jobs"""
132
- params = {"state": state} if state else None
149
+ params = {}
150
+ if state:
151
+ params["state"] = state
152
+ if tags:
153
+ params["tag"] = [f"{key}:{value}" for key, value in tags.items()]
154
+ if not params:
155
+ params = None
133
156
  data = self._http.get("/api/jobs", params=params)
134
157
  # API returns {"jobs": [...], "total_count": ...}
135
158
  jobs = data.get("jobs", []) if isinstance(data, dict) else data
@@ -153,6 +176,7 @@ class Jobs:
153
176
  ports: dict[str, int] = None,
154
177
  auth: bool = False,
155
178
  registry_auth: dict[str, str] = None,
179
+ tags: dict[str, str] = None,
156
180
  dockerfile: str = None,
157
181
  dry_run: bool = False,
158
182
  ) -> Job:
@@ -192,6 +216,8 @@ class Jobs:
192
216
  payload["auth"] = auth
193
217
  if registry_auth:
194
218
  payload["registry_auth"] = registry_auth
219
+ if tags:
220
+ payload["tags"] = tags
195
221
  if dockerfile:
196
222
  payload["dockerfile"] = dockerfile
197
223
  if dry_run:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hypercli-sdk"
7
- version = "1.0.3"
7
+ version = "2026.3.10"
8
8
  description = "Python SDK for HyperCLI - GPU orchestration and LLM API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -36,6 +36,7 @@ def test_reef_pod_from_dict():
36
36
  "last_error": None,
37
37
  "created_at": "2026-02-24T09:00:00Z",
38
38
  "updated_at": "2026-02-24T10:00:00Z",
39
+ "ports": [{"port": 18789, "auth": False, "prefix": "openclaw"}],
39
40
  }
40
41
 
41
42
  pod = ReefPod.from_dict(data)
@@ -57,6 +58,7 @@ def test_reef_pod_from_dict():
57
58
  assert pod.last_error is None
58
59
  assert isinstance(pod.created_at, datetime)
59
60
  assert isinstance(pod.updated_at, datetime)
61
+ assert pod.ports == [{"port": 18789, "auth": False, "prefix": "openclaw"}]
60
62
 
61
63
 
62
64
  def test_reef_pod_from_dict_minimal():
@@ -76,6 +78,7 @@ def test_reef_pod_from_dict_minimal():
76
78
  assert pod.name is None
77
79
  assert pod.cpu == 0
78
80
  assert pod.memory == 0
81
+ assert pod.ports == []
79
82
 
80
83
 
81
84
  def test_reef_pod_urls():
@@ -264,6 +267,8 @@ def test_agents_create(agents_client):
264
267
  size="medium",
265
268
  cpu=4,
266
269
  memory=16,
270
+ env={"FOO": "bar"},
271
+ ports=[{"port": 18789, "auth": False}],
267
272
  start=True,
268
273
  )
269
274
 
@@ -277,6 +282,8 @@ def test_agents_create(agents_client):
277
282
  assert posted_json["size"] == "medium"
278
283
  assert posted_json["cpu"] == 4
279
284
  assert posted_json["memory"] == 16
285
+ assert posted_json["env"] == {"FOO": "bar"}
286
+ assert posted_json["ports"] == [{"port": 18789, "auth": False}]
280
287
  assert posted_json["start"] is True
281
288
 
282
289
  # Verify response parsing
@@ -0,0 +1,151 @@
1
+ import pytest
2
+
3
+ from hypercli.jobs import Jobs
4
+ from hypercli.agents import Agents, ReefPod
5
+
6
+
7
+ class DummyHTTP:
8
+ def __init__(self):
9
+ self.api_key = "hyper_api_test"
10
+ self.base_url = "https://api.hypercli.com"
11
+ self.calls = []
12
+
13
+ def post(self, path, json=None, timeout=None):
14
+ self.calls.append(("post", path, json, timeout))
15
+ if path.endswith("/exec"):
16
+ return {"job_id": "job-1", "stdout": "ok\n", "stderr": "", "exit_code": 0}
17
+ return {
18
+ "job_id": "job-1",
19
+ "job_key": "job-key-123",
20
+ "state": "running",
21
+ "gpu_type": "l40s",
22
+ "gpu_count": 1,
23
+ "region": "oh",
24
+ "interruptible": True,
25
+ "price_per_hour": 1.2,
26
+ "price_per_second": 0.0003,
27
+ "docker_image": "nvidia/cuda",
28
+ "command": "ZWNobyBoaQ==",
29
+ "env_vars": {"FOO": "bar"},
30
+ "runtime": 120,
31
+ "cold_boot": False,
32
+ }
33
+
34
+ def get(self, path, params=None):
35
+ if path == "/api/jobs/job-1":
36
+ return {
37
+ "job_id": "job-1",
38
+ "job_key": "job-key-123",
39
+ "state": "running",
40
+ "gpu_type": "l40s",
41
+ "gpu_count": 1,
42
+ "region": "oh",
43
+ "interruptible": True,
44
+ "price_per_hour": 1.2,
45
+ "price_per_second": 0.0003,
46
+ "docker_image": "nvidia/cuda",
47
+ "command": "ZWNobyBoaQ==",
48
+ "env_vars": {"FOO": "bar"},
49
+ "runtime": 120,
50
+ }
51
+ return {}
52
+
53
+
54
+ def test_jobs_create_dry_run_payload():
55
+ http = DummyHTTP()
56
+ jobs = Jobs(http)
57
+
58
+ jobs.create(image="nvidia/cuda:12.0", command="echo hi", dry_run=True)
59
+
60
+ _, path, payload, _ = http.calls[0]
61
+ assert path == "/api/jobs"
62
+ assert payload["dry_run"] is True
63
+ assert "command" in payload
64
+
65
+
66
+ def test_jobs_exec():
67
+ http = DummyHTTP()
68
+ jobs = Jobs(http)
69
+
70
+ result = jobs.exec("job-1", "echo ok", timeout=15)
71
+
72
+ assert result.exit_code == 0
73
+ assert result.stdout == "ok\n"
74
+ assert http.calls[0][1] == "/api/jobs/job-1/exec"
75
+
76
+
77
+ def test_jobs_get_decodes_command_and_preserves_env():
78
+ http = DummyHTTP()
79
+ jobs = Jobs(http)
80
+
81
+ job = jobs.get("job-1")
82
+
83
+ assert job.command == "echo hi"
84
+ assert job.env_vars == {"FOO": "bar"}
85
+
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_jobs_shell_connect(monkeypatch):
89
+ http = DummyHTTP()
90
+ jobs = Jobs(http)
91
+ captured = {}
92
+
93
+ async def fake_connect(url, ping_interval=20, ping_timeout=20):
94
+ captured["url"] = url
95
+ return "ws-conn"
96
+
97
+ monkeypatch.setattr("websockets.connect", fake_connect)
98
+
99
+ ws = await jobs.shell_connect("job-1", shell="/bin/sh")
100
+
101
+ assert ws == "ws-conn"
102
+ assert captured["url"] == "wss://api.hypercli.com/orchestra/ws/shell/job-1?token=job-key-123&shell=/bin/sh"
103
+
104
+
105
+ def test_agents_exec(monkeypatch):
106
+ class FakeResponse:
107
+ status_code = 200
108
+
109
+ def json(self):
110
+ return {"exit_code": 0, "stdout": "done", "stderr": ""}
111
+
112
+ class FakeClient:
113
+ def __init__(self, timeout=None):
114
+ self.timeout = timeout
115
+
116
+ def __enter__(self):
117
+ return self
118
+
119
+ def __exit__(self, exc_type, exc, tb):
120
+ return False
121
+
122
+ def post(self, url, headers=None, json=None):
123
+ assert url.endswith("/api/agents/agent-1/exec")
124
+ assert json["command"] == "ls"
125
+ return FakeResponse()
126
+
127
+ monkeypatch.setattr("hypercli.agents.httpx.Client", FakeClient)
128
+
129
+ agents = Agents(DummyHTTP(), claw_api_key="sk-test")
130
+ pod = ReefPod(id="agent-1", user_id="u1", pod_id="p1", pod_name="pod", state="running")
131
+
132
+ result = agents.exec(pod, "ls", timeout=10)
133
+ assert result.exit_code == 0
134
+ assert result.stdout == "done"
135
+
136
+
137
+ @pytest.mark.asyncio
138
+ async def test_agents_shell_connect(monkeypatch):
139
+ agents = Agents(DummyHTTP(), claw_api_key="sk-test")
140
+ monkeypatch.setattr(agents, "_post", lambda path, json=None: {"token": "jwt-abc"})
141
+ captured = {}
142
+
143
+ async def fake_connect(url, ping_interval=20, ping_timeout=20):
144
+ captured["url"] = url
145
+ return "agent-ws"
146
+
147
+ monkeypatch.setattr("websockets.connect", fake_connect)
148
+
149
+ ws = await agents.shell_connect("agent-1")
150
+ assert ws == "agent-ws"
151
+ assert captured["url"] == "wss://api.hyperclaw.app/ws/shell/agent-1?jwt=jwt-abc"
@@ -0,0 +1,93 @@
1
+ from hypercli.jobs import Job, Jobs
2
+
3
+
4
+ class DummyHTTP:
5
+ def __init__(self):
6
+ self.calls = []
7
+
8
+ def get(self, path, params=None):
9
+ self.calls.append(("get", path, params))
10
+ return {
11
+ "jobs": [
12
+ {
13
+ "job_id": "job-1",
14
+ "job_key": "job-key-123",
15
+ "state": "running",
16
+ "gpu_type": "l40s",
17
+ "gpu_count": 1,
18
+ "region": "oh",
19
+ "interruptible": True,
20
+ "price_per_hour": 1.2,
21
+ "price_per_second": 0.0003,
22
+ "docker_image": "nvidia/cuda",
23
+ "runtime": 120,
24
+ "tags": {"team": "ml", "env": "prod"},
25
+ }
26
+ ]
27
+ }
28
+
29
+ def post(self, path, json=None, timeout=None):
30
+ self.calls.append(("post", path, json, timeout))
31
+ return {
32
+ "job_id": "job-1",
33
+ "job_key": "job-key-123",
34
+ "state": "running",
35
+ "gpu_type": "l40s",
36
+ "gpu_count": 1,
37
+ "region": "oh",
38
+ "interruptible": True,
39
+ "price_per_hour": 1.2,
40
+ "price_per_second": 0.0003,
41
+ "docker_image": "nvidia/cuda",
42
+ "runtime": 120,
43
+ "tags": json.get("tags"),
44
+ }
45
+
46
+
47
+ def test_job_from_dict_preserves_tags():
48
+ job = Job.from_dict(
49
+ {
50
+ "job_id": "job-1",
51
+ "job_key": "job-key-123",
52
+ "state": "running",
53
+ "gpu_type": "l40s",
54
+ "gpu_count": 1,
55
+ "region": "oh",
56
+ "interruptible": True,
57
+ "price_per_hour": 1.2,
58
+ "price_per_second": 0.0003,
59
+ "docker_image": "nvidia/cuda",
60
+ "runtime": 120,
61
+ "tags": {"team": "ml"},
62
+ }
63
+ )
64
+
65
+ assert job.tags == {"team": "ml"}
66
+
67
+
68
+ def test_jobs_list_sends_repeated_tag_filters():
69
+ http = DummyHTTP()
70
+ jobs = Jobs(http)
71
+
72
+ result = jobs.list(state="running", tags={"team": "ml", "env": "prod"})
73
+
74
+ assert result[0].tags == {"team": "ml", "env": "prod"}
75
+ assert http.calls[0] == (
76
+ "get",
77
+ "/api/jobs",
78
+ {"state": "running", "tag": ["team:ml", "env:prod"]},
79
+ )
80
+
81
+
82
+ def test_jobs_create_includes_tags():
83
+ http = DummyHTTP()
84
+ jobs = Jobs(http)
85
+
86
+ result = jobs.create(
87
+ image="nvidia/cuda:12.0",
88
+ command="echo hi",
89
+ tags={"team": "ml", "env": "prod"},
90
+ )
91
+
92
+ assert result.tags == {"team": "ml", "env": "prod"}
93
+ assert http.calls[0][2]["tags"] == {"team": "ml", "env": "prod"}
File without changes