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
134 changes: 109 additions & 25 deletions src/together/lib/cli/api/beta/endpoints/_utils/_resolve_model.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from __future__ import annotations

import re
from typing import NamedTuple

from together import APIError
from together import NotFoundError
from together.types.beta import Model, Endpoint
from together.lib.cli.utils.config import CLIConfigParameter
from together.lib.cli.utils._console import console
Expand All @@ -18,9 +19,12 @@
# Logic for resolving a model + config from a user input string
#
# 1. Raw model id (e.g. ml_...)
# → GET /configs?referenceModelId=... for the config and model path
# → retrieve from --project when possible; config via baseModelId / id
# → else GET /configs?referenceModelId=... (public / reference models)
# 2. Full model path (projects/.../models/...)
# → parse the model id, then same as (1)
# → retrieve that model (keep it as the deploy target), resolve config via
# baseModelId (or the model id when it is itself a reference model).
# Preserve an optional /revisions/... pin on the deploy path.
# 3. Named model (prefix/model-name)
# a. prefix == project slug → list private models by name, resolve config via
# baseModelId (path 1), but deploy the custom model path
Expand All @@ -31,7 +35,13 @@
# - one profile + no --config → use that profile's config
# - --config given → use the profile whose config matches
# After either path, re-validate against the user's --config when provided.
MODEL_PATH_RE = re.compile(r"^projects/([^/]+)/models/([^/]+)(?:/revisions/[^/]+)?$")
MODEL_PATH_RE = re.compile(r"^projects/([^/]+)/models/([^/]+)(?:/revisions/([^/]+))?$")


class ResolvedModelAndConfig(NamedTuple):
model: Model
config: Config
revision_id: str | None = None


async def resolve_model(
Expand All @@ -40,24 +50,47 @@ async def resolve_model(
*,
config_id: str | None = None,
) -> Model:
model, _config = await resolve_model_and_config(config, model_input, config_id=config_id)
return model
resolved = await resolve_model_and_config(config, model_input, config_id=config_id)
return resolved.model


async def resolve_model_and_config(
config: CLIConfigParameter,
model_input: str,
*,
config_id: str | None = None,
) -> tuple[Model, Config]:
) -> ResolvedModelAndConfig:
"""Resolve a deployable model and the config revision to pair with it."""
# 1 / 2. Full model path or raw model id → configs list by referenceModelId
# 2. Full model path → keep the user's model; config from its base/reference.
path_match = MODEL_PATH_RE.match(model_input)
if path_match:
_project_id, model_id = path_match.groups()
return await _resolve_via_configs(config, model_id, config_id=config_id, model_input=model_input)
project_id, model_id, revision_id = path_match.group(1), path_match.group(2), path_match.group(3)
return await _resolve_explicit_model(
config,
model_id=model_id,
project_id=project_id,
config_id=config_id,
model_input=model_input,
revision_id=revision_id,
)

# 1. Raw model id
if "/" not in model_input:
if config.project_id:
try:
model = await config.client.beta.models.retrieve(id=model_input, project_id=config.project_id)
except NotFoundError:
pass
else:
reference_model_id = model.base_model_id or model.id
assert reference_model_id is not None
return await _resolve_config_for_model(
config,
model,
reference_model_id=reference_model_id,
config_id=config_id,
model_input=model_input,
)
return await _resolve_via_configs(config, model_input, config_id=config_id, model_input=model_input)

# 3. Named model (prefix/model-name)
Expand All @@ -70,32 +103,82 @@ async def resolve_model_and_config(
reference_model_id = model.base_model_id or model.id
assert reference_model_id is not None
# Config comes from the base/reference model; deploy path stays the custom model.
_base_model, selected_config = await _resolve_via_configs(
return await _resolve_config_for_model(
config,
reference_model_id,
model,
reference_model_id=reference_model_id,
config_id=config_id,
model_input=model_input,
)
return model, selected_config

return await _resolve_public_model_and_config(config, model_input, config_id=config_id)


async def _resolve_explicit_model(
config: CLIConfigParameter,
*,
model_id: str,
project_id: str,
config_id: str | None,
model_input: str,
revision_id: str | None = None,
) -> ResolvedModelAndConfig:
"""Load the user-specified model and pair it with a compatible config."""
try:
model = await config.client.beta.models.retrieve(id=model_id, project_id=project_id)
except NotFoundError:
raise ValueError(f"Model {model_input} not found.") from None

reference_model_id = model.base_model_id or model.id
assert reference_model_id is not None
return await _resolve_config_for_model(
config,
model,
reference_model_id=reference_model_id,
config_id=config_id,
model_input=model_input,
revision_id=revision_id,
)


async def _resolve_config_for_model(
config: CLIConfigParameter,
model: Model,
*,
reference_model_id: str,
config_id: str | None,
model_input: str,
revision_id: str | None = None,
) -> ResolvedModelAndConfig:
selected = resolve_config(
await resolve_configs(config, reference_model_id),
config_id,
model=model_input,
)
selected = validate_requested_config(selected, config_id, model=model_input)
return ResolvedModelAndConfig(model=model, config=selected, revision_id=revision_id)


async def _resolve_via_configs(
config: CLIConfigParameter,
reference_model_id: str,
*,
config_id: str | None,
model_input: str,
) -> tuple[Model, Config]:
) -> ResolvedModelAndConfig:
"""Resolve a public/reference model id through the configs API.

The deploy target is the config's reference model — correct when the user
passed a bare reference-model id that is not retrievable under --project.
"""
selected = resolve_config(
await resolve_configs(config, reference_model_id),
config_id,
model=model_input,
)
selected = validate_requested_config(selected, config_id, model=model_input)
model = await _retrieve_model_from_reference(config, selected, model_input=model_input)
return model, selected
return ResolvedModelAndConfig(model=model, config=selected)


async def _retrieve_model_from_reference(
Expand All @@ -108,18 +191,16 @@ async def _retrieve_model_from_reference(
path = selected.reference_model or ""
match = MODEL_PATH_RE.match(path)
if match:
project_id, model_id = match.groups()
project_id, model_id = match.group(1), match.group(2)
elif selected.reference_model_id and selected.project_id:
project_id, model_id = selected.project_id, selected.reference_model_id
else:
raise ValueError(f"Config {selected.id} has no usable reference model path.")

try:
return await config.client.beta.models.retrieve(id=model_id, project_id=project_id)
except APIError as e:
if "not found" in e.message.lower():
raise ValueError(f"Model {model_input} not found.") from None
raise
except NotFoundError:
raise ValueError(f"Model {model_input} not found.") from None


async def _find_private_model_by_name(config: CLIConfigParameter, name: str) -> Model:
Expand Down Expand Up @@ -236,7 +317,7 @@ async def _resolve_public_model_and_config(
model_input: str,
*,
config_id: str | None = None,
) -> tuple[Model, Config]:
) -> ResolvedModelAndConfig:
supported_models = await config.client.beta.models.list_supported(search=model_input)
if not supported_models.data:
raise ValueError(f"Model {model_input} not found.")
Expand All @@ -255,19 +336,22 @@ async def _resolve_public_model_and_config(
match = MODEL_PATH_RE.match(profile.model or "")
if not match:
raise ValueError(f"Invalid model path: {profile.model}")
project_id, model_id = match.groups()
project_id, model_id, revision_id = match.group(1), match.group(2), match.group(3)

selected_config = validate_requested_config(
config_from_profile(profile),
config_id,
model=model_input,
)
model = Model.construct(id=model_id, projectId=project_id, name=public_model.name or model_id)
return model, selected_config
return ResolvedModelAndConfig(model=model, config=selected_config, revision_id=revision_id)


def construct_model_path(model: Model) -> str:
return f"projects/{model.project_id}/models/{model.id}"
def construct_model_path(model: Model, revision_id: str | None = None) -> str:
path = f"projects/{model.project_id}/models/{model.id}"
if revision_id:
return f"{path}/revisions/{revision_id}"
return path


async def resolve_endpoint(config: CLIConfigParameter, endpoint_id_or_name: str) -> Endpoint:
Expand Down
5 changes: 3 additions & 2 deletions src/together/lib/cli/api/beta/endpoints/ab.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ async def ab(
)
verify_control_receiving_traffic(endpoint, control)

resolved_model, config_value = await resolve_model_and_config(config, model, config_id=config_id)
resolved = await resolve_model_and_config(config, model, config_id=config_id)
resolved_model, config_value = resolved.model, resolved.config

autoscaling = build_autoscaling(
min_replicas=1,
Expand Down Expand Up @@ -103,7 +104,7 @@ async def ab(
endpoint_id=endpoint.id,
enable_lora=enable_lora,
name=name,
model=construct_model_path(resolved_model),
model=construct_model_path(resolved_model, resolved.revision_id),
config=construct_config_path(config_value),
autoscaling=autoscaling,
),
Expand Down
30 changes: 21 additions & 9 deletions src/together/lib/cli/api/beta/endpoints/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
PlacementProfile,
)
from together.lib.cli.api.beta.endpoints._utils._resolve_model import (
MODEL_PATH_RE,
construct_model_path,
resolve_model_and_config,
)
Expand Down Expand Up @@ -174,7 +175,18 @@ async def deploy(
config: CLIConfigParameter,
) -> None:
"""Create a deployment on a new or existing dedicated inference endpoint."""
resolved_model, config_value = await resolve_model_and_config(config, model, config_id=config_id)
model_path_match = MODEL_PATH_RE.match(model)
if model_revision is not None and model_path_match is not None and model_path_match.group(3) is not None:
raise ValueError(
"Do not pass --model-revision when --model already includes a revision. "
"Specify the revision only in the fully qualified --model path."
)

resolved = await resolve_model_and_config(config, model, config_id=config_id)
resolved_model, config_value = resolved.model, resolved.config
# Prefer revision pin from a fully-qualified model path; fall back to the
# deprecated --model-revision flag.
resolved_revision = resolved.revision_id or model_revision
Comment thread
blainekasten marked this conversation as resolved.

autoscaling = build_autoscaling(
min_replicas=min_replicas,
Expand All @@ -200,16 +212,18 @@ async def deploy(
else:
placement_value = placement.to_json()

model_path = construct_model_path(resolved_model, resolved_revision)

if not config.json:
_print_deployment_preview(
endpoint=endpoint_name_or_id,
deployment_name=deployment_name,
model=resolved_model,
model_path=model_path,
config_value=config_value,
autoscaling=autoscaling,
placement=placement_value,
enable_lora=enable_lora,
model_revision=model_revision,
traffic_weight=traffic_weight,
)
await assert_explicit_project_id(config)
Expand All @@ -222,11 +236,12 @@ async def deploy(
config.client.beta.endpoints.deployments.create(
endpoint.id,
name=deployment_name,
model=construct_model_path(resolved_model),
model=model_path,
config=construct_config_path(config_value),
autoscaling=autoscaling,
enable_lora=enable_lora if enable_lora is not None else omit,
model_revision_id=model_revision or omit,
# Revision is already embedded in model_path when present.
model_revision_id=omit,
placement=placement_value or omit,
),
)
Expand Down Expand Up @@ -267,11 +282,11 @@ def _print_deployment_preview(
endpoint: str,
deployment_name: str,
model: Model,
model_path: str,
config_value: Config,
autoscaling: DeploymentAutoscalingParam,
placement: Placement | None,
enable_lora: bool | None,
model_revision: str | None,
traffic_weight: float | None,
) -> None:
table = Table(expand=True, show_header=False, show_edge=False, show_lines=False, box=None, pad_edge=False)
Expand Down Expand Up @@ -302,9 +317,6 @@ def add_row(flag: str, value: str) -> None:
if percentile := metric.get("percentile"):
add_row("--scaling-percentile", percentile)

if model_revision:
add_row("--model-revision", model_revision)

if placement is not None:
if "profile" in placement:
add_row("--placement", placement["profile"]) # type: ignore[typeddict-item]
Expand All @@ -324,7 +336,7 @@ def add_row(flag: str, value: str) -> None:
add_row("--enable-lora", "true" if enable_lora else "false")
if traffic_weight is not None:
add_row("--traffic-weight", str(traffic_weight))
add_row("--model", model.name)
add_row("--model", f"{model.name} ({model_path})")
add_row("--config", config_value.id) # type: ignore

table.add_row("\n".join(args))
Expand Down
5 changes: 3 additions & 2 deletions src/together/lib/cli/api/beta/endpoints/shadow.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ async def shadow(
rate, target_qps = await resolve_rate_or_target_qps(rate, target_qps, config=config)

endpoint_id = (await resolve_endpoint(config, endpoint_id_or_name)).id
resolved_model, config_value = await resolve_model_and_config(config, model, config_id=config_id)
resolved = await resolve_model_and_config(config, model, config_id=config_id)
resolved_model, config_value = resolved.model, resolved.config

autoscaling = build_autoscaling(
min_replicas=1,
Expand Down Expand Up @@ -122,7 +123,7 @@ async def shadow(
endpoint_id=endpoint_id,
name=name,
enable_lora=enable_lora,
model=construct_model_path(resolved_model),
model=construct_model_path(resolved_model, resolved.revision_id),
config=construct_config_path(config_value),
autoscaling=autoscaling,
),
Expand Down
2 changes: 1 addition & 1 deletion src/together/lib/cli/api/beta/models/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ async def preprompt(self, config: CLIConfig) -> None:
for profile in model.deployment_profiles:
match = MODEL_PATH_RE.match(profile.model)
if match:
_, model_id = match.groups()
model_id = match.group(2)
self.choices.append((f"{model.name} ({profile.quantization})", model_id))


Expand Down
Loading
Loading