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
20 changes: 20 additions & 0 deletions src/together/lib/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
EVALS_HELP_EXAMPLES,
FILES_HELP_EXAMPLES,
MODELS_HELP_EXAMPLES,
BATCHES_HELP_EXAMPLES,
JIG_LOGS_HELP_EXAMPLES,
JIG_PUSH_HELP_EXAMPLES,
ENDPOINTS_HELP_EXAMPLES,
Expand All @@ -47,9 +48,11 @@
FILES_UPLOAD_HELP_EXAMPLES,
BETA_CLUSTERS_HELP_EXAMPLES,
MODELS_UPLOAD_HELP_EXAMPLES,
BATCHES_SUBMIT_HELP_EXAMPLES,
BETA_ENDPOINTS_HELP_EXAMPLES,
JIG_JOB_STATUS_HELP_EXAMPLES,
JIG_SECRETS_SET_HELP_EXAMPLES,
BATCHES_DOWNLOAD_HELP_EXAMPLES,
ENDPOINTS_CREATE_HELP_EXAMPLES,
ENDPOINTS_UPDATE_HELP_EXAMPLES,
BETA_ENDPOINTS_AB_HELP_EXAMPLES,
Expand Down Expand Up @@ -540,6 +543,23 @@ async def run_command() -> None:
evals_app.command((f"{_CLI}.evals.retrieve:retrieve"), alias="get", help="Get eval job details")
evals_app.command((f"{_CLI}.evals.status:status"), help="Get an eval job's status")

## Batches API commands
batches_app = app.command(App(name="batches", help="Submit and manage batch jobs", help_epilogue=BATCHES_HELP_EXAMPLES))
batches_app.command(
(f"{_CLI}.batches.submit:submit"),
help="Submit a new batch job",
help_epilogue=BATCHES_SUBMIT_HELP_EXAMPLES,
sort_key=1,
)
batches_app.command((f"{_CLI}.batches.list:list"), alias="ls", help="List batch jobs")
batches_app.command((f"{_CLI}.batches.retrieve:retrieve"), alias="get", help="Get batch job details")
batches_app.command(
(f"{_CLI}.batches.download:download"),
help="Download batch job output (and error) files",
help_epilogue=BATCHES_DOWNLOAD_HELP_EXAMPLES,
)
batches_app.command((f"{_CLI}.batches.cancel:cancel"), help="Cancel a batch job")

## Telemetry API commands
telemetry_app = app.command(App(name="telemetry", help="Configure CLI telemetry"))
telemetry_app.command((f"{_CLI}.telemetry.status:status"), help="Show telemetry status")
Expand Down
86 changes: 86 additions & 0 deletions src/together/lib/cli/api/batches/_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from __future__ import annotations

from typing import Literal
from typing_extensions import TypeAlias

from together.lib.utils.tools import format_datetime
from together.types.batch_job import BatchJob
from together.lib.cli.utils._console import console

BatchApiType: TypeAlias = Literal["chat.completions", "audio.transcriptions", "audio.translations"]
BatchEndpoint: TypeAlias = Literal["/v1/chat/completions", "/v1/audio/transcriptions", "/v1/audio/translations"]

API_TO_ENDPOINT: dict[BatchApiType, BatchEndpoint] = {
"chat.completions": "/v1/chat/completions",
"audio.transcriptions": "/v1/audio/transcriptions",
"audio.translations": "/v1/audio/translations",
}

ENDPOINT_TO_API: dict[str, BatchApiType] = {endpoint: api for api, endpoint in API_TO_ENDPOINT.items()}

STATUS_COLORS = {
"VALIDATING": "yellow",
"IN_PROGRESS": "yellow",
"COMPLETED": "green",
"FAILED": "red",
"EXPIRED": "red",
"CANCELLED": "red",
}

_INCOMPLETE_STATUSES = frozenset({"VALIDATING", "IN_PROGRESS"})
_DOWNLOADABLE_STATUSES = frozenset({"COMPLETED", "FAILED", "EXPIRED", "CANCELLED"})
_PROGRESS_BAR_WIDTH = 20


def format_endpoint(endpoint: str | None) -> str:
if not endpoint:
return ""
return ENDPOINT_TO_API.get(endpoint, endpoint)


def format_status(status: str | None) -> str:
if not status:
return ""
color = STATUS_COLORS.get(status, "white")
return f"[bold {color}]{status.capitalize()}[/bold {color}]"


def format_progress(progress: float) -> str:
pct = max(0.0, min(100.0, progress))
filled = round((pct / 100.0) * _PROGRESS_BAR_WIDTH)
bar = "█" * filled + "░" * (_PROGRESS_BAR_WIDTH - filled)
return f"[yellow]{bar}[/yellow] [bold]{pct:g}%[/bold]"


def print_batch_detail(job: BatchJob) -> None:
"""Print a curated human-readable view of a batch job."""
console.print("Batch job details:")
if job.created_at:
console.print(f" - Created at {format_datetime(job.created_at)}")
if job.status == "COMPLETED" and job.completed_at:
console.print(f" - Completed at {format_datetime(job.completed_at)}")

api = format_endpoint(job.endpoint)
if api:
console.print(f" - {api}")

if job.x_model_id:
console.print(f" - {job.x_model_id}")

if job.output_file_id:
console.print(f" - Output file ID {job.output_file_id}")

if job.error_file_id:
console.print(f" - [red]An error occurred[/red]")
console.print(f" - Error file ID {job.error_file_id}")

if job.error:
console.print(f" - [red]An error occurred[/red]")
Comment on lines +73 to +78

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: A job with both error_file_id and error set renders the header line once per block:

  • An error occurred
  • Error file ID file-err
  • An error occurred
    boom

console.print(job.error)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

console.print(job.error) hand API text straight to the markup parser. "validation failed: missing [/close] tag" raises MarkupError and we die with a traceback instead of showing the user their error; the milder "unexpected token [foo] at line 3" silently prints as "unexpected token at line 3". rich.markup.escape() or markup=False on all three.


if job.status in _INCOMPLETE_STATUSES and job.progress is not None:
console.print(f"{format_progress(job.progress)} {format_status(job.status)}")
Comment on lines +81 to +82

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

print_batch_detail only prints a status line when status in _INCOMPLETE_STATUSES and progress is not None. So on a CANCELLED job you get created-at, the API, the model, and nothing else — no indication it was cancelled. Same for EXPIRED, for FAILED with no error field, and for VALIDATING before progress is populated. STATUS_COLORS and format_status() already cover all six states, so the four terminal ones are effectively dead code. Can we just always print the status line?


if job.status in _DOWNLOADABLE_STATUSES and job.id and (job.output_file_id or job.error_file_id):
console.print("\nDownload results with:")
console.print(f"[dim]-[/dim] [primary]tg batches download {job.id} --output ./out[/primary]")
26 changes: 26 additions & 0 deletions src/together/lib/cli/api/batches/cancel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from __future__ import annotations

from typing import Annotated

from cyclopts import Parameter

from together._utils._json import openapi_dumps
from together.lib.cli.utils.config import CLIConfigParameter
from together.lib.cli.utils._console import console
from together.lib.cli.components.loader import show_loading_status
from together.lib.cli.components.model_dump import print_model_dump


async def cancel(
batch_id: Annotated[str, Parameter(help="The ID of the batch job to cancel")],
*,
config: CLIConfigParameter,
) -> None:
"""Cancel a batch job."""
response = await show_loading_status("Cancelling batch job...", config.client.batches.cancel(batch_id))
if config.json:
console.print_json(openapi_dumps(response).decode("utf-8"))
return

console.print("[green]√[/green] Cancelled batch job")
print_model_dump(response, show_nulls=False)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BatchJob declares x_model_id = FieldInfo(alias="model_id"), and print_model_dump renders model_dump() (field names, not aliases), so submit shows a row literally labelled "X Model Id:". cancel is worse — the top-level parse keeps model_id as an extra field alongside x_model_id, so it prints both rows with the same value (I confirmed this against the real construct_type path, not just a plain model_validate). retrieve sidesteps the whole thing with its curated view; submit/cancel want either the same treatment or a by_alias dump.

139 changes: 139 additions & 0 deletions src/together/lib/cli/api/batches/download.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
from __future__ import annotations

import sys
import base64
from typing import Any, Optional, Annotated
from pathlib import Path

from cyclopts import Parameter, validators

from together._utils._json import openapi_dumps
from together.lib.cli.utils.config import CLIConfigParameter
from together.lib.cli.utils._console import console
from together.lib.cli.components.loader import show_loading_status
from together.lib.cli.api.files.retrieve_content import download_file_content

_TERMINAL_STATUSES = frozenset({"COMPLETED", "FAILED", "EXPIRED", "CANCELLED"})


def _is_directory_output(path: Path) -> bool:
return path.is_dir() or path.suffix == ""
Comment on lines +19 to +20

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--output ./results crashes when ./results already exists as a file: _is_directory_output() treats any suffix-less path as a directory, and since the validator here was loosened to file_okay=True (files download uses file_okay=False), that path now reaches output.mkdir(parents=True, exist_ok=True) inside download_file_content — and exist_ok does not tolerate an existing non-directory:

touch ./results && tg batches download --output ./results
→ Error: [Errno 17] File exists: '/…/results'

An explicit output.exists() and not output.is_dir() check before the mkdir would sort it, or restore file_okay=False.



def _error_output_path(output: Path) -> Path:
"""Where to write the error file when *output* is a concrete file path."""
suffix = output.suffix or ".jsonl"
return output.with_name(f"{output.stem}.errors{suffix}")


async def download(
id: Annotated[str, Parameter(help="The ID of the batch job")],
output: Annotated[
Optional[Path],
Parameter(
name=["--output", "-o"],
help="File or directory to save batch result files to; omit to print output to stdout",
validator=validators.Path(file_okay=True, dir_okay=True),
),
] = None,
*,
config: CLIConfigParameter,
) -> None:
"""Download output (and error) files for a batch job."""
job = await show_loading_status("Retrieving batch job...", config.client.batches.retrieve(id))
status = job.status or ""

if status not in _TERMINAL_STATUSES:
console.print(
f"[red]Batch job is not ready to download yet[/red] "
f"(status: {status or 'unknown'}). "
f"Check progress with [primary]tg batches get {id}[/primary]."
)
sys.exit(1)

if not job.output_file_id and not job.error_file_id:
console.print(f"[red]Batch job has no output or error files to download[/red] (status: {status}).")
sys.exit(1)
Comment on lines +46 to +56

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three early exits print Rich prose to stdout regardless of config.json, so tg batches download --json | jq fails on non-JSON input. I noticed download is the one command excluded from the new test_json_mode_pipeable_to_jq case (tests/cli/test_json_mode_pipeable_to_jq.py:100) — if that's why, I'd rather fix the output than skip the check.


if output is not None:
saved: list[dict[str, str]] = []
directory_output = _is_directory_output(output)
error_file_id = job.error_file_id

if job.output_file_id:
out_path = await download_file_content(
config.client,
job.output_file_id,
output=output,
loading_message="Downloading batch output...",
)
assert isinstance(out_path, Path)
saved.append({"kind": "output", "id": job.output_file_id, "path": str(out_path)})
elif error_file_id and not directory_output:
# No output file — write the error file to the exact path the user asked for.
err_path = await download_file_content(
config.client,
error_file_id,
output=output,
loading_message="Downloading batch errors...",
)
assert isinstance(err_path, Path)
saved.append({"kind": "error", "id": error_file_id, "path": str(err_path)})
error_file_id = None

if error_file_id:
err_dest = output if directory_output else _error_output_path(output)
err_path = await download_file_content(
config.client,
error_file_id,
output=err_dest,
loading_message="Downloading batch errors...",
)
assert isinstance(err_path, Path)
saved.append({"kind": "error", "id": error_file_id, "path": str(err_path)})
Comment on lines +58 to +93

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When --output is a directory and the job has both an output and an error file, both downloads go through download_file_content(output=

), which names each file from whatever the Files API returns for it. If those two filenames match, the second write clobbers the first — and we still print both "Output saved to …" and "Errors saved to …" as if two files landed. I mocked both files returning filename: "batch.jsonl" and ended up with a single batch.jsonl containing only the error content.

_error_output_path() already solves this for the concrete-file case; the directory case needs the same guarantee (suffix on collision, or force .errors into the error filename).


if config.json:
console.print_json(openapi_dumps({"batch_id": id, "files": saved}).decode("utf-8"))
return

for item in saved:
label = "Output" if item["kind"] == "output" else "Errors"
console.print(f"[green]√[/green] {label} saved to [blue]{item['path']}[/blue]")
return

if not job.output_file_id:
console.print(
"[red]Batch job has no output file[/red]. "
"Use [primary]--output[/primary] to download the error file instead."
)
sys.exit(1)
Comment on lines +104 to +109

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three early exits print Rich prose to stdout regardless of config.json, so tg batches download --json | jq fails on non-JSON input. I noticed download is the one command excluded from the new test_json_mode_pipeable_to_jq case (tests/cli/test_json_mode_pipeable_to_jq.py:100) — if that's why, I'd rather fix the output than skip the check.


raw = await download_file_content(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

response.read() pulls the entire batch output into RAM, and in --json mode we then build a second full copy as a string (a third, base64, on decode failure). Batch outputs can be very large by design, and download_file_content already has a streaming write_to_file path for --output. tg batches download | head shouldn't need the whole body resident.

config.client,
job.output_file_id,
stdout=True,
loading_message="Downloading batch output...",
)
assert isinstance(raw, bytes)

if config.json:
try:
payload: dict[str, Any] = {
"batch_id": id,
"output_file_id": job.output_file_id,
"content": raw.decode("utf-8"),
}
except UnicodeDecodeError:
payload = {
"batch_id": id,
"output_file_id": job.output_file_id,
"content_base64": base64.b64encode(raw).decode("ascii"),
}
if job.error_file_id:
payload["error_file_id"] = job.error_file_id
console.print_json(openapi_dumps(payload).decode("utf-8"))
return

console.print(raw.decode("utf-8"))
if job.error_file_id:
console.print(f"\n[dim]Error file also available: tg batches download {id} --output ./out[/dim]")
Comment on lines +137 to +139

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default (no --output) path is console.print(raw.decode("utf-8")), so Rich does two things to the payload: it parses markup, and it hard-wraps at the console width. With a completion containing [bold]…[/bold] and a line over 80 chars, the tags get eaten and newlines get injected mid-JSON — > results.jsonl produces invalid JSONL. Model output containing an unbalanced tag like [/close] raises MarkupError and kills the command outright.

Since this is the default mode of the command, I think it has to be sys.stdout.buffer.write(raw) (or at minimum a markup=False, soft_wrap=True console). Two related things in the same block:

  • No UnicodeDecodeError guard here, even though the --json branch right above has one — a non-UTF-8 byte traces back instead of falling back.
  • The "Error file also available: …" hint on line 139 goes to stdout, so it becomes the last line of a redirected results.jsonl. Hints belong on stderr.

58 changes: 58 additions & 0 deletions src/together/lib/cli/api/batches/list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from __future__ import annotations

from datetime import datetime, timezone

from together._utils._json import openapi_dumps
from together.lib.utils.tools import format_datetime
from together.lib.cli.utils.config import CLIConfigParameter
from together.lib.cli.utils._console import console
from together.lib.cli.components.list import ListTable
from together.lib.cli.components.loader import show_loading_status
from together.lib.cli.api.batches._utils import STATUS_COLORS, format_endpoint
from together.lib.cli.utils._mock_pagination import AfterParameter, mock_pagination


async def list(
after: AfterParameter = None,
*,
config: CLIConfigParameter,
) -> None:
"""List batch jobs."""
response = await show_loading_status("Loading batch jobs...", config.client.batches.list())
jobs = response or []

epoch_start = datetime.fromtimestamp(0, tz=timezone.utc)
jobs.sort(key=lambda x: x.created_at or epoch_start, reverse=True)

jobs_to_display, next_cursor = mock_pagination(jobs, cursor_field="id", cursor=after)

if config.json:
console.print_json(openapi_dumps(jobs_to_display).decode("utf-8"))
return

table = ListTable(
empty_message="You don't have any batch jobs yet. To submit your first batch run:\n [dim]-[/dim] [primary]tg batches submit[/primary]"
)
table.add_primary_column("ID")
table.add_column("API")
table.add_column("Model")
table.add_column("Status")
table.add_column("Created At")

for job in jobs_to_display:
status = str(job.status) if job.status is not None else ""
status_color = STATUS_COLORS.get(status, "white")
if job.status == "IN_PROGRESS" and job.progress is not None:
status = f"{status}: {job.progress:g}%"

table.add_row(
job.id or "",
format_endpoint(job.endpoint),
job.x_model_id or "",
f"[{status_color}]{status}[/{status_color}]",
format_datetime(job.created_at) if job.created_at else "",
)
console.print(table)
if next_cursor:
console.print("\n[blue dim]To display the next page, run:[/blue dim]")
console.print(f" [dim]-[/dim] [white]tg batches list --after {next_cursor}[/white]")
25 changes: 25 additions & 0 deletions src/together/lib/cli/api/batches/retrieve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from __future__ import annotations

from typing import Annotated

from cyclopts import Parameter

from together._utils._json import openapi_dumps
from together.lib.cli.utils.config import CLIConfigParameter
from together.lib.cli.utils._console import console
from together.lib.cli.components.loader import show_loading_status
from together.lib.cli.api.batches._utils import print_batch_detail


async def retrieve(
batch_id: Annotated[str, Parameter(help="The ID of the batch job")],
*,
config: CLIConfigParameter,
) -> None:
"""Get details of a batch job."""
response = await show_loading_status("Retrieving batch job...", config.client.batches.retrieve(batch_id))
if config.json:
console.print_json(openapi_dumps(response).decode("utf-8"))
return

print_batch_detail(response)
Loading
Loading