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.
- together/_base_client.py +5 -2
- together/_client.py +1 -77
- together/_compat.py +3 -3
- together/_utils/_json.py +35 -0
- together/_version.py +1 -1
- together/lib/cli/api/beta/__init__.py +2 -0
- together/lib/cli/api/beta/jig/__init__.py +52 -0
- together/lib/cli/api/beta/jig/_config.py +170 -0
- together/lib/cli/api/beta/jig/jig.py +664 -0
- together/lib/cli/api/beta/jig/secrets.py +138 -0
- together/lib/cli/api/beta/jig/volumes.py +509 -0
- together/lib/cli/api/endpoints/create.py +7 -3
- together/lib/cli/api/endpoints/hardware.py +38 -7
- together/lib/cli/api/models/upload.py +5 -1
- together/resources/__init__.py +0 -28
- together/resources/beta/__init__.py +14 -0
- together/resources/beta/beta.py +32 -0
- together/resources/beta/clusters/clusters.py +12 -12
- together/resources/beta/clusters/storage.py +10 -10
- together/resources/beta/jig/__init__.py +61 -0
- together/resources/beta/jig/jig.py +1004 -0
- together/resources/beta/jig/queue.py +482 -0
- together/resources/beta/jig/secrets.py +548 -0
- together/resources/beta/jig/volumes.py +514 -0
- together/resources/chat/completions.py +10 -0
- together/resources/endpoints.py +103 -1
- together/resources/models/__init__.py +33 -0
- together/resources/{models.py → models/models.py} +41 -9
- together/resources/models/uploads.py +163 -0
- together/types/__init__.py +2 -4
- together/types/beta/__init__.py +6 -0
- together/types/beta/deployment.py +261 -0
- together/types/beta/deployment_logs.py +11 -0
- together/types/beta/jig/__init__.py +20 -0
- together/types/beta/jig/queue_cancel_params.py +13 -0
- together/types/beta/jig/queue_cancel_response.py +11 -0
- together/types/beta/jig/queue_metrics_params.py +12 -0
- together/types/beta/jig/queue_metrics_response.py +8 -0
- together/types/beta/jig/queue_retrieve_params.py +15 -0
- together/types/beta/jig/queue_retrieve_response.py +35 -0
- together/types/beta/jig/queue_submit_params.py +19 -0
- together/types/beta/jig/queue_submit_response.py +25 -0
- together/types/beta/jig/secret.py +33 -0
- together/types/beta/jig/secret_create_params.py +34 -0
- together/types/beta/jig/secret_list_response.py +16 -0
- together/types/beta/jig/secret_update_params.py +34 -0
- together/types/beta/jig/volume.py +47 -0
- together/types/beta/jig/volume_create_params.py +34 -0
- together/types/beta/jig/volume_list_response.py +16 -0
- together/types/beta/jig/volume_update_params.py +34 -0
- together/types/beta/jig_deploy_params.py +150 -0
- together/types/beta/jig_list_response.py +16 -0
- together/types/beta/jig_retrieve_logs_params.py +12 -0
- together/types/beta/jig_update_params.py +141 -0
- together/types/chat/completion_create_params.py +11 -0
- together/types/{hardware_list_params.py → endpoint_list_hardware_params.py} +2 -2
- together/types/{hardware_list_response.py → endpoint_list_hardware_response.py} +2 -2
- together/types/models/__init__.py +5 -0
- together/types/{job_retrieve_response.py → models/upload_status_response.py} +3 -3
- {together-2.0.0a17.dist-info → together-2.0.0a19.dist-info}/METADATA +15 -14
- {together-2.0.0a17.dist-info → together-2.0.0a19.dist-info}/RECORD +64 -30
- together/resources/hardware.py +0 -181
- together/resources/jobs.py +0 -214
- together/types/job_list_response.py +0 -47
- {together-2.0.0a17.dist-info → together-2.0.0a19.dist-info}/WHEEL +0 -0
- {together-2.0.0a17.dist-info → together-2.0.0a19.dist-info}/entry_points.txt +0 -0
- {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
|
|
128
|
-
|
|
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
|