hypercli-sdk 2026.4.22__tar.gz → 2026.5.5__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 (53) hide show
  1. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/PKG-INFO +4 -1
  2. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/README.md +3 -0
  3. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/__init__.py +1 -1
  4. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/agent.py +9 -0
  5. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/agents.py +100 -34
  6. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/openclaw/gateway.py +27 -0
  7. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/pyproject.toml +1 -1
  8. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_agents.py +146 -41
  9. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_claw.py +17 -0
  10. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_gateway.py +57 -0
  11. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/.gitignore +0 -0
  12. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/billing.py +0 -0
  13. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/client.py +0 -0
  14. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/config.py +0 -0
  15. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/files.py +0 -0
  16. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/gateway.py +0 -0
  17. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/http.py +0 -0
  18. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/instances.py +0 -0
  19. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/job/__init__.py +0 -0
  20. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/job/base.py +0 -0
  21. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/job/comfyui.py +0 -0
  22. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/job/gradio.py +0 -0
  23. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/jobs.py +0 -0
  24. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/keys.py +0 -0
  25. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/logs.py +0 -0
  26. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/models.py +0 -0
  27. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/openclaw/__init__.py +0 -0
  28. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/renders.py +0 -0
  29. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/shell.py +0 -0
  30. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/user.py +0 -0
  31. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/voice.py +0 -0
  32. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/x402.py +0 -0
  33. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/integration/conftest.py +0 -0
  34. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/integration/test_agents.py +0 -0
  35. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/integration/test_auth.py +0 -0
  36. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/integration/test_billing.py +0 -0
  37. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/integration/test_instances.py +0 -0
  38. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/integration/test_jobs_dryrun.py +0 -0
  39. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/integration/test_keys.py +0 -0
  40. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/integration/test_renders.py +0 -0
  41. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_apply_params.py +0 -0
  42. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_bootstrap_console_test_key.py +0 -0
  43. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_bootstrap_dev_test_keys.py +0 -0
  44. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_config.py +0 -0
  45. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_exec_shell_dryrun.py +0 -0
  46. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_gateway_retry.py +0 -0
  47. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_graph_to_api.py +0 -0
  48. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_http.py +0 -0
  49. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_jobs.py +0 -0
  50. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_keys.py +0 -0
  51. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_models.py +0 -0
  52. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_renders_subscription.py +0 -0
  53. {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_voice.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypercli-sdk
3
- Version: 2026.4.22
3
+ Version: 2026.5.5
4
4
  Summary: Python SDK for HyperCLI - GPU orchestration and HyperAgent API
5
5
  Project-URL: Homepage, https://hypercli.com
6
6
  Project-URL: Documentation, https://docs.hypercli.com
@@ -120,6 +120,7 @@ from openai import OpenAI
120
120
 
121
121
  sdk = HyperCLI(api_key="hyper_api_key", agent_api_key="sk-agent")
122
122
  plans = sdk.agent.plans()
123
+ activation = sdk.agent.redeem_grant_code("PROMO123")
123
124
 
124
125
  client = OpenAI(
125
126
  api_key="your_hyperagent_api_key",
@@ -132,6 +133,8 @@ response = client.chat.completions.create(
132
133
  )
133
134
  ```
134
135
 
136
+ `redeem_grant_code()` applies a promo/activation code to the current HyperClaw account and returns the created entitlement.
137
+
135
138
  ## OpenClaw Agents
136
139
 
137
140
  OpenClaw uses the generic deployment launch surface. `registry_url`, `registry_auth`, `sync_root`, and `sync_enabled` are generic deployment options; the OpenClaw helpers only add defaults such as routes, image, and `sync_root=/home/node`.
@@ -89,6 +89,7 @@ from openai import OpenAI
89
89
 
90
90
  sdk = HyperCLI(api_key="hyper_api_key", agent_api_key="sk-agent")
91
91
  plans = sdk.agent.plans()
92
+ activation = sdk.agent.redeem_grant_code("PROMO123")
92
93
 
93
94
  client = OpenAI(
94
95
  api_key="your_hyperagent_api_key",
@@ -101,6 +102,8 @@ response = client.chat.completions.create(
101
102
  )
102
103
  ```
103
104
 
105
+ `redeem_grant_code()` applies a promo/activation code to the current HyperClaw account and returns the created entitlement.
106
+
104
107
  ## OpenClaw Agents
105
108
 
106
109
  OpenClaw uses the generic deployment launch surface. `registry_url`, `registry_auth`, `sync_root`, and `sync_enabled` are generic deployment options; the OpenClaw helpers only add defaults such as routes, image, and `sync_root=/home/node`.
@@ -78,7 +78,7 @@ from .gateway import (
78
78
  extract_gateway_chat_tool_calls,
79
79
  normalize_gateway_chat_message,
80
80
  )
81
- __version__ = "2026.4.22"
81
+ __version__ = "2026.5.5"
82
82
  __all__ = [
83
83
  "HyperCLI",
84
84
  "configure",
@@ -911,6 +911,15 @@ class HyperAgent:
911
911
  response.raise_for_status()
912
912
  return response.json()
913
913
 
914
+ def redeem_grant_code(self, code: str) -> Dict[str, Any]:
915
+ response = self._http._session.post(
916
+ f"{self._control_base_url}/billing/grants/redeem",
917
+ headers={"Authorization": f"Bearer {self._api_key}"},
918
+ json={"code": str(code)},
919
+ )
920
+ response.raise_for_status()
921
+ return response.json()
922
+
914
923
  def purchase_via_x402(
915
924
  self,
916
925
  plan_id: str,
@@ -296,6 +296,18 @@ def _agent_kwargs_from_dict(data: dict) -> dict[str, Any]:
296
296
  }
297
297
 
298
298
 
299
+ def _is_openclaw_agent_data(data: dict) -> bool:
300
+ routes = data.get("routes")
301
+ if isinstance(routes, dict) and routes.get("openclaw"):
302
+ return True
303
+ launch_config = data.get("launch_config")
304
+ if isinstance(launch_config, dict):
305
+ launch_routes = launch_config.get("routes")
306
+ if isinstance(launch_routes, dict) and launch_routes.get("openclaw"):
307
+ return True
308
+ return False
309
+
310
+
299
311
  @dataclass
300
312
  class Agent:
301
313
  """Generic agent returned by the HyperClaw backend."""
@@ -428,20 +440,20 @@ class Agent:
428
440
  def health(self) -> dict:
429
441
  return self._require_deployments().health(self)
430
442
 
431
- def files_list(self, path: str = "") -> list[dict]:
432
- return self._require_deployments().files_list(self, path)
443
+ def files_list(self, path: str = "", source: str = "auto") -> list[dict]:
444
+ return self._require_deployments().files_list(self, path, source=source)
433
445
 
434
- def file_read_bytes(self, path: str) -> bytes:
435
- return self._require_deployments().file_read_bytes(self, path)
446
+ def file_read_bytes(self, path: str, source: str = "auto") -> bytes:
447
+ return self._require_deployments().file_read_bytes(self, path, source=source)
436
448
 
437
- def file_read(self, path: str) -> str:
438
- return self._require_deployments().file_read(self, path)
449
+ def file_read(self, path: str, source: str = "auto") -> str:
450
+ return self._require_deployments().file_read(self, path, source=source)
439
451
 
440
- def file_write_bytes(self, path: str, content: bytes) -> dict:
441
- return self._require_deployments().file_write_bytes(self, path, content)
452
+ def file_write_bytes(self, path: str, content: bytes, destination: str = "auto") -> dict:
453
+ return self._require_deployments().file_write_bytes(self, path, content, destination=destination)
442
454
 
443
- def file_write(self, path: str, content: str) -> dict:
444
- return self._require_deployments().file_write(self, path, content)
455
+ def file_write(self, path: str, content: str, destination: str = "auto") -> dict:
456
+ return self._require_deployments().file_write(self, path, content, destination=destination)
445
457
 
446
458
  def file_delete(self, path: str, recursive: bool = False) -> dict:
447
459
  return self._require_deployments().file_delete(self, path, recursive)
@@ -473,32 +485,85 @@ class OpenClawAgent(Agent):
473
485
  def from_dict(cls, data: dict) -> "OpenClawAgent":
474
486
  return cls(
475
487
  **_agent_kwargs_from_dict(data),
476
- gateway_url=(
477
- data.get("openclaw_url")
478
- or data.get("gateway_url")
479
- or (f"wss://{data['hostname']}" if data.get("hostname") else None)
480
- ),
488
+ gateway_url=None,
481
489
  gateway_token=data.get("gateway_token"),
482
490
  )
483
491
 
492
+ @staticmethod
493
+ def _gateway_url_from_hostname(hostname: str | None) -> str | None:
494
+ value = str(hostname or "").strip()
495
+ return f"wss://{value}" if value else None
496
+
497
+ def _current_gateway_hostname(self) -> str | None:
498
+ if self.hostname:
499
+ return self.hostname
500
+ if not self.gateway_url:
501
+ return None
502
+ raw = str(self.gateway_url).strip().replace("wss://", "").replace("ws://", "")
503
+ return raw.split("/", 1)[0] or None
504
+
505
+ def wait_for_gateway_context(self, timeout: float = 30.0, retry_interval: float = 1.0) -> dict[str, Any]:
506
+ """
507
+ Resolve gateway context through the deployment record plus `/env`.
508
+
509
+ Agent startup is eventually consistent: the deployment record can lag
510
+ behind hostname attachment, and runtime env can lag behind both. The
511
+ SDK derives the gateway URL from the attached hostname and reads the
512
+ gateway token from `OPENCLAW_GATEWAY_TOKEN` in the env route.
513
+ """
514
+ if self.gateway_token and self.gateway_url:
515
+ return {
516
+ "agent_id": self.id,
517
+ "hostname": self._current_gateway_hostname(),
518
+ "gateway_token": self.gateway_token,
519
+ }
520
+ deadline = time.monotonic() + timeout
521
+ last_error: Exception | None = None
522
+ while True:
523
+ try:
524
+ refreshed = self._require_deployments().get(self.id)
525
+ hostname = getattr(refreshed, "hostname", None)
526
+ gateway_url = self._gateway_url_from_hostname(hostname)
527
+ env_data = self._require_deployments().env(self.id)
528
+ gateway_token = (
529
+ (env_data.get("env") or {}).get("OPENCLAW_GATEWAY_TOKEN", "").strip()
530
+ if isinstance(env_data, dict)
531
+ else None
532
+ )
533
+ if gateway_token and gateway_url:
534
+ self.gateway_token = gateway_token
535
+ self.gateway_url = gateway_url
536
+ return {
537
+ "agent_id": self.id,
538
+ "hostname": hostname,
539
+ "gateway_token": gateway_token,
540
+ }
541
+ last_error = RuntimeError("missing gateway context")
542
+ except Exception as exc:
543
+ last_error = exc
544
+ if time.monotonic() >= deadline:
545
+ if last_error is not None:
546
+ raise last_error
547
+ raise RuntimeError("Timed out waiting for OpenClaw gateway context")
548
+ time.sleep(retry_interval)
549
+
484
550
  def resolve_gateway_token(self) -> str | None:
485
- """Resolve the gateway token. Fetches from pod env if not set locally."""
486
- if self.gateway_token:
487
- return self.gateway_token
488
- token_data = self._require_deployments().inference_token(self.id)
551
+ """Resolve the gateway token through the deployment env route."""
552
+ token_data = self.wait_for_gateway_context()
489
553
  self.gateway_token = token_data.get("gateway_token")
490
- self.gateway_url = token_data.get("openclaw_url") or self.gateway_url
491
554
  return self.gateway_token
492
555
 
493
556
  def gateway(self, **kwargs) -> "GatewayClient":
494
557
  """Create a GatewayClient for this OpenClaw agent."""
495
558
  from .gateway import GatewayClient
559
+ if not self.gateway_url:
560
+ self.wait_for_gateway_context()
496
561
  if not self.gateway_url:
497
562
  raise ValueError("Agent has no OpenClaw gateway URL")
498
563
  deployments = self._require_deployments()
499
564
  if "gateway_token" not in kwargs:
500
565
  if not self.gateway_token:
501
- self.resolve_gateway_token()
566
+ self.wait_for_gateway_context()
502
567
  if self.gateway_token:
503
568
  kwargs["gateway_token"] = self.gateway_token
504
569
  kwargs.setdefault("deployment_id", self.id)
@@ -531,6 +596,8 @@ class OpenClawAgent(Agent):
531
596
  probe: str = "config",
532
597
  **kwargs,
533
598
  ) -> dict:
599
+ if not self.gateway_url or ("gateway_token" not in kwargs and not self.gateway_token):
600
+ self.wait_for_gateway_context()
534
601
  gw = self.gateway(**kwargs)
535
602
  try:
536
603
  return await gw.wait_ready(timeout=timeout, retry_interval=retry_interval, probe=probe)
@@ -933,7 +1000,7 @@ class Deployments:
933
1000
  )
934
1001
 
935
1002
  def _hydrate_agent(self, data: dict) -> Agent:
936
- if data.get("openclaw_url") or data.get("gateway_url"):
1003
+ if _is_openclaw_agent_data(data):
937
1004
  agent = OpenClawAgent.from_dict(data)
938
1005
  else:
939
1006
  agent = Agent.from_dict(data)
@@ -1342,10 +1409,6 @@ class Deployments:
1342
1409
  """
1343
1410
  return self._get(f"{AGENTS_API_PREFIX}/{agent_id}/token")
1344
1411
 
1345
- def inference_token(self, agent_id: str) -> dict:
1346
- """Fetch the scoped OpenClaw gateway token for an agent."""
1347
- return self._get(f"{AGENTS_API_PREFIX}/{agent_id}/inference/token")
1348
-
1349
1412
  def create_scoped_key(self, agent_id: str, name: str | None = None) -> dict:
1350
1413
  payload = {"name": name} if name is not None else {}
1351
1414
  return self._post(f"{AGENTS_API_PREFIX}/{agent_id}/keys", json=payload or None)
@@ -1422,7 +1485,7 @@ class Deployments:
1422
1485
  raise APIError(resp.status_code, resp.text)
1423
1486
  return resp.json()
1424
1487
 
1425
- def files_list(self, pod: Agent | str, path: str = "") -> list[dict]:
1488
+ def files_list(self, pod: Agent | str, path: str = "", source: str = "auto") -> list[dict]:
1426
1489
  """List files on an agent via the backend file API."""
1427
1490
  agent_id = self._agent_id_for_target(pod)
1428
1491
  with httpx.Client(timeout=10) as client:
@@ -1431,19 +1494,21 @@ class Deployments:
1431
1494
  if path
1432
1495
  else f"{self._api_base}{AGENTS_API_PREFIX}/{agent_id}/files",
1433
1496
  headers=self._file_headers(),
1497
+ params={"source": source},
1434
1498
  )
1435
1499
  if resp.status_code >= 400:
1436
1500
  raise APIError(resp.status_code, resp.text)
1437
1501
  payload = resp.json()
1438
1502
  return [*(payload.get("directories") or []), *(payload.get("files") or [])]
1439
1503
 
1440
- def file_read_bytes(self, pod: Agent | str, path: str) -> bytes:
1504
+ def file_read_bytes(self, pod: Agent | str, path: str, source: str = "auto") -> bytes:
1441
1505
  """Read a file from an agent via the backend file API."""
1442
1506
  agent_id = self._agent_id_for_target(pod)
1443
1507
  with httpx.Client(timeout=10) as client:
1444
1508
  resp = client.get(
1445
1509
  f"{self._api_base}{AGENTS_API_PREFIX}/{agent_id}/files/{self._encode_file_path(path)}",
1446
1510
  headers=self._file_headers(),
1511
+ params={"source": source},
1447
1512
  )
1448
1513
  if resp.status_code >= 400:
1449
1514
  raise APIError(resp.status_code, resp.text)
@@ -1457,26 +1522,27 @@ class Deployments:
1457
1522
  raise ValueError(f"Path is a directory: {path}. Use files_list(path) instead.")
1458
1523
  return resp.content
1459
1524
 
1460
- def file_read(self, pod: Agent | str, path: str) -> str:
1525
+ def file_read(self, pod: Agent | str, path: str, source: str = "auto") -> str:
1461
1526
  """Read a UTF-8 text file from an agent."""
1462
- return self.file_read_bytes(pod, path).decode(errors="replace")
1527
+ return self.file_read_bytes(pod, path, source=source).decode(errors="replace")
1463
1528
 
1464
- def file_write_bytes(self, pod: Agent | str, path: str, content: bytes) -> dict:
1529
+ def file_write_bytes(self, pod: Agent | str, path: str, content: bytes, destination: str = "auto") -> dict:
1465
1530
  """Write raw bytes to an agent via the backend file API."""
1466
1531
  agent_id = self._agent_id_for_target(pod)
1467
1532
  with httpx.Client(timeout=10) as client:
1468
- resp = client.put(
1533
+ resp = client.post(
1469
1534
  f"{self._api_base}{AGENTS_API_PREFIX}/{agent_id}/files/{self._encode_file_path(path)}",
1470
1535
  headers=self._file_headers(content_type="application/octet-stream"),
1536
+ params={"destination": destination},
1471
1537
  content=content,
1472
1538
  )
1473
1539
  if resp.status_code >= 400:
1474
1540
  raise APIError(resp.status_code, resp.text)
1475
1541
  return resp.json()
1476
1542
 
1477
- def file_write(self, pod: Agent | str, path: str, content: str) -> dict:
1543
+ def file_write(self, pod: Agent | str, path: str, content: str, destination: str = "auto") -> dict:
1478
1544
  """Write a UTF-8 text file to an agent."""
1479
- return self.file_write_bytes(pod, path, content.encode())
1545
+ return self.file_write_bytes(pod, path, content.encode(), destination=destination)
1480
1546
 
1481
1547
  def file_delete(self, pod: Agent | str, path: str, recursive: bool = False) -> dict:
1482
1548
  """Delete a file or directory from an agent."""
@@ -71,6 +71,9 @@ def _storage_scope_key(scope: str, role: str) -> str:
71
71
  return f"{scope.strip()}|{role.strip()}"
72
72
 
73
73
 
74
+ GatewayConnectionState = Literal["disconnected", "connecting", "connected"]
75
+
76
+
74
77
  @dataclass
75
78
  class DeviceTokenEntry:
76
79
  token: str
@@ -763,8 +766,10 @@ class GatewayClient:
763
766
  self._pending: dict[str, asyncio.Future] = {}
764
767
  self._event_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
765
768
  self._event_handlers: set[Callable[[dict[str, Any]], None]] = set()
769
+ self._connection_state_handlers: set[Callable[[GatewayConnectionState], None]] = set()
766
770
  self._reader_task: asyncio.Task | None = None
767
771
  self._connected = False
772
+ self._connection_state: GatewayConnectionState = "disconnected"
768
773
  self._closed = False
769
774
  self._version: str | None = None
770
775
  self._protocol: int | None = None
@@ -784,10 +789,28 @@ class GatewayClient:
784
789
  def is_connected(self) -> bool:
785
790
  return self._connected
786
791
 
792
+ @property
793
+ def connection_state(self) -> GatewayConnectionState:
794
+ return self._connection_state
795
+
787
796
  @property
788
797
  def pending_pairing(self) -> GatewayPairingState | None:
789
798
  return self._pending_pairing
790
799
 
800
+ def on_connection_state(self, handler: Callable[[GatewayConnectionState], None]) -> Callable[[], None]:
801
+ self._connection_state_handlers.add(handler)
802
+ return lambda: self._connection_state_handlers.discard(handler)
803
+
804
+ def _set_connection_state(self, state: GatewayConnectionState) -> None:
805
+ if self._connection_state == state:
806
+ return
807
+ self._connection_state = state
808
+ for handler in list(self._connection_state_handlers):
809
+ try:
810
+ handler(state)
811
+ except Exception:
812
+ pass
813
+
791
814
  def set_gateway_token(self, token: str | None) -> None:
792
815
  self.gateway_token = token.strip() if isinstance(token, str) and token.strip() else None
793
816
 
@@ -870,6 +893,7 @@ class GatewayClient:
870
893
  )
871
894
  finally:
872
895
  self._connected = False
896
+ self._set_connection_state("disconnected")
873
897
  self._ws = None
874
898
  close_error = close_info.error if close_info and close_info.error else GatewayError(
875
899
  "UNAVAILABLE",
@@ -1002,6 +1026,7 @@ class GatewayClient:
1002
1026
  self._closed = False
1003
1027
  if self._connected:
1004
1028
  return
1029
+ self._set_connection_state("connecting")
1005
1030
 
1006
1031
  delay = INITIAL_RECONNECT_DELAY
1007
1032
  deadline = asyncio.get_running_loop().time() + max(self.timeout, 30.0)
@@ -1026,6 +1051,7 @@ class GatewayClient:
1026
1051
  self._version = hello.get("server", {}).get("version") or hello.get("version")
1027
1052
  self._protocol = hello.get("protocol")
1028
1053
  self._connected = True
1054
+ self._set_connection_state("connected")
1029
1055
  self._update_pairing_state(None)
1030
1056
  self._last_seq = None
1031
1057
  self._reader_task = asyncio.create_task(self._reader_loop())
@@ -1142,6 +1168,7 @@ class GatewayClient:
1142
1168
  async def close(self) -> None:
1143
1169
  self._closed = True
1144
1170
  self._connected = False
1171
+ self._set_connection_state("disconnected")
1145
1172
  reader = self._reader_task
1146
1173
  self._reader_task = None
1147
1174
  if reader:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hypercli-sdk"
7
- version = "2026.4.22"
7
+ version = "2026.5.5"
8
8
  description = "Python SDK for HyperCLI - GPU orchestration and HyperAgent API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -97,7 +97,6 @@ def test_openclaw_agent_from_dict():
97
97
  "pod_name": "test-pod",
98
98
  "state": "running",
99
99
  "hostname": "test.hypercli.com",
100
- "openclaw_url": "wss://openclaw-test.hypercli.com",
101
100
  "gateway_token": "gw123",
102
101
  "jwt_token": "jwt123",
103
102
  "jwt_expires_at": "2026-03-01T12:00:00Z",
@@ -111,7 +110,7 @@ def test_openclaw_agent_from_dict():
111
110
  }
112
111
  )
113
112
 
114
- assert agent.gateway_url == "wss://openclaw-test.hypercli.com"
113
+ assert agent.gateway_url is None
115
114
  assert agent.gateway_token == "gw123"
116
115
  assert agent.jwt_token == "jwt123"
117
116
  assert isinstance(agent.jwt_expires_at, datetime)
@@ -122,7 +121,7 @@ def test_openclaw_agent_from_dict():
122
121
  assert agent.entrypoint == ["/bin/sh", "-c"]
123
122
 
124
123
 
125
- def test_openclaw_agent_from_dict_falls_back_to_root_gateway_host():
124
+ def test_openclaw_agent_from_dict_does_not_guess_gateway_url_from_hostname():
126
125
  agent = OpenClawAgent.from_dict(
127
126
  {
128
127
  "id": "agent-123",
@@ -134,7 +133,7 @@ def test_openclaw_agent_from_dict_falls_back_to_root_gateway_host():
134
133
  }
135
134
  )
136
135
 
137
- assert agent.gateway_url == "wss://test.hypercli.com"
136
+ assert agent.gateway_url is None
138
137
 
139
138
 
140
139
  def test_openclaw_agent_gateway_requires_url():
@@ -145,7 +144,7 @@ def test_openclaw_agent_gateway_requires_url():
145
144
  pod_name="test-pod",
146
145
  state="running",
147
146
  )
148
- with pytest.raises(ValueError, match="OpenClaw gateway URL"):
147
+ with pytest.raises(ValueError, match="Deployments client"):
149
148
  agent.gateway()
150
149
 
151
150
  def test_openclaw_agent_gateway_allows_jwtless_when_route_auth_disabled():
@@ -196,6 +195,39 @@ def test_openclaw_agent_gateway_ignores_jwt_and_uses_bound_tokens():
196
195
  assert gw.api_base == "https://api.test.hypercli.com"
197
196
 
198
197
 
198
+ def test_openclaw_agent_wait_running_still_delegates_to_deployments():
199
+ manager = Mock()
200
+ ready = OpenClawAgent(
201
+ id="agent-123",
202
+ user_id="user-456",
203
+ pod_id="pod-ready",
204
+ pod_name="ready-pod",
205
+ state="running",
206
+ hostname="ready.hypercli.com",
207
+ )
208
+ ready._deployments = manager
209
+ manager.wait_running.return_value = ready
210
+
211
+ agent = OpenClawAgent(
212
+ id="agent-123",
213
+ user_id="user-456",
214
+ pod_id="pod-pending",
215
+ pod_name="pending-pod",
216
+ state="starting",
217
+ hostname="ready.hypercli.com",
218
+ _deployments=manager,
219
+ )
220
+ agent.wait_for_gateway_context = Mock(side_effect=AssertionError("wait_for_gateway_context should not be used by wait_running"))
221
+
222
+ result = agent.wait_running(timeout=42, poll_interval=1.5)
223
+
224
+ manager.wait_running.assert_called_once_with("agent-123", timeout=42, poll_interval=1.5)
225
+ agent.wait_for_gateway_context.assert_not_called()
226
+ assert result is agent
227
+ assert agent.state == "running"
228
+ assert agent.pod_id == "pod-ready"
229
+
230
+
199
231
  def test_agent_wait_running_delegates_to_deployments():
200
232
  manager = Mock()
201
233
  ready = Agent(
@@ -466,7 +498,8 @@ def test_create_openclaw_defaults_routes_when_omitted(agents_client):
466
498
  "pod_id": "pod-789",
467
499
  "pod_name": "test-pod",
468
500
  "state": "starting",
469
- "openclaw_url": "wss://test.hypercli.com",
501
+ "hostname": "test.hypercli.com",
502
+ "routes": {"openclaw": {"port": 18789, "auth": False, "prefix": ""}},
470
503
  }
471
504
  mock_client.post.return_value = mock_response
472
505
  mock_client.__enter__.return_value = mock_client
@@ -560,7 +593,8 @@ def test_agents_create_returns_openclaw_agent(agents_client):
560
593
  "state": "starting",
561
594
  "cpu": 2,
562
595
  "memory": 8,
563
- "openclaw_url": "wss://openclaw-test.hypercli.com",
596
+ "hostname": "openclaw-test.hypercli.com",
597
+ "routes": {"openclaw": {"port": 18789, "auth": False, "prefix": ""}},
564
598
  }
565
599
  mock_client.post.return_value = mock_response
566
600
  mock_client.__enter__.return_value = mock_client
@@ -606,7 +640,7 @@ def test_agents_create_returns_openclaw_agent(agents_client):
606
640
  assert posted_json["registry_auth"] == {"username": "u", "password": "p"}
607
641
  assert isinstance(agent, OpenClawAgent)
608
642
  assert agent.gateway_token == "gw-token-123"
609
- assert agent.gateway_url == "wss://openclaw-test.hypercli.com"
643
+ assert agent.gateway_url is None
610
644
  assert agent.meta_ui is None
611
645
  assert agent._deployments is agents_client
612
646
 
@@ -622,7 +656,8 @@ def test_create_openclaw_defaults_sync_root(agents_client):
622
656
  "pod_id": "pod-789",
623
657
  "pod_name": "test-pod",
624
658
  "state": "starting",
625
- "openclaw_url": "wss://openclaw-test.hypercli.com",
659
+ "hostname": "openclaw-test.hypercli.com",
660
+ "routes": {"openclaw": {"port": 18789, "auth": False, "prefix": ""}},
626
661
  }
627
662
  mock_client.post.return_value = mock_response
628
663
  mock_client.__enter__.return_value = mock_client
@@ -648,7 +683,8 @@ def test_start_openclaw_defaults_sync_root(agents_client):
648
683
  "pod_id": "pod-789",
649
684
  "pod_name": "test-pod",
650
685
  "state": "starting",
651
- "openclaw_url": "wss://openclaw-test.hypercli.com",
686
+ "hostname": "openclaw-test.hypercli.com",
687
+ "routes": {"openclaw": {"port": 18789, "auth": False, "prefix": ""}},
652
688
  }
653
689
  mock_client.post.return_value = mock_response
654
690
  mock_client.__enter__.return_value = mock_client
@@ -711,14 +747,16 @@ def test_agents_file_ops_use_backend_file_api(agents_client):
711
747
 
712
748
  def get(self, url, headers=None, params=None, follow_redirects=None):
713
749
  if url.endswith("/deployments/agent-123/files"):
714
- assert params is None
750
+ assert params == {"source": "auto"}
715
751
  return FakeResponse(json_data={"directories": [{"name": "dir", "type": "directory"}], "files": [{"name": "a.txt", "type": "file"}]})
716
752
  if url.endswith("/deployments/agent-123/files/workspace"):
717
- assert params is None
753
+ assert params == {"source": "auto"}
718
754
  return FakeResponse(json_data={"directories": [{"name": "dir", "type": "directory"}], "files": [{"name": "a.txt", "type": "file"}]})
719
755
  if url.endswith("/deployments/agent-123/files/workspace/a.txt"):
756
+ assert params == {"source": "auto"}
720
757
  return FakeResponse(content=b"hello")
721
758
  if url.endswith("/deployments/agent-123/files/.openclaw"):
759
+ assert params == {"source": "auto"}
722
760
  return FakeResponse(
723
761
  json_data={
724
762
  "type": "directory",
@@ -731,8 +769,9 @@ def test_agents_file_ops_use_backend_file_api(agents_client):
731
769
  )
732
770
  raise AssertionError(url)
733
771
 
734
- def put(self, url, headers=None, content=None):
772
+ def post(self, url, headers=None, params=None, content=None):
735
773
  assert url.endswith("/deployments/agent-123/files/workspace/a.txt")
774
+ assert params == {"destination": "auto"}
736
775
  assert content == b"payload"
737
776
  return FakeResponse(json_data={"status": "ok"})
738
777
 
@@ -802,7 +841,8 @@ def test_agents_start_stop_delete(agents_client):
802
841
  "pod_id": "pod-789",
803
842
  "pod_name": "test-pod",
804
843
  "state": "starting",
805
- "openclaw_url": "wss://openclaw-test.hypercli.com",
844
+ "hostname": "openclaw-test.hypercli.com",
845
+ "routes": {"openclaw": {"port": 18789, "auth": False, "prefix": ""}},
806
846
  }
807
847
  mock_client.post.return_value = mock_response
808
848
  mock_client.__enter__.return_value = mock_client
@@ -990,27 +1030,6 @@ def test_agents_refresh_token(agents_client):
990
1030
  assert result["token"] == "jwt-new-token"
991
1031
 
992
1032
 
993
- def test_agents_inference_token(agents_client):
994
- with patch("httpx.Client") as mock_client_class:
995
- mock_client = MagicMock()
996
- mock_response = Mock()
997
- mock_response.status_code = 200
998
- mock_response.json.return_value = {
999
- "agent_id": "agent-123",
1000
- "openclaw_url": "wss://openclaw-test.hypercli.com",
1001
- "gateway_token": "gw-inference",
1002
- }
1003
- mock_client.get.return_value = mock_response
1004
- mock_client.__enter__.return_value = mock_client
1005
- mock_client.__exit__.return_value = False
1006
- mock_client_class.return_value = mock_client
1007
-
1008
- result = agents_client.inference_token("agent-123")
1009
-
1010
- assert result["gateway_token"] == "gw-inference"
1011
- assert mock_client.get.call_args[0][0].endswith("/deployments/agent-123/inference/token")
1012
-
1013
-
1014
1033
  def test_agents_create_scoped_key(agents_client):
1015
1034
  with patch("httpx.Client") as mock_client_class:
1016
1035
  mock_client = MagicMock()
@@ -1076,12 +1095,18 @@ def test_agents_redeem_grant_code(agents_client):
1076
1095
  assert mock_client.post.call_args[1]["json"] == {"code": "promo-123"}
1077
1096
 
1078
1097
 
1079
- def test_openclaw_agent_resolve_gateway_token_uses_inference_endpoint():
1098
+ def test_openclaw_agent_resolve_gateway_token_uses_env_route():
1080
1099
  manager = Mock()
1081
- manager.inference_token.return_value = {
1082
- "openclaw_url": "wss://openclaw-test.hypercli.com",
1083
- "gateway_token": "gw-fetched",
1084
- }
1100
+ manager.get.return_value = OpenClawAgent.from_dict({
1101
+ "id": "agent-123",
1102
+ "user_id": "user-456",
1103
+ "pod_id": "pod-789",
1104
+ "pod_name": "test-pod",
1105
+ "state": "running",
1106
+ "hostname": "openclaw-test.hypercli.com",
1107
+ "routes": {"openclaw": {"port": 18789, "auth": False}},
1108
+ })
1109
+ manager.env.return_value = {"env": {"OPENCLAW_GATEWAY_TOKEN": "gw-fetched"}}
1085
1110
  agent = OpenClawAgent(
1086
1111
  id="agent-123",
1087
1112
  user_id="user-456",
@@ -1096,7 +1121,87 @@ def test_openclaw_agent_resolve_gateway_token_uses_inference_endpoint():
1096
1121
  assert token == "gw-fetched"
1097
1122
  assert agent.gateway_token == "gw-fetched"
1098
1123
  assert agent.gateway_url == "wss://openclaw-test.hypercli.com"
1099
- manager.inference_token.assert_called_once_with("agent-123")
1124
+ manager.get.assert_called_once_with("agent-123")
1125
+ manager.env.assert_called_once_with("agent-123")
1126
+
1127
+
1128
+ def test_openclaw_agent_wait_for_gateway_context_retries_until_ready(monkeypatch):
1129
+ manager = Mock()
1130
+ manager.get.side_effect = [
1131
+ OpenClawAgent.from_dict({
1132
+ "id": "agent-123",
1133
+ "user_id": "user-456",
1134
+ "pod_id": "pod-789",
1135
+ "pod_name": "test-pod",
1136
+ "state": "running",
1137
+ "hostname": None,
1138
+ "routes": {"openclaw": {"port": 18789, "auth": False}},
1139
+ }),
1140
+ OpenClawAgent.from_dict({
1141
+ "id": "agent-123",
1142
+ "user_id": "user-456",
1143
+ "pod_id": "pod-789",
1144
+ "pod_name": "test-pod",
1145
+ "state": "running",
1146
+ "hostname": "openclaw-test.hypercli.com",
1147
+ "routes": {"openclaw": {"port": 18789, "auth": False}},
1148
+ }),
1149
+ ]
1150
+ manager.env.side_effect = [
1151
+ {"env": {"OPENCLAW_GATEWAY_TOKEN": "gw-fetched"}},
1152
+ {"env": {"OPENCLAW_GATEWAY_TOKEN": "gw-fetched"}},
1153
+ ]
1154
+ agent = OpenClawAgent(
1155
+ id="agent-123",
1156
+ user_id="user-456",
1157
+ pod_id="pod-789",
1158
+ pod_name="test-pod",
1159
+ state="running",
1160
+ _deployments=manager,
1161
+ )
1162
+ monkeypatch.setattr("hypercli.agents.time.sleep", lambda _seconds: None)
1163
+
1164
+ context = agent.wait_for_gateway_context(timeout=0.1, retry_interval=0)
1165
+
1166
+ assert context["gateway_token"] == "gw-fetched"
1167
+ assert context["hostname"] == "openclaw-test.hypercli.com"
1168
+ assert agent.gateway_token == "gw-fetched"
1169
+ assert agent.gateway_url == "wss://openclaw-test.hypercli.com"
1170
+ assert manager.get.call_count == 2
1171
+ assert manager.env.call_count == 2
1172
+
1173
+
1174
+ def test_openclaw_agent_gateway_resolves_missing_url_via_env_route():
1175
+ manager = Mock()
1176
+ manager._api_key = "sk-hyper-test123"
1177
+ manager._api_base = "https://api.test.hypercli.com"
1178
+ manager.get.return_value = OpenClawAgent.from_dict({
1179
+ "id": "agent-123",
1180
+ "user_id": "user-456",
1181
+ "pod_id": "pod-789",
1182
+ "pod_name": "test-pod",
1183
+ "state": "running",
1184
+ "hostname": "openclaw-test.hypercli.com",
1185
+ "routes": {"openclaw": {"port": 18789, "auth": False}},
1186
+ })
1187
+ manager.env.return_value = {"env": {"OPENCLAW_GATEWAY_TOKEN": "gw-fetched"}}
1188
+ agent = OpenClawAgent(
1189
+ id="agent-123",
1190
+ user_id="user-456",
1191
+ pod_id="pod-789",
1192
+ pod_name="test-pod",
1193
+ state="running",
1194
+ gateway_token="gw-inline",
1195
+ _deployments=manager,
1196
+ )
1197
+
1198
+ gw = agent.gateway()
1199
+
1200
+ assert gw.url == "wss://openclaw-test.hypercli.com"
1201
+ assert agent.gateway_url == "wss://openclaw-test.hypercli.com"
1202
+ assert agent.gateway_token == "gw-fetched"
1203
+ manager.get.assert_called_once_with("agent-123")
1204
+ manager.env.assert_called_once_with("agent-123")
1100
1205
 
1101
1206
 
1102
1207
  def test_agents_api_error(agents_client):
@@ -318,6 +318,23 @@ class TestHyperAgentClient:
318
318
  "https://api.hypercli.com/agents/subscriptions/sub-1/cancel",
319
319
  headers={"Authorization": "Bearer sk-hyper-test"},
320
320
  )
321
+
322
+ def test_redeem_grant_code(self, mock_http):
323
+ mock_http._session.post.return_value.json.return_value = {
324
+ "grant": {"id": "grant-1", "code": "promo-123"},
325
+ "entitlement": {"id": "ent-1", "plan_id": "basic"},
326
+ }
327
+ mock_http._session.post.return_value.raise_for_status = Mock()
328
+
329
+ agent = HyperAgent(mock_http, agent_api_key="sk-hyper-test", agents_api_base_url="https://api.hypercli.com/agents")
330
+ result = agent.redeem_grant_code("promo-123")
331
+
332
+ assert result["grant"]["code"] == "promo-123"
333
+ mock_http._session.post.assert_called_with(
334
+ "https://api.hypercli.com/agents/billing/grants/redeem",
335
+ headers={"Authorization": "Bearer sk-hyper-test"},
336
+ json={"code": "promo-123"},
337
+ )
321
338
 
322
339
  def test_openai_client_creation(self, mock_http):
323
340
  """Test that OpenAI client is created with correct config."""
@@ -36,6 +36,63 @@ class MockConnection:
36
36
  return await self.recv()
37
37
 
38
38
 
39
+
40
+
41
+ @pytest.mark.asyncio
42
+ async def test_connection_state_transitions(monkeypatch: pytest.MonkeyPatch) -> None:
43
+ sockets: list[MockConnection] = []
44
+
45
+ async def fake_connect(*args, **kwargs):
46
+ conn = MockConnection()
47
+ sockets.append(conn)
48
+ return conn
49
+
50
+ monkeypatch.setattr("hypercli.openclaw.gateway.websockets.connect", fake_connect)
51
+
52
+ client = GatewayClient(
53
+ url="wss://openclaw-agent.example",
54
+ token="jwt-token",
55
+ gateway_token="gw-token",
56
+ )
57
+ seen: list[str] = []
58
+ unsubscribe = client.on_connection_state(lambda state: seen.append(state))
59
+
60
+ connect_task = asyncio.create_task(client.connect())
61
+ assert client.connection_state == "connecting"
62
+
63
+ while not sockets:
64
+ await asyncio.sleep(0)
65
+
66
+ first = sockets[0]
67
+ first.push({"type": "event", "event": "connect.challenge", "payload": {"nonce": "nonce-1"}})
68
+ while not first.sent:
69
+ await asyncio.sleep(0)
70
+ connect_request = first.sent[0]
71
+ first.push({
72
+ "type": "res",
73
+ "id": connect_request["id"],
74
+ "ok": True,
75
+ "payload": {
76
+ "protocol": 3,
77
+ "server": {"version": "test"},
78
+ "auth": {
79
+ "deviceToken": "device-token-1",
80
+ "role": "operator",
81
+ "scopes": ["operator.admin"],
82
+ },
83
+ },
84
+ })
85
+
86
+ await connect_task
87
+ assert client.connection_state == "connected"
88
+
89
+ await client.close()
90
+ assert client.connection_state == "disconnected"
91
+ assert "connecting" in seen
92
+ assert "connected" in seen
93
+ assert "disconnected" in seen
94
+ unsubscribe()
95
+
39
96
  @pytest.mark.asyncio
40
97
  async def test_connect_auto_approves_pairing_and_reconnects(monkeypatch: pytest.MonkeyPatch) -> None:
41
98
  sockets: list[MockConnection] = []