hypercli-sdk 2026.4.9__tar.gz → 2026.4.13__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.9 → hypercli_sdk-2026.4.13}/PKG-INFO +14 -1
  2. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/README.md +13 -0
  3. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/__init__.py +5 -1
  4. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/agent.py +148 -2
  5. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/agents.py +8 -30
  6. hypercli_sdk-2026.4.13/hypercli/gateway.py +7 -0
  7. hypercli_sdk-2026.4.13/hypercli/openclaw/__init__.py +25 -0
  8. {hypercli_sdk-2026.4.9/hypercli → hypercli_sdk-2026.4.13/hypercli/openclaw}/gateway.py +7 -2
  9. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/pyproject.toml +1 -1
  10. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_agents.py +6 -4
  11. hypercli_sdk-2026.4.13/tests/test_bootstrap_console_test_key.py +81 -0
  12. hypercli_sdk-2026.4.13/tests/test_bootstrap_dev_test_keys.py +104 -0
  13. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_claw.py +206 -0
  14. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_gateway.py +52 -2
  15. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_gateway_retry.py +4 -2
  16. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/.gitignore +0 -0
  17. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/billing.py +0 -0
  18. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/client.py +0 -0
  19. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/config.py +0 -0
  20. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/files.py +0 -0
  21. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/http.py +0 -0
  22. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/instances.py +0 -0
  23. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/job/__init__.py +0 -0
  24. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/job/base.py +0 -0
  25. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/job/comfyui.py +0 -0
  26. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/job/gradio.py +0 -0
  27. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/jobs.py +0 -0
  28. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/keys.py +0 -0
  29. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/logs.py +0 -0
  30. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/models.py +0 -0
  31. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/renders.py +0 -0
  32. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/shell.py +0 -0
  33. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/user.py +0 -0
  34. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/voice.py +0 -0
  35. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/x402.py +0 -0
  36. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/integration/conftest.py +0 -0
  37. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/integration/test_agents.py +0 -0
  38. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/integration/test_auth.py +0 -0
  39. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/integration/test_billing.py +0 -0
  40. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/integration/test_instances.py +0 -0
  41. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/integration/test_jobs_dryrun.py +0 -0
  42. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/integration/test_keys.py +0 -0
  43. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/integration/test_renders.py +0 -0
  44. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_apply_params.py +0 -0
  45. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_config.py +0 -0
  46. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_exec_shell_dryrun.py +0 -0
  47. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_graph_to_api.py +0 -0
  48. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_http.py +0 -0
  49. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_jobs.py +0 -0
  50. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_keys.py +0 -0
  51. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_models.py +0 -0
  52. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_renders_subscription.py +0 -0
  53. {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/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.9
3
+ Version: 2026.4.13
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
@@ -132,6 +132,19 @@ response = client.chat.completions.create(
132
132
  )
133
133
  ```
134
134
 
135
+ ## OpenClaw Agents
136
+
137
+ 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`.
138
+
139
+ ```python
140
+ agent = client.deployments.create_openclaw(
141
+ name="docs-demo",
142
+ start=True,
143
+ registry_url="git.nedos.co",
144
+ registry_auth={"username": "ci", "password": "token"},
145
+ )
146
+ ```
147
+
135
148
  ## Error Handling
136
149
 
137
150
  ```python
@@ -101,6 +101,19 @@ response = client.chat.completions.create(
101
101
  )
102
102
  ```
103
103
 
104
+ ## OpenClaw Agents
105
+
106
+ 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`.
107
+
108
+ ```python
109
+ agent = client.deployments.create_openclaw(
110
+ name="docs-demo",
111
+ start=True,
112
+ registry_url="git.nedos.co",
113
+ registry_auth={"username": "ci", "password": "token"},
114
+ )
115
+ ```
116
+
104
117
  ## Error Handling
105
118
 
106
119
  ```python
@@ -43,6 +43,8 @@ from .agent import (
43
43
  HyperAgent,
44
44
  HyperAgentPlan,
45
45
  HyperAgentCurrentPlan,
46
+ HyperAgentEntitlements,
47
+ HyperAgentEntitlementsSummary,
46
48
  HyperAgentSubscription,
47
49
  HyperAgentSubscriptionSummary,
48
50
  HyperAgentModel,
@@ -58,7 +60,7 @@ from .gateway import (
58
60
  extract_gateway_chat_tool_calls,
59
61
  normalize_gateway_chat_message,
60
62
  )
61
- __version__ = "2026.4.7"
63
+ __version__ = "2026.4.13"
62
64
  __all__ = [
63
65
  "HyperCLI",
64
66
  "configure",
@@ -141,6 +143,8 @@ __all__ = [
141
143
  "HyperAgent",
142
144
  "HyperAgentPlan",
143
145
  "HyperAgentCurrentPlan",
146
+ "HyperAgentEntitlements",
147
+ "HyperAgentEntitlementsSummary",
144
148
  "HyperAgentSubscription",
145
149
  "HyperAgentSubscriptionSummary",
146
150
  "HyperAgentModel",
@@ -85,7 +85,7 @@ class HyperAgentCurrentPlan:
85
85
 
86
86
  @dataclass
87
87
  class HyperAgentSubscription:
88
- """A purchased HyperClaw entitlement/subscription instance."""
88
+ """A recurring HyperClaw billing subscription."""
89
89
 
90
90
  id: str
91
91
  user_id: str
@@ -106,10 +106,11 @@ class HyperAgentSubscription:
106
106
  plan_tpd: int = 0
107
107
  plan_agent_tier: str | None = None
108
108
  slot_grants: dict[str, int] | None = None
109
+ entitlements: list["HyperAgentEntitlement"] | None = None
109
110
 
110
111
  @classmethod
111
112
  def from_dict(cls, data: dict) -> "HyperAgentSubscription":
112
- expires_at = data.get("expires_at")
113
+ expires_at = data.get("current_period_end", data.get("expires_at"))
113
114
  updated_at = data.get("updated_at")
114
115
  return cls(
115
116
  id=data["id"],
@@ -131,6 +132,92 @@ class HyperAgentSubscription:
131
132
  plan_tpd=int(data.get("plan_tpd", 0) or 0),
132
133
  plan_agent_tier=data.get("plan_agent_tier"),
133
134
  slot_grants=data.get("slot_grants") or None,
135
+ entitlements=[HyperAgentEntitlement.from_dict(item) for item in data.get("entitlements", [])] or None,
136
+ )
137
+
138
+
139
+ @dataclass
140
+ class HyperAgentEntitlement:
141
+ """A concrete 1:1 entitlement grant."""
142
+
143
+ id: str
144
+ user_id: str
145
+ subscription_id: str | None
146
+ plan_id: str
147
+ plan_name: str
148
+ provider: str
149
+ status: str
150
+ expires_at: datetime | None = None
151
+ updated_at: datetime | None = None
152
+ tpm_limit: int = 0
153
+ rpm_limit: int = 0
154
+ tpd_limit: int = 0
155
+ agent_tier: str | None = None
156
+ features: dict[str, bool] | None = None
157
+ tags: list[str] | None = None
158
+ meta: dict[str, Any] | None = None
159
+ slot_grants: dict[str, int] | None = None
160
+ active_agent_count: int = 0
161
+ active_agent_ids: list[str] | None = None
162
+
163
+ @classmethod
164
+ def from_dict(cls, data: dict) -> "HyperAgentEntitlement":
165
+ expires_at = data.get("expires_at")
166
+ updated_at = data.get("updated_at")
167
+ return cls(
168
+ id=data["id"],
169
+ user_id=data.get("user_id", ""),
170
+ subscription_id=data.get("subscription_id"),
171
+ plan_id=data.get("plan_id", ""),
172
+ plan_name=data.get("plan_name", data.get("plan_id", "")),
173
+ provider=data.get("provider", ""),
174
+ status=data.get("status", ""),
175
+ expires_at=datetime.fromisoformat(str(expires_at).replace("Z", "+00:00")) if expires_at else None,
176
+ updated_at=datetime.fromisoformat(str(updated_at).replace("Z", "+00:00")) if updated_at else None,
177
+ tpm_limit=int(data.get("tpm_limit", 0) or 0),
178
+ rpm_limit=int(data.get("rpm_limit", 0) or 0),
179
+ tpd_limit=int(data.get("tpd_limit", 0) or 0),
180
+ agent_tier=data.get("agent_tier"),
181
+ features=data.get("features") or {},
182
+ tags=data.get("tags") or [],
183
+ meta=data.get("meta") or None,
184
+ slot_grants=data.get("slot_grants") or None,
185
+ active_agent_count=int(data.get("active_agent_count", 0) or 0),
186
+ active_agent_ids=data.get("active_agent_ids") or [],
187
+ )
188
+
189
+
190
+ @dataclass
191
+ class HyperAgentEntitlements:
192
+ """Effective account entitlements computed by the backend."""
193
+
194
+ effective_plan_id: str
195
+ pooled_tpm_limit: int
196
+ pooled_rpm_limit: int
197
+ pooled_tpd: int
198
+ slot_inventory: dict[str, Any]
199
+ active_entitlement_count: int
200
+ billing_reset_at: datetime | None = None
201
+
202
+ @classmethod
203
+ def from_dict(cls, data: dict) -> "HyperAgentEntitlements":
204
+ payload = data.get("entitlements") if isinstance(data.get("entitlements"), dict) else data
205
+ return cls(
206
+ effective_plan_id=payload.get("effective_plan_id", data.get("effective_plan_id", "")),
207
+ pooled_tpm_limit=int(payload.get("pooled_tpm_limit", data.get("pooled_tpm_limit", 0)) or 0),
208
+ pooled_rpm_limit=int(payload.get("pooled_rpm_limit", data.get("pooled_rpm_limit", 0)) or 0),
209
+ pooled_tpd=int(payload.get("pooled_tpd", data.get("pooled_tpd", 0)) or 0),
210
+ slot_inventory=payload.get("slot_inventory") or data.get("slot_inventory") or {},
211
+ active_entitlement_count=int(
212
+ payload.get(
213
+ "active_entitlement_count",
214
+ data.get("active_entitlement_count", data.get("active_subscription_count", 0)),
215
+ )
216
+ or 0
217
+ ),
218
+ billing_reset_at=datetime.fromisoformat(str(payload.get("billing_reset_at")).replace("Z", "+00:00"))
219
+ if payload.get("billing_reset_at")
220
+ else None,
134
221
  )
135
222
 
136
223
 
@@ -140,11 +227,16 @@ class HyperAgentSubscriptionSummary:
140
227
 
141
228
  effective_plan_id: str
142
229
  current_subscription_id: str | None
230
+ current_entitlement_id: str | None
143
231
  pooled_tpm_limit: int
144
232
  pooled_rpm_limit: int
145
233
  pooled_tpd: int
146
234
  slot_inventory: dict[str, Any]
235
+ billing_reset_at: datetime | None
147
236
  active_subscription_count: int
237
+ active_entitlement_count: int
238
+ entitlements: HyperAgentEntitlements
239
+ entitlement_items: list[HyperAgentEntitlement]
148
240
  active_subscriptions: list[HyperAgentSubscription]
149
241
  subscriptions: list[HyperAgentSubscription]
150
242
  user: dict[str, Any]
@@ -154,17 +246,42 @@ class HyperAgentSubscriptionSummary:
154
246
  return cls(
155
247
  effective_plan_id=data.get("effective_plan_id", ""),
156
248
  current_subscription_id=data.get("current_subscription_id"),
249
+ current_entitlement_id=data.get("current_entitlement_id", data.get("current_subscription_id")),
157
250
  pooled_tpm_limit=int(data.get("pooled_tpm_limit", 0) or 0),
158
251
  pooled_rpm_limit=int(data.get("pooled_rpm_limit", 0) or 0),
159
252
  pooled_tpd=int(data.get("pooled_tpd", 0) or 0),
160
253
  slot_inventory=data.get("slot_inventory") or {},
254
+ billing_reset_at=datetime.fromisoformat(str(data.get("billing_reset_at")).replace("Z", "+00:00"))
255
+ if data.get("billing_reset_at")
256
+ else None,
161
257
  active_subscription_count=int(data.get("active_subscription_count", 0) or 0),
258
+ active_entitlement_count=int(data.get("active_entitlement_count", data.get("active_subscription_count", 0)) or 0),
259
+ entitlements=HyperAgentEntitlements.from_dict(data),
260
+ entitlement_items=[HyperAgentEntitlement.from_dict(item) for item in data.get("entitlement_items", [])],
162
261
  active_subscriptions=[HyperAgentSubscription.from_dict(item) for item in data.get("active_subscriptions", [])],
163
262
  subscriptions=[HyperAgentSubscription.from_dict(item) for item in data.get("subscriptions", [])],
164
263
  user=data.get("user") or {},
165
264
  )
166
265
 
167
266
 
267
+ HyperAgentEntitlementsSummary = HyperAgentSubscriptionSummary
268
+
269
+
270
+ @dataclass
271
+ class HyperAgentSubscriptionMutationResult:
272
+ ok: bool
273
+ message: str
274
+ subscription: HyperAgentSubscription | None = None
275
+
276
+ @classmethod
277
+ def from_dict(cls, data: dict) -> "HyperAgentSubscriptionMutationResult":
278
+ return cls(
279
+ ok=bool(data.get("ok", False)),
280
+ message=str(data.get("message") or ""),
281
+ subscription=HyperAgentSubscription.from_dict(data["subscription"]) if data.get("subscription") else None,
282
+ )
283
+
284
+
168
285
  @dataclass
169
286
  class HyperAgentModel:
170
287
  """Available model on HyperAgent."""
@@ -368,6 +485,35 @@ class HyperAgent:
368
485
  response.raise_for_status()
369
486
  return HyperAgentSubscriptionSummary.from_dict(response.json())
370
487
 
488
+ def entitlements(self) -> HyperAgentEntitlementsSummary:
489
+ response = self._http._session.get(
490
+ f"{self._control_base_url}/entitlements",
491
+ headers={"Authorization": f"Bearer {self._api_key}"},
492
+ )
493
+ response.raise_for_status()
494
+ return HyperAgentEntitlementsSummary.from_dict(response.json())
495
+
496
+ def entitlement_instances(self) -> list[HyperAgentEntitlement]:
497
+ response = self._http._session.get(
498
+ f"{self._control_base_url}/entitlements/instances",
499
+ headers={"Authorization": f"Bearer {self._api_key}"},
500
+ )
501
+ response.raise_for_status()
502
+ data = response.json()
503
+ return [HyperAgentEntitlement.from_dict(item) for item in data.get("items", [])]
504
+
505
+ def update_subscription(self, subscription_id: str, bundle: dict[str, int] | None) -> HyperAgentSubscriptionMutationResult:
506
+ response = self._http._session.post(
507
+ f"{self._control_base_url}/subscriptions/{subscription_id}/update",
508
+ headers={"Authorization": f"Bearer {self._api_key}"},
509
+ json={"bundle": dict(bundle or {})},
510
+ )
511
+ response.raise_for_status()
512
+ return HyperAgentSubscriptionMutationResult.from_dict(response.json())
513
+
514
+ def cancel_subscription(self, subscription_id: str) -> HyperAgentSubscriptionMutationResult:
515
+ return self.update_subscription(subscription_id, {})
516
+
371
517
  def discovery_health(self) -> Dict[str, Any]:
372
518
  response = self._http._session.get(f"{self._api_base_without_v1()}/discovery/health")
373
519
  response.raise_for_status()
@@ -32,7 +32,7 @@ DEV_AGENTS_API_BASE = "https://api.dev.hypercli.com/agents"
32
32
  DEV_AGENTS_WS_URL = "wss://api.agents.dev.hypercli.com/ws"
33
33
  DEFAULT_OPENCLAW_IMAGE = "ghcr.io/hypercli/hypercli-openclaw:prod"
34
34
  LAUNCH_CONFIG_KEYS = frozenset({"image", "env", "routes", "ports", "command", "entrypoint", "sync_root", "sync_enabled", "registry_url", "registry_auth"})
35
- DEFAULT_OPENCLAW_SYNC_ROOT = "/home/ubuntu"
35
+ DEFAULT_OPENCLAW_SYNC_ROOT = "/home/node"
36
36
 
37
37
 
38
38
  def _is_directory_listing_payload(value: object) -> bool:
@@ -391,8 +391,6 @@ class Agent:
391
391
  *,
392
392
  name: str | None = None,
393
393
  size: str | None = None,
394
- cpu: float | None = None,
395
- memory: int | None = None,
396
394
  refresh_from_lagoon: bool | None = None,
397
395
  last_error: str | None = None,
398
396
  ) -> "Agent":
@@ -400,8 +398,6 @@ class Agent:
400
398
  self.id,
401
399
  name=name,
402
400
  size=size,
403
- cpu=cpu,
404
- memory=memory,
405
401
  refresh_from_lagoon=refresh_from_lagoon,
406
402
  last_error=last_error,
407
403
  )
@@ -409,8 +405,8 @@ class Agent:
409
405
  self._deployments = agent._deployments
410
406
  return self
411
407
 
412
- def resize(self, *, size: str | None = None, cpu: float | None = None, memory: int | None = None) -> "Agent":
413
- return self.update(size=size, cpu=cpu, memory=memory)
408
+ def resize(self, *, size: str | None = None) -> "Agent":
409
+ return self.update(size=size)
414
410
 
415
411
  def env(self) -> dict[str, str]:
416
412
  """Fetch runtime environment from the pod's K8s secret."""
@@ -1006,8 +1002,6 @@ class Deployments:
1006
1002
  self,
1007
1003
  name: str = None,
1008
1004
  size: str = None,
1009
- cpu: int = None,
1010
- memory: int = None,
1011
1005
  config: dict = None,
1012
1006
  tags: list[str] = None,
1013
1007
  env: dict = None,
@@ -1030,8 +1024,6 @@ class Deployments:
1030
1024
  Args:
1031
1025
  name: Agent name.
1032
1026
  size: Size preset (small/medium/large). Default: medium.
1033
- cpu: Custom CPU in cores (overrides size).
1034
- memory: Custom memory in GB (overrides size).
1035
1027
  config: Optional config overrides.
1036
1028
  env: Optional environment variables to pass through to the pod.
1037
1029
  ports: Optional exposed ports config.
@@ -1061,10 +1053,6 @@ class Deployments:
1061
1053
  body["name"] = name
1062
1054
  if size:
1063
1055
  body["size"] = size
1064
- if cpu is not None:
1065
- body["cpu"] = cpu
1066
- if memory is not None:
1067
- body["memory"] = memory
1068
1056
  if meta_ui:
1069
1057
  body["meta"] = {"ui": copy.deepcopy(meta_ui)}
1070
1058
  if tags:
@@ -1082,8 +1070,6 @@ class Deployments:
1082
1070
  self,
1083
1071
  name: str = None,
1084
1072
  size: str = None,
1085
- cpu: int = None,
1086
- memory: int = None,
1087
1073
  config: dict = None,
1088
1074
  tags: list[str] = None,
1089
1075
  env: dict = None,
@@ -1103,14 +1089,13 @@ class Deployments:
1103
1089
  openclaw_routes: dict | None = None,
1104
1090
  openclaw_route_options: dict | None = None,
1105
1091
  ) -> Agent:
1092
+ effective_env = dict(env or {})
1106
1093
  return self.create(
1107
1094
  name=name,
1108
1095
  size=size,
1109
- cpu=cpu,
1110
- memory=memory,
1111
1096
  config=config,
1112
1097
  tags=tags,
1113
- env=env,
1098
+ env=effective_env,
1114
1099
  ports=ports,
1115
1100
  routes=_resolve_openclaw_routes(
1116
1101
  routes,
@@ -1255,10 +1240,11 @@ class Deployments:
1255
1240
  openclaw_routes: dict | None = None,
1256
1241
  openclaw_route_options: dict | None = None,
1257
1242
  ) -> Agent:
1243
+ effective_env = dict(env or {})
1258
1244
  return self.start(
1259
1245
  agent_id,
1260
1246
  config=config,
1261
- env=env,
1247
+ env=effective_env,
1262
1248
  ports=ports,
1263
1249
  routes=_resolve_openclaw_routes(
1264
1250
  routes,
@@ -1282,8 +1268,6 @@ class Deployments:
1282
1268
  *,
1283
1269
  name: str | None = None,
1284
1270
  size: str | None = None,
1285
- cpu: float | None = None,
1286
- memory: int | None = None,
1287
1271
  refresh_from_lagoon: bool | None = None,
1288
1272
  last_error: str | None = None,
1289
1273
  ) -> Agent:
@@ -1292,10 +1276,6 @@ class Deployments:
1292
1276
  body["name"] = name
1293
1277
  if size is not None:
1294
1278
  body["size"] = size
1295
- if cpu is not None:
1296
- body["cpu"] = cpu
1297
- if memory is not None:
1298
- body["memory"] = memory
1299
1279
  if refresh_from_lagoon is not None:
1300
1280
  body["refresh_from_lagoon"] = refresh_from_lagoon
1301
1281
  if last_error is not None:
@@ -1308,10 +1288,8 @@ class Deployments:
1308
1288
  agent_id: str,
1309
1289
  *,
1310
1290
  size: str | None = None,
1311
- cpu: float | None = None,
1312
- memory: int | None = None,
1313
1291
  ) -> Agent:
1314
- return self.update(agent_id, size=size, cpu=cpu, memory=memory)
1292
+ return self.update(agent_id, size=size)
1315
1293
 
1316
1294
  def stop(self, agent_id: str) -> Agent:
1317
1295
  """Stop an agent (tears down pod, keeps DB record).
@@ -0,0 +1,7 @@
1
+ """Backward-compatible module alias for OpenClaw gateway surfaces."""
2
+
3
+ import sys
4
+
5
+ from .openclaw import gateway as _gateway
6
+
7
+ sys.modules[__name__] = _gateway
@@ -0,0 +1,25 @@
1
+ """OpenClaw-specific SDK surfaces."""
2
+
3
+ from .gateway import (
4
+ GatewayClient,
5
+ GatewayError,
6
+ ChatEvent,
7
+ GatewayChatToolCall,
8
+ GatewayChatMessageSummary,
9
+ extract_gateway_chat_thinking,
10
+ extract_gateway_chat_media_urls,
11
+ extract_gateway_chat_tool_calls,
12
+ normalize_gateway_chat_message,
13
+ )
14
+
15
+ __all__ = [
16
+ "GatewayClient",
17
+ "GatewayError",
18
+ "ChatEvent",
19
+ "GatewayChatToolCall",
20
+ "GatewayChatMessageSummary",
21
+ "extract_gateway_chat_thinking",
22
+ "extract_gateway_chat_media_urls",
23
+ "extract_gateway_chat_tool_calls",
24
+ "normalize_gateway_chat_message",
25
+ ]
@@ -14,12 +14,13 @@ import base64
14
14
  import hashlib
15
15
  import json
16
16
  import os
17
+ import shlex
17
18
  import time
18
19
  import uuid
19
20
  from dataclasses import asdict, dataclass
20
21
  from pathlib import Path
21
22
  from typing import Any, AsyncIterator, Callable, Literal, Optional
22
- from urllib.parse import parse_qsl, quote, urlsplit
23
+ from urllib.parse import parse_qsl, quote, urlsplit, urlunsplit
23
24
 
24
25
  import httpx
25
26
  import websockets
@@ -815,6 +816,10 @@ class GatewayClient:
815
816
  raise RuntimeError(
816
817
  "auto_approve_pairing requires deployment_id, api_key, and api_base"
817
818
  )
819
+ command = (
820
+ "openclaw devices approve "
821
+ f"{shlex.quote(request_id)} --json"
822
+ )
818
823
  async with httpx.AsyncClient(timeout=30) as client:
819
824
  response = await client.post(
820
825
  f"{self.api_base}/deployments/{quote(self.deployment_id or '')}/exec",
@@ -823,7 +828,7 @@ class GatewayClient:
823
828
  "Content-Type": "application/json",
824
829
  },
825
830
  json={
826
- "command": f"openclaw devices approve {request_id}",
831
+ "command": command,
827
832
  "timeout": 30,
828
833
  },
829
834
  )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hypercli-sdk"
7
- version = "2026.4.9"
7
+ version = "2026.4.13"
8
8
  description = "Python SDK for HyperCLI - GPU orchestration and HyperAgent API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -520,8 +520,6 @@ def test_agents_create_returns_openclaw_agent(agents_client):
520
520
  agent = agents_client.create(
521
521
  name="test-agent",
522
522
  size="medium",
523
- cpu=4,
524
- memory=16,
525
523
  meta_ui={
526
524
  "avatar": {
527
525
  "image": "data:image/png;base64,xyz",
@@ -584,8 +582,9 @@ def test_create_openclaw_defaults_sync_root(agents_client):
584
582
  agents_client.create_openclaw(name="test-agent")
585
583
 
586
584
  posted_json = mock_client.post.call_args[1]["json"]
587
- assert posted_json["sync_root"] == "/home/ubuntu"
585
+ assert posted_json["sync_root"] == "/home/node"
588
586
  assert posted_json["sync_enabled"] is True
587
+ assert "HOME" not in posted_json["env"]
589
588
 
590
589
 
591
590
  def test_start_openclaw_defaults_sync_root(agents_client):
@@ -609,8 +608,9 @@ def test_start_openclaw_defaults_sync_root(agents_client):
609
608
  agents_client.start_openclaw("agent-123")
610
609
 
611
610
  posted_json = mock_client.post.call_args[1]["json"]
612
- assert posted_json["sync_root"] == "/home/ubuntu"
611
+ assert posted_json["sync_root"] == "/home/node"
613
612
  assert posted_json["sync_enabled"] is True
613
+ assert "HOME" not in posted_json["env"]
614
614
 
615
615
 
616
616
  def test_agents_get_returns_generic_agent_without_gateway_metadata(agents_client):
@@ -879,6 +879,7 @@ def test_agents_start_preserves_generic_launch_fields(agents_client):
879
879
  image="python:3.12-alpine",
880
880
  command=["sh", "-c", "python -m http.server 80"],
881
881
  routes={"web": {"port": 80, "auth": False, "prefix": ""}},
882
+ sync_root="/workspace",
882
883
  sync_enabled=True,
883
884
  )
884
885
 
@@ -887,6 +888,7 @@ def test_agents_start_preserves_generic_launch_fields(agents_client):
887
888
  assert posted_json["image"] == "python:3.12-alpine"
888
889
  assert posted_json["command"] == ["sh", "-c", "python -m http.server 80"]
889
890
  assert posted_json["routes"] == {"web": {"port": 80, "auth": False, "prefix": ""}}
891
+ assert posted_json["sync_root"] == "/workspace"
890
892
  assert posted_json["sync_enabled"] is True
891
893
 
892
894
 
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import sys
5
+ import types
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ try:
11
+ import requests as _requests
12
+ except ModuleNotFoundError:
13
+ _requests = types.ModuleType("requests")
14
+
15
+ class _ReadTimeout(Exception):
16
+ pass
17
+
18
+ class _ConnectionError(Exception):
19
+ pass
20
+
21
+ _requests.exceptions = types.SimpleNamespace(
22
+ ReadTimeout=_ReadTimeout,
23
+ Timeout=_ReadTimeout,
24
+ ConnectionError=_ConnectionError,
25
+ )
26
+ _requests.request = lambda *_args, **_kwargs: None # pragma: no cover
27
+
28
+ sys.modules.setdefault("requests", _requests)
29
+
30
+
31
+ SCRIPT_PATH = Path(__file__).resolve().parents[2] / ".github" / "scripts" / "bootstrap_console_test_key.py"
32
+ SPEC = importlib.util.spec_from_file_location("bootstrap_console_test_key", SCRIPT_PATH)
33
+ assert SPEC and SPEC.loader
34
+ MODULE = importlib.util.module_from_spec(SPEC)
35
+ sys.modules[SPEC.name] = MODULE
36
+ SPEC.loader.exec_module(MODULE)
37
+
38
+
39
+ class _FakeResponse:
40
+ def __init__(self, status_code: int, payload: dict | list | None = None, text: str = "") -> None:
41
+ self.status_code = status_code
42
+ self._payload = payload if payload is not None else {}
43
+ self.text = text
44
+
45
+ def json(self):
46
+ return self._payload
47
+
48
+
49
+ def test_request_retries_transient_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
50
+ calls: list[int] = []
51
+
52
+ def fake_request(*_args, **_kwargs):
53
+ calls.append(1)
54
+ if len(calls) == 1:
55
+ raise _requests.exceptions.ReadTimeout("timed out")
56
+ return _FakeResponse(200, {"ok": True})
57
+
58
+ monkeypatch.setattr(MODULE.requests, "request", fake_request)
59
+ monkeypatch.setattr(MODULE.time, "sleep", lambda _seconds: None)
60
+
61
+ response = MODULE._request("GET", "https://example.test/api/admin/users")
62
+
63
+ assert response.status_code == 200
64
+ assert len(calls) == 2
65
+
66
+
67
+ def test_create_user_returns_existing_user_after_conflict(monkeypatch: pytest.MonkeyPatch) -> None:
68
+ responses = [
69
+ _FakeResponse(409, text="User already exists"),
70
+ _FakeResponse(200, [{"user_id": "console-e2e-existing", "email": "console@example.com"}]),
71
+ ]
72
+
73
+ def fake_request(*_args, **_kwargs):
74
+ return responses.pop(0)
75
+
76
+ monkeypatch.setattr(MODULE.requests, "request", fake_request)
77
+ monkeypatch.setattr(MODULE.time, "sleep", lambda _seconds: None)
78
+
79
+ user_id = MODULE._create_user("https://api.dev.hypercli.com/api", "admin-key", "console@example.com")
80
+
81
+ assert user_id == "console-e2e-existing"
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import sys
5
+ import types
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ try:
11
+ import requests as _requests
12
+ except ModuleNotFoundError:
13
+ _requests = types.ModuleType("requests")
14
+
15
+ class _ReadTimeout(Exception):
16
+ pass
17
+
18
+ class _ConnectionError(Exception):
19
+ pass
20
+
21
+ _requests.exceptions = types.SimpleNamespace(
22
+ ReadTimeout=_ReadTimeout,
23
+ Timeout=_ReadTimeout,
24
+ ConnectionError=_ConnectionError,
25
+ )
26
+ _requests.request = lambda *_args, **_kwargs: None # pragma: no cover
27
+
28
+ sys.modules.setdefault("requests", _requests)
29
+
30
+
31
+ SCRIPT_PATH = Path(__file__).resolve().parents[2] / ".github" / "scripts" / "bootstrap_dev_test_keys.py"
32
+ SPEC = importlib.util.spec_from_file_location("bootstrap_dev_test_keys", SCRIPT_PATH)
33
+ assert SPEC and SPEC.loader
34
+ MODULE = importlib.util.module_from_spec(SPEC)
35
+ sys.modules[SPEC.name] = MODULE
36
+ SPEC.loader.exec_module(MODULE)
37
+
38
+
39
+ class _FakeResponse:
40
+ def __init__(self, status_code: int, payload: dict | None = None, text: str = "") -> None:
41
+ self.status_code = status_code
42
+ self._payload = payload or {}
43
+ self.text = text
44
+
45
+ def json(self) -> dict:
46
+ return self._payload
47
+
48
+
49
+ def test_request_retries_transient_status(monkeypatch: pytest.MonkeyPatch) -> None:
50
+ calls: list[int] = []
51
+
52
+ def fake_request(*_args, **_kwargs):
53
+ calls.append(1)
54
+ if len(calls) == 1:
55
+ return _FakeResponse(504, text="Gateway Timeout")
56
+ return _FakeResponse(200, {"ok": True})
57
+
58
+ monkeypatch.setattr(MODULE.requests, "request", fake_request)
59
+ monkeypatch.setattr(MODULE.time, "sleep", lambda _seconds: None)
60
+
61
+ response = MODULE._request("POST", "https://example.test/admin/users")
62
+
63
+ assert response.status_code == 200
64
+ assert len(calls) == 2
65
+
66
+
67
+ def test_request_retries_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
68
+ calls: list[int] = []
69
+
70
+ def fake_request(*_args, **_kwargs):
71
+ calls.append(1)
72
+ if len(calls) == 1:
73
+ raise _requests.exceptions.ReadTimeout("timed out")
74
+ return _FakeResponse(200, {"ok": True})
75
+
76
+ monkeypatch.setattr(MODULE.requests, "request", fake_request)
77
+ monkeypatch.setattr(MODULE.time, "sleep", lambda _seconds: None)
78
+
79
+ response = MODULE._request("GET", "https://example.test/admin/users")
80
+
81
+ assert response.status_code == 200
82
+ assert len(calls) == 2
83
+
84
+
85
+ def test_create_or_get_hyperclaw_user_resolves_conflict(monkeypatch: pytest.MonkeyPatch) -> None:
86
+ responses = [
87
+ _FakeResponse(409, text="User already exists"),
88
+ _FakeResponse(200, {"items": [{"id": "user-123", "orchestra_user_id": "orch-123"}]}),
89
+ ]
90
+
91
+ def fake_request(*_args, **_kwargs):
92
+ return responses.pop(0)
93
+
94
+ monkeypatch.setattr(MODULE.requests, "request", fake_request)
95
+ monkeypatch.setattr(MODULE.time, "sleep", lambda _seconds: None)
96
+
97
+ payload = MODULE._create_or_get_hyperclaw_user(
98
+ agents_api_base="https://api.dev.hypercli.com/agents",
99
+ agents_admin_key="admin-key",
100
+ orchestra_user_id="orch-123",
101
+ email="sdk-int@example.com",
102
+ )
103
+
104
+ assert payload["id"] == "user-123"
@@ -7,6 +7,9 @@ from unittest.mock import Mock, patch, MagicMock
7
7
  from hypercli import HyperCLI
8
8
  from hypercli.agent import (
9
9
  HyperAgent,
10
+ HyperAgentEntitlement,
11
+ HyperAgentEntitlements,
12
+ HyperAgentEntitlementsSummary,
10
13
  HyperAgentPlan,
11
14
  HyperAgentCurrentPlan,
12
15
  HyperAgentSubscription,
@@ -71,11 +74,40 @@ class TestHyperAgentDataclasses:
71
74
  {
72
75
  "effective_plan_id": "large",
73
76
  "current_subscription_id": "sub-1",
77
+ "current_entitlement_id": "sub-1",
74
78
  "pooled_tpm_limit": 2000,
75
79
  "pooled_rpm_limit": 20,
76
80
  "pooled_tpd": 2000000,
81
+ "billing_reset_at": "2026-04-15T00:00:00Z",
77
82
  "slot_inventory": {"large": {"granted": 2, "used": 1, "available": 1}},
78
83
  "active_subscription_count": 1,
84
+ "active_entitlement_count": 1,
85
+ "entitlements": {
86
+ "effective_plan_id": "large",
87
+ "pooled_tpm_limit": 2000,
88
+ "pooled_rpm_limit": 20,
89
+ "pooled_tpd": 2000000,
90
+ "billing_reset_at": "2026-04-15T00:00:00Z",
91
+ "slot_inventory": {"large": {"granted": 2, "used": 1, "available": 1}},
92
+ "active_entitlement_count": 1,
93
+ },
94
+ "entitlement_items": [
95
+ {
96
+ "id": "ent-1",
97
+ "user_id": "user-1",
98
+ "subscription_id": "sub-1",
99
+ "plan_id": "large",
100
+ "plan_name": "Large",
101
+ "provider": "STRIPE",
102
+ "status": "ACTIVE",
103
+ "expires_at": "2026-04-15T00:00:00Z",
104
+ "agent_tier": "large",
105
+ "features": {"voice": True},
106
+ "tags": ["customer=acme"],
107
+ "active_agent_count": 1,
108
+ "active_agent_ids": ["agent-1"],
109
+ }
110
+ ],
79
111
  "active_subscriptions": [
80
112
  {
81
113
  "id": "sub-1",
@@ -91,8 +123,15 @@ class TestHyperAgentDataclasses:
91
123
  }
92
124
  )
93
125
  assert summary.effective_plan_id == "large"
126
+ assert summary.current_entitlement_id == "sub-1"
94
127
  assert summary.active_subscription_count == 1
128
+ assert isinstance(summary.entitlements, HyperAgentEntitlements)
129
+ assert summary.entitlements.active_entitlement_count == 1
130
+ assert summary.billing_reset_at is not None
131
+ assert summary.entitlements.billing_reset_at is not None
95
132
  assert summary.active_subscriptions[0].plan_id == "large"
133
+ assert isinstance(summary.entitlement_items[0], HyperAgentEntitlement)
134
+ assert summary.entitlement_items[0].tags == ["customer=acme"]
96
135
 
97
136
 
98
137
  class TestHyperAgentClient:
@@ -151,6 +190,7 @@ class TestHyperAgentClient:
151
190
  "provider": "STRIPE",
152
191
  "status": "ACTIVE",
153
192
  "quantity": 2,
193
+ "current_period_end": "2026-04-15T00:00:00Z",
154
194
  }
155
195
  ]
156
196
  }
@@ -161,6 +201,7 @@ class TestHyperAgentClient:
161
201
 
162
202
  assert len(subscriptions) == 1
163
203
  assert subscriptions[0].quantity == 2
204
+ assert subscriptions[0].expires_at is not None
164
205
  mock_http._session.get.assert_called_with(
165
206
  "https://api.hypercli.com/agents/subscriptions",
166
207
  headers={"Authorization": "Bearer sk-hyper-test"},
@@ -175,6 +216,24 @@ class TestHyperAgentClient:
175
216
  "pooled_tpd": 2000000,
176
217
  "slot_inventory": {"large": {"granted": 2, "used": 1, "available": 1}},
177
218
  "active_subscription_count": 1,
219
+ "active_entitlement_count": 1,
220
+ "entitlement_items": [
221
+ {
222
+ "id": "ent-1",
223
+ "user_id": "user-1",
224
+ "subscription_id": "sub-1",
225
+ "plan_id": "large",
226
+ "plan_name": "Large",
227
+ "provider": "STRIPE",
228
+ "status": "ACTIVE",
229
+ "expires_at": "2026-04-15T00:00:00Z",
230
+ "agent_tier": "large",
231
+ "features": {"voice": True},
232
+ "tags": ["customer=acme"],
233
+ "active_agent_count": 1,
234
+ "active_agent_ids": ["agent-1"],
235
+ }
236
+ ],
178
237
  "active_subscriptions": [
179
238
  {
180
239
  "id": "sub-1",
@@ -195,10 +254,157 @@ class TestHyperAgentClient:
195
254
 
196
255
  assert summary.current_subscription_id == "sub-1"
197
256
  assert summary.slot_inventory["large"]["available"] == 1
257
+ assert summary.entitlement_items[0].plan_id == "large"
198
258
  mock_http._session.get.assert_called_with(
199
259
  "https://api.hypercli.com/agents/subscriptions/summary",
200
260
  headers={"Authorization": "Bearer sk-hyper-test"},
201
261
  )
262
+
263
+ def test_entitlements(self, mock_http):
264
+ mock_http._session.get.return_value.json.return_value = {
265
+ "effective_plan_id": "large",
266
+ "current_subscription_id": "sub-1",
267
+ "current_entitlement_id": "sub-1",
268
+ "pooled_tpm_limit": 2000,
269
+ "pooled_rpm_limit": 20,
270
+ "pooled_tpd": 2000000,
271
+ "billing_reset_at": "2026-04-15T00:00:00Z",
272
+ "slot_inventory": {"large": {"granted": 2, "used": 1, "available": 1}},
273
+ "active_subscription_count": 1,
274
+ "active_entitlement_count": 1,
275
+ "entitlements": {
276
+ "effective_plan_id": "large",
277
+ "pooled_tpm_limit": 2000,
278
+ "pooled_rpm_limit": 20,
279
+ "pooled_tpd": 2000000,
280
+ "billing_reset_at": "2026-04-15T00:00:00Z",
281
+ "slot_inventory": {"large": {"granted": 2, "used": 1, "available": 1}},
282
+ "active_entitlement_count": 1,
283
+ },
284
+ "entitlement_items": [
285
+ {
286
+ "id": "ent-1",
287
+ "user_id": "user-1",
288
+ "plan_id": "large",
289
+ "plan_name": "Large",
290
+ "provider": "X402",
291
+ "status": "ACTIVE",
292
+ "expires_at": "2026-04-20T00:00:00Z",
293
+ "agent_tier": "large",
294
+ "features": {"voice": True},
295
+ "tags": ["customer=acme"],
296
+ "active_agent_count": 0,
297
+ "active_agent_ids": [],
298
+ }
299
+ ],
300
+ "active_subscriptions": [],
301
+ "subscriptions": [],
302
+ "user": {"id": "user-1", "team_id": "team-1"},
303
+ }
304
+ mock_http._session.get.return_value.raise_for_status = Mock()
305
+
306
+ agent = HyperAgent(mock_http, agent_api_key="sk-hyper-test", agents_api_base_url="https://api.hypercli.com/agents")
307
+ summary = agent.entitlements()
308
+
309
+ assert isinstance(summary, HyperAgentEntitlementsSummary)
310
+ assert summary.billing_reset_at is not None
311
+ assert summary.entitlements.slot_inventory["large"]["available"] == 1
312
+ assert summary.entitlement_items[0].provider == "X402"
313
+ mock_http._session.get.assert_called_with(
314
+ "https://api.hypercli.com/agents/entitlements",
315
+ headers={"Authorization": "Bearer sk-hyper-test"},
316
+ )
317
+
318
+ def test_entitlement_instances(self, mock_http):
319
+ mock_http._session.get.return_value.json.return_value = {
320
+ "items": [
321
+ {
322
+ "id": "ent-1",
323
+ "user_id": "user-1",
324
+ "subscription_id": None,
325
+ "plan_id": "large",
326
+ "plan_name": "Large",
327
+ "provider": "X402",
328
+ "status": "ACTIVE",
329
+ "expires_at": "2026-04-20T00:00:00Z",
330
+ "agent_tier": "large",
331
+ "features": {"voice": True},
332
+ "tags": ["customer=acme"],
333
+ "active_agent_count": 0,
334
+ "active_agent_ids": [],
335
+ }
336
+ ]
337
+ }
338
+ mock_http._session.get.return_value.raise_for_status = Mock()
339
+
340
+ agent = HyperAgent(mock_http, agent_api_key="sk-hyper-test", agents_api_base_url="https://api.hypercli.com/agents")
341
+ entitlements = agent.entitlement_instances()
342
+
343
+ assert len(entitlements) == 1
344
+ assert entitlements[0].plan_id == "large"
345
+ assert entitlements[0].tags == ["customer=acme"]
346
+ mock_http._session.get.assert_called_with(
347
+ "https://api.hypercli.com/agents/entitlements/instances",
348
+ headers={"Authorization": "Bearer sk-hyper-test"},
349
+ )
350
+
351
+ def test_cancel_subscription(self, mock_http):
352
+ mock_http._session.post.return_value.json.return_value = {
353
+ "ok": True,
354
+ "message": "Subscription will be cancelled at the end of the current billing period",
355
+ "subscription": {
356
+ "id": "sub-1",
357
+ "user_id": "user-1",
358
+ "plan_id": "large",
359
+ "plan_name": "Large",
360
+ "provider": "STRIPE",
361
+ "status": "ACTIVE",
362
+ "cancel_at_period_end": True,
363
+ "can_cancel": True,
364
+ },
365
+ }
366
+ mock_http._session.post.return_value.raise_for_status = Mock()
367
+
368
+ agent = HyperAgent(mock_http, agent_api_key="sk-hyper-test", agents_api_base_url="https://api.hypercli.com/agents")
369
+ result = agent.cancel_subscription("sub-1")
370
+
371
+ assert result.ok is True
372
+ assert result.subscription is not None
373
+ assert result.subscription.cancel_at_period_end is True
374
+ mock_http._session.post.assert_called_with(
375
+ "https://api.hypercli.com/agents/subscriptions/sub-1/update",
376
+ headers={"Authorization": "Bearer sk-hyper-test"},
377
+ json={"bundle": {}},
378
+ )
379
+
380
+ def test_update_subscription(self, mock_http):
381
+ mock_http._session.post.return_value.json.return_value = {
382
+ "ok": True,
383
+ "message": "Subscription upgraded immediately",
384
+ "subscription": {
385
+ "id": "sub-1",
386
+ "user_id": "user-1",
387
+ "plan_id": "large",
388
+ "plan_name": "Large",
389
+ "provider": "STRIPE",
390
+ "status": "ACTIVE",
391
+ "cancel_at_period_end": False,
392
+ "can_cancel": True,
393
+ },
394
+ }
395
+ mock_http._session.post.return_value.raise_for_status = Mock()
396
+
397
+ agent = HyperAgent(mock_http, agent_api_key="sk-hyper-test", agents_api_base_url="https://api.hypercli.com/agents")
398
+ result = agent.update_subscription("sub-1", {"large": 1})
399
+
400
+ assert result.ok is True
401
+ assert result.subscription is not None
402
+ assert result.subscription.plan_id == "large"
403
+ mock_http._session.post.assert_called_with(
404
+ "https://api.hypercli.com/agents/subscriptions/sub-1/update",
405
+ headers={"Authorization": "Bearer sk-hyper-test"},
406
+ json={"bundle": {"large": 1}},
407
+ )
202
408
 
203
409
  def test_openai_client_creation(self, mock_http):
204
410
  """Test that OpenAI client is created with correct config."""
@@ -4,8 +4,9 @@ import asyncio
4
4
  import json
5
5
 
6
6
  import pytest
7
+ import httpx
7
8
 
8
- from hypercli.gateway import GatewayClient, normalize_gateway_chat_message
9
+ from hypercli.openclaw.gateway import GatewayClient, normalize_gateway_chat_message
9
10
 
10
11
 
11
12
  class MockConnection:
@@ -49,7 +50,7 @@ async def test_connect_auto_approves_pairing_and_reconnects(monkeypatch: pytest.
49
50
  async def fake_approve(self: GatewayClient, request_id: str) -> None:
50
51
  approvals.append((self.deployment_id or "", request_id))
51
52
 
52
- monkeypatch.setattr("hypercli.gateway.websockets.connect", fake_connect)
53
+ monkeypatch.setattr("hypercli.openclaw.gateway.websockets.connect", fake_connect)
53
54
  monkeypatch.setattr(GatewayClient, "_approve_pairing_request", fake_approve)
54
55
 
55
56
  client = GatewayClient(
@@ -118,6 +119,55 @@ async def test_connect_auto_approves_pairing_and_reconnects(monkeypatch: pytest.
118
119
  assert client.is_connected is True
119
120
  assert client.pending_pairing is None
120
121
 
122
+ await client.close()
123
+
124
+
125
+ @pytest.mark.asyncio
126
+ async def test_approve_pairing_request_uses_direct_local_pairing_api(monkeypatch: pytest.MonkeyPatch) -> None:
127
+ captured: dict = {}
128
+
129
+ class FakeResponse:
130
+ status_code = 200
131
+
132
+ def json(self) -> dict:
133
+ return {"exit_code": 0, "stdout": "approved", "stderr": ""}
134
+
135
+ class FakeAsyncClient:
136
+ def __init__(self, *args, **kwargs) -> None:
137
+ pass
138
+
139
+ async def __aenter__(self):
140
+ return self
141
+
142
+ async def __aexit__(self, exc_type, exc, tb) -> None:
143
+ return None
144
+
145
+ async def post(self, url: str, *, headers: dict, json: dict):
146
+ captured["url"] = url
147
+ captured["headers"] = headers
148
+ captured["json"] = json
149
+ return FakeResponse()
150
+
151
+ monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
152
+
153
+ client = GatewayClient(
154
+ url="wss://openclaw-agent.example",
155
+ gateway_token="gw-token",
156
+ deployment_id="deployment-123",
157
+ api_key="agent-key",
158
+ api_base="https://api.dev.hypercli.com/agents",
159
+ auto_approve_pairing=True,
160
+ )
161
+
162
+ await client._approve_pairing_request("pairing-req-1")
163
+
164
+ assert captured["url"] == "https://api.dev.hypercli.com/agents/deployments/deployment-123/exec"
165
+ assert captured["json"]["timeout"] == 30
166
+ command = captured["json"]["command"]
167
+ assert command.startswith("openclaw devices approve ")
168
+ assert " --json" in command
169
+ assert "pairing-req-1" in command
170
+
121
171
 
122
172
  def test_set_gateway_token_normalizes_blank_values() -> None:
123
173
  client = GatewayClient(url="wss://openclaw-agent.example", gateway_token="gw-token-1")
@@ -6,7 +6,7 @@ import json
6
6
  import pytest
7
7
  from websockets.exceptions import InvalidStatus
8
8
 
9
- from hypercli.gateway import GatewayClient
9
+ from hypercli.openclaw.gateway import GatewayClient
10
10
 
11
11
 
12
12
  class _FakeResponse:
@@ -55,7 +55,7 @@ async def test_connect_retries_transient_503(monkeypatch: pytest.MonkeyPatch) ->
55
55
  raise InvalidStatus(_FakeResponse(503))
56
56
  return connection
57
57
 
58
- monkeypatch.setattr("hypercli.gateway.websockets.connect", fake_connect)
58
+ monkeypatch.setattr("hypercli.openclaw.gateway.websockets.connect", fake_connect)
59
59
 
60
60
  client = GatewayClient(
61
61
  url="wss://openclaw-agent.example",
@@ -93,3 +93,5 @@ async def test_connect_retries_transient_503(monkeypatch: pytest.MonkeyPatch) ->
93
93
 
94
94
  assert attempts == 2
95
95
  assert client.is_connected is True
96
+
97
+ await client.close()