hypercli-sdk 2026.4.7__tar.gz → 2026.4.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/PKG-INFO +1 -1
  2. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/__init__.py +3 -0
  3. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/agents.py +63 -0
  4. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/client.py +2 -0
  5. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/http.py +2 -2
  6. hypercli_sdk-2026.4.9/hypercli/models.py +33 -0
  7. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/renders.py +67 -4
  8. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/voice.py +8 -0
  9. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/pyproject.toml +1 -1
  10. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/integration/test_agents.py +29 -31
  11. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/integration/test_keys.py +13 -2
  12. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/test_agents.py +71 -0
  13. hypercli_sdk-2026.4.9/tests/test_models.py +27 -0
  14. hypercli_sdk-2026.4.9/tests/test_renders_subscription.py +188 -0
  15. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/test_voice.py +6 -3
  16. hypercli_sdk-2026.4.7/tests/test_renders_subscription.py +0 -86
  17. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/.gitignore +0 -0
  18. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/README.md +0 -0
  19. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/agent.py +0 -0
  20. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/billing.py +0 -0
  21. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/config.py +0 -0
  22. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/files.py +0 -0
  23. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/gateway.py +0 -0
  24. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/instances.py +0 -0
  25. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/job/__init__.py +0 -0
  26. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/job/base.py +0 -0
  27. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/job/comfyui.py +0 -0
  28. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/job/gradio.py +0 -0
  29. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/jobs.py +0 -0
  30. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/keys.py +0 -0
  31. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/logs.py +0 -0
  32. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/shell.py +0 -0
  33. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/user.py +0 -0
  34. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/hypercli/x402.py +0 -0
  35. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/integration/conftest.py +0 -0
  36. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/integration/test_auth.py +0 -0
  37. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/integration/test_billing.py +0 -0
  38. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/integration/test_instances.py +0 -0
  39. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/integration/test_jobs_dryrun.py +0 -0
  40. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/integration/test_renders.py +0 -0
  41. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/test_apply_params.py +0 -0
  42. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/test_claw.py +0 -0
  43. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/test_config.py +0 -0
  44. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/test_exec_shell_dryrun.py +0 -0
  45. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/test_gateway.py +0 -0
  46. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/test_gateway_retry.py +0 -0
  47. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/test_graph_to_api.py +0 -0
  48. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/test_http.py +0 -0
  49. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/test_jobs.py +0 -0
  50. {hypercli_sdk-2026.4.7 → hypercli_sdk-2026.4.9}/tests/test_keys.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypercli-sdk
3
- Version: 2026.4.7
3
+ Version: 2026.4.9
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
@@ -32,6 +32,7 @@ from .jobs import (
32
32
  )
33
33
  from .renders import Render, RenderStatus
34
34
  from .voice import VoiceAPI
35
+ from .models import Model, ModelsAPI
35
36
  from .x402 import X402Client, X402JobLaunch, X402FlowCreate, X402RenderCreate, FlowCatalogItem
36
37
  from .files import File, AsyncFiles
37
38
  from .job import BaseJob, ComfyUIJob, GradioJob, apply_params, apply_graph_modes, find_node, find_nodes, load_template, graph_to_api, expand_subgraphs, DEFAULT_OBJECT_INFO
@@ -93,6 +94,8 @@ __all__ = [
93
94
  "Render",
94
95
  "RenderStatus",
95
96
  "VoiceAPI",
97
+ "Model",
98
+ "ModelsAPI",
96
99
  # x402 API
97
100
  "X402Client",
98
101
  "X402JobLaunch",
@@ -386,6 +386,32 @@ class Agent:
386
386
  self._deployments = agent._deployments
387
387
  return self
388
388
 
389
+ def update(
390
+ self,
391
+ *,
392
+ name: str | None = None,
393
+ size: str | None = None,
394
+ cpu: float | None = None,
395
+ memory: int | None = None,
396
+ refresh_from_lagoon: bool | None = None,
397
+ last_error: str | None = None,
398
+ ) -> "Agent":
399
+ agent = self._require_deployments().update(
400
+ self.id,
401
+ name=name,
402
+ size=size,
403
+ cpu=cpu,
404
+ memory=memory,
405
+ refresh_from_lagoon=refresh_from_lagoon,
406
+ last_error=last_error,
407
+ )
408
+ self.__dict__.update(agent.__dict__)
409
+ self._deployments = agent._deployments
410
+ return self
411
+
412
+ def resize(self, *, size: str | None = None, cpu: float | None = None, memory: int | None = None) -> "Agent":
413
+ return self.update(size=size, cpu=cpu, memory=memory)
414
+
389
415
  def env(self) -> dict[str, str]:
390
416
  """Fetch runtime environment from the pod's K8s secret."""
391
417
  data = self._require_deployments().env(self.id)
@@ -1250,6 +1276,43 @@ class Deployments:
1250
1276
  dry_run=dry_run,
1251
1277
  )
1252
1278
 
1279
+ def update(
1280
+ self,
1281
+ agent_id: str,
1282
+ *,
1283
+ name: str | None = None,
1284
+ size: str | None = None,
1285
+ cpu: float | None = None,
1286
+ memory: int | None = None,
1287
+ refresh_from_lagoon: bool | None = None,
1288
+ last_error: str | None = None,
1289
+ ) -> Agent:
1290
+ body: dict[str, Any] = {}
1291
+ if name is not None:
1292
+ body["name"] = name
1293
+ if size is not None:
1294
+ body["size"] = size
1295
+ if cpu is not None:
1296
+ body["cpu"] = cpu
1297
+ if memory is not None:
1298
+ body["memory"] = memory
1299
+ if refresh_from_lagoon is not None:
1300
+ body["refresh_from_lagoon"] = refresh_from_lagoon
1301
+ if last_error is not None:
1302
+ body["last_error"] = last_error
1303
+ data = self._http.patch(f"{AGENTS_API_PREFIX}/{agent_id}", json=body)
1304
+ return self._hydrate_agent(data)
1305
+
1306
+ def resize(
1307
+ self,
1308
+ agent_id: str,
1309
+ *,
1310
+ size: str | None = None,
1311
+ cpu: float | None = None,
1312
+ memory: int | None = None,
1313
+ ) -> Agent:
1314
+ return self.update(agent_id, size=size, cpu=cpu, memory=memory)
1315
+
1253
1316
  def stop(self, agent_id: str) -> Agent:
1254
1317
  """Stop an agent (tears down pod, keeps DB record).
1255
1318
 
@@ -19,6 +19,7 @@ from .voice import VoiceAPI
19
19
  from .agents import Deployments
20
20
  from .agent import HyperAgent
21
21
  from .keys import KeysAPI
22
+ from .models import ModelsAPI
22
23
 
23
24
 
24
25
  def _derive_agents_api_base(api_url: str, agent_dev: bool) -> str:
@@ -100,6 +101,7 @@ class HyperCLI:
100
101
  self.files = Files(self._http)
101
102
  self.voice = VoiceAPI(self._http)
102
103
  self.keys = KeysAPI(self._http)
104
+ self.models = ModelsAPI(self._http)
103
105
  self.agent = HyperAgent(
104
106
  self._http,
105
107
  agent_api_key=resolved_agent_api_key,
@@ -166,10 +166,10 @@ class HTTPClient:
166
166
  )
167
167
  return _handle_response(resp)
168
168
 
169
- def post_bytes(self, path: str, json: dict = None) -> bytes:
169
+ def post_bytes(self, path: str, json: dict = None, timeout: float | None = None) -> bytes:
170
170
  resp = request_with_retry(
171
171
  "post", f"{self.base_url}{path}",
172
- headers=self.headers, timeout=self.timeout, json=json
172
+ headers=self.headers, timeout=timeout if timeout is not None else self.timeout, json=json
173
173
  )
174
174
  return _handle_bytes_response(resp)
175
175
 
@@ -0,0 +1,33 @@
1
+ """OpenAI-compatible models API"""
2
+ from dataclasses import dataclass
3
+ from typing import TYPE_CHECKING, List
4
+
5
+ if TYPE_CHECKING:
6
+ from .http import HTTPClient
7
+
8
+
9
+ @dataclass
10
+ class Model:
11
+ id: str
12
+ object: str
13
+ owned_by: str | None = None
14
+
15
+ @classmethod
16
+ def from_dict(cls, data: dict) -> "Model":
17
+ return cls(
18
+ id=data.get("id", ""),
19
+ object=data.get("object", "model"),
20
+ owned_by=data.get("owned_by"),
21
+ )
22
+
23
+
24
+ class ModelsAPI:
25
+ """OpenAI-compatible models API"""
26
+
27
+ def __init__(self, http: "HTTPClient"):
28
+ self._http = http
29
+
30
+ def list(self) -> List[Model]:
31
+ payload = self._http.get("/v1/models")
32
+ data = payload.get("data") if isinstance(payload, dict) else payload
33
+ return [Model.from_dict(item) for item in (data or [])]
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  """Renders API"""
4
4
  from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ import time
5
7
  from typing import TYPE_CHECKING, Any, List
6
8
 
7
9
  if TYPE_CHECKING:
@@ -56,11 +58,28 @@ class RenderStatus:
56
58
  class Renders:
57
59
  """Renders API wrapper"""
58
60
 
61
+ DEFAULT_WAIT_TIMEOUT = 3600.0
62
+ DEFAULT_QUEUE_GRACE = 1800.0
63
+ DEFAULT_ACTIVE_GRACE = 300.0
64
+
59
65
  def __init__(self, http: "HTTPClient", auth_http: "HTTPClient" | None = None):
60
66
  self._http = http
61
67
  self._auth_http = auth_http or http
62
68
  self._auth_me_cache: dict[str, Any] | None = None
63
69
 
70
+ @staticmethod
71
+ def _parse_render_timestamp(value: Any) -> float | None:
72
+ if value is None:
73
+ return None
74
+ if isinstance(value, (int, float)):
75
+ return float(value)
76
+ if isinstance(value, str):
77
+ try:
78
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc).timestamp()
79
+ except ValueError:
80
+ return None
81
+ return None
82
+
64
83
  def _auth_me(self) -> dict[str, Any]:
65
84
  if self._auth_me_cache is None:
66
85
  self._auth_me_cache = self._auth_http.get("/api/auth/me")
@@ -93,21 +112,21 @@ class Renders:
93
112
 
94
113
  def _get_render(self, render_id: str, *, status: bool = False) -> dict[str, Any]:
95
114
  suffix = "/status" if status else ""
96
- primary = f"/agents/flow/renders/{render_id}{suffix}" if self._supports_subscription_family("flows") else f"/api/renders/{render_id}{suffix}"
115
+ primary = f"/agents/flow/renders/{render_id}{suffix}" if self._supports_subscription_family("flows") else f"/api/flow/renders/{render_id}{suffix}"
97
116
  try:
98
117
  return self._http.get(primary)
99
118
  except APIError as exc:
100
119
  if primary.startswith("/agents/flow/") and exc.status_code in {403, 404}:
101
- return self._http.get(f"/api/renders/{render_id}{suffix}")
120
+ return self._http.get(f"/api/flow/renders/{render_id}{suffix}")
102
121
  raise
103
122
 
104
123
  def _delete_render(self, render_id: str) -> dict:
105
- primary = f"/agents/flow/renders/{render_id}" if self._supports_subscription_family("flows") else f"/api/renders/{render_id}"
124
+ primary = f"/agents/flow/renders/{render_id}" if self._supports_subscription_family("flows") else f"/api/flow/renders/{render_id}"
106
125
  try:
107
126
  return self._http.delete(primary)
108
127
  except APIError as exc:
109
128
  if primary.startswith("/agents/flow/") and exc.status_code in {403, 404}:
110
- return self._http.delete(f"/api/renders/{render_id}")
129
+ return self._http.delete(f"/api/flow/renders/{render_id}")
111
130
  raise
112
131
 
113
132
  def list(
@@ -179,6 +198,50 @@ class Renders:
179
198
  data = self._get_render(render_id, status=True)
180
199
  return RenderStatus.from_dict(data)
181
200
 
201
+ def wait(
202
+ self,
203
+ render_id: str,
204
+ timeout: float = DEFAULT_WAIT_TIMEOUT,
205
+ poll_interval: float = 5.0,
206
+ queue_grace: float = DEFAULT_QUEUE_GRACE,
207
+ active_grace: float = DEFAULT_ACTIVE_GRACE,
208
+ ) -> Render:
209
+ """Wait for a render to reach a terminal state.
210
+
211
+ This tolerates long queue times in shared dev environments by granting
212
+ one bounded queue grace window and one bounded active-runtime grace
213
+ window when the render only starts near the original deadline.
214
+ """
215
+ deadline = time.time() + timeout
216
+ queue_grace_used = False
217
+ active_grace_used = False
218
+ last_render: Render | None = None
219
+
220
+ while True:
221
+ render = self.get(render_id)
222
+ last_render = render
223
+ state = (render.state or "").lower()
224
+ if state in {"completed", "failed", "cancelled"}:
225
+ return render
226
+
227
+ now = time.time()
228
+ if now >= deadline:
229
+ started_at = self._parse_render_timestamp(render.started_at)
230
+ if not queue_grace_used and started_at is None:
231
+ queue_grace_used = True
232
+ deadline = now + queue_grace
233
+ elif not active_grace_used and started_at is not None:
234
+ active_grace_used = True
235
+ deadline = max(deadline, started_at + active_grace)
236
+ else:
237
+ raise TimeoutError(
238
+ f"Render {render_id} did not complete within {timeout:.0f}s "
239
+ f"(+{queue_grace:.0f}s queue grace, +{active_grace:.0f}s active grace); "
240
+ f"last_render={last_render}"
241
+ )
242
+
243
+ time.sleep(poll_interval)
244
+
182
245
  # =========================================================================
183
246
  # Flow endpoints - simplified interfaces
184
247
  # =========================================================================
@@ -20,6 +20,8 @@ def _encode_reference_audio(ref_audio: bytes | str | Path) -> str:
20
20
  class VoiceAPI:
21
21
  """Voice capability API wrapper."""
22
22
 
23
+ DEFAULT_TIMEOUT = 300.0
24
+
23
25
  def __init__(self, http: "HTTPClient"):
24
26
  self._http = http
25
27
 
@@ -30,6 +32,7 @@ class VoiceAPI:
30
32
  voice: str = "Chelsie",
31
33
  language: str = "auto",
32
34
  response_format: str = "mp3",
35
+ timeout: float = DEFAULT_TIMEOUT,
33
36
  ) -> bytes:
34
37
  return self._http.post_bytes(
35
38
  "/agents/voice/tts",
@@ -39,6 +42,7 @@ class VoiceAPI:
39
42
  "language": language,
40
43
  "response_format": response_format,
41
44
  },
45
+ timeout=timeout,
42
46
  )
43
47
 
44
48
  def clone(
@@ -49,6 +53,7 @@ class VoiceAPI:
49
53
  language: str = "auto",
50
54
  x_vector_only: bool = True,
51
55
  response_format: str = "mp3",
56
+ timeout: float = DEFAULT_TIMEOUT,
52
57
  ) -> bytes:
53
58
  return self._http.post_bytes(
54
59
  "/agents/voice/clone",
@@ -59,6 +64,7 @@ class VoiceAPI:
59
64
  "x_vector_only": x_vector_only,
60
65
  "response_format": response_format,
61
66
  },
67
+ timeout=timeout,
62
68
  )
63
69
 
64
70
  def design(
@@ -68,6 +74,7 @@ class VoiceAPI:
68
74
  description: str,
69
75
  language: str = "auto",
70
76
  response_format: str = "mp3",
77
+ timeout: float = DEFAULT_TIMEOUT,
71
78
  ) -> bytes:
72
79
  return self._http.post_bytes(
73
80
  "/agents/voice/design",
@@ -77,4 +84,5 @@ class VoiceAPI:
77
84
  "language": language,
78
85
  "response_format": response_format,
79
86
  },
87
+ timeout=timeout,
80
88
  )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hypercli-sdk"
7
- version = "2026.4.7"
7
+ version = "2026.4.9"
8
8
  description = "Python SDK for HyperCLI - GPU orchestration and HyperAgent API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -9,37 +9,35 @@ from hypercli.http import APIError
9
9
 
10
10
 
11
11
  def _create_agent_with_available_tier(client: HyperCLI, name: str, tags: list[str]) -> tuple[str, str]:
12
- last_error: APIError | None = None
13
- for tier in ("large", "medium", "small"):
14
- agent_id: str | None = None
15
- try:
16
- agent = client.deployments.create(
17
- name=name,
18
- size=tier,
19
- start=False,
20
- tags=tags,
21
- )
22
- agent_id = agent.id
23
- client.deployments.start_openclaw(agent.id, dry_run=True)
24
- return agent.id, tier
25
- except APIError as exc:
26
- if exc.status_code == 429:
27
- if agent_id:
28
- try:
29
- client.deployments.delete(agent_id)
30
- except APIError:
31
- pass
32
- last_error = exc
33
- continue
34
- if agent_id:
35
- try:
36
- client.deployments.delete(agent_id)
37
- except APIError:
38
- pass
39
- raise
40
- if last_error is not None:
41
- raise last_error
42
- raise AssertionError("No available entitlement slots for integration agent tests")
12
+ budget = client.deployments.budget()
13
+ slots = (budget or {}).get("slots") or {}
14
+
15
+ tier = next((candidate for candidate in ("large", "medium", "small") if int((slots.get(candidate) or {}).get("available") or 0) > 0), None)
16
+ if not tier:
17
+ raise AssertionError("No available entitlement slots for integration agent tests")
18
+
19
+ agent_id: str | None = None
20
+ try:
21
+ agent = client.deployments.create(
22
+ name=name,
23
+ size=tier,
24
+ start=False,
25
+ tags=tags,
26
+ )
27
+ agent_id = agent.id
28
+ client.deployments.start_openclaw(agent.id, dry_run=True)
29
+ return agent.id, tier
30
+ except APIError as exc:
31
+ if agent_id:
32
+ try:
33
+ client.deployments.delete(agent_id)
34
+ except APIError:
35
+ pass
36
+ if exc.status_code == 429:
37
+ raise AssertionError(
38
+ f"Budget reported '{tier}' available but dry-run start was rejected for slot exhaustion"
39
+ ) from exc
40
+ raise
43
41
 
44
42
 
45
43
  def test_list_agents_requires_agent_key(client, test_agent_api_key: str):
@@ -1,5 +1,3 @@
1
- from __future__ import annotations
2
-
3
1
  import uuid
4
2
 
5
3
  import pytest
@@ -40,6 +38,19 @@ def test_create_and_disable_tagged_key(client):
40
38
  assert disabled["status"] == "deactivated"
41
39
 
42
40
 
41
+ def test_created_key_authenticates_against_models_api(client, test_api_base: str):
42
+ name = f"sdk-models-scope-{uuid.uuid4().hex[:8]}"
43
+ created = client.keys.create(name=name, tags=["models:*"])
44
+
45
+ try:
46
+ scoped = HyperCLI(api_key=created.api_key, api_url=test_api_base)
47
+ models = scoped.models.list()
48
+ assert models
49
+ assert all(model.id for model in models)
50
+ finally:
51
+ client.keys.disable(created.key_id)
52
+
53
+
43
54
  def test_api_scoped_key_allows_key_admin_but_denies_profile(client, test_api_base: str):
44
55
  name = f"sdk-api-scope-{uuid.uuid4().hex[:8]}"
45
56
  created = client.keys.create(name=name, tags=["api:self"])
@@ -785,6 +785,77 @@ def test_agents_start_stop_delete(agents_client):
785
785
  assert agents_client.delete("agent-123") == {"status": "deleted"}
786
786
 
787
787
 
788
+ def test_agents_update_and_resize(agents_client):
789
+ patch_calls = []
790
+
791
+ def fake_patch(path, json=None):
792
+ patch_calls.append((path, json))
793
+ return {
794
+ "id": "agent-123",
795
+ "user_id": "user-456",
796
+ "pod_id": None,
797
+ "pod_name": None,
798
+ "state": "stopped",
799
+ "cpu": 4,
800
+ "memory": 4,
801
+ }
802
+
803
+ agents_client._http.patch = fake_patch
804
+
805
+ updated = agents_client.update("agent-123", size="large", refresh_from_lagoon=True)
806
+ assert updated.id == "agent-123"
807
+ assert patch_calls[0] == (
808
+ "/deployments/agent-123",
809
+ {"size": "large", "refresh_from_lagoon": True},
810
+ )
811
+
812
+ resized = agents_client.resize("agent-123", size="large")
813
+ assert resized.id == "agent-123"
814
+ assert patch_calls[1] == ("/deployments/agent-123", {"size": "large"})
815
+
816
+
817
+ def test_bound_agent_resize_delegates_to_deployments(agents_client):
818
+ patch_calls = []
819
+
820
+ def fake_patch(path, json=None):
821
+ patch_calls.append((path, json))
822
+ return {
823
+ "id": "agent-123",
824
+ "user_id": "user-456",
825
+ "pod_id": None,
826
+ "pod_name": None,
827
+ "state": "stopped",
828
+ "cpu": 4,
829
+ "memory": 4,
830
+ }
831
+
832
+ agents_client._http.patch = fake_patch
833
+
834
+ with patch("httpx.Client") as mock_client_class:
835
+ mock_client = MagicMock()
836
+ get_response = Mock()
837
+ get_response.status_code = 200
838
+ get_response.json.return_value = {
839
+ "id": "agent-123",
840
+ "user_id": "user-456",
841
+ "pod_id": None,
842
+ "pod_name": None,
843
+ "state": "stopped",
844
+ "cpu": 2,
845
+ "memory": 2,
846
+ }
847
+ mock_client.get.return_value = get_response
848
+ mock_client.__enter__.return_value = mock_client
849
+ mock_client.__exit__.return_value = False
850
+ mock_client_class.return_value = mock_client
851
+
852
+ agent = agents_client.get("agent-123")
853
+ resized = agent.resize(size="large")
854
+
855
+ assert resized.cpu == 4
856
+ assert patch_calls == [("/deployments/agent-123", {"size": "large"})]
857
+
858
+
788
859
  def test_agents_start_preserves_generic_launch_fields(agents_client):
789
860
  with patch("httpx.Client") as mock_client_class, patch("hypercli.agents.secrets.token_hex", return_value="gw-token-generic"):
790
861
  mock_client = MagicMock()
@@ -0,0 +1,27 @@
1
+ from hypercli.models import ModelsAPI
2
+
3
+
4
+ class DummyHTTP:
5
+ def __init__(self):
6
+ self.calls = []
7
+
8
+ def get(self, path):
9
+ self.calls.append(("get", path, None))
10
+ assert path == "/v1/models"
11
+ return {
12
+ "object": "list",
13
+ "data": [
14
+ {"id": "glm-5", "object": "model", "owned_by": "hypercli"},
15
+ {"id": "kimi-k2.5", "object": "model", "owned_by": "hypercli"},
16
+ ],
17
+ }
18
+
19
+
20
+ def test_models_list_reads_openai_models_payload():
21
+ http = DummyHTTP()
22
+ models = ModelsAPI(http)
23
+
24
+ listed = models.list()
25
+
26
+ assert [model.id for model in listed] == ["glm-5", "kimi-k2.5"]
27
+ assert http.calls == [("get", "/v1/models", None)]
@@ -0,0 +1,188 @@
1
+ from hypercli.http import APIError
2
+ from hypercli.renders import Renders
3
+ from hypercli.http import APIError
4
+
5
+
6
+ class DummyHTTP:
7
+ def __init__(self):
8
+ self.calls = []
9
+ self.fail_once = {}
10
+
11
+ def get(self, path, params=None):
12
+ self.calls.append(("get", path, params))
13
+ if path == "/api/auth/me":
14
+ return self.auth_me
15
+ if path.endswith("/status"):
16
+ render_id = path.split("/")[-2]
17
+ return {"id": render_id, "state": "running", "progress": 0.5}
18
+ render_id = path.split("/")[-1]
19
+ return {"id": render_id, "state": "queued"}
20
+
21
+ def post(self, path, json=None):
22
+ self.calls.append(("post", path, json))
23
+ if self.fail_once.get(path):
24
+ error = self.fail_once.pop(path)
25
+ raise error
26
+ return {"id": "render-123", "state": "queued"}
27
+
28
+ def delete(self, path):
29
+ self.calls.append(("delete", path, None))
30
+ if self.fail_once.get(path):
31
+ error = self.fail_once.pop(path)
32
+ raise error
33
+ return {"status": "cancelled"}
34
+
35
+
36
+ class Clock:
37
+ def __init__(self):
38
+ self.now = 0.0
39
+
40
+ def time(self):
41
+ return self.now
42
+
43
+ def sleep(self, seconds):
44
+ self.now += seconds
45
+
46
+
47
+ def test_create_flow_uses_subscription_route_when_available():
48
+ http = DummyHTTP()
49
+ http.auth_me = {
50
+ "auth_type": "orchestra_key",
51
+ "capabilities": ["flows:*"],
52
+ "has_active_subscription": True,
53
+ }
54
+ renders = Renders(http)
55
+
56
+ render = renders.create_flow("text-to-image", prompt="hello")
57
+
58
+ assert render.render_id == "render-123"
59
+ assert http.calls[0] == ("get", "/api/auth/me", None)
60
+ assert http.calls[1] == ("post", "/agents/flow/text-to-image", {"prompt": "hello"})
61
+
62
+
63
+ def test_create_flow_falls_back_to_paid_route_on_subscription_rejection():
64
+ http = DummyHTTP()
65
+ http.auth_me = {
66
+ "auth_type": "orchestra_key",
67
+ "capabilities": ["flows:*"],
68
+ "has_active_subscription": True,
69
+ }
70
+ http.fail_once["/agents/flow/text-to-image"] = APIError(403, "Active paid subscription required")
71
+ renders = Renders(http)
72
+
73
+ render = renders.create_flow("text-to-image", prompt="hello")
74
+
75
+ assert render.render_id == "render-123"
76
+ assert http.calls[1] == ("post", "/agents/flow/text-to-image", {"prompt": "hello"})
77
+ assert http.calls[2] == ("post", "/api/flow/text-to-image", {"prompt": "hello"})
78
+
79
+
80
+ def test_get_status_and_cancel_prefer_subscription_render_routes():
81
+ http = DummyHTTP()
82
+ http.auth_me = {
83
+ "auth_type": "user",
84
+ "capabilities": [],
85
+ "has_active_subscription": True,
86
+ }
87
+ renders = Renders(http)
88
+
89
+ render = renders.get("render-123")
90
+ status = renders.status("render-123")
91
+ cancelled = renders.cancel("render-123")
92
+
93
+ assert render.render_id == "render-123"
94
+ assert status.progress == 0.5
95
+ assert cancelled["status"] == "cancelled"
96
+ assert ("get", "/agents/flow/renders/render-123", None) in http.calls
97
+ assert ("get", "/agents/flow/renders/render-123/status", None) in http.calls
98
+ assert ("delete", "/agents/flow/renders/render-123", None) in http.calls
99
+
100
+
101
+ def test_flow_only_keys_fallback_to_api_flow_render_routes_when_auth_me_is_denied():
102
+ calls = []
103
+
104
+ class FlowHTTP:
105
+ def get(self, path, params=None):
106
+ calls.append(("get", path, params))
107
+ if path == "/api/auth/me":
108
+ raise APIError(status_code=403, detail="Access denied", response_text='{"detail":"Access denied"}')
109
+ return {"id": "render-123", "state": "queued"}
110
+
111
+ def delete(self, path):
112
+ calls.append(("delete", path, None))
113
+ return {"status": "cancelled"}
114
+
115
+ renders = Renders(FlowHTTP())
116
+
117
+ render = renders.get("render-123")
118
+ status = renders.status("render-123")
119
+ cancelled = renders.cancel("render-123")
120
+
121
+ assert render.render_id == "render-123"
122
+ assert status.render_id == "render-123"
123
+ assert cancelled["status"] == "cancelled"
124
+ assert ("get", "/api/flow/renders/render-123", None) in calls
125
+ assert ("get", "/api/flow/renders/render-123/status", None) in calls
126
+ assert ("delete", "/api/flow/renders/render-123", None) in calls
127
+
128
+
129
+ def test_wait_allows_queue_grace(monkeypatch):
130
+ http = DummyHTTP()
131
+ http.auth_me = {
132
+ "auth_type": "user",
133
+ "capabilities": [],
134
+ "has_active_subscription": True,
135
+ }
136
+ clock = Clock()
137
+ calls = {"count": 0}
138
+
139
+ def fake_get(path, params=None):
140
+ http.calls.append(("get", path, params))
141
+ if path == "/api/auth/me":
142
+ return http.auth_me
143
+ calls["count"] += 1
144
+ if calls["count"] <= 3:
145
+ return {"id": "render-123", "state": "running", "started_at": None}
146
+ return {"id": "render-123", "state": "completed", "started_at": "1970-01-01T00:00:15+00:00"}
147
+
148
+ monkeypatch.setattr(http, "get", fake_get)
149
+ monkeypatch.setattr("hypercli.renders.time.time", clock.time)
150
+ monkeypatch.setattr("hypercli.renders.time.sleep", clock.sleep)
151
+ renders = Renders(http)
152
+
153
+ render = renders.wait("render-123", timeout=10, poll_interval=5, queue_grace=10, active_grace=5)
154
+
155
+ assert render.state == "completed"
156
+ assert clock.time() == 15
157
+
158
+
159
+ def test_wait_allows_recent_active_grace(monkeypatch):
160
+ http = DummyHTTP()
161
+ http.auth_me = {
162
+ "auth_type": "user",
163
+ "capabilities": [],
164
+ "has_active_subscription": True,
165
+ }
166
+ clock = Clock()
167
+ calls = {"count": 0}
168
+
169
+ def fake_get(path, params=None):
170
+ http.calls.append(("get", path, params))
171
+ if path == "/api/auth/me":
172
+ return http.auth_me
173
+ calls["count"] += 1
174
+ if calls["count"] == 1:
175
+ return {"id": "render-123", "state": "running", "started_at": None}
176
+ if calls["count"] == 2:
177
+ return {"id": "render-123", "state": "running", "started_at": "1970-01-01T00:00:04+00:00"}
178
+ return {"id": "render-123", "state": "completed", "started_at": "1970-01-01T00:00:04+00:00"}
179
+
180
+ monkeypatch.setattr(http, "get", fake_get)
181
+ monkeypatch.setattr("hypercli.renders.time.time", clock.time)
182
+ monkeypatch.setattr("hypercli.renders.time.sleep", clock.sleep)
183
+ renders = Renders(http)
184
+
185
+ render = renders.wait("render-123", timeout=5, poll_interval=5, queue_grace=5, active_grace=6)
186
+
187
+ assert render.state == "completed"
188
+ assert clock.time() == 10
@@ -7,8 +7,8 @@ class DummyVoiceHTTP:
7
7
  def __init__(self):
8
8
  self.calls = []
9
9
 
10
- def post_bytes(self, path, json=None):
11
- self.calls.append((path, json))
10
+ def post_bytes(self, path, json=None, timeout=None):
11
+ self.calls.append((path, json, timeout))
12
12
  return b"audio-bytes"
13
13
 
14
14
 
@@ -28,6 +28,7 @@ def test_voice_tts_posts_to_agents_voice_prefix():
28
28
  "language": "english",
29
29
  "response_format": "wav",
30
30
  },
31
+ VoiceAPI.DEFAULT_TIMEOUT,
31
32
  )
32
33
  ]
33
34
 
@@ -42,11 +43,12 @@ def test_voice_clone_base64_encodes_reference(tmp_path: Path):
42
43
  audio = voice.clone("clone me", ref_audio=ref, response_format="wav")
43
44
 
44
45
  assert audio == b"audio-bytes"
45
- path, payload = http.calls[0]
46
+ path, payload, timeout = http.calls[0]
46
47
  assert path == "/agents/voice/clone"
47
48
  assert payload["text"] == "clone me"
48
49
  assert payload["response_format"] == "wav"
49
50
  assert payload["ref_audio_base64"] == "cmVmZXJlbmNlLWF1ZGlv"
51
+ assert timeout == VoiceAPI.DEFAULT_TIMEOUT
50
52
 
51
53
 
52
54
  def test_voice_design_posts_description():
@@ -65,5 +67,6 @@ def test_voice_design_posts_description():
65
67
  "language": "auto",
66
68
  "response_format": "wav",
67
69
  },
70
+ VoiceAPI.DEFAULT_TIMEOUT,
68
71
  )
69
72
  ]
@@ -1,86 +0,0 @@
1
- from hypercli.http import APIError
2
- from hypercli.renders import Renders
3
-
4
-
5
- class DummyHTTP:
6
- def __init__(self):
7
- self.calls = []
8
- self.fail_once = {}
9
-
10
- def get(self, path, params=None):
11
- self.calls.append(("get", path, params))
12
- if path == "/api/auth/me":
13
- return self.auth_me
14
- if path.endswith("/status"):
15
- render_id = path.split("/")[-2]
16
- return {"id": render_id, "state": "running", "progress": 0.5}
17
- render_id = path.split("/")[-1]
18
- return {"id": render_id, "state": "queued"}
19
-
20
- def post(self, path, json=None):
21
- self.calls.append(("post", path, json))
22
- if self.fail_once.get(path):
23
- error = self.fail_once.pop(path)
24
- raise error
25
- return {"id": "render-123", "state": "queued"}
26
-
27
- def delete(self, path):
28
- self.calls.append(("delete", path, None))
29
- if self.fail_once.get(path):
30
- error = self.fail_once.pop(path)
31
- raise error
32
- return {"status": "cancelled"}
33
-
34
-
35
- def test_create_flow_uses_subscription_route_when_available():
36
- http = DummyHTTP()
37
- http.auth_me = {
38
- "auth_type": "orchestra_key",
39
- "capabilities": ["flows:*"],
40
- "has_active_subscription": True,
41
- }
42
- renders = Renders(http)
43
-
44
- render = renders.create_flow("text-to-image", prompt="hello")
45
-
46
- assert render.render_id == "render-123"
47
- assert http.calls[0] == ("get", "/api/auth/me", None)
48
- assert http.calls[1] == ("post", "/agents/flow/text-to-image", {"prompt": "hello"})
49
-
50
-
51
- def test_create_flow_falls_back_to_paid_route_on_subscription_rejection():
52
- http = DummyHTTP()
53
- http.auth_me = {
54
- "auth_type": "orchestra_key",
55
- "capabilities": ["flows:*"],
56
- "has_active_subscription": True,
57
- }
58
- http.fail_once["/agents/flow/text-to-image"] = APIError(403, "Active paid subscription required")
59
- renders = Renders(http)
60
-
61
- render = renders.create_flow("text-to-image", prompt="hello")
62
-
63
- assert render.render_id == "render-123"
64
- assert http.calls[1] == ("post", "/agents/flow/text-to-image", {"prompt": "hello"})
65
- assert http.calls[2] == ("post", "/api/flow/text-to-image", {"prompt": "hello"})
66
-
67
-
68
- def test_get_status_and_cancel_prefer_subscription_render_routes():
69
- http = DummyHTTP()
70
- http.auth_me = {
71
- "auth_type": "user",
72
- "capabilities": [],
73
- "has_active_subscription": True,
74
- }
75
- renders = Renders(http)
76
-
77
- render = renders.get("render-123")
78
- status = renders.status("render-123")
79
- cancelled = renders.cancel("render-123")
80
-
81
- assert render.render_id == "render-123"
82
- assert status.progress == 0.5
83
- assert cancelled["status"] == "cancelled"
84
- assert ("get", "/agents/flow/renders/render-123", None) in http.calls
85
- assert ("get", "/agents/flow/renders/render-123/status", None) in http.calls
86
- assert ("delete", "/agents/flow/renders/render-123", None) in http.calls