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.
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/PKG-INFO +14 -1
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/README.md +13 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/__init__.py +5 -1
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/agent.py +148 -2
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/agents.py +8 -30
- hypercli_sdk-2026.4.13/hypercli/gateway.py +7 -0
- hypercli_sdk-2026.4.13/hypercli/openclaw/__init__.py +25 -0
- {hypercli_sdk-2026.4.9/hypercli → hypercli_sdk-2026.4.13/hypercli/openclaw}/gateway.py +7 -2
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/pyproject.toml +1 -1
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_agents.py +6 -4
- hypercli_sdk-2026.4.13/tests/test_bootstrap_console_test_key.py +81 -0
- hypercli_sdk-2026.4.13/tests/test_bootstrap_dev_test_keys.py +104 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_claw.py +206 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_gateway.py +52 -2
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_gateway_retry.py +4 -2
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/.gitignore +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/billing.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/client.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/config.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/files.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/http.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/instances.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/job/__init__.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/job/base.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/job/comfyui.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/job/gradio.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/jobs.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/keys.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/logs.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/models.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/renders.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/shell.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/user.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/voice.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/hypercli/x402.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/integration/conftest.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/integration/test_agents.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/integration/test_auth.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/integration/test_billing.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/integration/test_instances.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/integration/test_jobs_dryrun.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/integration/test_keys.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/integration/test_renders.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_apply_params.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_config.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_exec_shell_dryrun.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_graph_to_api.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_http.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_jobs.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_keys.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_models.py +0 -0
- {hypercli_sdk-2026.4.9 → hypercli_sdk-2026.4.13}/tests/test_renders_subscription.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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/
|
|
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
|
|
413
|
-
return self.update(size=size
|
|
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=
|
|
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=
|
|
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
|
|
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,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":
|
|
831
|
+
"command": command,
|
|
827
832
|
"timeout": 30,
|
|
828
833
|
},
|
|
829
834
|
)
|
|
@@ -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/
|
|
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/
|
|
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()
|
|
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
|