Skip to content
112 changes: 94 additions & 18 deletions src/together/lib/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import os
import sys
import inspect
from typing import Optional, Annotated, get_args, get_origin
Expand All @@ -14,13 +13,19 @@
from together.lib.utils import log_debug
from together._exceptions import APIError
from together._utils._json import openapi_dumps
from together._utils._logs import setup_logging
from together.lib.cli._track_cli import (
CliTrackingEvents,
track_cli,
flush_pending_events,
format_cli_error_for_telemetry,
)
from together.lib.cli.utils._debug import (
log_debug_note,
log_debug_session,
teardown_cli_debug,
setup_cli_debug_logging,
install_http_debug_hooks,
)
from together.lib.cli.utils.config import CLIConfig
from together.lib.cli.utils._prompt import PromptParameter
from together.lib.cli.utils._console import console
Expand Down Expand Up @@ -134,14 +139,22 @@ def _propagate_global_param_group(target_app: App) -> None:
_propagate_global_param_group(sub)


# Stainless requires a non-empty key to construct a client. When the user has
# none, we substitute this placeholder and block real requests. Do not treat the
# placeholder as a real key in --debug session output.
_PLACEHOLDER_API_KEY = "0" * 40


def _create_client(
api_key: Optional[str],
base_url: Optional[str],
timeout: Optional[int],
max_retries: Optional[int],
project_id: Optional[str],
require_api_key: bool = True,
) -> AsyncTogether:
debug: bool = False,
) -> tuple[AsyncTogether, bool]:
missing_api_key = False
try:
client = AsyncTogether(
api_key=api_key,
Expand All @@ -152,22 +165,14 @@ def _create_client(
)
except Exception as e:
if "api_key" in str(e):
missing_api_key = True
client = AsyncTogether(
api_key="0000000000000000000000000000000000000000",
api_key=_PLACEHOLDER_API_KEY,
base_url=base_url,
timeout=timeout,
max_retries=max_retries if max_retries is not None else 0,
project_id=project_id,
)

def block_requests_for_api_key(_: httpx.Request) -> None:
console.print(
"[red]x[/red] 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",
)
console.print("You can find your api key at https://api.together.ai/settings/api-keys")
sys.exit(1)

client._client.event_hooks["request"].append(block_requests_for_api_key)
else:
raise e

Expand All @@ -181,6 +186,21 @@ async def track_request(request: httpx.Request) -> None:
except Exception as e:
log_debug("Error tracking api request", error=e)

if debug:
install_http_debug_hooks(client._client)

if missing_api_key:
# After debug hooks so `--debug` still emits `→ GET` before we exit,
# but before analytics so a request that is never sent is not tracked.
async def block_requests_for_api_key(_: httpx.Request) -> None:
console.print(
"[red]x[/red] 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",
)
console.print("You can find your api key at https://api.together.ai/settings/api-keys")
sys.exit(1)

client._client.event_hooks["request"].append(block_requests_for_api_key)

client._client.event_hooks["request"].append(track_request)

# Out-of-band-auth commands (e.g. `beta clusters ssh`) make no Together API
Expand All @@ -193,7 +213,7 @@ async def track_request(request: httpx.Request) -> None:
console.print("You can find your api key at https://api.together.ai/settings/api-keys")
sys.exit(1)

return client
return client, missing_api_key


global_options = Group(
Expand All @@ -208,7 +228,14 @@ async def launcher(
base_url: Annotated[Optional[str], Parameter(show=False)] = None,
timeout: Annotated[Optional[int], Parameter(show=False)] = None,
max_retries: Annotated[Optional[int], Parameter(show=False)] = None,
debug: Annotated[Optional[bool], Parameter(show=False)] = False,
debug: Annotated[
Optional[bool],
Parameter(
group=global_options,
negative=(),
help="Print HTTP request/response details to stderr",
),
] = False,
non_interactive: Annotated[
Optional[bool], Parameter(group=global_options, negative=(), help="Disable interactive prompts")
] = False,
Expand All @@ -231,9 +258,37 @@ async def launcher(
] = False,
) -> None:
if debug:
os.environ.setdefault("TOGETHER_LOG", "debug")
setup_logging()
setup_cli_debug_logging()

try:
await _run_launcher(
tokens,
api_key=api_key,
base_url=base_url,
timeout=timeout,
max_retries=max_retries,
debug=debug,
non_interactive=non_interactive,
project_id=project_id,
output_json=output_json,
)
finally:
if debug:
teardown_cli_debug()


async def _run_launcher(
tokens: tuple[str, ...],
*,
api_key: Optional[str],
base_url: Optional[str],
timeout: Optional[int],
max_retries: Optional[int],
debug: Optional[bool],
non_interactive: Optional[bool],
project_id: Optional[str],
output_json: Optional[bool],
) -> None:
(parsed_command, explicit_args, is_beta_command, remaining) = preparse_tokens(app, [*tokens])

# Some commands authenticate out-of-band (OIDC / step-ca signed certificates)
Expand All @@ -245,13 +300,34 @@ async def launcher(
# they stay keyless.
no_auth_command = is_beta_command and parsed_command in _NO_AUTH_COMMANDS

client = _create_client(api_key, base_url, timeout, max_retries, project_id, require_api_key=not no_auth_command)
client, missing_api_key = _create_client(
api_key,
base_url,
timeout,
max_retries,
project_id,
require_api_key=not no_auth_command,
debug=bool(debug),
)

if debug:
log_debug_session(
command=parsed_command,
is_beta_command=is_beta_command,
base_url=str(client.base_url),
project_id=client.project_id,
api_key=None if missing_api_key else (client.api_key or None),
timeout=client.timeout,
max_retries=client.max_retries,
)

# Skip the project-resolution whoami() for out-of-band-auth commands: it is a
# Together API call and would reintroduce the API-key dependency for keyless
# commands like `beta clusters ssh`.
if not no_auth_command and client.project_id is None:
client.project_id = await _resolve_project_id(client)
if debug and client.project_id:
log_debug_note(f"resolved project {client.project_id}")

is_interactive = sys.stdin.isatty() and sys.stdout.isatty() and sys.stderr.isatty() and not _is_agent_or_ci()
non_interactive_mode = non_interactive or output_json or not is_interactive
Expand Down
7 changes: 2 additions & 5 deletions src/together/lib/cli/api/beta/models/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from together._utils import path_template
from together.lib.cli.utils.config import CLIConfig, CLIConfigParameter
from together.lib.cli.utils._console import console
from together.lib.cli.components.loader import loading_status
from together.lib.cli.components.upload_progress import UploadProgressTracker, format_bytes
from together.lib.cli.utils._assert_explicit_project_id import assert_explicit_project_id

Expand Down Expand Up @@ -290,11 +291,7 @@ async def _upload_model_files(
show_progress: bool = False,
) -> str:
if show_progress:
with console.status(
"[progress.description]Preparing files (computing hashes)...[/progress.description]",
spinner="dots",
spinner_style="bar.pulse",
):
with loading_status("Preparing files (computing hashes)..."):
local_files = await _prepare_files(local_path)
else:
local_files = await _prepare_files(local_path)
Expand Down
8 changes: 2 additions & 6 deletions src/together/lib/cli/api/endpoints/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from together.lib.cli.utils._exit import CliDiagnosticExit
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.loader import loading_status, show_loading_status
from together.lib.cli.api.endpoints._utils import print_endpoint, handle_endpoint_api_errors

from .hardware import hardware as list_hardware
Expand Down Expand Up @@ -133,11 +133,7 @@ async def create(
print_endpoint(response)

if wait:
with console.status(
"[progress.description]Waiting for endpoint to start...[/progress.description]",
spinner="dots",
spinner_style="bar.pulse",
):
with loading_status("Waiting for endpoint to start..."):
while (await config.client.endpoints.retrieve(response.id)).state != "STARTED":
await asyncio.sleep(1)
console.print("[green]√[/green] Endpoint started")
8 changes: 2 additions & 6 deletions src/together/lib/cli/api/endpoints/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
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.loader import loading_status, show_loading_status


async def start(
Expand All @@ -31,11 +31,7 @@ async def start(

if wait:
console.print("[green]√[/green] Successfully requested endpoint to start.")
with console.status(
"[progress.description]Waiting for endpoint to start...[/progress.description]",
spinner="dots",
spinner_style="bar.pulse",
):
with loading_status("Waiting for endpoint to start..."):
while (await config.client.endpoints.retrieve(endpoint_id)).state != "STARTED":
await asyncio.sleep(1)
console.print("[green]√[/green] Endpoint started")
Expand Down
8 changes: 2 additions & 6 deletions src/together/lib/cli/api/endpoints/stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
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.loader import loading_status, show_loading_status
from together.lib.cli.api.endpoints._utils import handle_endpoint_api_errors


Expand All @@ -28,11 +28,7 @@ async def stop(

if wait:
console.print("[green]√[/green] Successfully requested endpoint to stop.")
with console.status(
"[progress.description]Waiting for endpoint to stop...[/progress.description]",
spinner="dots",
spinner_style="bar.pulse",
):
with loading_status("Waiting for endpoint to stop..."):
while (await config.client.endpoints.retrieve(endpoint_id)).state != "STOPPED":
await asyncio.sleep(1)
console.print("[green]√[/green] Endpoint stopped")
Expand Down
4 changes: 3 additions & 1 deletion src/together/lib/cli/components/check_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ def __init__(self, file: Path, *, enabled: bool) -> None:
self._total = 1

def __enter__(self) -> CheckProgressTracker:
if not self.enabled:
from together.lib.cli.utils._debug import is_enabled

if not self.enabled or is_enabled():
return self
total = _expected_check_work_bytes(self.file)
self._total = total
Expand Down
23 changes: 18 additions & 5 deletions src/together/lib/cli/components/download_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ def for_single_file(
)

def __enter__(self) -> DownloadProgressTracker:
if not self.enabled or not console.is_terminal:
from together.lib.cli.utils._debug import is_enabled

if not self.enabled or not console.is_terminal or is_enabled():
return self
self._progress = Progress(
SpinnerColumn(style="bar.pulse"),
Expand Down Expand Up @@ -121,16 +123,27 @@ async def file_completed(self, file_path: str, *, skipped: bool = False) -> None
self.completed_files += 1
if skipped:
self.skipped_files += 1
if not self.enabled or self._progress is None:
if not self._should_report():
return
if self.show_files:
if self._progress is not None and self.show_files:
assert self._files_task is not None
self._progress.update(
self._files_task,
completed=self.completed_files,
description=f"Files ({self.completed_files}/{self.total_files})",
)
if self.show_files:
if skipped:
self._progress.console.print(f"[dim]↷[/dim] {file_path} skipped (already exists)")
console.print(f"[dim]↷[/dim] {file_path} skipped (already exists)")
else:
self._progress.console.print(f"[success]✓[/success] {file_path} complete")
console.print(f"[success]✓[/success] {file_path} complete")

def _should_report(self) -> bool:
"""Live bar is skipped under --debug / non-TTY; still emit ✓/↷ lines in debug."""
if not self.enabled:
return False
if self._progress is not None:
return True
from together.lib.cli.utils._debug import is_enabled

return is_enabled()
22 changes: 20 additions & 2 deletions src/together/lib/cli/components/loader.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,34 @@
from __future__ import annotations

from typing import TypeVar, Awaitable
from typing import TypeVar, Iterator, Awaitable
from contextlib import contextmanager

from together.lib.cli.utils._debug import is_enabled, log_debug_note
from together.lib.cli.utils._console import console

T = TypeVar("T")


async def show_loading_status(message: str, request: Awaitable[T]) -> T:
@contextmanager
def loading_status(message: str) -> Iterator[None]:
"""Show a spinner, or a debug line when ``--debug`` is on.

Rich Live (stdout) and debug logs (stderr) share the terminal cursor, so a
spinner would overwrite HTTP debug lines. Skip Live UI in debug mode.
"""
if is_enabled():
if message:
log_debug_note(message)
yield
return
with console.status(
f"[progress.description]{message}[/progress.description]",
spinner="dots",
spinner_style="bar.pulse",
):
yield


async def show_loading_status(message: str, request: Awaitable[T]) -> T:
with loading_status(message):
return await request
Loading
Loading