-
Notifications
You must be signed in to change notification settings - Fork 1
feat(CLI): add tg batches commands for the batch API #525
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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]") | ||
| console.print(job.error) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]") | ||
| 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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
|
||
| 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]") |
| 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) |
There was a problem hiding this comment.
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:
boom