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.23.0"
".": "2.24.0"
}
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## [2.24.0](https://github.com/togethercomputer/together-py/compare/v2.23.0...v2.24.0) (2026-07-16)


### Features

* Add CLI commands for new dedicated endpoints ([#25](https://github.com/togethercomputer/together-py/issues/25)) ([c658cb0](https://github.com/togethercomputer/together-py/commit/c658cb014f8fc1d0465929b30d81d8aa56d564ff))
* SDK methods for new dedicated models inference ([574dd24](https://github.com/togethercomputer/together-py/commit/574dd248591a12d917e9a86e93aa54e853691074))


### Chores

* Remove stale model_limits utils ([#440](https://github.com/togethercomputer/together-py/issues/440)) ([e9d9e33](https://github.com/togethercomputer/together-py/commit/e9d9e33a2dded4300a0994ad36126a4ec96605ff))

## [2.23.0](https://github.com/togethercomputer/together-py/compare/v2.22.1...v2.23.0) (2026-07-13)


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.23.0"
version = "2.24.0"
description = "The official Python library for the together API"
dynamic = ["readme"]
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion scripts/mock

Large diffs are not rendered by default.

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.23.0" # x-release-please-version
__version__ = "2.24.0" # x-release-please-version
33 changes: 28 additions & 5 deletions src/together/lib/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@
"--version": "Display application version",
}

# Commands that authenticate out-of-band (OIDC / step-ca) and make no Together
# API calls, so the launcher must not require an API key or run the up-front
# whoami() for them. Values match preparse_tokens() command paths (beta prefix
# stripped; reported separately via is_beta_command).
_NO_AUTH_COMMANDS = frozenset({"clusters ssh"})


async def _resolve_project_id(client: AsyncTogether) -> str:
me = await client.whoami()
Expand Down Expand Up @@ -130,6 +136,7 @@ def _create_client(
timeout: Optional[int],
max_retries: Optional[int],
project_id: Optional[str],
require_api_key: bool = True,
) -> AsyncTogether:
try:
client = AsyncTogether(
Expand Down Expand Up @@ -172,7 +179,10 @@ async def track_request(request: httpx.Request) -> None:

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

if client.api_key == "":
# Out-of-band-auth commands (e.g. `beta clusters ssh`) make no Together API
# calls, so a missing key is not fatal for them. The block hook installed
# above still errors clearly if such a command ever does hit the API.
if require_api_key and client.api_key == "":
console.print(
"[red]Error:[/red] Together 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",
)
Expand Down Expand Up @@ -219,9 +229,24 @@ async def launcher(
if debug:
os.environ.setdefault("TOGETHER_LOG", "debug")
setup_logging()
client = _create_client(api_key, base_url, timeout, max_retries, project_id)

if client.project_id is None:
(parsed_command, explicit_args, is_beta_command, remaining) = preparse_tokens(app, [*tokens])

# Some commands authenticate out-of-band (OIDC / step-ca signed certificates)
# and never call the Together API. They must not be gated on an API key or the
# up-front whoami() used for project resolution. `tg beta clusters ssh` is one:
# its auth is entirely the cluster's Dex OIDC flow (see
# together.lib.cli.api.beta.clusters.ssh). Before the whoami() was added for
# project resolution these commands worked with no key; skip client setup so
# 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)

# 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)

config = CLIConfig(
Expand All @@ -233,8 +258,6 @@ async def launcher(
project_id=project_id,
)

(parsed_command, explicit_args, is_beta_command, remaining) = preparse_tokens(app, [*tokens])

if output_json:
explicit_args.append("json")
if non_interactive:
Expand Down
24 changes: 12 additions & 12 deletions src/together/lib/cli/api/beta/clusters/ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,6 @@ def _ssh_command(
bastion: str,
key_path: str,
cert_path: str,
known_hosts_path: str,
remote_command: tuple[str, ...],
) -> list[str]:
_validate_ssh_destination(login, host, bastion)
Expand All @@ -438,13 +437,16 @@ def _ssh_command(
+ " ".join(shlex.quote(arg) for arg in proxy_common)
+ f" -W %h:%p {shlex.quote(login)}@{shlex.quote(bastion)}"
)
# Second hop (bastion -> target host): host-key verification disabled by
# default. Cluster hosts are ephemeral (reprovisioned by CAPI, cert-based
# user auth), so their host keys aren't pinned in a known_hosts file and
# StrictHostKeyChecking would just prompt/fail. Trust is established by the
# step-ca user certificate, not the target host key.
inner_verification = [
"-o",
"StrictHostKeyChecking=ask",
"StrictHostKeyChecking=no",
"-o",
f"UserKnownHostsFile={known_hosts_path}",
"-o",
f"HostKeyAlias={host}.{bastion}",
"UserKnownHostsFile=/dev/null",
]
return (
["ssh"]
Expand Down Expand Up @@ -479,7 +481,6 @@ def _ssh_config_entry(
bastion: str,
key_path: str,
cert_path: str,
known_hosts_path: str,
) -> str:
_validate_ssh_alias(alias)
_validate_ssh_destination(login, host, bastion)
Expand All @@ -498,9 +499,10 @@ def _ssh_config_entry(
f" IdentityFile {_ssh_config_value(key_path)}",
f" CertificateFile {_ssh_config_value(cert_path)}",
" IdentitiesOnly yes",
" StrictHostKeyChecking ask",
f" UserKnownHostsFile {_ssh_config_value(known_hosts_path)}",
f" HostKeyAlias {_ssh_config_value(f'{host}.{bastion}')}",
# Second hop insecure by default: ephemeral cluster hosts, cert-based
# user auth, no pinned host keys. Trust is the step-ca user cert.
" StrictHostKeyChecking no",
" UserKnownHostsFile /dev/null",
f" ProxyCommand {proxy}",
]
)
Expand Down Expand Up @@ -690,8 +692,7 @@ async def ssh(
crt = _sign(ca_url, ott, pub_blob, ca_ctx)
_atomic_write(cert_path, f"{_CERT_ALGO[key_type]} {crt} together-ssh\n", 0o644)

known_hosts_path = os.path.join(os.path.dirname(key_path), "known_hosts")
cmd = _ssh_command(login, host, bastion, key_path, cert_path, known_hosts_path, remote_command)
cmd = _ssh_command(login, host, bastion, key_path, cert_path, remote_command)
if ssh_config_alias is not None:
entry = _ssh_config_entry(
ssh_config_alias,
Expand All @@ -700,7 +701,6 @@ async def ssh(
bastion,
key_path,
cert_path,
known_hosts_path,
)
if write_ssh_config:
managed_config, main_config = _write_ssh_config(ssh_config_alias, entry, cache_root)
Expand Down
9 changes: 9 additions & 0 deletions src/together/types/fine_tuning_list_checkpoints_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ class Data(BaseModel):
checkpoint: Optional[Literal["model", "adapter"]] = None
"""Canonical artifact selector for checkpoint download requests."""

object_id: Optional[str] = None
"""Together model registry object ID for the checkpoint artifact (e.g. `ml_...`)."""

object_revision_id: Optional[str] = None
"""Together model registry revision ID for the checkpoint artifact (e.g.

`rv_...`).
"""


class FineTuningListCheckpointsResponse(BaseModel):
data: List[Data]
15 changes: 15 additions & 0 deletions src/together/types/finetune_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ class FinetuneResponse(BaseModel):
user_id: str
"""ID of the user who owns the fine-tune job."""

adapter_object_id: Optional[str] = None
"""Together model registry object ID for the final adapter weights on LoRA jobs."""

adapter_object_revision_id: Optional[str] = None
"""Together model registry revision ID for the final adapter weights on LoRA jobs."""

batch_size: Union[int, Literal["max"], None] = None

created_at: Optional[datetime] = None
Expand Down Expand Up @@ -181,6 +187,15 @@ class FinetuneResponse(BaseModel):

model: Optional[str] = None

api_model_object_id: Optional[str] = FieldInfo(alias="model_object_id", default=None)
"""Together model registry object ID for the final model weights (e.g. `ml_...`)."""

api_model_object_revision_id: Optional[str] = FieldInfo(alias="model_object_revision_id", default=None)
"""Together model registry revision ID for the final model weights (e.g.

`rv_...`).
"""

x_model_output_name: Optional[str] = FieldInfo(alias="model_output_name", default=None)

x_model_output_path: Optional[str] = FieldInfo(alias="model_output_path", default=None)
Expand Down
33 changes: 24 additions & 9 deletions tests/cli/test_beta_clusters.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,18 +233,18 @@ def test_ssh_command_preserves_remote_args_and_proxy(self) -> None:
"ssh.t-abc123.s1.us-central-2a.cloud.together.ai",
"/tmp/id",
"/tmp/id-cert.pub",
"/tmp/known_hosts",
("sinfo", "-h"),
)

assert cmd[:5] == ["ssh", "-i", "/tmp/id", "-o", "CertificateFile=/tmp/id-cert.pub"]
assert "jhu@slurm-login" in cmd
assert cmd[cmd.index("--") + 1] == "jhu@slurm-login"
assert cmd[-2:] == ["sinfo", "-h"]
# First hop (client -> bastion) verifies; second hop (bastion -> host) does not.
assert any("ProxyCommand=ssh" in arg for arg in cmd)
assert "StrictHostKeyChecking=ask" in cmd
assert "UserKnownHostsFile=/tmp/known_hosts" in cmd
assert "HostKeyAlias=slurm-login.ssh.t-abc123.s1.us-central-2a.cloud.together.ai" in cmd
assert any("StrictHostKeyChecking=ask" in arg for arg in cmd) # first hop (inside ProxyCommand)
assert "StrictHostKeyChecking=no" in cmd # second hop (own -o element)
assert "UserKnownHostsFile=/dev/null" in cmd

def test_ssh_config_entry_points_plain_ssh_at_cached_cert(self) -> None:
entry = ssh_cli._ssh_config_entry(
Expand All @@ -254,16 +254,17 @@ def test_ssh_config_entry_points_plain_ssh_at_cached_cert(self) -> None:
"ssh.t-abc123.s1.us-central-2a.cloud.together.ai",
"/home/jhu/.together/ssh/t-abc123/jhu/id",
"/home/jhu/.together/ssh/t-abc123/jhu/id-cert.pub",
"/home/jhu/.together/ssh/t-abc123/jhu/known_hosts",
)

assert "Host test-oidc" in entry
assert "HostName slurm-login" in entry
assert "User jhu" in entry
assert "IdentityFile /home/jhu/.together/ssh/t-abc123/jhu/id" in entry
assert "CertificateFile /home/jhu/.together/ssh/t-abc123/jhu/id-cert.pub" in entry
assert "StrictHostKeyChecking ask" in entry
assert "UserKnownHostsFile /home/jhu/.together/ssh/t-abc123/jhu/known_hosts" in entry
# Second hop insecure; first hop (in the ProxyCommand) still verifies.
assert "StrictHostKeyChecking no" in entry
assert "UserKnownHostsFile /dev/null" in entry
assert "StrictHostKeyChecking=ask" in entry # first hop inside ProxyCommand
assert "ProxyCommand ssh" in entry

@pytest.mark.parametrize(
Expand All @@ -285,7 +286,6 @@ def test_ssh_command_rejects_invalid_destination(self, login: str, host: str, ba
bastion,
"/tmp/id",
"/tmp/id-cert.pub",
"/tmp/known_hosts",
(),
)

Expand All @@ -298,7 +298,6 @@ def test_ssh_config_entry_rejects_invalid_alias(self) -> None:
"ssh.t-abc123.s1.us-central-2a.cloud.together.ai",
"/tmp/id",
"/tmp/id-cert.pub",
"/tmp/known_hosts",
)

def test_get_or_create_keypair_replaces_partial_cache(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
Expand Down Expand Up @@ -1098,3 +1097,19 @@ def test_remediations_reject_resolves_cluster_and_instance(
assert json.loads(result.output)["state"] == "CANCELLED"
assert json.loads(cast(Call, route.calls[0]).request.content.decode()) == {"comment": "skip"}
assert result.exit_code == 0


def test_ssh_second_hop_host_key_checking_disabled() -> None:
"""Second hop (bastion -> ephemeral cluster host) skips host-key verification;
first hop (client -> bastion) keeps StrictHostKeyChecking=ask."""
from together.lib.cli.api.beta.clusters.ssh import _ssh_command, _ssh_config_entry

cmd = " ".join(_ssh_command("me", "worker1", "bastion.x", "/k", "/c", ("uptime",)))
assert "StrictHostKeyChecking=no" in cmd
assert "UserKnownHostsFile=/dev/null" in cmd
assert "StrictHostKeyChecking=ask" in cmd # proxy (first hop) still verifies

entry = _ssh_config_entry("myalias", "me", "worker1", "bastion.x", "/k", "/c")
assert "StrictHostKeyChecking no" in entry
assert "UserKnownHostsFile /dev/null" in entry
assert "StrictHostKeyChecking=ask" in entry # first hop in ProxyCommand
82 changes: 82 additions & 0 deletions tests/unit/test_cli_no_auth_ssh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Regression: `tg beta clusters ssh` authenticates via OIDC/step-ca and must not
be gated on an API key or the launcher's up-front whoami() (project resolution).

Guards against reintroducing the #25 behavior where every command — including the
keyless ssh command — triggered `_create_client` + `_resolve_project_id`.
"""

from __future__ import annotations

import pytest

import together.lib.cli as cli


def _run(argv: list[str]) -> None:
try:
cli.app.meta(argv)
except SystemExit:
pass
except Exception:
# ssh proceeds past auth into OIDC discovery / ssh exec, which fails in a
# unit test (no network / no login). That's fine — we only assert the
# launcher never touched the API client for this command.
pass


def test_ssh_command_does_not_require_api_key_or_whoami(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("TOGETHER_API_KEY", raising=False)

require_flags: list[bool] = []
resolve_calls = 0

real_create = cli._create_client

def spy_create(*args: object, require_api_key: bool = True, **kwargs: object): # type: ignore[no-untyped-def]
require_flags.append(require_api_key)
return real_create(*args, require_api_key=require_api_key, **kwargs) # type: ignore[arg-type]

async def spy_resolve(_client: object) -> str:
nonlocal resolve_calls
resolve_calls += 1
return "proj"

monkeypatch.setattr(cli, "_create_client", spy_create)
monkeypatch.setattr(cli, "_resolve_project_id", spy_resolve)

_run(
[
"beta",
"clusters",
"ssh",
"https://dex.together.ai/abc",
"-l",
"me",
"--host",
"host",
"--ssh-config-alias",
"x",
]
)

# The client is built with the api-key requirement waived (no hard exit on a
# missing key) and the project-resolution whoami() is skipped entirely.
assert require_flags == [False], "ssh must build the client with require_api_key=False"
assert resolve_calls == 0, "ssh must not run the project-resolution whoami()"


def test_non_ssh_command_still_requires_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("TOGETHER_API_KEY", raising=False)

require_flags: list[bool] = []
real_create = cli._create_client

def spy_create(*args: object, require_api_key: bool = True, **kwargs: object): # type: ignore[no-untyped-def]
require_flags.append(require_api_key)
return real_create(*args, require_api_key=require_api_key, **kwargs) # type: ignore[arg-type]

monkeypatch.setattr(cli, "_create_client", spy_create)

_run(["endpoints", "list"])

assert require_flags and all(require_flags), "API-backed commands must still gate on the API key"
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.

Loading