together 1.5.24__py3-none-any.whl → 1.5.26__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/cli/api/evaluation.py +84 -18
- together/cli/api/finetune.py +27 -0
- together/cli/api/models.py +79 -1
- together/constants.py +14 -2
- together/filemanager.py +230 -5
- together/resources/batch.py +30 -0
- together/resources/evaluation.py +92 -14
- together/resources/files.py +12 -3
- together/resources/finetune.py +63 -0
- together/resources/models.py +118 -0
- together/types/__init__.py +5 -1
- together/types/batch.py +1 -0
- together/types/evaluation.py +7 -3
- together/types/files.py +1 -1
- together/types/finetune.py +5 -0
- together/types/models.py +50 -1
- together/utils/files.py +1 -1
- {together-1.5.24.dist-info → together-1.5.26.dist-info}/METADATA +4 -2
- {together-1.5.24.dist-info → together-1.5.26.dist-info}/RECORD +22 -22
- {together-1.5.24.dist-info → together-1.5.26.dist-info}/WHEEL +1 -1
- {together-1.5.24.dist-info → together-1.5.26.dist-info}/entry_points.txt +0 -0
- {together-1.5.24.dist-info → together-1.5.26.dist-info/licenses}/LICENSE +0 -0
together/cli/api/evaluation.py
CHANGED
|
@@ -24,10 +24,22 @@ def evaluation(ctx: click.Context) -> None:
|
|
|
24
24
|
help="Type of evaluation to create.",
|
|
25
25
|
)
|
|
26
26
|
@click.option(
|
|
27
|
-
"--judge-model
|
|
27
|
+
"--judge-model",
|
|
28
28
|
type=str,
|
|
29
29
|
required=True,
|
|
30
|
-
help="Name of the judge model to use for evaluation.",
|
|
30
|
+
help="Name or URL of the judge model to use for evaluation.",
|
|
31
|
+
)
|
|
32
|
+
@click.option(
|
|
33
|
+
"--judge-model-source",
|
|
34
|
+
type=click.Choice(["serverless", "dedicated", "external"]),
|
|
35
|
+
required=True,
|
|
36
|
+
help="Source of the judge model.",
|
|
37
|
+
)
|
|
38
|
+
@click.option(
|
|
39
|
+
"--judge-external-api-token",
|
|
40
|
+
type=str,
|
|
41
|
+
required=False,
|
|
42
|
+
help="Optional external API token for the judge model.",
|
|
31
43
|
)
|
|
32
44
|
@click.option(
|
|
33
45
|
"--judge-system-template",
|
|
@@ -48,10 +60,20 @@ def evaluation(ctx: click.Context) -> None:
|
|
|
48
60
|
"Can not be used when model-a-name and other model config parameters are specified",
|
|
49
61
|
)
|
|
50
62
|
@click.option(
|
|
51
|
-
"--model-to-evaluate
|
|
63
|
+
"--model-to-evaluate",
|
|
52
64
|
type=str,
|
|
53
65
|
help="Model name when using the detailed config",
|
|
54
66
|
)
|
|
67
|
+
@click.option(
|
|
68
|
+
"--model-to-evaluate-source",
|
|
69
|
+
type=click.Choice(["serverless", "dedicated", "external"]),
|
|
70
|
+
help="Source of the model to evaluate.",
|
|
71
|
+
)
|
|
72
|
+
@click.option(
|
|
73
|
+
"--model-to-evaluate-external-api-token",
|
|
74
|
+
type=str,
|
|
75
|
+
help="Optional external API token for the model to evaluate.",
|
|
76
|
+
)
|
|
55
77
|
@click.option(
|
|
56
78
|
"--model-to-evaluate-max-tokens",
|
|
57
79
|
type=int,
|
|
@@ -104,9 +126,19 @@ def evaluation(ctx: click.Context) -> None:
|
|
|
104
126
|
Can not be used when model-a-name and other model config parameters are specified",
|
|
105
127
|
)
|
|
106
128
|
@click.option(
|
|
107
|
-
"--model-a
|
|
129
|
+
"--model-a",
|
|
108
130
|
type=str,
|
|
109
|
-
help="Model name for model A when using detailed config.",
|
|
131
|
+
help="Model name or URL for model A when using detailed config.",
|
|
132
|
+
)
|
|
133
|
+
@click.option(
|
|
134
|
+
"--model-a-source",
|
|
135
|
+
type=click.Choice(["serverless", "dedicated", "external"]),
|
|
136
|
+
help="Source of model A.",
|
|
137
|
+
)
|
|
138
|
+
@click.option(
|
|
139
|
+
"--model-a-external-api-token",
|
|
140
|
+
type=str,
|
|
141
|
+
help="Optional external API token for model A.",
|
|
110
142
|
)
|
|
111
143
|
@click.option(
|
|
112
144
|
"--model-a-max-tokens",
|
|
@@ -135,9 +167,19 @@ def evaluation(ctx: click.Context) -> None:
|
|
|
135
167
|
Can not be used when model-b-name and other model config parameters are specified",
|
|
136
168
|
)
|
|
137
169
|
@click.option(
|
|
138
|
-
"--model-b
|
|
170
|
+
"--model-b",
|
|
139
171
|
type=str,
|
|
140
|
-
help="Model name for model B when using detailed config.",
|
|
172
|
+
help="Model name or URL for model B when using detailed config.",
|
|
173
|
+
)
|
|
174
|
+
@click.option(
|
|
175
|
+
"--model-b-source",
|
|
176
|
+
type=click.Choice(["serverless", "dedicated", "external"]),
|
|
177
|
+
help="Source of model B.",
|
|
178
|
+
)
|
|
179
|
+
@click.option(
|
|
180
|
+
"--model-b-external-api-token",
|
|
181
|
+
type=str,
|
|
182
|
+
help="Optional external API token for model B.",
|
|
141
183
|
)
|
|
142
184
|
@click.option(
|
|
143
185
|
"--model-b-max-tokens",
|
|
@@ -162,11 +204,15 @@ def evaluation(ctx: click.Context) -> None:
|
|
|
162
204
|
def create(
|
|
163
205
|
ctx: click.Context,
|
|
164
206
|
type: str,
|
|
165
|
-
|
|
207
|
+
judge_model: str,
|
|
208
|
+
judge_model_source: str,
|
|
166
209
|
judge_system_template: str,
|
|
210
|
+
judge_external_api_token: Optional[str],
|
|
167
211
|
input_data_file_path: str,
|
|
168
212
|
model_field: Optional[str],
|
|
169
|
-
|
|
213
|
+
model_to_evaluate: Optional[str],
|
|
214
|
+
model_to_evaluate_source: Optional[str],
|
|
215
|
+
model_to_evaluate_external_api_token: Optional[str],
|
|
170
216
|
model_to_evaluate_max_tokens: Optional[int],
|
|
171
217
|
model_to_evaluate_temperature: Optional[float],
|
|
172
218
|
model_to_evaluate_system_template: Optional[str],
|
|
@@ -177,13 +223,17 @@ def create(
|
|
|
177
223
|
max_score: Optional[float],
|
|
178
224
|
pass_threshold: Optional[float],
|
|
179
225
|
model_a_field: Optional[str],
|
|
180
|
-
|
|
226
|
+
model_a: Optional[str],
|
|
227
|
+
model_a_source: Optional[str],
|
|
228
|
+
model_a_external_api_token: Optional[str],
|
|
181
229
|
model_a_max_tokens: Optional[int],
|
|
182
230
|
model_a_temperature: Optional[float],
|
|
183
231
|
model_a_system_template: Optional[str],
|
|
184
232
|
model_a_input_template: Optional[str],
|
|
185
233
|
model_b_field: Optional[str],
|
|
186
|
-
|
|
234
|
+
model_b: Optional[str],
|
|
235
|
+
model_b_source: Optional[str],
|
|
236
|
+
model_b_external_api_token: Optional[str],
|
|
187
237
|
model_b_max_tokens: Optional[int],
|
|
188
238
|
model_b_temperature: Optional[float],
|
|
189
239
|
model_b_system_template: Optional[str],
|
|
@@ -203,7 +253,8 @@ def create(
|
|
|
203
253
|
# Check if any config parameters are provided
|
|
204
254
|
config_params_provided = any(
|
|
205
255
|
[
|
|
206
|
-
|
|
256
|
+
model_to_evaluate,
|
|
257
|
+
model_to_evaluate_source,
|
|
207
258
|
model_to_evaluate_max_tokens,
|
|
208
259
|
model_to_evaluate_temperature,
|
|
209
260
|
model_to_evaluate_system_template,
|
|
@@ -223,17 +274,23 @@ def create(
|
|
|
223
274
|
elif config_params_provided:
|
|
224
275
|
# Config mode: config parameters are provided
|
|
225
276
|
model_to_evaluate_final = {
|
|
226
|
-
"
|
|
277
|
+
"model": model_to_evaluate,
|
|
278
|
+
"model_source": model_to_evaluate_source,
|
|
227
279
|
"max_tokens": model_to_evaluate_max_tokens,
|
|
228
280
|
"temperature": model_to_evaluate_temperature,
|
|
229
281
|
"system_template": model_to_evaluate_system_template,
|
|
230
282
|
"input_template": model_to_evaluate_input_template,
|
|
231
283
|
}
|
|
284
|
+
if model_to_evaluate_external_api_token:
|
|
285
|
+
model_to_evaluate_final["external_api_token"] = (
|
|
286
|
+
model_to_evaluate_external_api_token
|
|
287
|
+
)
|
|
232
288
|
|
|
233
289
|
# Build model-a configuration
|
|
234
290
|
model_a_final: Union[Dict[str, Any], None, str] = None
|
|
235
291
|
model_a_config_params = [
|
|
236
|
-
|
|
292
|
+
model_a,
|
|
293
|
+
model_a_source,
|
|
237
294
|
model_a_max_tokens,
|
|
238
295
|
model_a_temperature,
|
|
239
296
|
model_a_system_template,
|
|
@@ -252,17 +309,21 @@ def create(
|
|
|
252
309
|
elif any(model_a_config_params):
|
|
253
310
|
# Config mode: config parameters are provided
|
|
254
311
|
model_a_final = {
|
|
255
|
-
"
|
|
312
|
+
"model": model_a,
|
|
313
|
+
"model_source": model_a_source,
|
|
256
314
|
"max_tokens": model_a_max_tokens,
|
|
257
315
|
"temperature": model_a_temperature,
|
|
258
316
|
"system_template": model_a_system_template,
|
|
259
317
|
"input_template": model_a_input_template,
|
|
260
318
|
}
|
|
319
|
+
if model_a_external_api_token:
|
|
320
|
+
model_a_final["external_api_token"] = model_a_external_api_token
|
|
261
321
|
|
|
262
322
|
# Build model-b configuration
|
|
263
323
|
model_b_final: Union[Dict[str, Any], None, str] = None
|
|
264
324
|
model_b_config_params = [
|
|
265
|
-
|
|
325
|
+
model_b,
|
|
326
|
+
model_b_source,
|
|
266
327
|
model_b_max_tokens,
|
|
267
328
|
model_b_temperature,
|
|
268
329
|
model_b_system_template,
|
|
@@ -281,18 +342,23 @@ def create(
|
|
|
281
342
|
elif any(model_b_config_params):
|
|
282
343
|
# Config mode: config parameters are provided
|
|
283
344
|
model_b_final = {
|
|
284
|
-
"
|
|
345
|
+
"model": model_b,
|
|
346
|
+
"model_source": model_b_source,
|
|
285
347
|
"max_tokens": model_b_max_tokens,
|
|
286
348
|
"temperature": model_b_temperature,
|
|
287
349
|
"system_template": model_b_system_template,
|
|
288
350
|
"input_template": model_b_input_template,
|
|
289
351
|
}
|
|
352
|
+
if model_b_external_api_token:
|
|
353
|
+
model_b_final["external_api_token"] = model_b_external_api_token
|
|
290
354
|
|
|
291
355
|
try:
|
|
292
356
|
response = client.evaluation.create(
|
|
293
357
|
type=type,
|
|
294
|
-
|
|
358
|
+
judge_model=judge_model,
|
|
359
|
+
judge_model_source=judge_model_source,
|
|
295
360
|
judge_system_template=judge_system_template,
|
|
361
|
+
judge_external_api_token=judge_external_api_token,
|
|
296
362
|
input_data_file_path=input_data_file_path,
|
|
297
363
|
model_to_evaluate=model_to_evaluate_final,
|
|
298
364
|
labels=labels_list,
|
together/cli/api/finetune.py
CHANGED
|
@@ -543,3 +543,30 @@ def download(
|
|
|
543
543
|
)
|
|
544
544
|
|
|
545
545
|
click.echo(json.dumps(response.model_dump(exclude_none=True), indent=4))
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
@fine_tuning.command()
|
|
549
|
+
@click.pass_context
|
|
550
|
+
@click.argument("fine_tune_id", type=str, required=True)
|
|
551
|
+
@click.option("--force", is_flag=True, help="Force deletion without confirmation")
|
|
552
|
+
@click.option(
|
|
553
|
+
"--quiet", is_flag=True, help="Do not prompt for confirmation before deleting job"
|
|
554
|
+
)
|
|
555
|
+
def delete(
|
|
556
|
+
ctx: click.Context, fine_tune_id: str, force: bool = False, quiet: bool = False
|
|
557
|
+
) -> None:
|
|
558
|
+
"""Delete fine-tuning job"""
|
|
559
|
+
client: Together = ctx.obj
|
|
560
|
+
|
|
561
|
+
if not quiet:
|
|
562
|
+
confirm_response = input(
|
|
563
|
+
f"Are you sure you want to delete fine-tuning job {fine_tune_id}? "
|
|
564
|
+
"This action cannot be undone. [y/N] "
|
|
565
|
+
)
|
|
566
|
+
if confirm_response.lower() != "y":
|
|
567
|
+
click.echo("Deletion cancelled")
|
|
568
|
+
return
|
|
569
|
+
|
|
570
|
+
response = client.fine_tuning.delete(fine_tune_id, force=force)
|
|
571
|
+
|
|
572
|
+
click.echo(json.dumps(response.model_dump(exclude_none=True), indent=4))
|
together/cli/api/models.py
CHANGED
|
@@ -4,7 +4,7 @@ import click
|
|
|
4
4
|
from tabulate import tabulate
|
|
5
5
|
|
|
6
6
|
from together import Together
|
|
7
|
-
from together.types.models import ModelObject
|
|
7
|
+
from together.types.models import ModelObject, ModelUploadResponse
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
@click.group()
|
|
@@ -53,3 +53,81 @@ def list(ctx: click.Context, type: str | None, json: bool) -> None:
|
|
|
53
53
|
click.echo(json_lib.dumps(display_list, indent=2))
|
|
54
54
|
else:
|
|
55
55
|
click.echo(tabulate(display_list, headers="keys", tablefmt="plain"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@models.command()
|
|
59
|
+
@click.option(
|
|
60
|
+
"--model-name",
|
|
61
|
+
required=True,
|
|
62
|
+
help="The name to give to your uploaded model",
|
|
63
|
+
)
|
|
64
|
+
@click.option(
|
|
65
|
+
"--model-source",
|
|
66
|
+
required=True,
|
|
67
|
+
help="The source location of the model (Hugging Face repo or S3 path)",
|
|
68
|
+
)
|
|
69
|
+
@click.option(
|
|
70
|
+
"--model-type",
|
|
71
|
+
type=click.Choice(["model", "adapter"]),
|
|
72
|
+
default="model",
|
|
73
|
+
help="Whether the model is a full model or an adapter",
|
|
74
|
+
)
|
|
75
|
+
@click.option(
|
|
76
|
+
"--hf-token",
|
|
77
|
+
help="Hugging Face token (if uploading from Hugging Face)",
|
|
78
|
+
)
|
|
79
|
+
@click.option(
|
|
80
|
+
"--description",
|
|
81
|
+
help="A description of your model",
|
|
82
|
+
)
|
|
83
|
+
@click.option(
|
|
84
|
+
"--base-model",
|
|
85
|
+
help="The base model to use for an adapter if setting it to run against a serverless pool. Only used for model_type 'adapter'.",
|
|
86
|
+
)
|
|
87
|
+
@click.option(
|
|
88
|
+
"--lora-model",
|
|
89
|
+
help="The lora pool to use for an adapter if setting it to run against, say, a dedicated pool. Only used for model_type 'adapter'.",
|
|
90
|
+
)
|
|
91
|
+
@click.option(
|
|
92
|
+
"--json",
|
|
93
|
+
is_flag=True,
|
|
94
|
+
help="Output in JSON format",
|
|
95
|
+
)
|
|
96
|
+
@click.pass_context
|
|
97
|
+
def upload(
|
|
98
|
+
ctx: click.Context,
|
|
99
|
+
model_name: str,
|
|
100
|
+
model_source: str,
|
|
101
|
+
model_type: str,
|
|
102
|
+
hf_token: str | None,
|
|
103
|
+
description: str | None,
|
|
104
|
+
base_model: str | None,
|
|
105
|
+
lora_model: str | None,
|
|
106
|
+
json: bool,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Upload a custom model or adapter from Hugging Face or S3"""
|
|
109
|
+
client: Together = ctx.obj
|
|
110
|
+
|
|
111
|
+
response: ModelUploadResponse = client.models.upload(
|
|
112
|
+
model_name=model_name,
|
|
113
|
+
model_source=model_source,
|
|
114
|
+
model_type=model_type,
|
|
115
|
+
hf_token=hf_token,
|
|
116
|
+
description=description,
|
|
117
|
+
base_model=base_model,
|
|
118
|
+
lora_model=lora_model,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if json:
|
|
122
|
+
click.echo(json_lib.dumps(response.model_dump(), indent=2))
|
|
123
|
+
else:
|
|
124
|
+
click.echo(f"Model upload job created successfully!")
|
|
125
|
+
if response.job_id:
|
|
126
|
+
click.echo(f"Job ID: {response.job_id}")
|
|
127
|
+
if response.model_name:
|
|
128
|
+
click.echo(f"Model Name: {response.model_name}")
|
|
129
|
+
if response.model_id:
|
|
130
|
+
click.echo(f"Model ID: {response.model_id}")
|
|
131
|
+
if response.model_source:
|
|
132
|
+
click.echo(f"Model Source: {response.model_source}")
|
|
133
|
+
click.echo(f"Message: {response.message}")
|
together/constants.py
CHANGED
|
@@ -15,6 +15,20 @@ BASE_URL = "https://api.together.xyz/v1"
|
|
|
15
15
|
DOWNLOAD_BLOCK_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
16
16
|
DISABLE_TQDM = False
|
|
17
17
|
|
|
18
|
+
# Upload defaults
|
|
19
|
+
MAX_CONCURRENT_PARTS = 4 # Maximum concurrent parts for multipart upload
|
|
20
|
+
|
|
21
|
+
# Multipart upload constants
|
|
22
|
+
MIN_PART_SIZE_MB = 5 # Minimum part size (S3 requirement)
|
|
23
|
+
TARGET_PART_SIZE_MB = 100 # Target part size for optimal performance
|
|
24
|
+
MAX_MULTIPART_PARTS = 250 # Maximum parts per upload (S3 limit)
|
|
25
|
+
MULTIPART_UPLOAD_TIMEOUT = 300 # Timeout in seconds for uploading each part
|
|
26
|
+
MULTIPART_THRESHOLD_GB = 5.0 # threshold for switching to multipart upload
|
|
27
|
+
|
|
28
|
+
# maximum number of GB sized files we support finetuning for
|
|
29
|
+
MAX_FILE_SIZE_GB = 25.0
|
|
30
|
+
|
|
31
|
+
|
|
18
32
|
# Messages
|
|
19
33
|
MISSING_API_KEY_MESSAGE = """TOGETHER_API_KEY not found.
|
|
20
34
|
Please set it as an environment variable or set it as together.api_key
|
|
@@ -26,8 +40,6 @@ MIN_SAMPLES = 1
|
|
|
26
40
|
# the number of bytes in a gigabyte, used to convert bytes to GB for readable comparison
|
|
27
41
|
NUM_BYTES_IN_GB = 2**30
|
|
28
42
|
|
|
29
|
-
# maximum number of GB sized files we support finetuning for
|
|
30
|
-
MAX_FILE_SIZE_GB = 4.9
|
|
31
43
|
|
|
32
44
|
# expected columns for Parquet files
|
|
33
45
|
PARQUET_EXPECTED_COLUMNS = ["input_ids", "attention_mask", "labels"]
|
together/filemanager.py
CHANGED
|
@@ -1,28 +1,40 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import math
|
|
3
4
|
import os
|
|
4
5
|
import shutil
|
|
5
6
|
import stat
|
|
6
7
|
import tempfile
|
|
7
8
|
import uuid
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
10
|
from functools import partial
|
|
9
11
|
from pathlib import Path
|
|
10
|
-
from typing import Tuple
|
|
12
|
+
from typing import Any, Dict, List, Tuple
|
|
11
13
|
|
|
12
14
|
import requests
|
|
13
15
|
from filelock import FileLock
|
|
14
16
|
from requests.structures import CaseInsensitiveDict
|
|
15
17
|
from tqdm import tqdm
|
|
16
|
-
from tqdm.utils import CallbackIOWrapper
|
|
17
18
|
|
|
18
|
-
import together.utils
|
|
19
19
|
from together.abstract import api_requestor
|
|
20
|
-
from together.constants import
|
|
20
|
+
from together.constants import (
|
|
21
|
+
DISABLE_TQDM,
|
|
22
|
+
DOWNLOAD_BLOCK_SIZE,
|
|
23
|
+
MAX_CONCURRENT_PARTS,
|
|
24
|
+
MAX_FILE_SIZE_GB,
|
|
25
|
+
MAX_RETRIES,
|
|
26
|
+
MIN_PART_SIZE_MB,
|
|
27
|
+
NUM_BYTES_IN_GB,
|
|
28
|
+
TARGET_PART_SIZE_MB,
|
|
29
|
+
MAX_MULTIPART_PARTS,
|
|
30
|
+
MULTIPART_UPLOAD_TIMEOUT,
|
|
31
|
+
)
|
|
21
32
|
from together.error import (
|
|
22
33
|
APIError,
|
|
23
34
|
AuthenticationError,
|
|
24
35
|
DownloadError,
|
|
25
36
|
FileTypeError,
|
|
37
|
+
ResponseError,
|
|
26
38
|
)
|
|
27
39
|
from together.together_response import TogetherResponse
|
|
28
40
|
from together.types import (
|
|
@@ -32,6 +44,8 @@ from together.types import (
|
|
|
32
44
|
TogetherClient,
|
|
33
45
|
TogetherRequest,
|
|
34
46
|
)
|
|
47
|
+
from tqdm.utils import CallbackIOWrapper
|
|
48
|
+
import together.utils
|
|
35
49
|
|
|
36
50
|
|
|
37
51
|
def chmod_and_replace(src: Path, dst: Path) -> None:
|
|
@@ -339,7 +353,7 @@ class UploadManager:
|
|
|
339
353
|
)
|
|
340
354
|
redirect_url, file_id = self.get_upload_url(url, file, purpose, filetype)
|
|
341
355
|
|
|
342
|
-
file_size = os.stat(file
|
|
356
|
+
file_size = os.stat(file).st_size
|
|
343
357
|
|
|
344
358
|
with tqdm(
|
|
345
359
|
total=file_size,
|
|
@@ -385,3 +399,214 @@ class UploadManager:
|
|
|
385
399
|
assert isinstance(response, TogetherResponse)
|
|
386
400
|
|
|
387
401
|
return FileResponse(**response.data)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class MultipartUploadManager:
|
|
405
|
+
"""Handles multipart uploads for large files"""
|
|
406
|
+
|
|
407
|
+
def __init__(self, client: TogetherClient) -> None:
|
|
408
|
+
self._client = client
|
|
409
|
+
self.max_concurrent_parts = MAX_CONCURRENT_PARTS
|
|
410
|
+
|
|
411
|
+
def upload(
|
|
412
|
+
self,
|
|
413
|
+
url: str,
|
|
414
|
+
file: Path,
|
|
415
|
+
purpose: FilePurpose,
|
|
416
|
+
) -> FileResponse:
|
|
417
|
+
"""Upload large file using multipart upload"""
|
|
418
|
+
|
|
419
|
+
file_size = os.stat(file).st_size
|
|
420
|
+
|
|
421
|
+
file_size_gb = file_size / NUM_BYTES_IN_GB
|
|
422
|
+
if file_size_gb > MAX_FILE_SIZE_GB:
|
|
423
|
+
raise FileTypeError(
|
|
424
|
+
f"File size {file_size_gb:.1f}GB exceeds maximum supported size of {MAX_FILE_SIZE_GB}GB"
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
part_size, num_parts = self._calculate_parts(file_size)
|
|
428
|
+
|
|
429
|
+
file_type = self._get_file_type(file)
|
|
430
|
+
upload_info = None
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
upload_info = self._initiate_upload(
|
|
434
|
+
url, file, file_size, num_parts, purpose, file_type
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
completed_parts = self._upload_parts_concurrent(
|
|
438
|
+
file, upload_info, part_size
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
return self._complete_upload(
|
|
442
|
+
url, upload_info["upload_id"], upload_info["file_id"], completed_parts
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
except Exception as e:
|
|
446
|
+
# Cleanup on failure
|
|
447
|
+
if upload_info is not None:
|
|
448
|
+
self._abort_upload(
|
|
449
|
+
url, upload_info["upload_id"], upload_info["file_id"]
|
|
450
|
+
)
|
|
451
|
+
raise e
|
|
452
|
+
|
|
453
|
+
def _get_file_type(self, file: Path) -> str:
|
|
454
|
+
"""Get file type from extension, raising ValueError for unsupported extensions"""
|
|
455
|
+
if file.suffix == ".jsonl":
|
|
456
|
+
return "jsonl"
|
|
457
|
+
elif file.suffix == ".parquet":
|
|
458
|
+
return "parquet"
|
|
459
|
+
elif file.suffix == ".csv":
|
|
460
|
+
return "csv"
|
|
461
|
+
else:
|
|
462
|
+
raise ValueError(
|
|
463
|
+
f"Unsupported file extension: '{file.suffix}'. "
|
|
464
|
+
f"Supported extensions: .jsonl, .parquet, .csv"
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
def _calculate_parts(self, file_size: int) -> tuple[int, int]:
|
|
468
|
+
"""Calculate optimal part size and count"""
|
|
469
|
+
min_part_size = MIN_PART_SIZE_MB * 1024 * 1024 # 5MB
|
|
470
|
+
target_part_size = TARGET_PART_SIZE_MB * 1024 * 1024 # 100MB
|
|
471
|
+
|
|
472
|
+
if file_size <= target_part_size:
|
|
473
|
+
return file_size, 1
|
|
474
|
+
|
|
475
|
+
num_parts = min(MAX_MULTIPART_PARTS, math.ceil(file_size / target_part_size))
|
|
476
|
+
part_size = math.ceil(file_size / num_parts)
|
|
477
|
+
|
|
478
|
+
if part_size < min_part_size:
|
|
479
|
+
part_size = min_part_size
|
|
480
|
+
num_parts = math.ceil(file_size / part_size)
|
|
481
|
+
|
|
482
|
+
return part_size, num_parts
|
|
483
|
+
|
|
484
|
+
def _initiate_upload(
|
|
485
|
+
self,
|
|
486
|
+
url: str,
|
|
487
|
+
file: Path,
|
|
488
|
+
file_size: int,
|
|
489
|
+
num_parts: int,
|
|
490
|
+
purpose: FilePurpose,
|
|
491
|
+
file_type: str,
|
|
492
|
+
) -> Any:
|
|
493
|
+
"""Initiate multipart upload with backend"""
|
|
494
|
+
|
|
495
|
+
requestor = api_requestor.APIRequestor(client=self._client)
|
|
496
|
+
|
|
497
|
+
payload = {
|
|
498
|
+
"file_name": file.name,
|
|
499
|
+
"file_size": file_size,
|
|
500
|
+
"num_parts": num_parts,
|
|
501
|
+
"purpose": purpose.value,
|
|
502
|
+
"file_type": file_type,
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
response, _, _ = requestor.request(
|
|
506
|
+
options=TogetherRequest(
|
|
507
|
+
method="POST",
|
|
508
|
+
url="files/multipart/initiate",
|
|
509
|
+
params=payload,
|
|
510
|
+
),
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
return response.data
|
|
514
|
+
|
|
515
|
+
def _upload_parts_concurrent(
|
|
516
|
+
self, file: Path, upload_info: Dict[str, Any], part_size: int
|
|
517
|
+
) -> List[Dict[str, Any]]:
|
|
518
|
+
"""Upload file parts concurrently with progress tracking"""
|
|
519
|
+
|
|
520
|
+
parts = upload_info["parts"]
|
|
521
|
+
completed_parts = []
|
|
522
|
+
|
|
523
|
+
with ThreadPoolExecutor(max_workers=self.max_concurrent_parts) as executor:
|
|
524
|
+
with tqdm(total=len(parts), desc="Uploading parts", unit="part") as pbar:
|
|
525
|
+
future_to_part = {}
|
|
526
|
+
|
|
527
|
+
with open(file, "rb") as f:
|
|
528
|
+
for part_info in parts:
|
|
529
|
+
f.seek((part_info["PartNumber"] - 1) * part_size)
|
|
530
|
+
part_data = f.read(part_size)
|
|
531
|
+
|
|
532
|
+
future = executor.submit(
|
|
533
|
+
self._upload_single_part, part_info, part_data
|
|
534
|
+
)
|
|
535
|
+
future_to_part[future] = part_info["PartNumber"]
|
|
536
|
+
|
|
537
|
+
# Collect results
|
|
538
|
+
for future in as_completed(future_to_part):
|
|
539
|
+
part_number = future_to_part[future]
|
|
540
|
+
try:
|
|
541
|
+
etag = future.result()
|
|
542
|
+
completed_parts.append(
|
|
543
|
+
{"part_number": part_number, "etag": etag}
|
|
544
|
+
)
|
|
545
|
+
pbar.update(1)
|
|
546
|
+
except Exception as e:
|
|
547
|
+
raise Exception(f"Failed to upload part {part_number}: {e}")
|
|
548
|
+
|
|
549
|
+
completed_parts.sort(key=lambda x: x["part_number"])
|
|
550
|
+
return completed_parts
|
|
551
|
+
|
|
552
|
+
def _upload_single_part(self, part_info: Dict[str, Any], part_data: bytes) -> str:
|
|
553
|
+
"""Upload a single part and return ETag"""
|
|
554
|
+
|
|
555
|
+
response = requests.put(
|
|
556
|
+
part_info["URL"],
|
|
557
|
+
data=part_data,
|
|
558
|
+
headers=part_info.get("Headers", {}),
|
|
559
|
+
timeout=MULTIPART_UPLOAD_TIMEOUT,
|
|
560
|
+
)
|
|
561
|
+
response.raise_for_status()
|
|
562
|
+
|
|
563
|
+
etag = response.headers.get("ETag", "").strip('"')
|
|
564
|
+
if not etag:
|
|
565
|
+
raise ResponseError(f"No ETag returned for part {part_info['PartNumber']}")
|
|
566
|
+
|
|
567
|
+
return etag
|
|
568
|
+
|
|
569
|
+
def _complete_upload(
|
|
570
|
+
self,
|
|
571
|
+
url: str,
|
|
572
|
+
upload_id: str,
|
|
573
|
+
file_id: str,
|
|
574
|
+
completed_parts: List[Dict[str, Any]],
|
|
575
|
+
) -> FileResponse:
|
|
576
|
+
"""Complete the multipart upload"""
|
|
577
|
+
|
|
578
|
+
requestor = api_requestor.APIRequestor(client=self._client)
|
|
579
|
+
|
|
580
|
+
payload = {
|
|
581
|
+
"upload_id": upload_id,
|
|
582
|
+
"file_id": file_id,
|
|
583
|
+
"parts": completed_parts,
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
response, _, _ = requestor.request(
|
|
587
|
+
options=TogetherRequest(
|
|
588
|
+
method="POST",
|
|
589
|
+
url="files/multipart/complete",
|
|
590
|
+
params=payload,
|
|
591
|
+
),
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
return FileResponse(**response.data.get("file", response.data))
|
|
595
|
+
|
|
596
|
+
def _abort_upload(self, url: str, upload_id: str, file_id: str) -> None:
|
|
597
|
+
"""Abort the multipart upload"""
|
|
598
|
+
|
|
599
|
+
requestor = api_requestor.APIRequestor(client=self._client)
|
|
600
|
+
|
|
601
|
+
payload = {
|
|
602
|
+
"upload_id": upload_id,
|
|
603
|
+
"file_id": file_id,
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
requestor.request(
|
|
607
|
+
options=TogetherRequest(
|
|
608
|
+
method="POST",
|
|
609
|
+
url="files/multipart/abort",
|
|
610
|
+
params=payload,
|
|
611
|
+
),
|
|
612
|
+
)
|
together/resources/batch.py
CHANGED
|
@@ -72,6 +72,21 @@ class Batches:
|
|
|
72
72
|
jobs = response.data or []
|
|
73
73
|
return [BatchJob(**job) for job in jobs]
|
|
74
74
|
|
|
75
|
+
def cancel_batch(self, batch_job_id: str) -> BatchJob:
|
|
76
|
+
requestor = api_requestor.APIRequestor(
|
|
77
|
+
client=self._client,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
response, _, _ = requestor.request(
|
|
81
|
+
options=TogetherRequest(
|
|
82
|
+
method="POST",
|
|
83
|
+
url=f"batches/{batch_job_id}/cancel",
|
|
84
|
+
),
|
|
85
|
+
stream=False,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return BatchJob(**response.data)
|
|
89
|
+
|
|
75
90
|
|
|
76
91
|
class AsyncBatches:
|
|
77
92
|
def __init__(self, client: TogetherClient) -> None:
|
|
@@ -133,3 +148,18 @@ class AsyncBatches:
|
|
|
133
148
|
assert isinstance(response, TogetherResponse)
|
|
134
149
|
jobs = response.data or []
|
|
135
150
|
return [BatchJob(**job) for job in jobs]
|
|
151
|
+
|
|
152
|
+
async def cancel_batch(self, batch_job_id: str) -> BatchJob:
|
|
153
|
+
requestor = api_requestor.APIRequestor(
|
|
154
|
+
client=self._client,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
response, _, _ = await requestor.arequest(
|
|
158
|
+
options=TogetherRequest(
|
|
159
|
+
method="POST",
|
|
160
|
+
url=f"batches/{batch_job_id}/cancel",
|
|
161
|
+
),
|
|
162
|
+
stream=False,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return BatchJob(**response.data)
|