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.
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/PKG-INFO +4 -1
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/README.md +3 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/__init__.py +1 -1
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/agent.py +9 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/agents.py +100 -34
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/openclaw/gateway.py +27 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/pyproject.toml +1 -1
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_agents.py +146 -41
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_claw.py +17 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_gateway.py +57 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/.gitignore +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/billing.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/client.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/config.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/files.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/gateway.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/http.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/instances.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/job/__init__.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/job/base.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/job/comfyui.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/job/gradio.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/jobs.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/keys.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/logs.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/models.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/openclaw/__init__.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/renders.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/shell.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/user.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/voice.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/hypercli/x402.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/integration/conftest.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/integration/test_agents.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/integration/test_auth.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/integration/test_billing.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/integration/test_instances.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/integration/test_jobs_dryrun.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/integration/test_keys.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/integration/test_renders.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_apply_params.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_bootstrap_console_test_key.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_bootstrap_dev_test_keys.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_config.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_exec_shell_dryrun.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_gateway_retry.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_graph_to_api.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_http.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_jobs.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_keys.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_models.py +0 -0
- {hypercli_sdk-2026.4.22 → hypercli_sdk-2026.5.5}/tests/test_renders_subscription.py +0 -0
- {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.
|
|
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`.
|
|
@@ -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
|
|
486
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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:
|
|
@@ -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
|
|
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
|
|
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
|
|
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="
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
|
1098
|
+
def test_openclaw_agent_resolve_gateway_token_uses_env_route():
|
|
1080
1099
|
manager = Mock()
|
|
1081
|
-
manager.
|
|
1082
|
-
"
|
|
1083
|
-
"
|
|
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.
|
|
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] = []
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|