together 2.0.0a17__py3-none-any.whl → 2.0.0a19__py3-none-any.whl

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 (67) hide show
  1. together/_base_client.py +5 -2
  2. together/_client.py +1 -77
  3. together/_compat.py +3 -3
  4. together/_utils/_json.py +35 -0
  5. together/_version.py +1 -1
  6. together/lib/cli/api/beta/__init__.py +2 -0
  7. together/lib/cli/api/beta/jig/__init__.py +52 -0
  8. together/lib/cli/api/beta/jig/_config.py +170 -0
  9. together/lib/cli/api/beta/jig/jig.py +664 -0
  10. together/lib/cli/api/beta/jig/secrets.py +138 -0
  11. together/lib/cli/api/beta/jig/volumes.py +509 -0
  12. together/lib/cli/api/endpoints/create.py +7 -3
  13. together/lib/cli/api/endpoints/hardware.py +38 -7
  14. together/lib/cli/api/models/upload.py +5 -1
  15. together/resources/__init__.py +0 -28
  16. together/resources/beta/__init__.py +14 -0
  17. together/resources/beta/beta.py +32 -0
  18. together/resources/beta/clusters/clusters.py +12 -12
  19. together/resources/beta/clusters/storage.py +10 -10
  20. together/resources/beta/jig/__init__.py +61 -0
  21. together/resources/beta/jig/jig.py +1004 -0
  22. together/resources/beta/jig/queue.py +482 -0
  23. together/resources/beta/jig/secrets.py +548 -0
  24. together/resources/beta/jig/volumes.py +514 -0
  25. together/resources/chat/completions.py +10 -0
  26. together/resources/endpoints.py +103 -1
  27. together/resources/models/__init__.py +33 -0
  28. together/resources/{models.py → models/models.py} +41 -9
  29. together/resources/models/uploads.py +163 -0
  30. together/types/__init__.py +2 -4
  31. together/types/beta/__init__.py +6 -0
  32. together/types/beta/deployment.py +261 -0
  33. together/types/beta/deployment_logs.py +11 -0
  34. together/types/beta/jig/__init__.py +20 -0
  35. together/types/beta/jig/queue_cancel_params.py +13 -0
  36. together/types/beta/jig/queue_cancel_response.py +11 -0
  37. together/types/beta/jig/queue_metrics_params.py +12 -0
  38. together/types/beta/jig/queue_metrics_response.py +8 -0
  39. together/types/beta/jig/queue_retrieve_params.py +15 -0
  40. together/types/beta/jig/queue_retrieve_response.py +35 -0
  41. together/types/beta/jig/queue_submit_params.py +19 -0
  42. together/types/beta/jig/queue_submit_response.py +25 -0
  43. together/types/beta/jig/secret.py +33 -0
  44. together/types/beta/jig/secret_create_params.py +34 -0
  45. together/types/beta/jig/secret_list_response.py +16 -0
  46. together/types/beta/jig/secret_update_params.py +34 -0
  47. together/types/beta/jig/volume.py +47 -0
  48. together/types/beta/jig/volume_create_params.py +34 -0
  49. together/types/beta/jig/volume_list_response.py +16 -0
  50. together/types/beta/jig/volume_update_params.py +34 -0
  51. together/types/beta/jig_deploy_params.py +150 -0
  52. together/types/beta/jig_list_response.py +16 -0
  53. together/types/beta/jig_retrieve_logs_params.py +12 -0
  54. together/types/beta/jig_update_params.py +141 -0
  55. together/types/chat/completion_create_params.py +11 -0
  56. together/types/{hardware_list_params.py → endpoint_list_hardware_params.py} +2 -2
  57. together/types/{hardware_list_response.py → endpoint_list_hardware_response.py} +2 -2
  58. together/types/models/__init__.py +5 -0
  59. together/types/{job_retrieve_response.py → models/upload_status_response.py} +3 -3
  60. {together-2.0.0a17.dist-info → together-2.0.0a19.dist-info}/METADATA +15 -14
  61. {together-2.0.0a17.dist-info → together-2.0.0a19.dist-info}/RECORD +64 -30
  62. together/resources/hardware.py +0 -181
  63. together/resources/jobs.py +0 -214
  64. together/types/job_list_response.py +0 -47
  65. {together-2.0.0a17.dist-info → together-2.0.0a19.dist-info}/WHEEL +0 -0
  66. {together-2.0.0a17.dist-info → together-2.0.0a19.dist-info}/entry_points.txt +0 -0
  67. {together-2.0.0a17.dist-info → together-2.0.0a19.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,138 @@
1
+ """Secrets management CLI commands for jig."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from together import Together
8
+ from together._exceptions import APIStatusError
9
+ from together.lib.cli.api._utils import handle_api_errors
10
+ from together.lib.cli.api.beta.jig._config import State, Config
11
+
12
+
13
+ @click.group()
14
+ @click.pass_context
15
+ def secrets(ctx: click.Context) -> None:
16
+ """Manage deployment secrets"""
17
+ pass
18
+
19
+
20
+ @secrets.command("set")
21
+ @click.pass_context
22
+ @click.option("--name", required=True, help="Secret name")
23
+ @click.option("--value", required=True, help="Secret value")
24
+ @click.option("--description", default="", help="Secret description")
25
+ @click.option("--config", "config_path", default=None, help="Configuration file path")
26
+ @handle_api_errors("Secrets")
27
+ def secrets_set(
28
+ ctx: click.Context,
29
+ name: str,
30
+ value: str,
31
+ description: str,
32
+ config_path: str | None,
33
+ ) -> None:
34
+ """Set a secret (create or update)"""
35
+ client: Together = ctx.obj
36
+ config = Config.find(config_path)
37
+ state = State.load(config._path.parent)
38
+
39
+ deployment_secret_name = f"{config.model_name}-{name}"
40
+
41
+ try:
42
+ client.beta.jig.secrets.retrieve(deployment_secret_name)
43
+ # Secret exists, update it
44
+ client.beta.jig.secrets.update(
45
+ deployment_secret_name,
46
+ name=deployment_secret_name,
47
+ description=description,
48
+ value=value,
49
+ )
50
+ click.echo(f"\N{CHECK MARK} Updated secret: '{name}'")
51
+ except APIStatusError as e:
52
+ if hasattr(e, "status_code") and e.status_code == 404:
53
+ click.echo("\N{ROCKET} Creating new secret")
54
+ client.beta.jig.secrets.create(
55
+ name=deployment_secret_name,
56
+ value=value,
57
+ description=description,
58
+ )
59
+ click.echo(f"\N{CHECK MARK} Created secret: {name}")
60
+ else:
61
+ raise
62
+
63
+ state.secrets[name] = deployment_secret_name
64
+ state.save()
65
+
66
+
67
+ @secrets.command("unset")
68
+ @click.pass_context
69
+ @click.option("--name", required=True, help="Secret name to remove")
70
+ @click.option("--config", "config_path", default=None, help="Configuration file path")
71
+ @handle_api_errors("Secrets")
72
+ def secrets_unset(
73
+ ctx: click.Context, # noqa: ARG001
74
+ name: str,
75
+ config_path: str | None,
76
+ ) -> None:
77
+ """Remove a secret from both remote and local state"""
78
+ config = Config.find(config_path)
79
+ state = State.load(config._path.parent)
80
+
81
+ if state.secrets.pop(name, ""):
82
+ state.save()
83
+ click.echo(f"\N{CHECK MARK} Deleted secret '{name}' from local state")
84
+ else:
85
+ click.echo(f"\N{CROSS MARK} Secret '{name}' is not set")
86
+
87
+
88
+ @secrets.command("list")
89
+ @click.pass_context
90
+ @click.option("--config", "config_path", default=None, help="Configuration file path")
91
+ @handle_api_errors("Secrets")
92
+ def secrets_list(
93
+ ctx: click.Context,
94
+ config_path: str | None,
95
+ ) -> None:
96
+ """List all secrets with sync status"""
97
+ client: Together = ctx.obj
98
+ config = Config.find(config_path)
99
+ state = State.load(config._path.parent)
100
+
101
+ prefix = f"{config.model_name}-"
102
+
103
+ # Get remote secrets for this deployment
104
+ remote_response = client.beta.jig.secrets.list()
105
+ remote_secrets: set[str] = set()
106
+
107
+ if hasattr(remote_response, "data") and remote_response.data:
108
+ for secret in remote_response.data:
109
+ secret_name = getattr(secret, "name", None)
110
+ if secret_name and secret_name.startswith(prefix):
111
+ # Strip prefix to get local name
112
+ remote_secrets.add(secret_name[len(prefix) :])
113
+
114
+ # Get local secrets
115
+ local_secrets = set(state.secrets.keys())
116
+
117
+ # Combine all secrets
118
+ all_secrets = local_secrets | remote_secrets
119
+
120
+ if not all_secrets:
121
+ click.echo(f"\N{INFORMATION SOURCE} No secrets configured for deployment '{config.model_name}'")
122
+ return
123
+
124
+ click.echo(f"\N{INFORMATION SOURCE} Secrets for deployment '{config.model_name}':")
125
+ click.echo()
126
+
127
+ for name in sorted(all_secrets):
128
+ in_local = name in local_secrets
129
+ in_remote = name in remote_secrets
130
+
131
+ if in_local and in_remote:
132
+ status = click.style("synced", fg="green")
133
+ elif in_local and not in_remote:
134
+ status = click.style("local only", fg="yellow")
135
+ else: # in_remote and not in_local
136
+ status = click.style("remote only", fg="yellow")
137
+
138
+ click.echo(f" - {name} [{status}]")
@@ -0,0 +1,509 @@
1
+ """Volume management CLI commands for jig."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import asyncio
7
+ import itertools
8
+ from typing import Any
9
+ from pathlib import Path
10
+
11
+ import click
12
+ import httpx
13
+ from rich.pretty import pprint
14
+
15
+ from together import Together
16
+ from together._exceptions import APIStatusError
17
+ from together.lib.cli.api._utils import handle_api_errors
18
+ from together.lib.cli.api.beta.jig._config import (
19
+ DEBUG,
20
+ MAX_UPLOAD_RETRIES,
21
+ MULTIPART_THRESHOLD_MB,
22
+ MULTIPART_CHUNK_SIZE_MB,
23
+ UPLOAD_CONCURRENCY_LIMIT,
24
+ State,
25
+ Config,
26
+ )
27
+
28
+
29
+ @click.group()
30
+ @click.pass_context
31
+ def volumes(ctx: click.Context) -> None:
32
+ """Manage volumes"""
33
+ pass
34
+
35
+
36
+ # --- File upload ---
37
+
38
+
39
+ def format_filename(filename: str, max_len: int = 100) -> str:
40
+ if len(filename) <= max_len:
41
+ return filename
42
+ return "..." + filename[-(max_len - 3) :]
43
+
44
+
45
+ class Uploader:
46
+ """Helper to handle file upload"""
47
+
48
+ chunk_size = MULTIPART_CHUNK_SIZE_MB * 1024 * 1024
49
+ multipart_threshold = MULTIPART_THRESHOLD_MB * 1024 * 1024
50
+ spinner_chars = "|/-\\"
51
+
52
+ def __init__(self, client: Together) -> None:
53
+ self.client = client
54
+ # progress
55
+ self.start_time = time.time()
56
+ self.completed_files = 0
57
+ self.uploaded_bytes = 0
58
+ self.current_file = ""
59
+ self.total_bytes = 0
60
+ self.total_files = 0
61
+ # cycle through spinner chars forever
62
+ self.spinner_running = True
63
+ self.spinner_iter = itertools.cycle("|/-\\")
64
+ # these will be set in upload_files when event loop is running
65
+ self.semaphore: asyncio.Semaphore
66
+ self.progress_lock: asyncio.Lock
67
+ self.http_client: httpx.AsyncClient
68
+
69
+ def update_progress(self) -> None:
70
+ spinner = next(self.spinner_iter)
71
+
72
+ bytes_denominator = self.total_bytes or float("inf")
73
+ percent = int(100 * self.uploaded_bytes / bytes_denominator)
74
+
75
+ display_file = format_filename(self.current_file)
76
+
77
+ uploaded_mb = self.uploaded_bytes / (1024 * 1024)
78
+ total_mb = self.total_bytes / (1024 * 1024)
79
+ size_str = f"({uploaded_mb:.1f}MB/{total_mb:.1f}MB)"
80
+
81
+ elapsed = time.time() - self.start_time
82
+ speed_str = ""
83
+ if elapsed > 0.5 and self.uploaded_bytes > 0:
84
+ speed_kbps = self.uploaded_bytes / elapsed / 1024
85
+ speed_str = f"{speed_kbps:.1f} KB/s - "
86
+ if speed_kbps > 1024:
87
+ speed_str = f"{(speed_kbps / 1024):.1f} MB/s - "
88
+
89
+ msg = f"\r{spinner} {percent}% - {speed_str}{display_file} {size_str} ({self.completed_files}/{self.total_files} files)"
90
+
91
+ # \r moves cursor to start of line, \033[K clears from cursor to end of line
92
+ print(f"\r{msg}\033[K", end="", flush=True) # noqa: T201
93
+
94
+ async def increment_progress(self, bytes_count: int, filename: str = "", file_complete: bool = False) -> None:
95
+ async with self.progress_lock:
96
+ if bytes_count > 0:
97
+ self.uploaded_bytes += bytes_count
98
+ if DEBUG:
99
+ click.echo(f"\nDEBUG: bytes_count={bytes_count}, total={self.uploaded_bytes}")
100
+ if file_complete:
101
+ self.completed_files += 1
102
+ if filename:
103
+ self.current_file = filename
104
+ self.update_progress()
105
+
106
+ async def spinner_updater(self) -> None:
107
+ while self.spinner_running:
108
+ async with self.progress_lock:
109
+ self.update_progress()
110
+ await asyncio.sleep(0.1)
111
+
112
+ async def upload_files(self, source_path: Path, volume_name: str) -> None:
113
+ """Upload all files from source directory with progress tracking"""
114
+ # these require a running event loop
115
+ self.semaphore = asyncio.Semaphore(UPLOAD_CONCURRENCY_LIMIT)
116
+ self.progress_lock = asyncio.Lock()
117
+
118
+ source_prefix = f"{volume_name}/{source_path.name}"
119
+ files_to_upload: list[tuple[Path, str, int]] = []
120
+
121
+ for file_path in source_path.rglob("*"):
122
+ if file_path.is_file():
123
+ rel_path = file_path.relative_to(source_path)
124
+ remote_path = f"{source_prefix}/{rel_path.as_posix()}"
125
+ file_size = file_path.stat().st_size
126
+ files_to_upload.append((file_path, remote_path, file_size))
127
+
128
+ if not files_to_upload:
129
+ raise ValueError(f"No files found in {source_path}")
130
+
131
+ files_to_upload.sort(key=lambda x: x[2], reverse=True)
132
+
133
+ self.total_bytes = sum(size for _, _, size in files_to_upload)
134
+ self.total_files = len(files_to_upload)
135
+ spinner_task = asyncio.create_task(self.spinner_updater())
136
+ async with httpx.AsyncClient(timeout=300.0) as self.http_client:
137
+ try:
138
+ tasks = [self.upload_file_with_retry(fp, rp, fs) for fp, rp, fs in files_to_upload]
139
+ await asyncio.gather(*tasks)
140
+ finally:
141
+ self.spinner_running = False
142
+ await spinner_task
143
+
144
+ elapsed_time = time.time() - self.start_time
145
+ click.echo(f"\n\N{CHECK MARK} Upload completed in {elapsed_time:.1f} seconds")
146
+
147
+ async def upload_file_with_retry(self, file_path: Path, remote_path: str, file_size: int) -> None:
148
+ for attempt in range(MAX_UPLOAD_RETRIES):
149
+ # Snapshot progress before attempt
150
+ async with self.progress_lock:
151
+ snapshot_bytes = self.uploaded_bytes
152
+
153
+ try:
154
+ if file_size >= self.multipart_threshold:
155
+ await self._upload_file_multipart(file_path, remote_path, file_size)
156
+ else:
157
+ await self._upload_file_simple(file_path, remote_path, file_size)
158
+ return
159
+ except Exception as e:
160
+ # Rollback to snapshot on failure
161
+ async with self.progress_lock:
162
+ self.uploaded_bytes = snapshot_bytes
163
+ if attempt == MAX_UPLOAD_RETRIES - 1:
164
+ raise RuntimeError(
165
+ f"Failed to upload {remote_path} after {MAX_UPLOAD_RETRIES} attempts: {e}"
166
+ ) from e
167
+ await asyncio.sleep(1 * (attempt + 1))
168
+
169
+ async def _upload_file_simple(
170
+ self,
171
+ file_path: Path,
172
+ remote_path: str,
173
+ file_size: int,
174
+ ) -> None:
175
+ """Upload a single file using simple upload"""
176
+ async with self.semaphore:
177
+ response = self.client._client.post(
178
+ "/storage/upload-request",
179
+ json={"filename": remote_path},
180
+ headers=self.client.auth_headers,
181
+ )
182
+ response.raise_for_status()
183
+ upload_data = response.json()
184
+
185
+ upload_url = upload_data["upload_url"]["url"]
186
+ method = upload_data["upload_url"]["method"]
187
+ headers = upload_data["upload_url"].get("headers", {})
188
+
189
+ file_data = await asyncio.to_thread(Path(file_path).read_bytes)
190
+
191
+ try:
192
+ resp = await self.http_client.request(method, upload_url, content=file_data, headers=headers)
193
+ resp.raise_for_status()
194
+ except Exception as e:
195
+ raise RuntimeError(f"Failed to upload {remote_path}: {e}") from e
196
+
197
+ await self.increment_progress(max(file_size, 1), remote_path, file_complete=True)
198
+
199
+ async def _upload_file_multipart(
200
+ self,
201
+ file_path: Path,
202
+ remote_path: str,
203
+ file_size: int,
204
+ ) -> None:
205
+ """Upload a file using multipart upload"""
206
+ parts_count = (file_size + self.chunk_size - 1) // self.chunk_size
207
+
208
+ response = self.client._client.post(
209
+ "/storage/multipart/init",
210
+ json={"filename": remote_path, "parts_count": parts_count},
211
+ headers=self.client.auth_headers,
212
+ )
213
+ response.raise_for_status()
214
+ init_data = response.json()
215
+
216
+ upload_id = init_data["upload_id"]
217
+ part_urls = init_data["part_upload_urls"]
218
+
219
+ try:
220
+ completed_parts = await self._upload_parts(file_path, part_urls)
221
+
222
+ self.client._client.post(
223
+ "/storage/multipart/complete",
224
+ json={
225
+ "filename": remote_path,
226
+ "upload_id": upload_id,
227
+ "parts": completed_parts,
228
+ },
229
+ headers=self.client.auth_headers,
230
+ )
231
+
232
+ await self.increment_progress(0, remote_path, file_complete=True)
233
+ except Exception:
234
+ try:
235
+ self.client._client.post(
236
+ "/storage/multipart/abort",
237
+ json={"filename": remote_path, "upload_id": upload_id},
238
+ headers=self.client.auth_headers,
239
+ )
240
+ except Exception as e:
241
+ click.echo(f"Failed to abort multipart upload request: {repr(e)}")
242
+ raise
243
+
244
+ async def _upload_parts(
245
+ self,
246
+ file_path: Path,
247
+ part_urls: list[dict[str, Any]],
248
+ ) -> list[dict[str, Any]]:
249
+ """Upload file parts concurrently"""
250
+
251
+ async def upload_part(part_info: dict[str, Any], data: bytes) -> dict[str, Any]:
252
+ err = None
253
+ async with self.semaphore:
254
+ part_number = part_info["part_number"]
255
+ url = part_info["url"]
256
+ method = part_info["method"]
257
+ headers = part_info.get("headers", {})
258
+
259
+ part_size = len(data)
260
+
261
+ for attempt in range(MAX_UPLOAD_RETRIES):
262
+ try:
263
+ response = await self.http_client.request(method, url, content=data, headers=headers)
264
+ response.raise_for_status()
265
+ etag = response.headers.get("ETag", "").strip('"')
266
+ await self.increment_progress(
267
+ part_size,
268
+ f"{file_path.name} (part {part_number}/{len(part_urls)})",
269
+ )
270
+ return {"part_number": part_number, "etag": etag}
271
+ except Exception as e:
272
+ err = e
273
+ if attempt < MAX_UPLOAD_RETRIES - 1:
274
+ await asyncio.sleep(1 * (attempt + 1))
275
+ raise RuntimeError(f"Failed to upload part {part_number}: {err}")
276
+
277
+ with open(file_path, "rb") as f:
278
+ tasks = [
279
+ asyncio.create_task(
280
+ upload_part(
281
+ part_info=part_info,
282
+ # read file sequentially while uploads proceed
283
+ data=await asyncio.to_thread(f.read, self.chunk_size),
284
+ )
285
+ )
286
+ for part_info in part_urls
287
+ ]
288
+
289
+ completed_parts = await asyncio.gather(*tasks)
290
+ return sorted(completed_parts, key=lambda x: x["part_number"])
291
+
292
+
293
+ async def _create_volume(client: Together, name: str, source: str) -> None:
294
+ """Create a volume and upload files"""
295
+ source_path = Path(source)
296
+ if not source_path.exists():
297
+ raise ValueError(f"Source path does not exist: {source}")
298
+ if not source_path.is_dir():
299
+ raise ValueError(f"Source path must be a directory: {source}")
300
+
301
+ source_prefix = f"{name}/{source_path.name}"
302
+
303
+ click.echo(f"\N{ROCKET} Creating volume '{name}' with source prefix '{source_prefix}'")
304
+ try:
305
+ volume_response = client.beta.jig.volumes.create(
306
+ name=name,
307
+ type="readOnly",
308
+ content={"type": "files", "source_prefix": source_prefix},
309
+ )
310
+ click.echo(f"\N{CHECK MARK} Volume created: {volume_response.id}")
311
+ except Exception as e:
312
+ raise RuntimeError(f"Failed to create volume: {e}") from e
313
+
314
+ try:
315
+ await Uploader(client).upload_files(source_path, volume_name=name)
316
+ except Exception as e:
317
+ click.echo(f"\N{CROSS MARK} Upload failed: {e}")
318
+ click.echo(f"\N{WASTEBASKET} Cleaning up volume '{name}'")
319
+ try:
320
+ client.beta.jig.volumes.delete(name)
321
+ except Exception as cleanup_error:
322
+ click.echo(f"\N{WARNING SIGN} Failed to delete volume: {cleanup_error}")
323
+ raise
324
+
325
+
326
+ async def _update_volume(client: Together, name: str, source: str) -> None:
327
+ """Update a volume and re-upload files"""
328
+ source_path = Path(source)
329
+ if not source_path.exists():
330
+ raise ValueError(f"Source path does not exist: {source}")
331
+ if not source_path.is_dir():
332
+ raise ValueError(f"Source path must be a directory: {source}")
333
+
334
+ try:
335
+ client.beta.jig.volumes.retrieve(name)
336
+ except APIStatusError as e:
337
+ if hasattr(e, "status_code") and e.status_code == 404:
338
+ raise ValueError(f"Volume '{name}' does not exist") from e
339
+ raise
340
+
341
+ source_prefix = f"{name}/{source_path.name}"
342
+
343
+ click.echo(f"\N{INFORMATION SOURCE} Uploading files for volume '{name}'")
344
+ await Uploader(client).upload_files(source_path, volume_name=name)
345
+
346
+ click.echo(f"\N{INFORMATION SOURCE} Updating volume '{name}' with source prefix '{source_prefix}'")
347
+ client.beta.jig.volumes.update(
348
+ name,
349
+ content={"type": "files", "source_prefix": source_prefix},
350
+ )
351
+ click.echo("\N{CHECK MARK} Volume updated successfully")
352
+
353
+
354
+ # --- CLI Commands ---
355
+
356
+
357
+ @volumes.command("create")
358
+ @click.pass_context
359
+ @click.option("--name", required=True, help="Volume name")
360
+ @click.option("--source", required=True, help="Source directory path")
361
+ @handle_api_errors("Volumes")
362
+ def volumes_create(
363
+ ctx: click.Context,
364
+ name: str,
365
+ source: str,
366
+ ) -> None:
367
+ """Create a volume and upload files"""
368
+ client: Together = ctx.obj
369
+ asyncio.run(_create_volume(client, name, source))
370
+
371
+
372
+ @volumes.command("update")
373
+ @click.pass_context
374
+ @click.option("--name", required=True, help="Volume name")
375
+ @click.option("--source", required=True, help="New source directory path")
376
+ @handle_api_errors("Volumes")
377
+ def volumes_update(
378
+ ctx: click.Context,
379
+ name: str,
380
+ source: str,
381
+ ) -> None:
382
+ """Update a volume and re-upload files"""
383
+ client: Together = ctx.obj
384
+ asyncio.run(_update_volume(client, name, source))
385
+
386
+
387
+ def _unset_volume_state(name: str, state: State) -> bool:
388
+ """Remove volume mount from deployment configuration. Returns True if was mounted."""
389
+ if name in state.volumes:
390
+ del state.volumes[name]
391
+ state.save()
392
+ click.echo(f"\N{CHECK MARK} Removed volume '{name}' from deployment configuration")
393
+ return True
394
+
395
+ click.echo(f"\N{WARNING SIGN} Volume '{name}' is not configured for deployment")
396
+ return False
397
+
398
+
399
+ @volumes.command("set")
400
+ @click.pass_context
401
+ @click.option("--name", required=True, help="Volume name")
402
+ @click.option("--mount-path", required=True, help="Mount path in container")
403
+ @click.option("--config", "config_path", default=None, help="Configuration file path")
404
+ @handle_api_errors("Volumes")
405
+ def volumes_set(
406
+ ctx: click.Context,
407
+ name: str,
408
+ mount_path: str,
409
+ config_path: str | None,
410
+ ) -> None:
411
+ """Set volume mount configuration for deployment"""
412
+ client: Together = ctx.obj
413
+
414
+ # Check if volume exists
415
+ try:
416
+ client.beta.jig.volumes.retrieve(name)
417
+ except APIStatusError as e:
418
+ if hasattr(e, "status_code") and e.status_code == 404:
419
+ click.echo(f"\N{CROSS MARK} Volume '{name}' not found")
420
+ return
421
+ raise
422
+
423
+ config = Config.find(config_path)
424
+ state = State.load(config._path.parent)
425
+
426
+ if len(state.volumes) > 0 and name not in state.volumes:
427
+ raise ValueError("Only one read-only volume is supported per deployment")
428
+
429
+ state.volumes[name] = mount_path
430
+ state.save()
431
+ click.echo(f"\N{CHECK MARK} Volume '{name}' will be mounted at '{mount_path}' during deployment")
432
+
433
+
434
+ @volumes.command("unset")
435
+ @click.pass_context
436
+ @click.option("--name", required=True, help="Volume name to remove from local state")
437
+ @click.option("--config", "config_path", default=None, help="Configuration file path")
438
+ @handle_api_errors("Volumes")
439
+ def volumes_unset(
440
+ ctx: click.Context, # noqa: ARG001
441
+ name: str,
442
+ config_path: str | None,
443
+ ) -> None:
444
+ """Remove volume from local deployment configuration (does not delete remote volume)"""
445
+ config = Config.find(config_path)
446
+ state = State.load(config._path.parent)
447
+ _unset_volume_state(name, state)
448
+
449
+
450
+ @volumes.command("delete")
451
+ @click.pass_context
452
+ @click.option("--name", required=True, help="Volume name")
453
+ @click.option("--config", "config_path", default=None, help="Configuration file path")
454
+ @handle_api_errors("Volumes")
455
+ def volumes_delete(
456
+ ctx: click.Context,
457
+ name: str,
458
+ config_path: str | None,
459
+ ) -> None:
460
+ """Delete a volume"""
461
+ client: Together = ctx.obj
462
+ config = Config.find(config_path)
463
+ state = State.load(config._path.parent)
464
+
465
+ # Unset volume first before deleting
466
+ volume_mounted = _unset_volume_state(name, state)
467
+ if volume_mounted:
468
+ click.echo("\N{WARNING SIGN} Please redeploy first before deleting the volume")
469
+ return
470
+
471
+ try:
472
+ client.beta.jig.volumes.delete(name)
473
+ click.echo(f"\N{CHECK MARK} Deleted volume '{name}'")
474
+ except APIStatusError as e:
475
+ if hasattr(e, "status_code") and e.status_code == 404:
476
+ click.echo(f"\N{CROSS MARK} Volume '{name}' not found")
477
+ return
478
+ raise
479
+
480
+
481
+ @volumes.command("describe")
482
+ @click.pass_context
483
+ @click.option("--name", required=True, help="Volume name")
484
+ @handle_api_errors("Volumes")
485
+ def volumes_describe(
486
+ ctx: click.Context,
487
+ name: str,
488
+ ) -> None:
489
+ """Describe a volume"""
490
+ client: Together = ctx.obj
491
+
492
+ try:
493
+ response = client.beta.jig.volumes.retrieve(name)
494
+ pprint(response.model_dump() if hasattr(response, "model_dump") else response, indent_guides=False)
495
+ except APIStatusError as e:
496
+ if hasattr(e, "status_code") and e.status_code == 404:
497
+ click.echo(f"\N{CROSS MARK} Volume '{name}' not found")
498
+ return
499
+ raise
500
+
501
+
502
+ @volumes.command("list")
503
+ @click.pass_context
504
+ @handle_api_errors("Volumes")
505
+ def volumes_list(ctx: click.Context) -> None:
506
+ """List all volumes"""
507
+ client: Together = ctx.obj
508
+ response = client.beta.jig.volumes.list()
509
+ pprint(response.model_dump() if hasattr(response, "model_dump") else response, indent_guides=False)
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  import sys
4
4
 
5
5
  import click
6
- from rich import print
7
6
 
8
7
  from together import APIError, Together, omit
9
8
  from together.lib.cli.api._utils import handle_api_errors
@@ -124,8 +123,13 @@ def create(
124
123
  extra_query={"availability_zone": availability_zone or omit},
125
124
  )
126
125
  except APIError as e:
127
- if "check the hardware api" in str(e.args[0]).lower() or "invalid hardware provided" in str(e.args[0]).lower():
128
- print("Invalid hardware provided")
126
+ if (
127
+ "check the hardware api" in str(e.args[0]).lower()
128
+ or "invalid hardware provided" in str(e.args[0]).lower()
129
+ or "the selected configuration" in str(e.args[0]).lower()
130
+ ):
131
+ click.secho("Invalid hardware selected.", fg="red", err=True)
132
+ click.echo("\nAvailable hardware options:")
129
133
  ctx.invoke(hardware, available=True, model=model, json=False)
130
134
  sys.exit(1)
131
135
  raise e