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
53 changes: 53 additions & 0 deletions src/together/lib/cli/api/beta/models/remote_uploads/_utils.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,67 @@
from __future__ import annotations

import re
from typing import TYPE_CHECKING
from urllib.parse import ParseResult, urlparse, urlunparse

from together.lib.cli.utils._console import console

# Matches hermes ParseRemoteSource: org/model or org/model@revision.
_HF_REPO_ID = re.compile(r"^[a-zA-Z0-9_\-\.]+/[a-zA-Z0-9_\-\.]+(@[a-zA-Z0-9_\-\.]+)?$")
_HF_HOSTS = frozenset({"huggingface.co", "www.huggingface.co"})

if TYPE_CHECKING:
from together.types.beta.models.remote_upload_create_response import RemoteUploadCreateResponse
from together.types.beta.models.remote_upload_retrieve_response import RemoteUploadRetrieveResponse


def apply_huggingface_revision(remote_url: str, revision: str | None) -> str:
"""Pin a Hugging Face revision on a remote-upload source URL.

CreateRemoteUpload has no revision field. Hermes reads the pin from an
``@<commit|tag|branch>`` suffix on a Hugging Face repo id or URL.
"""
if not revision:
return remote_url

if remote_url.startswith(("http://", "https://")):
parsed = urlparse(remote_url)
if parsed.hostname in _HF_HOSTS:
return _pin_hf_url_revision(remote_url, parsed, revision)
raise ValueError("--revision is only supported for Hugging Face sources")

if _HF_REPO_ID.match(remote_url):
return _pin_repo_id_revision(remote_url, revision)

raise ValueError("--revision is only supported for Hugging Face sources")


def _pin_repo_id_revision(remote_url: str, revision: str) -> str:
_, separator, embedded = remote_url.partition("@")
if separator:
if embedded != revision:
raise ValueError("conflicting revisions from --from (@…) and --revision")
return remote_url
return f"{remote_url}@{revision}"


def _pin_hf_url_revision(remote_url: str, parsed: ParseResult, revision: str) -> str:
path = parsed.path.strip("/")
parts = path.split("/", 2)
if len(parts) < 2 or not parts[0] or not parts[1]:
raise ValueError(f"invalid Hugging Face URL (expected https://huggingface.co/org/model): {remote_url}")
_, separator, embedded = f"{parts[0]}/{parts[1]}".partition("@")
if separator:
if embedded != revision:
raise ValueError("conflicting revisions from --from (@…) and --revision")
return remote_url
parts[1] = f"{parts[1]}@{revision}"
new_path = "/" + "/".join(parts)
if parsed.path.endswith("/") and not new_path.endswith("/"):
new_path += "/"
return urlunparse(parsed._replace(path=new_path))


def format_status(status: str | None) -> str:
if not status:
return ""
Expand Down
17 changes: 15 additions & 2 deletions src/together/lib/cli/api/beta/models/remote_uploads/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,27 @@
from together.lib.cli.utils._console import console
from together.lib.cli.components.loader import show_loading_status
from together.lib.cli.utils._assert_explicit_project_id import assert_explicit_project_id
from together.lib.cli.api.beta.models.remote_uploads._utils import print_remote_upload_detail
from together.lib.cli.api.beta.models.remote_uploads._utils import (
apply_huggingface_revision,
print_remote_upload_detail,
)


async def create(
model_id: Annotated[str, Parameter(help="Existing model or adapter ID to upload files to")],
*,
remote_url: Annotated[
str,
Parameter(name="--from", help="Hugging Face repository URL or presigned S3/GCS archive URL"),
Parameter(
name="--from",
help="Hugging Face repository URL, org/model, or presigned S3/GCS archive URL. "
"Pin an HF revision with @<commit|tag|branch> or --revision",
),
],
revision: Annotated[
Optional[str],
Parameter(help="Hugging Face commit, tag, or branch to import"),
] = None,
token: Annotated[
Optional[str], Parameter(help="Source credential for a gated or private Hugging Face repository")
] = None,
Expand All @@ -30,6 +41,8 @@ async def create(

await assert_explicit_project_id(config)

remote_url = apply_huggingface_revision(remote_url, revision)

response = await show_loading_status(
"Starting remote upload...",
config.client.beta.models.remote_uploads.create(
Expand Down
4 changes: 4 additions & 0 deletions src/together/lib/cli/utils/_help_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,10 @@
[primary]tg beta models remote-uploads create ml_xxxxxxxxxxxx \\
--from https://huggingface.co/org/model[/primary]

[dim]-[/dim] Pin a Hugging Face commit, tag, or branch:
[primary]tg beta models remote-uploads create ml_xxxxxxxxxxxx \\
--from https://huggingface.co/org/model --revision abc123[/primary]

[dim]-[/dim] Import a gated/private HF repo:
[primary]tg beta models remote-uploads create ml_xxxxxxxxxxxx \\
--from https://huggingface.co/org/private-model --token "$HF_TOKEN"[/primary]
Expand Down
128 changes: 128 additions & 0 deletions tests/cli/test_beta_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
{
"path": "weights.bin",
"sizeBytes": "11",
"hash": hashlib.md5(b"hello world").hexdigest(),

Check failure on line 58 in tests/cli/test_beta_models.py

View check run for this annotation

Broly - Code Security Scanner / Broly Security Scan

[HIGH] Weak hash (MD5)

Weak hash (MD5): Weak hash (MD5) (CWE-328)
}
],
"next_cursor": None,
Expand Down Expand Up @@ -704,7 +704,7 @@
"files": [
{
"path": "weights.bin",
"hash": hashlib.md5(b"hello world").hexdigest(),

Check failure on line 707 in tests/cli/test_beta_models.py

View check run for this annotation

Broly - Code Security Scanner / Broly Security Scan

[HIGH] Weak hash (MD5)

Weak hash (MD5): Weak hash (MD5) (CWE-328)
"numParts": 1,
}
],
Expand All @@ -717,7 +717,7 @@
"files": [
{
"path": "weights.bin",
"hash": hashlib.md5(b"hello world").hexdigest(),

Check failure on line 720 in tests/cli/test_beta_models.py

View check run for this annotation

Broly - Code Security Scanner / Broly Security Scan

[HIGH] Weak hash (MD5)

Weak hash (MD5): Weak hash (MD5) (CWE-328)
"uploadDetails": {
"uploadId": "upload-1",
"parts": [{"partNumber": 1, "hash": "etag-1"}],
Expand Down Expand Up @@ -766,7 +766,7 @@
"files": [
{
"path": "weights.bin",
"hash": hashlib.md5(b"cached").hexdigest(),

Check failure on line 769 in tests/cli/test_beta_models.py

View check run for this annotation

Broly - Code Security Scanner / Broly Security Scan

[HIGH] Weak hash (MD5)

Weak hash (MD5): Weak hash (MD5) (CWE-328)
"skipUpload": True,
}
],
Expand Down Expand Up @@ -794,7 +794,7 @@
"files": [
{
"path": "weights.bin",
"hash": hashlib.md5(content).hexdigest(),

Check failure on line 797 in tests/cli/test_beta_models.py

View check run for this annotation

Broly - Code Security Scanner / Broly Security Scan

[HIGH] Weak hash (MD5)

Weak hash (MD5): Weak hash (MD5) (CWE-328)
"sizeBytes": len(content),
"parts": [
{
Expand Down Expand Up @@ -885,6 +885,134 @@
}
assert json.loads(result.output)["id"] == "ru_1"

@pytest.mark.respx(base_url=base_url)
def test_create_remote_upload_with_revision(self, respx_mock: MockRouter, cli_runner: CliRunner) -> None:
route = respx_mock.post("/projects/proj/models/uploads").mock(
return_value=httpx.Response(200, json=_remote_upload_body())
)

result = cli_runner.invoke(
[
"beta",
"models",
"remote-uploads",
"create",
"ml_1",
"--project",
"proj",
"--from",
"https://huggingface.co/acme/model",
"--revision",
"abc123",
"--json",
]
)

assert result.exit_code == 0, result.output
assert json.loads(cast(Call, route.calls[0]).request.content.decode()) == {
"modelId": "ml_1",
"remoteUrl": "https://huggingface.co/acme/model@abc123",
}

@pytest.mark.respx(base_url=base_url)
def test_create_remote_upload_with_repo_id_revision(self, respx_mock: MockRouter, cli_runner: CliRunner) -> None:
route = respx_mock.post("/projects/proj/models/uploads").mock(
return_value=httpx.Response(200, json=_remote_upload_body())
)

result = cli_runner.invoke(
[
"beta",
"models",
"remote-uploads",
"create",
"ml_1",
"--project",
"proj",
"--from",
"acme/model",
"--revision",
"main",
"--json",
]
)

assert result.exit_code == 0, result.output
assert json.loads(cast(Call, route.calls[0]).request.content.decode())["remoteUrl"] == "acme/model@main"

@pytest.mark.respx(base_url=base_url)
def test_create_remote_upload_matching_embedded_revision(
self, respx_mock: MockRouter, cli_runner: CliRunner
) -> None:
route = respx_mock.post("/projects/proj/models/uploads").mock(
return_value=httpx.Response(200, json=_remote_upload_body())
)

result = cli_runner.invoke(
[
"beta",
"models",
"remote-uploads",
"create",
"ml_1",
"--project",
"proj",
"--from",
"https://huggingface.co/acme/model@abc123",
"--revision",
"abc123",
"--json",
]
)

assert result.exit_code == 0, result.output
assert (
json.loads(cast(Call, route.calls[0]).request.content.decode())["remoteUrl"]
== "https://huggingface.co/acme/model@abc123"
)

def test_create_remote_upload_rejects_conflicting_revision(self, cli_runner: CliRunner) -> None:
result = cli_runner.invoke(
[
"beta",
"models",
"remote-uploads",
"create",
"ml_1",
"--project",
"proj",
"--from",
"https://huggingface.co/acme/model@rev-a",
"--revision",
"rev-b",
"--json",
]
)

assert result.exit_code != 0
assert "conflicting revisions" in result.output

def test_create_remote_upload_rejects_revision_on_presigned_url(self, cli_runner: CliRunner) -> None:
result = cli_runner.invoke(
[
"beta",
"models",
"remote-uploads",
"create",
"ml_1",
"--project",
"proj",
"--from",
"https://bucket.s3.amazonaws.com/model.tar.gz",
"--revision",
"abc123",
"--json",
]
)

assert result.exit_code != 0
assert "only supported for Hugging Face sources" in result.output

@pytest.mark.respx(base_url=base_url)
def test_retrieve_remote_upload(self, respx_mock: MockRouter, cli_runner: CliRunner) -> None:
respx_mock.get("/projects/proj/models/uploads/ru_1").mock(
Expand Down
Loading