Skip to content
Open
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
8 changes: 8 additions & 0 deletions src/together/lib/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ def main(
os.environ.setdefault("TOGETHER_LOG", "debug")
setup_logging() # Must run this again here to allow the new logging configuration to take effect

if api_key == "":
click.secho(
"Error: api key missing.\n\nThe api_key must be set either by passing --api-key to the command or by setting the TOGETHER_API_KEY environment variable",
fg="red",
)
click.secho("\nYou can find your api key at https://api.together.xyz/settings/api-keys", fg="yellow")
sys.exit(1)

try:
ctx.obj = together.Together(
api_key=api_key,
Expand Down
37 changes: 33 additions & 4 deletions src/together/lib/cli/api/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,38 @@ def _human_readable_time(timedelta: float) -> str:
return " ".join(parts) if parts else "0s"


def generate_progress_text(
finetune_job: Union[Data, FinetuneResponse, _FinetuneResponse], current_time: datetime
) -> str:
"""Generate a progress text for a finetune job.
Args:
finetune_job: The finetune job to generate a progress text for.
current_time: The current time.
Returns:
A string representing the progress text.
"""
time_text = ""
if getattr(finetune_job, "started_at", None) is not None and isinstance(finetune_job.started_at, datetime):
started_at = finetune_job.started_at.astimezone()

if finetune_job.progress is not None:
if current_time < started_at:
return ""

if not finetune_job.progress.estimate_available:
return ""

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

elapsed_time = (current_time - started_at).total_seconds()
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"
return time_text


def generate_progress_bar(
finetune_job: Union[Data, FinetuneResponse, _FinetuneResponse], current_time: datetime, use_rich: bool = False
) -> str:
Expand Down Expand Up @@ -122,10 +154,7 @@ def generate_progress_bar(
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"
time_text = generate_progress_text(finetune_job, current_time)
progress = f"Progress: {bar} [bold]{percentage:>3.0f}%[/bold] [yellow]{time_text}[/yellow]"

if use_rich:
Expand Down
30 changes: 22 additions & 8 deletions src/together/lib/cli/api/files/list.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,47 @@
from typing import Any, Dict, List
from textwrap import wrap
from datetime import datetime, timezone

import click
from tabulate import tabulate

from together import Together
from together.lib.utils import convert_bytes, convert_unix_timestamp
from together._utils._json import openapi_dumps
from together.lib.utils.tools import format_timestamp
from together.lib.cli.api._utils import handle_api_errors


@click.command()
@click.pass_context
@click.option("--json", is_flag=True, help="Print output in JSON format")
@handle_api_errors("Files")
def list(ctx: click.Context) -> None:
def list(ctx: click.Context, json: bool) -> None:
"""List files"""
client: Together = ctx.obj

response = client.files.list()

response.data = response.data or []

# Use a default datetime for None values to make sure the key function always returns a comparable value
# Sort newest to oldest
epoch_start = datetime.fromtimestamp(0, tz=timezone.utc)
response.data.sort(key=lambda x: x.created_at or epoch_start, reverse=True)

if json:
click.echo(openapi_dumps(response.data))
return

display_list: List[Dict[str, Any]] = []
for i in response.data or []:
for i in response.data:
display_list.append(
{
"File name": "\n".join(wrap(i.filename or "", width=30)),
"File ID": i.id,
"Size": convert_bytes(float(str(i.bytes))), # convert to string for mypy typing
"Created At": convert_unix_timestamp(i.created_at or 0),
"ID": click.style(i.id, fg="blue"),
"File name": click.style(i.filename or "", fg="blue"),
"Size": click.style(convert_bytes(float(str(i.bytes))), fg="blue"), # convert to string for mypy typing
"Created At": click.style(format_timestamp(convert_unix_timestamp(i.created_at or 0)), fg="blue"),
}
)
table = tabulate(display_list, headers="keys", tablefmt="grid", showindex=True)
table = tabulate(display_list, headers="keys")

click.echo(table)
5 changes: 4 additions & 1 deletion src/together/lib/cli/api/fine_tuning/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,10 @@ def create(
)

if model is None and from_checkpoint is None:
raise click.BadParameter("You must specify either a model or a checkpoint")
raise click.MissingParameter(
"",
param_type="option --model or --from-checkpoint",
)

model_name = model
if from_checkpoint is not None:
Expand Down
27 changes: 19 additions & 8 deletions src/together/lib/cli/api/fine_tuning/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@

import click

from together import NOT_GIVEN, NotGiven, Together
from together import NOT_GIVEN, APIError, NotGiven, Together, APIStatusError
from together.lib import DownloadManager
from together.lib.cli.api._utils import handle_api_errors
from together.types.finetune_response import TrainingTypeFullTrainingType, TrainingTypeLoRaTrainingType

_FT_JOB_WITH_STEP_REGEX = r"^ft-[\dabcdef-]+:\d+$"
Expand Down Expand Up @@ -40,6 +41,7 @@
default="merged",
help="Specifies checkpoint type. 'merged' and 'adapter' options work only for LoRA jobs.",
)
@handle_api_errors("Fine-tuning")
def download(
ctx: click.Context,
fine_tune_id: str,
Expand Down Expand Up @@ -84,11 +86,20 @@ def download(
if isinstance(output_dir, str):
output = Path(output_dir)

file_path, file_size = DownloadManager(client).download(
url=url,
output=output,
remote_name=remote_name,
fetch_metadata=True,
)
try:
file_path, file_size = DownloadManager(client).download(
url=url,
output=output,
remote_name=remote_name,
fetch_metadata=True,
)

click.echo(json.dumps({"object": "local", "id": fine_tune_id, "filename": file_path, "size": file_size}, indent=4))
click.echo(
json.dumps({"object": "local", "id": fine_tune_id, "filename": file_path, "size": file_size}, indent=4)
)
except APIStatusError as e:
raise APIError(
"Training job is not downloadable. This may be because the job is not in a completed state.",
request=e.request,
body=None,
) from e
60 changes: 41 additions & 19 deletions src/together/lib/cli/api/fine_tuning/list.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
from typing import Any, Dict, List
from datetime import datetime, timezone
from textwrap import wrap

import click
from tabulate import tabulate

from together import Together
from together.lib.utils import finetune_price_to_dollars
from together.lib.cli.api._utils import handle_api_errors, generate_progress_bar
from together.lib.utils.serializer import datetime_serializer
from together._utils._json import openapi_dumps
from together.lib.utils.tools import format_datetime
from together.lib.cli.api._utils import handle_api_errors, generate_progress_text


@click.command()
Expand All @@ -21,32 +21,54 @@ def list(ctx: click.Context, json: bool) -> None:

response = client.fine_tuning.list()

if json:
from json import dumps

click.echo(dumps(response.model_dump(exclude_none=True), indent=2, default=datetime_serializer))
return

response.data = response.data or []

# Use a default datetime for None values to make sure the key function always returns a comparable value
# Sort newest to oldest
epoch_start = datetime.fromtimestamp(0, tz=timezone.utc)
response.data.sort(key=lambda x: x.created_at or epoch_start)
response.data.sort(key=lambda x: x.created_at or epoch_start, reverse=True)

if json:
click.echo(openapi_dumps(response.data))
return

display_list: List[Dict[str, Any]] = []
for i in response.data:
price = finetune_price_to_dollars(float(str(i.total_price))) # convert to string for mypy typing

# Show the progress text if the job is running
status = str(i.status) # Convert to string for mypy typing
status_color = status_colors[i.status] if i.status in status_colors else "white"
if i.status == "running":
status += f": {generate_progress_text(i, datetime.now(timezone.utc))}"

display_list.append(
{
"Fine-tune ID": i.id,
"Model Output Name": "\n".join(wrap(i.x_model_output_name or "", width=30)),
"Status": i.status,
"Created At": i.created_at,
"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),
"ID": click.style(i.id, fg=status_color),
"Base Model": click.style(i.model or "", fg=status_color),
"Suffix": click.style(i.suffix or "", fg=status_color),
"Status": click.style(status, fg=status_color),
"Price": click.style(f"${price:,.2f}", fg=status_color),
"Created At": click.style(format_datetime(i.created_at), fg=status_color),
}
)
table = tabulate(display_list, headers="keys", tablefmt="grid", showindex=True)
table = tabulate(display_list, headers="keys")

click.echo(table)


status_colors = {
# Active status are yellow
"pending": "yellow",
"queued": "yellow",
"running": "yellow",
"compressing": "yellow",
"uploading": "yellow",
"cancel_requested": "yellow",
# Bad ending states are red
"cancelled": "red",
"error": "red",
"user_error": "red",
# good ending states are green
"completed": "green",
}
8 changes: 7 additions & 1 deletion src/together/lib/cli/api/fine_tuning/retrieve.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,26 @@
from rich.json import JSON

from together import Together
from together._utils._json import openapi_dumps
from together.lib.cli.api._utils import handle_api_errors, generate_progress_bar
from together.lib.utils.serializer import datetime_serializer


@click.command()
@click.pass_context
@click.argument("fine_tune_id", type=str, required=True)
@click.option("--json", is_flag=True, help="Output the response in JSON format")
@handle_api_errors("Fine-tuning")
def retrieve(ctx: click.Context, fine_tune_id: str) -> None:
def retrieve(ctx: click.Context, fine_tune_id: str, json: bool) -> None:
"""Retrieve fine-tuning job details"""
client: Together = ctx.obj

response = client.fine_tuning.retrieve(fine_tune_id)

if json:
click.echo(openapi_dumps(response.model_dump(exclude_none=True)))
return

# remove events from response for cleaner output
response.events = None

Expand Down
14 changes: 13 additions & 1 deletion src/together/lib/utils/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@ def format_timestamp(timestamp_str: str) -> str:
"""
try:
timestamp = parse_timestamp(timestamp_str)
return timestamp.strftime("%m/%d/%Y, %I:%M %p")
return format_datetime(timestamp)
except ValueError:
return ""


def format_datetime(datetime_obj: datetime) -> str:
"""Format datetime object to a readable date string.

Args:
datetime_obj: A datetime object

Returns:
str: Formatted timestamp string (MM/DD/YYYY, HH:MM AM/PM)
"""
return datetime_obj.strftime("%m/%d/%Y, %I:%M %p")
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.