hypercli-sdk 0.4.6__tar.gz → 0.4.7__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypercli-sdk
3
- Version: 0.4.6
3
+ Version: 0.4.7
4
4
  Summary: Python SDK for HyperCLI - GPU orchestration and LLM API
5
5
  Project-URL: Homepage, https://hypercli.com
6
6
  Project-URL: Documentation, https://docs.hypercli.com
@@ -1,20 +1,17 @@
1
1
  """HyperCLI SDK - Python client for HyperCLI API"""
2
2
  from .client import HyperCLI
3
-
4
- # Backwards compatibility alias
5
- C3 = HyperCLI
6
3
  from .config import configure, GHCR_IMAGES, COMFYUI_IMAGE
7
4
  from .http import APIError, AsyncHTTPClient
8
5
  from .instances import GPUType, GPUConfig, Region, GPUPricing, PricingTier
9
6
  from .jobs import Job, JobMetrics, GPUMetrics, find_job, find_by_id, find_by_hostname, find_by_ip
10
7
  from .renders import Render, RenderStatus
11
8
  from .files import File, AsyncFiles
12
- from .job import BaseJob, ComfyUIJob, apply_params, apply_graph_modes, find_node, find_nodes, load_template, graph_to_api, DEFAULT_OBJECT_INFO
9
+ from .job import BaseJob, ComfyUIJob, apply_params, apply_graph_modes, find_node, find_nodes, load_template, graph_to_api, expand_subgraphs, DEFAULT_OBJECT_INFO
13
10
  from .logs import LogStream, stream_logs, fetch_logs
14
11
 
15
- __version__ = "0.2.1"
12
+ __version__ = "0.4.7"
16
13
  __all__ = [
17
- "HyperCLI", "C3",
14
+ "HyperCLI",
18
15
  "configure",
19
16
  "APIError",
20
17
  # Images
@@ -52,6 +49,7 @@ __all__ = [
52
49
  "find_nodes",
53
50
  "load_template",
54
51
  "graph_to_api",
52
+ "expand_subgraphs",
55
53
  "DEFAULT_OBJECT_INFO",
56
54
  # Log streaming
57
55
  "LogStream",
@@ -14,18 +14,18 @@ class HyperCLI:
14
14
  HyperCLI API Client
15
15
 
16
16
  Usage:
17
- from hypercli import C3
17
+ from hypercli import HyperCLI
18
18
 
19
- c3 = C3() # Uses HYPERCLI_API_KEY from env or ~/.hypercli/config
19
+ client = HyperCLI() # Uses HYPERCLI_API_KEY from env or ~/.hypercli/config
20
20
  # or
21
- c3 = C3(api_key="your_key")
21
+ client = HyperCLI(api_key="your_key")
22
22
 
23
23
  # Billing
24
- balance = c3.billing.balance()
24
+ balance = client.billing.balance()
25
25
  print(f"Balance: ${balance.total}")
26
26
 
27
27
  # Jobs
28
- job = c3.jobs.create(
28
+ job = client.jobs.create(
29
29
  image="nvidia/cuda:12.0",
30
30
  gpu_type="l40s",
31
31
  command="python train.py"
@@ -33,7 +33,7 @@ class HyperCLI:
33
33
  print(f"Job: {job.job_id}")
34
34
 
35
35
  # User
36
- user = c3.user.get()
36
+ user = client.user.get()
37
37
  """
38
38
 
39
39
  def __init__(self, api_key: str = None, api_url: str = None):
@@ -11,7 +11,7 @@ DEFAULT_WS_URL = "wss://api.hypercli.com"
11
11
  WS_LOGS_PATH = "/orchestra/ws/logs" # WebSocket path for job logs: {WS_URL}{WS_LOGS_PATH}/{job_key}
12
12
 
13
13
  # GHCR images
14
- GHCR_IMAGES = "ghcr.io/hypercliai/images"
14
+ GHCR_IMAGES = "ghcr.io/compute3ai/images"
15
15
  COMFYUI_IMAGE = f"{GHCR_IMAGES}/comfyui"
16
16
 
17
17
 
@@ -23,7 +23,7 @@ class File:
23
23
  filename: str
24
24
  content_type: str
25
25
  file_size: int
26
- url: str # Internal S3 reference - only valid for use in C3 renders
26
+ url: str # Internal S3 reference - only valid for use in HyperCLI renders
27
27
  state: str | None = None # processing, done, failed (for async uploads)
28
28
  error: str | None = None # Error message if state=failed
29
29
  created_at: str | None = None
@@ -72,11 +72,11 @@ class Files:
72
72
 
73
73
  Returns:
74
74
  File object with id and internal url for use in render calls.
75
- The url is an S3 reference that only works within C3.
75
+ The url is an S3 reference that only works within HyperCLI.
76
76
 
77
77
  Example:
78
- file = c3.files.upload("./my_image.png")
79
- render = c3.renders.image_to_video("dancing", file.url)
78
+ file = client.files.upload("./my_image.png")
79
+ render = client.renders.image_to_video("dancing", file.url)
80
80
  """
81
81
  # Read file
82
82
  with open(file_path, "rb") as f:
@@ -106,7 +106,7 @@ class Files:
106
106
  File object with id and url for use in render calls
107
107
 
108
108
  Example:
109
- file = c3.files.upload_bytes(image_bytes, "image.png", "image/png")
109
+ file = client.files.upload_bytes(image_bytes, "image.png", "image/png")
110
110
  """
111
111
  files = {"file": (filename, content, content_type)}
112
112
  data = self._http.post_multipart("/api/files/multi", files=files)
@@ -126,8 +126,8 @@ class Files:
126
126
  File object with id and state=processing.
127
127
 
128
128
  Example:
129
- file = c3.files.upload_url("https://example.com/image.png")
130
- file = c3.files.wait_ready(file.id) # Wait for completion
129
+ file = client.files.upload_url("https://example.com/image.png")
130
+ file = client.files.wait_ready(file.id) # Wait for completion
131
131
  """
132
132
  payload = {"url": url}
133
133
  if path:
@@ -159,8 +159,8 @@ class Files:
159
159
  Example:
160
160
  import base64
161
161
  b64_data = base64.b64encode(image_bytes).decode()
162
- file = c3.files.upload_b64(b64_data, "image.png", "image/png")
163
- file = c3.files.wait_ready(file.id)
162
+ file = client.files.upload_b64(b64_data, "image.png", "image/png")
163
+ file = client.files.wait_ready(file.id)
164
164
  """
165
165
  payload = {"data": data, "filename": filename}
166
166
  if content_type:
@@ -216,8 +216,8 @@ class Files:
216
216
  ValueError: If file upload failed
217
217
 
218
218
  Example:
219
- file = c3.files.upload_url("https://example.com/image.png")
220
- file = c3.files.wait_ready(file.id, timeout=30)
219
+ file = client.files.upload_url("https://example.com/image.png")
220
+ file = client.files.wait_ready(file.id, timeout=30)
221
221
  print(f"Ready: {file.url}")
222
222
  """
223
223
  start = time.time()
@@ -8,6 +8,7 @@ from .comfyui import (
8
8
  find_nodes,
9
9
  load_template,
10
10
  graph_to_api,
11
+ expand_subgraphs,
11
12
  DEFAULT_OBJECT_INFO,
12
13
  )
13
14
 
@@ -20,5 +21,6 @@ __all__ = [
20
21
  "find_nodes",
21
22
  "load_template",
22
23
  "graph_to_api",
24
+ "expand_subgraphs",
23
25
  "DEFAULT_OBJECT_INFO",
24
26
  ]
@@ -4,7 +4,7 @@ import httpx
4
4
  from typing import TYPE_CHECKING
5
5
 
6
6
  if TYPE_CHECKING:
7
- from ..client import C3
7
+ from ..client import HyperCLI
8
8
  from ..jobs import Job
9
9
 
10
10
 
@@ -16,8 +16,8 @@ class BaseJob:
16
16
  HEALTH_ENDPOINT: str = "/"
17
17
  HEALTH_TIMEOUT: float = 5.0
18
18
 
19
- def __init__(self, c3: "C3", job: "Job"):
20
- self.c3 = c3
19
+ def __init__(self, client: "HyperCLI", job: "Job"):
20
+ self.client = client
21
21
  self.job = job
22
22
  self._base_url: str | None = None
23
23
 
@@ -39,24 +39,24 @@ class BaseJob:
39
39
  @property
40
40
  def auth_headers(self) -> dict:
41
41
  """Headers for authenticated requests. Override in subclasses for custom auth."""
42
- return {"Authorization": f"Bearer {self.c3._api_key}"}
42
+ return {"Authorization": f"Bearer {self.client._api_key}"}
43
43
 
44
44
  @classmethod
45
- def get_running(cls, c3: "C3", image_filter: str = None) -> "BaseJob | None":
45
+ def get_running(cls, client: "HyperCLI", image_filter: str = None) -> "BaseJob | None":
46
46
  """Find an existing running job, optionally filtering by image"""
47
- jobs = c3.jobs.list(state="running")
47
+ jobs = client.jobs.list(state="running")
48
48
  for job in jobs:
49
49
  if image_filter and image_filter not in job.docker_image:
50
50
  continue
51
- return cls(c3, job)
51
+ return cls(client, job)
52
52
  return None
53
53
 
54
54
  @classmethod
55
- def get_by_instance(cls, c3: "C3", instance: str, state: str = "running") -> "BaseJob":
55
+ def get_by_instance(cls, client: "HyperCLI", instance: str, state: str = "running") -> "BaseJob":
56
56
  """Get a job by ID, hostname, or IP address.
57
57
 
58
58
  Args:
59
- c3: C3 client
59
+ client: HyperCLI client instance
60
60
  instance: Job ID (UUID), hostname (partial match), or IP address
61
61
  state: State filter for hostname/IP search (default: running)
62
62
 
@@ -68,15 +68,15 @@ class BaseJob:
68
68
  """
69
69
  from ..jobs import find_job
70
70
 
71
- job = find_job(c3.jobs, instance, state=state)
71
+ job = find_job(client.jobs, instance, state=state)
72
72
  if not job:
73
73
  raise ValueError(f"No job found matching: {instance}")
74
- return cls(c3, job)
74
+ return cls(client, job)
75
75
 
76
76
  @classmethod
77
77
  def create(
78
78
  cls,
79
- c3: "C3",
79
+ client: "HyperCLI",
80
80
  image: str = None,
81
81
  gpu_type: str = None,
82
82
  gpu_count: int = 1,
@@ -84,19 +84,19 @@ class BaseJob:
84
84
  **kwargs,
85
85
  ) -> "BaseJob":
86
86
  """Create a new job"""
87
- job = c3.jobs.create(
87
+ job = client.jobs.create(
88
88
  image=image or cls.DEFAULT_IMAGE,
89
89
  gpu_type=gpu_type or cls.DEFAULT_GPU_TYPE,
90
90
  gpu_count=gpu_count,
91
91
  runtime=runtime,
92
92
  **kwargs,
93
93
  )
94
- return cls(c3, job)
94
+ return cls(client, job)
95
95
 
96
96
  @classmethod
97
97
  def get_or_create(
98
98
  cls,
99
- c3: "C3",
99
+ client: "HyperCLI",
100
100
  image: str = None,
101
101
  gpu_type: str = None,
102
102
  gpu_count: int = 1,
@@ -106,12 +106,12 @@ class BaseJob:
106
106
  ) -> "BaseJob":
107
107
  """Get existing running job or create new one"""
108
108
  if reuse:
109
- existing = cls.get_running(c3, image_filter=image or cls.DEFAULT_IMAGE)
109
+ existing = cls.get_running(client, image_filter=image or cls.DEFAULT_IMAGE)
110
110
  if existing:
111
111
  return existing
112
112
 
113
113
  return cls.create(
114
- c3,
114
+ client,
115
115
  image=image,
116
116
  gpu_type=gpu_type,
117
117
  gpu_count=gpu_count,
@@ -121,7 +121,7 @@ class BaseJob:
121
121
 
122
122
  def refresh(self) -> "BaseJob":
123
123
  """Refresh job state from API"""
124
- self.job = self.c3.jobs.get(self.job_id)
124
+ self.job = self.client.jobs.get(self.job_id)
125
125
  self._base_url = None
126
126
  return self
127
127
 
@@ -241,9 +241,9 @@ class BaseJob:
241
241
 
242
242
  def shutdown(self) -> dict:
243
243
  """Cancel the job"""
244
- return self.c3.jobs.cancel(self.job_id)
244
+ return self.client.jobs.cancel(self.job_id)
245
245
 
246
246
  def extend(self, runtime: int) -> "BaseJob":
247
247
  """Extend job runtime"""
248
- self.job = self.c3.jobs.extend(self.job_id, runtime=runtime)
248
+ self.job = self.client.jobs.extend(self.job_id, runtime=runtime)
249
249
  return self
@@ -12,7 +12,7 @@ from ..config import COMFYUI_IMAGE
12
12
 
13
13
 
14
14
  if TYPE_CHECKING:
15
- from ..client import C3
15
+ from ..client import HyperCLI
16
16
 
17
17
 
18
18
  # Default object_info for offline workflow conversion (no running instance needed)
@@ -495,6 +495,235 @@ def apply_params(workflow: dict, **params) -> dict:
495
495
  return workflow
496
496
 
497
497
 
498
+ def expand_subgraphs(graph: dict, debug: bool = False) -> dict:
499
+ """
500
+ Expand subgraph/group nodes into their constituent nodes.
501
+
502
+ ComfyUI supports "workflow components" (group nodes) where a subgraph is
503
+ collapsed into a single node with a UUID type. These must be expanded
504
+ before the workflow can be executed.
505
+
506
+ Args:
507
+ graph: Workflow in graph format (may contain subgraph definitions)
508
+ debug: If True, print debug info
509
+
510
+ Returns:
511
+ New graph with subgraphs expanded (original graph not modified)
512
+ """
513
+ import copy
514
+
515
+ # Check if there are subgraph definitions
516
+ subgraphs = {sg["id"]: sg for sg in graph.get("definitions", {}).get("subgraphs", [])}
517
+ if not subgraphs:
518
+ return graph # No subgraphs, return as-is
519
+
520
+ # Deep copy to avoid mutating original
521
+ graph = copy.deepcopy(graph)
522
+
523
+ # Find group nodes that reference subgraphs
524
+ nodes = graph.get("nodes", [])
525
+ links = graph.get("links", [])
526
+
527
+ # Track new nodes and links to add
528
+ new_nodes = []
529
+ new_links = []
530
+ nodes_to_remove = set()
531
+ links_to_remove = set()
532
+
533
+ # Track ID remapping for each expanded subgraph
534
+ # We prefix subgraph node IDs to avoid conflicts
535
+ next_node_id = graph.get("last_node_id", 0) + 1
536
+ next_link_id = graph.get("last_link_id", 0) + 1
537
+
538
+ for node in nodes:
539
+ node_type = str(node.get("type", ""))
540
+ node_id = node.get("id")
541
+ mode = node.get("mode", 0)
542
+
543
+ if node_type not in subgraphs:
544
+ continue
545
+
546
+ # Skip bypassed/muted group nodes
547
+ if mode in (2, 4):
548
+ if debug:
549
+ print(f"Skipping bypassed group node {node_id}")
550
+ nodes_to_remove.add(node_id)
551
+ # Remove links to/from this node
552
+ for link in links:
553
+ if link[1] == node_id or link[3] == node_id:
554
+ links_to_remove.add(link[0])
555
+ continue
556
+
557
+ if debug:
558
+ print(f"Expanding group node {node_id} -> subgraph {node_type[:20]}...")
559
+
560
+ sg = subgraphs[node_type]
561
+ sg_nodes = sg.get("nodes", [])
562
+ sg_links = sg.get("links", [])
563
+
564
+ # Build ID mapping: old_sg_node_id -> new_node_id
565
+ id_map = {}
566
+ for sg_node in sg_nodes:
567
+ old_id = sg_node.get("id")
568
+ id_map[old_id] = next_node_id
569
+ next_node_id += 1
570
+
571
+ # Apply widget values from proxyWidgets
572
+ proxy_widgets = node.get("properties", {}).get("proxyWidgets", [])
573
+ widget_values = node.get("widgets_values", [])
574
+
575
+ # proxyWidgets format: [[target_node_id, widget_name], ...]
576
+ # widget_values are in same order as proxyWidgets
577
+ widget_overrides = {} # {node_id: {widget_name: value}}
578
+ for i, (target_id, widget_name) in enumerate(proxy_widgets):
579
+ if i < len(widget_values):
580
+ target_id = str(target_id)
581
+ if target_id not in widget_overrides:
582
+ widget_overrides[target_id] = {}
583
+ widget_overrides[target_id][widget_name] = widget_values[i]
584
+
585
+ # Copy subgraph nodes with new IDs and apply widget overrides
586
+ for sg_node in sg_nodes:
587
+ old_id = sg_node.get("id")
588
+ new_node = copy.deepcopy(sg_node)
589
+ new_node["id"] = id_map[old_id]
590
+
591
+ # Apply widget overrides
592
+ if str(old_id) in widget_overrides:
593
+ overrides = widget_overrides[str(old_id)]
594
+ sg_widgets = new_node.get("widgets_values", [])
595
+
596
+ # Need to find widget index by name - use input order
597
+ # For simplicity, just update widgets_values if it's a dict
598
+ if isinstance(sg_widgets, dict):
599
+ sg_widgets.update(overrides)
600
+ else:
601
+ # For list format, we need the node's input spec to map names to indices
602
+ # For now, handle common cases
603
+ node_type_inner = new_node.get("type", "")
604
+ if "text" in overrides and node_type_inner in ("CLIPTextEncode", "CLIPTextEncodeFlux"):
605
+ # text is usually first widget
606
+ if sg_widgets:
607
+ sg_widgets[0] = overrides["text"]
608
+ else:
609
+ new_node["widgets_values"] = [overrides["text"]]
610
+
611
+ new_nodes.append(new_node)
612
+
613
+ # Build link ID mapping: old_link_id -> new_link_id
614
+ link_id_map = {}
615
+
616
+ # Copy subgraph links with remapped IDs
617
+ # Subgraph links can be dicts or arrays
618
+ for sg_link in sg_links:
619
+ if isinstance(sg_link, dict):
620
+ old_link_id = sg_link.get("id")
621
+ from_node = sg_link.get("origin_id")
622
+ from_slot = sg_link.get("origin_slot", 0)
623
+ to_node = sg_link.get("target_id")
624
+ to_slot = sg_link.get("target_slot", 0)
625
+ link_type = sg_link.get("type", "")
626
+ else:
627
+ # Array format: [link_id, from_node, from_slot, to_node, to_slot, type]
628
+ old_link_id, from_node, from_slot, to_node, to_slot, link_type = sg_link[:6]
629
+
630
+ # Remap node IDs
631
+ new_from = id_map.get(from_node, from_node)
632
+ new_to = id_map.get(to_node, to_node)
633
+
634
+ new_link = [next_link_id, new_from, from_slot, new_to, to_slot, link_type]
635
+ link_id_map[old_link_id] = next_link_id
636
+ next_link_id += 1
637
+ new_links.append(new_link)
638
+
639
+ # Update node input link references using the link ID mapping
640
+ for new_node in new_nodes:
641
+ for inp in new_node.get("inputs", []):
642
+ old_link = inp.get("link")
643
+ if old_link is not None and old_link in link_id_map:
644
+ inp["link"] = link_id_map[old_link]
645
+
646
+ # Helper to get link fields from dict or array format
647
+ def get_link_fields(lnk):
648
+ if isinstance(lnk, dict):
649
+ return (lnk.get("id"), lnk.get("origin_id"), lnk.get("origin_slot", 0),
650
+ lnk.get("target_id"), lnk.get("target_slot", 0), lnk.get("type", ""))
651
+ return tuple(lnk[:6])
652
+
653
+ # Rewire external connections
654
+ # Group node inputs -> subgraph input nodes
655
+ sg_inputs = sg.get("inputs", [])
656
+ for inp in node.get("inputs", []):
657
+ link_id = inp.get("link")
658
+ if link_id is None:
659
+ continue
660
+
661
+ # Find which subgraph input this maps to
662
+ inp_name = inp.get("name")
663
+ for sg_inp in sg_inputs:
664
+ if sg_inp.get("name") == inp_name or sg_inp.get("label") == inp.get("label"):
665
+ # Find the internal link that connects to this input
666
+ for sg_link_id in sg_inp.get("linkIds", []):
667
+ # Find the link in sg_links
668
+ for sg_link in sg_links:
669
+ lf = get_link_fields(sg_link)
670
+ if lf[0] == sg_link_id:
671
+ # Redirect external link to the internal target
672
+ target_node = id_map.get(lf[3], lf[3])
673
+ target_slot = lf[4]
674
+
675
+ # Update the external link's target
676
+ for link in links:
677
+ if link[0] == link_id:
678
+ link[3] = target_node
679
+ link[4] = target_slot
680
+ break
681
+ break
682
+ break
683
+
684
+ # Group node outputs -> subgraph output nodes
685
+ sg_outputs = sg.get("outputs", [])
686
+ for out_idx, out in enumerate(node.get("outputs", [])):
687
+ out_links = out.get("links", [])
688
+ if not out_links:
689
+ continue
690
+
691
+ # Find which subgraph output this maps to
692
+ for sg_out in sg_outputs:
693
+ # Match by index or name
694
+ for sg_link_id in sg_out.get("linkIds", []):
695
+ # Find the internal link
696
+ for sg_link in sg_links:
697
+ lf = get_link_fields(sg_link)
698
+ if lf[0] == sg_link_id:
699
+ # This internal link's source is the real output
700
+ source_node = id_map.get(lf[1], lf[1])
701
+ source_slot = lf[2]
702
+
703
+ # Redirect all external links from this output
704
+ for ext_link_id in out_links:
705
+ for link in links:
706
+ if link[0] == ext_link_id:
707
+ link[1] = source_node
708
+ link[2] = source_slot
709
+ break
710
+ break
711
+ break
712
+
713
+ nodes_to_remove.add(node_id)
714
+
715
+ # Apply changes
716
+ graph["nodes"] = [n for n in nodes if n.get("id") not in nodes_to_remove] + new_nodes
717
+ graph["links"] = [l for l in links if l[0] not in links_to_remove] + new_links
718
+ graph["last_node_id"] = next_node_id
719
+ graph["last_link_id"] = next_link_id
720
+
721
+ if debug:
722
+ print(f"Expanded {len(nodes_to_remove)} group nodes, added {len(new_nodes)} nodes")
723
+
724
+ return graph
725
+
726
+
498
727
  def graph_to_api(graph: dict, object_info: dict = None, debug: bool = False) -> dict:
499
728
  """
500
729
  Convert ComfyUI graph format (from UI) to API format (for /prompt endpoint).
@@ -507,6 +736,9 @@ def graph_to_api(graph: dict, object_info: dict = None, debug: bool = False) ->
507
736
  Returns:
508
737
  Workflow in API format (node IDs as keys)
509
738
  """
739
+ # First, expand any subgraphs/group nodes
740
+ graph = expand_subgraphs(graph, debug=debug)
741
+
510
742
  if object_info is None:
511
743
  object_info = DEFAULT_OBJECT_INFO
512
744
  api = {}
@@ -670,8 +902,8 @@ class ComfyUIJob(BaseJob):
670
902
 
671
903
  COMFYUI_PORT = 8188
672
904
 
673
- def __init__(self, c3: "C3", job, template: str = None, use_lb: bool = False, use_auth: bool = False):
674
- super().__init__(c3, job)
905
+ def __init__(self, client: "HyperCLI", job, template: str = None, use_lb: bool = False, use_auth: bool = False):
906
+ super().__init__(client, job)
675
907
  self._object_info: dict | None = None
676
908
  self._auth_headers: dict | None = None
677
909
  self._job_token: str | None = None
@@ -707,16 +939,16 @@ class ComfyUIJob(BaseJob):
707
939
  if self.use_auth:
708
940
  # Use job-specific token for LB auth
709
941
  if not self._job_token:
710
- self._job_token = self.c3.jobs.token(self.job_id)
942
+ self._job_token = self.client.jobs.token(self.job_id)
711
943
  return {"Authorization": f"Bearer {self._job_token}"}
712
944
  else:
713
945
  # Use API key for direct connections
714
- return {"Authorization": f"Bearer {self.c3._api_key}"}
946
+ return {"Authorization": f"Bearer {self.client._api_key}"}
715
947
 
716
948
  @classmethod
717
949
  def create_for_template(
718
950
  cls,
719
- c3: "C3",
951
+ client: "HyperCLI",
720
952
  template: str,
721
953
  gpu_type: str = None,
722
954
  gpu_count: int = 1,
@@ -728,7 +960,7 @@ class ComfyUIJob(BaseJob):
728
960
  """Create a new ComfyUI job configured for a specific template.
729
961
 
730
962
  Args:
731
- c3: C3 client
963
+ client: HyperCLI client instance
732
964
  template: Template name (passed as COMFYUI_TEMPLATES env var)
733
965
  gpu_type: GPU type
734
966
  gpu_count: Number of GPUs
@@ -747,7 +979,7 @@ class ComfyUIJob(BaseJob):
747
979
  # Direct port exposure (HTTP)
748
980
  ports[str(cls.COMFYUI_PORT)] = cls.COMFYUI_PORT
749
981
 
750
- job = c3.jobs.create(
982
+ job = client.jobs.create(
751
983
  image=cls.DEFAULT_IMAGE,
752
984
  gpu_type=gpu_type or cls.DEFAULT_GPU_TYPE,
753
985
  gpu_count=gpu_count,
@@ -757,12 +989,12 @@ class ComfyUIJob(BaseJob):
757
989
  auth=auth,
758
990
  **kwargs,
759
991
  )
760
- return cls(c3, job, template=template, use_lb=bool(lb), use_auth=auth)
992
+ return cls(client, job, template=template, use_lb=bool(lb), use_auth=auth)
761
993
 
762
994
  @classmethod
763
995
  def get_instance(
764
996
  cls,
765
- c3: "C3",
997
+ client: "HyperCLI",
766
998
  instance: str,
767
999
  use_lb: bool = False,
768
1000
  use_auth: bool = False,
@@ -770,17 +1002,17 @@ class ComfyUIJob(BaseJob):
770
1002
  """Connect to a specific ComfyUI instance by job ID or hostname.
771
1003
 
772
1004
  Args:
773
- c3: C3 client
1005
+ client: HyperCLI client instance
774
1006
  instance: Job ID (UUID) or hostname
775
1007
  use_lb: Whether instance uses HTTPS load balancer
776
1008
  use_auth: Whether instance uses token auth
777
1009
  """
778
1010
  # Check if it looks like a UUID (job ID)
779
1011
  if "-" in instance and len(instance) > 30:
780
- job = c3.jobs.get(instance)
1012
+ job = client.jobs.get(instance)
781
1013
  else:
782
1014
  # Assume hostname - search running jobs
783
- jobs = c3.jobs.list(state="running")
1015
+ jobs = client.jobs.list(state="running")
784
1016
  job = None
785
1017
  for j in jobs:
786
1018
  if j.hostname and (j.hostname == instance or j.hostname.startswith(instance)):
@@ -789,12 +1021,12 @@ class ComfyUIJob(BaseJob):
789
1021
  if not job:
790
1022
  raise ValueError(f"No running job found with hostname: {instance}")
791
1023
 
792
- return cls(c3, job, use_lb=use_lb, use_auth=use_auth)
1024
+ return cls(client, job, use_lb=use_lb, use_auth=use_auth)
793
1025
 
794
1026
  @classmethod
795
1027
  def get_or_create_for_template(
796
1028
  cls,
797
- c3: "C3",
1029
+ client: "HyperCLI",
798
1030
  template: str,
799
1031
  gpu_type: str = None,
800
1032
  gpu_count: int = 1,
@@ -810,7 +1042,7 @@ class ComfyUIJob(BaseJob):
810
1042
  (note: the existing job may have different models loaded).
811
1043
  """
812
1044
  if reuse:
813
- existing = cls.get_running(c3, image_filter=cls.DEFAULT_IMAGE)
1045
+ existing = cls.get_running(client, image_filter=cls.DEFAULT_IMAGE)
814
1046
  if existing:
815
1047
  existing.template = template
816
1048
  # TODO: detect lb/auth from existing job's config
@@ -819,7 +1051,7 @@ class ComfyUIJob(BaseJob):
819
1051
  return existing
820
1052
 
821
1053
  return cls.create_for_template(
822
- c3,
1054
+ client,
823
1055
  template=template,
824
1056
  gpu_type=gpu_type,
825
1057
  gpu_count=gpu_count,
@@ -9,7 +9,7 @@ import websockets
9
9
  from .config import get_ws_url, WS_LOGS_PATH
10
10
 
11
11
  if TYPE_CHECKING:
12
- from .client import C3
12
+ from .client import HyperCLI
13
13
 
14
14
 
15
15
  # Default limits to prevent memory blowup
@@ -17,11 +17,11 @@ DEFAULT_MAX_INITIAL_LINES = 1000 # Max lines to fetch on initial REST call
17
17
  DEFAULT_MAX_BUFFER = 5000 # Max lines to keep in memory buffer
18
18
 
19
19
 
20
- def fetch_logs(c3: "C3", job_id: str, tail: int = None) -> list[str]:
20
+ def fetch_logs(client: "HyperCLI", job_id: str, tail: int = None) -> list[str]:
21
21
  """Fetch logs via REST API (one-time call).
22
22
 
23
23
  Args:
24
- c3: C3 client
24
+ client: HyperCLI client instance
25
25
  job_id: Job ID
26
26
  tail: Only return last N lines (default: all)
27
27
 
@@ -29,7 +29,7 @@ def fetch_logs(c3: "C3", job_id: str, tail: int = None) -> list[str]:
29
29
  List of log lines
30
30
  """
31
31
  try:
32
- logs = c3.jobs.logs(job_id)
32
+ logs = client.jobs.logs(job_id)
33
33
  if not logs:
34
34
  return []
35
35
  lines = logs.strip().split("\n")
@@ -44,7 +44,7 @@ class LogStream:
44
44
  """Async log streamer - websocket streaming with optional initial fetch.
45
45
 
46
46
  Usage:
47
- stream = LogStream(c3, job)
47
+ stream = LogStream(client, job)
48
48
  await stream.connect()
49
49
  async for line in stream:
50
50
  print(line)
@@ -59,7 +59,7 @@ class LogStream:
59
59
 
60
60
  def __init__(
61
61
  self,
62
- c3: "C3",
62
+ client: "HyperCLI",
63
63
  job_id: str,
64
64
  job_key: str = None,
65
65
  fetch_initial: bool = True,
@@ -68,14 +68,14 @@ class LogStream:
68
68
  ):
69
69
  """
70
70
  Args:
71
- c3: C3 client
71
+ client: HyperCLI client instance
72
72
  job_id: Job ID for REST log fetch
73
73
  job_key: Job key for websocket (if None, fetched from job)
74
74
  fetch_initial: Whether to fetch existing logs on connect
75
75
  max_initial_lines: Max lines to fetch initially (prevents huge fetch)
76
76
  max_buffer: Max lines to keep in buffer (oldest dropped)
77
77
  """
78
- self.c3 = c3
78
+ self.client = client
79
79
  self.job_id = job_id
80
80
  self.job_key = job_key
81
81
  self.fetch_initial = fetch_initial
@@ -112,21 +112,31 @@ class LogStream:
112
112
 
113
113
  # Fetch initial logs ONCE (bounded)
114
114
  if self.fetch_initial and not self._initial_fetched:
115
- initial_lines = fetch_logs(self.c3, self.job_id, tail=self.max_initial_lines)
115
+ initial_lines = fetch_logs(self.client, self.job_id, tail=self.max_initial_lines)
116
116
  for line in initial_lines:
117
117
  self._buffer.append(line)
118
118
  self._initial_fetched = True
119
119
 
120
120
  # Get job_key if not provided
121
121
  if not self.job_key:
122
- job = self.c3.jobs.get(self.job_id)
122
+ job = self.client.jobs.get(self.job_id)
123
123
  self.job_key = job.job_key
124
124
 
125
- # Connect websocket
125
+ # Connect websocket with timeout
126
126
  if self.job_key and not self._ws:
127
127
  ws_url = get_ws_url()
128
128
  full_url = f"{ws_url}{WS_LOGS_PATH}/{self.job_key}"
129
- self._ws = await websockets.connect(full_url)
129
+ self._ws = await asyncio.wait_for(
130
+ websockets.connect(
131
+ full_url,
132
+ ping_interval=30,
133
+ ping_timeout=20,
134
+ close_timeout=5,
135
+ max_size=2**20, # 1MB max message size
136
+ compression=None, # Disable compression for lower latency
137
+ ),
138
+ timeout=30,
139
+ )
130
140
  self._connected = True
131
141
 
132
142
  return initial_lines
@@ -181,7 +191,7 @@ class LogStream:
181
191
 
182
192
 
183
193
  async def stream_logs(
184
- c3: "C3",
194
+ client: "HyperCLI",
185
195
  job_id: str,
186
196
  on_line: Callable[[str], None],
187
197
  until_state: set[str] = None,
@@ -193,7 +203,7 @@ async def stream_logs(
193
203
  """Stream logs until job reaches a terminal state.
194
204
 
195
205
  Args:
196
- c3: C3 client
206
+ client: HyperCLI client instance
197
207
  job_id: Job ID to stream logs from
198
208
  on_line: Callback for each log line (called immediately, no buffering)
199
209
  until_state: States to stop on (default: terminal states)
@@ -211,7 +221,7 @@ async def stream_logs(
211
221
  if until_state is None:
212
222
  until_state = {"succeeded", "failed", "canceled", "terminated"}
213
223
 
214
- job = c3.jobs.get(job_id)
224
+ job = client.jobs.get(job_id)
215
225
  initial_fetched = False
216
226
  ws = None
217
227
 
@@ -219,18 +229,18 @@ async def stream_logs(
219
229
  # Wait for job to be assigned/running
220
230
  while job.state in ("pending", "queued"):
221
231
  await asyncio.sleep(poll_state_interval)
222
- job = c3.jobs.get(job_id)
232
+ job = client.jobs.get(job_id)
223
233
 
224
234
  # Check for immediate terminal state
225
235
  if job.state in until_state:
226
236
  if fetch_final:
227
- for line in fetch_logs(c3, job_id, tail=max_initial_lines):
237
+ for line in fetch_logs(client, job_id, tail=max_initial_lines):
228
238
  on_line(line)
229
239
  return
230
240
 
231
241
  # Fetch initial logs ONCE when running (bounded)
232
242
  if fetch_initial and job.state == "running" and not initial_fetched:
233
- for line in fetch_logs(c3, job_id, tail=max_initial_lines):
243
+ for line in fetch_logs(client, job_id, tail=max_initial_lines):
234
244
  on_line(line)
235
245
  initial_fetched = True
236
246
 
@@ -255,7 +265,7 @@ async def stream_logs(
255
265
  continue
256
266
  except asyncio.TimeoutError:
257
267
  # Check job state (NOT polling logs!)
258
- job = c3.jobs.get(job_id)
268
+ job = client.jobs.get(job_id)
259
269
  if job.state in until_state:
260
270
  break
261
271
  except websockets.ConnectionClosed:
@@ -265,7 +275,7 @@ async def stream_logs(
265
275
  if fetch_final:
266
276
  # Small delay to let final logs flush
267
277
  await asyncio.sleep(0.5)
268
- for line in fetch_logs(c3, job_id, tail=max_initial_lines):
278
+ for line in fetch_logs(client, job_id, tail=max_initial_lines):
269
279
  on_line(line)
270
280
 
271
281
  finally:
@@ -145,7 +145,7 @@ class Renders:
145
145
  notify_url: Optional webhook URL for completion notification
146
146
 
147
147
  Example:
148
- render = c3.renders.text_to_image("a cat wearing sunglasses")
148
+ render = client.renders.text_to_image("a cat wearing sunglasses")
149
149
  """
150
150
  return self._flow("/api/flow/text-to-image", prompt=prompt, negative=negative, width=width, height=height, notify_url=notify_url)
151
151
 
@@ -167,7 +167,7 @@ class Renders:
167
167
  notify_url: Optional webhook URL for completion notification
168
168
 
169
169
  Example:
170
- render = c3.renders.text_to_image_hidream("a mystical forest")
170
+ render = client.renders.text_to_image_hidream("a mystical forest")
171
171
  """
172
172
  return self._flow("/api/flow/text-to-image-hidream", prompt=prompt, negative=negative, width=width, height=height, notify_url=notify_url)
173
173
 
@@ -189,7 +189,7 @@ class Renders:
189
189
  notify_url: Optional webhook URL for completion notification
190
190
 
191
191
  Example:
192
- render = c3.renders.text_to_video("a cat walking through a garden")
192
+ render = client.renders.text_to_video("a cat walking through a garden")
193
193
  """
194
194
  return self._flow("/api/flow/text-to-video", prompt=prompt, negative=negative, width=width, height=height, notify_url=notify_url)
195
195
 
@@ -213,7 +213,7 @@ class Renders:
213
213
  notify_url: Optional webhook URL for completion notification
214
214
 
215
215
  Example:
216
- render = c3.renders.image_to_video("dancing", "https://example.com/img.png", width=832, height=480)
216
+ render = client.renders.image_to_video("dancing", "https://example.com/img.png", width=832, height=480)
217
217
  """
218
218
  return self._flow("/api/flow/image-to-video", prompt=prompt, image_url=image_url, negative=negative, width=width, height=height, notify_url=notify_url)
219
219
 
@@ -239,7 +239,7 @@ class Renders:
239
239
  notify_url: Optional webhook URL for completion notification
240
240
 
241
241
  Example:
242
- render = c3.renders.speaking_video(
242
+ render = client.renders.speaking_video(
243
243
  "A person talking to camera",
244
244
  "https://example.com/face.png",
245
245
  "https://example.com/speech.mp3"
@@ -269,7 +269,7 @@ class Renders:
269
269
  notify_url: Optional webhook URL for completion notification
270
270
 
271
271
  Example:
272
- render = c3.renders.speaking_video_wan(
272
+ render = client.renders.speaking_video_wan(
273
273
  "The person is singing",
274
274
  "https://example.com/face.png",
275
275
  "https://example.com/song.mp3"
@@ -297,7 +297,7 @@ class Renders:
297
297
  notify_url: Optional webhook URL for completion notification
298
298
 
299
299
  Example:
300
- render = c3.renders.image_to_image(
300
+ render = client.renders.image_to_image(
301
301
  "Apply the artistic style from the references",
302
302
  [
303
303
  "https://example.com/subject.jpg",
@@ -330,7 +330,7 @@ class Renders:
330
330
  notify_url: Optional webhook URL for completion notification
331
331
 
332
332
  Example:
333
- render = c3.renders.first_last_frame_video(
333
+ render = client.renders.first_last_frame_video(
334
334
  "smooth transition from day to night",
335
335
  "https://example.com/day.png",
336
336
  "https://example.com/night.png"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hypercli-sdk"
7
- version = "0.4.6"
7
+ version = "0.4.7"
8
8
  description = "Python SDK for HyperCLI - GPU orchestration and LLM API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes
File without changes