Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "2.0.0-alpha.10"
".": "2.0.0-alpha.11"
}
4 changes: 2 additions & 2 deletions .stats.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
configured_endpoints: 44
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai%2Ftogetherai-291c169d09f5ccc52f1e20f6f239db136003f4735ebd82f14f10cfdf96bb88fd.yml
openapi_spec_hash: 241fba23e79ab8bcfb06c7781c01aa27
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai%2Ftogetherai-817bdc0e9a5082575f07386056968f56af20cbc40cbbc716ab4b8c4ec9220b53.yml
openapi_spec_hash: 30b3f6d251dfd02bca8ffa3f755e7574
config_hash: 9749f2f8998aa6b15452b2187ff675b9
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## 2.0.0-alpha.11 (2025-12-16)

Full Changelog: [v2.0.0-alpha.10...v2.0.0-alpha.11](https://github.com/togethercomputer/together-py/compare/v2.0.0-alpha.10...v2.0.0-alpha.11)

### Features

* **api:** api update ([17ad3ec](https://github.com/togethercomputer/together-py/commit/17ad3ec91a06a7e886252d4b688c3a9e217a3799))
* **api:** api update ([ebc3414](https://github.com/togethercomputer/together-py/commit/ebc3414e28db0309fef5aeed456e242048b5d13c))
* **files:** add support for string alternative to file upload type ([db59ed6](https://github.com/togethercomputer/together-py/commit/db59ed6235f2e18db100a72084c2fefc22354d15))


### Chores

* **internal:** add missing files argument to base client ([6977285](https://github.com/togethercomputer/together-py/commit/69772856908b8378c74eed382735523e91011d90))

## 2.0.0-alpha.10 (2025-12-15)

Full Changelog: [v2.0.0-alpha.9...v2.0.0-alpha.10](https://github.com/togethercomputer/together-py/compare/v2.0.0-alpha.9...v2.0.0-alpha.10)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "together"
version = "2.0.0-alpha.10"
version = "2.0.0-alpha.11"
description = "The official Python library for the together API"
dynamic = ["readme"]
license = "Apache-2.0"
Expand Down
10 changes: 8 additions & 2 deletions src/together/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1247,9 +1247,12 @@ def patch(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
files: RequestFiles | None = None,
options: RequestOptions = {},
) -> ResponseT:
opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options)
opts = FinalRequestOptions.construct(
method="patch", url=path, json_data=body, files=to_httpx_files(files), **options
)
return self.request(cast_to, opts)

def put(
Expand Down Expand Up @@ -1767,9 +1770,12 @@ async def patch(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
files: RequestFiles | None = None,
options: RequestOptions = {},
) -> ResponseT:
opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options)
opts = FinalRequestOptions.construct(
method="patch", url=path, json_data=body, files=to_httpx_files(files), **options
)
return await self.request(cast_to, opts)

async def put(
Expand Down
2 changes: 1 addition & 1 deletion src/together/_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

__title__ = "together"
__version__ = "2.0.0-alpha.10" # x-release-please-version
__version__ = "2.0.0-alpha.11" # x-release-please-version
15 changes: 12 additions & 3 deletions src/together/lib/cli/api/fine_tuning.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@
import click
from rich import print as rprint
from tabulate import tabulate
from rich.json import JSON
from click.core import ParameterSource # type: ignore[attr-defined]

from together import Together
from together.types import fine_tuning_estimate_price_params as pe_params
from together._types import NOT_GIVEN, NotGiven
from together.lib.utils import log_warn
from together.lib.utils.tools import format_timestamp, finetune_price_to_dollars
from together.lib.cli.api.utils import INT_WITH_MAX, BOOL_WITH_AUTO
from together.lib.cli.api.utils import INT_WITH_MAX, BOOL_WITH_AUTO, generate_progress_bar
from together.lib.resources.files import DownloadManager
from together.lib.utils.serializer import datetime_serializer
from together.types.finetune_response import TrainingTypeFullTrainingType, TrainingTypeLoRaTrainingType
Expand Down Expand Up @@ -361,7 +362,7 @@ def create(
rpo_alpha=rpo_alpha or 0,
simpo_gamma=simpo_gamma or 0,
)

finetune_price_estimation_result = client.fine_tuning.estimate_price(
training_file=training_file,
validation_file=validation_file,
Expand Down Expand Up @@ -425,6 +426,9 @@ def list(ctx: click.Context) -> None:
"Price": f"""${
finetune_price_to_dollars(float(str(i.total_price)))
}""", # convert to string for mypy typing
"Progress": generate_progress_bar(
i, datetime.now().astimezone(), use_rich=False
),
}
)
table = tabulate(display_list, headers="keys", tablefmt="grid", showindex=True)
Expand All @@ -444,7 +448,12 @@ def retrieve(ctx: click.Context, fine_tune_id: str) -> None:
# remove events from response for cleaner output
response.events = None

click.echo(json.dumps(response.model_dump(exclude_none=True), indent=4))
rprint(JSON.from_data(response.model_json_schema()))
progress_text = generate_progress_bar(
response, datetime.now().astimezone(), use_rich=True
)
prefix = f"Status: [bold]{response.status}[/bold],"
rprint(f"{prefix} {progress_text}")


@fine_tuning.command()
Expand Down
105 changes: 97 additions & 8 deletions src/together/lib/cli/api/utils.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
from __future__ import annotations

from typing import Literal
import re
import math
from typing import List, Union, Literal
from gettext import gettext as _
from typing_extensions import override
from datetime import datetime

import click

from together.lib.types.fine_tuning import COMPLETED_STATUSES, FinetuneResponse
from together.types.finetune_response import FinetuneResponse as _FinetuneResponse
from together.types.fine_tuning_list_response import Data

_PROGRESS_BAR_WIDTH = 40


class AutoIntParamType(click.ParamType):
name = "integer_or_max"
_number_class = int

@override
def convert(
def convert( # pyright: ignore[reportImplicitOverride]
self, value: str, param: click.Parameter | None, ctx: click.Context | None
) -> int | Literal["max"] | None:
if value == "max":
Expand All @@ -21,7 +28,9 @@ def convert(
return int(value)
except ValueError:
self.fail(
_("{value!r} is not a valid {number_type}.").format(value=value, number_type=self.name),
_("{value!r} is not a valid {number_type}.").format(
value=value, number_type=self.name
),
param,
ctx,
)
Expand All @@ -30,8 +39,7 @@ def convert(
class BooleanWithAutoParamType(click.ParamType):
name = "boolean_or_auto"

@override
def convert(
def convert( # pyright: ignore[reportImplicitOverride]
self, value: str, param: click.Parameter | None, ctx: click.Context | None
) -> bool | Literal["auto"] | None:
if value == "auto":
Expand All @@ -40,11 +48,92 @@ def convert(
return bool(value)
except ValueError:
self.fail(
_("{value!r} is not a valid {type}.").format(value=value, type=self.name),
_("{value!r} is not a valid {type}.").format(
value=value, type=self.name
),
param,
ctx,
)


INT_WITH_MAX = AutoIntParamType()
BOOL_WITH_AUTO = BooleanWithAutoParamType()


def _human_readable_time(timedelta: float) -> str:
"""Convert a timedelta to a compact human-readble string
Examples:
00:00:10 -> 10s
01:23:45 -> 1h 23min 45s
1 Month 23 days 04:56:07 -> 1month 23d 4h 56min 7s
Args:
timedelta (float): The timedelta in seconds to convert.
Returns:
A string representing the timedelta in a human-readable format.
"""
units = [
(30 * 24 * 60 * 60, "month"), # 30 days
(24 * 60 * 60, "d"),
(60 * 60, "h"),
(60, "min"),
(1, "s"),
]

total_seconds = int(timedelta)
parts: List[str] = []

for unit_seconds, unit_name in units:
if total_seconds >= unit_seconds:
value = total_seconds // unit_seconds
total_seconds %= unit_seconds
parts.append(f"{value}{unit_name}")

return " ".join(parts) if parts else "0s"


def generate_progress_bar(
finetune_job: Union[Data, FinetuneResponse, _FinetuneResponse], current_time: datetime, use_rich: bool = False
) -> str:
"""Generate a progress bar for a finetune job.
Args:
finetune_job: The finetune job to generate a progress bar for.
current_time: The current time.
use_rich: Whether to use rich formatting.
Returns:
A string representing the progress bar.
"""
progress = "Progress: [bold red]unavailable[/bold red]"
if finetune_job.status in COMPLETED_STATUSES:
progress = "Progress: [bold green]completed[/bold green]"
elif finetune_job.updated_at is not None:
update_at = finetune_job.updated_at.astimezone()

if finetune_job.progress is not None:
if current_time < update_at:
return progress

if not finetune_job.progress.estimate_available:
return progress

if finetune_job.progress.seconds_remaining <= 0:
return progress

elapsed_time = (current_time - update_at).total_seconds()
ratio_filled = min(
elapsed_time / finetune_job.progress.seconds_remaining, 1.0
)
percentage = ratio_filled * 100
filled = math.ceil(ratio_filled * _PROGRESS_BAR_WIDTH)
bar = "█" * filled + "░" * (_PROGRESS_BAR_WIDTH - filled)
time_left = "N/A"
if finetune_job.progress.seconds_remaining > elapsed_time:
time_left = _human_readable_time(
finetune_job.progress.seconds_remaining - elapsed_time
)
time_text = f"{time_left} left"
progress = f"Progress: {bar} [bold]{percentage:>3.0f}%[/bold] [yellow]{time_text}[/yellow]"

if use_rich:
return progress

return re.sub(r"\[/?[^\]]+\]", "", progress)
3 changes: 3 additions & 0 deletions src/together/lib/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
# Download defaults
DOWNLOAD_BLOCK_SIZE = 10 * 1024 * 1024 # 10 MB
DISABLE_TQDM = False
MAX_DOWNLOAD_RETRIES = 5 # Maximum retries for download failures
DOWNLOAD_INITIAL_RETRY_DELAY = 1.0 # Initial retry delay in seconds
DOWNLOAD_MAX_RETRY_DELAY = 30.0 # Maximum retry delay in seconds

# Upload defaults
MAX_CONCURRENT_PARTS = 4 # Maximum concurrent parts for multipart upload
Expand Down
71 changes: 65 additions & 6 deletions src/together/lib/resources/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
import math
import stat
import time
import uuid
import shutil
import asyncio
Expand All @@ -29,12 +30,15 @@
MAX_MULTIPART_PARTS,
TARGET_PART_SIZE_MB,
MAX_CONCURRENT_PARTS,
MAX_DOWNLOAD_RETRIES,
MULTIPART_THRESHOLD_GB,
DOWNLOAD_MAX_RETRY_DELAY,
MULTIPART_UPLOAD_TIMEOUT,
DOWNLOAD_INITIAL_RETRY_DELAY,
)
from ..._resource import SyncAPIResource, AsyncAPIResource
from ..types.error import DownloadError, FileTypeError
from ..._exceptions import APIStatusError, AuthenticationError
from ..._exceptions import APIStatusError, APIConnectionError, AuthenticationError

log: logging.Logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -198,21 +202,76 @@ def download(

assert file_size != 0, "Unable to retrieve remote file."

# Download with retry logic
bytes_downloaded = 0
retry_count = 0
retry_delay = DOWNLOAD_INITIAL_RETRY_DELAY

with tqdm(
total=file_size,
unit="B",
unit_scale=True,
desc=f"Downloading file {file_path.name}",
disable=bool(DISABLE_TQDM),
) as pbar:
for chunk in response.iter_bytes(DOWNLOAD_BLOCK_SIZE):
pbar.update(len(chunk))
temp_file.write(chunk) # type: ignore
while bytes_downloaded < file_size:
try:
# If this is a retry, close the previous response and create a new one with Range header
if bytes_downloaded > 0:
response.close()

log.info(f"Resuming download from byte {bytes_downloaded}")
response = self._client.get(
path=url,
cast_to=httpx.Response,
stream=True,
options=RequestOptions(
headers={"Range": f"bytes={bytes_downloaded}-"},
),
)

# Download chunks
for chunk in response.iter_bytes(DOWNLOAD_BLOCK_SIZE):
temp_file.write(chunk) # type: ignore
bytes_downloaded += len(chunk)
pbar.update(len(chunk))

# Successfully completed download
break

except (httpx.RequestError, httpx.StreamError, APIConnectionError) as e:
if retry_count >= MAX_DOWNLOAD_RETRIES:
log.error(f"Download failed after {retry_count} retries")
raise DownloadError(
f"Download failed after {retry_count} retries. Last error: {str(e)}"
) from e

retry_count += 1
log.warning(
f"Download interrupted at {bytes_downloaded}/{file_size} bytes. "
f"Retry {retry_count}/{MAX_DOWNLOAD_RETRIES} in {retry_delay}s..."
)
time.sleep(retry_delay)

# Exponential backoff with max delay cap
retry_delay = min(retry_delay * 2, DOWNLOAD_MAX_RETRY_DELAY)

except APIStatusError as e:
# For API errors, don't retry
log.error(f"API error during download: {e}")
raise APIStatusError(
"Error downloading file",
response=e.response,
body=e.response,
) from e

# Close the response
response.close()

# Raise exception if remote file size does not match downloaded file size
if os.stat(temp_file.name).st_size != file_size:
DownloadError(
f"Downloaded file size `{pbar.n}` bytes does not match remote file size `{file_size}` bytes."
raise DownloadError(
f"Downloaded file size `{bytes_downloaded}` bytes does not match remote file size `{file_size}` bytes."
)

# Moves temp file to output file path
Expand Down
Loading