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.
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/.gitignore +1 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/PKG-INFO +1 -1
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/__init__.py +1 -1
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/agents.py +16 -8
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/claw.py +5 -4
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/gateway.py +86 -33
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/jobs.py +28 -2
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/pyproject.toml +1 -1
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/tests/test_agents.py +7 -0
- hypercli_sdk-2026.3.10/tests/test_exec_shell_dryrun.py +151 -0
- hypercli_sdk-2026.3.10/tests/test_jobs.py +93 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/README.md +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/billing.py +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/client.py +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/config.py +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/files.py +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/http.py +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/instances.py +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/job/__init__.py +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/job/base.py +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/job/comfyui.py +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/job/gradio.py +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/keys.py +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/logs.py +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/renders.py +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/shell.py +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/user.py +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/hypercli/x402.py +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/tests/test_apply_params.py +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/tests/test_claw.py +0 -0
- {hypercli_sdk-1.0.3 → hypercli_sdk-2026.3.10}/tests/test_graph_to_api.py +0 -0
|
@@ -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__ = "
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
349
|
-
headers=self.
|
|
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
|
|
59
|
-
price_usd=
|
|
60
|
-
tpm_limit=data
|
|
61
|
-
rpm_limit=data
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
413
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
|
423
|
-
yield ChatEvent(type="
|
|
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 = {
|
|
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:
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|