Skip to content

Commit 7859f99

Browse files
fix: Ensure endpoints deploy command uses the deploy id and revision id when provided (#463)
* fix: Ensure `endpoints deploy` command uses the deploy id and revision id when provided * fix(cli): reject duplicate endpoint model revisions Co-authored-by: Blaine Kasten <blainekasten@gmail.com> * refactor(cli): handle model not found by exception type Co-authored-by: Blaine Kasten <blainekasten@gmail.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent e870160 commit 7859f99

8 files changed

Lines changed: 267 additions & 70 deletions

File tree

src/together/lib/cli/api/beta/endpoints/_utils/_resolve_model.py

Lines changed: 109 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from __future__ import annotations
22

33
import re
4+
from typing import NamedTuple
45

5-
from together import APIError
6+
from together import NotFoundError
67
from together.types.beta import Model, Endpoint
78
from together.lib.cli.utils.config import CLIConfigParameter
89
from together.lib.cli.utils._console import console
@@ -18,9 +19,12 @@
1819
# Logic for resolving a model + config from a user input string
1920
#
2021
# 1. Raw model id (e.g. ml_...)
21-
# → GET /configs?referenceModelId=... for the config and model path
22+
# → retrieve from --project when possible; config via baseModelId / id
23+
# → else GET /configs?referenceModelId=... (public / reference models)
2224
# 2. Full model path (projects/.../models/...)
23-
# → parse the model id, then same as (1)
25+
# → retrieve that model (keep it as the deploy target), resolve config via
26+
# baseModelId (or the model id when it is itself a reference model).
27+
# Preserve an optional /revisions/... pin on the deploy path.
2428
# 3. Named model (prefix/model-name)
2529
# a. prefix == project slug → list private models by name, resolve config via
2630
# baseModelId (path 1), but deploy the custom model path
@@ -31,7 +35,13 @@
3135
# - one profile + no --config → use that profile's config
3236
# - --config given → use the profile whose config matches
3337
# After either path, re-validate against the user's --config when provided.
34-
MODEL_PATH_RE = re.compile(r"^projects/([^/]+)/models/([^/]+)(?:/revisions/[^/]+)?$")
38+
MODEL_PATH_RE = re.compile(r"^projects/([^/]+)/models/([^/]+)(?:/revisions/([^/]+))?$")
39+
40+
41+
class ResolvedModelAndConfig(NamedTuple):
42+
model: Model
43+
config: Config
44+
revision_id: str | None = None
3545

3646

3747
async def resolve_model(
@@ -40,24 +50,47 @@ async def resolve_model(
4050
*,
4151
config_id: str | None = None,
4252
) -> Model:
43-
model, _config = await resolve_model_and_config(config, model_input, config_id=config_id)
44-
return model
53+
resolved = await resolve_model_and_config(config, model_input, config_id=config_id)
54+
return resolved.model
4555

4656

4757
async def resolve_model_and_config(
4858
config: CLIConfigParameter,
4959
model_input: str,
5060
*,
5161
config_id: str | None = None,
52-
) -> tuple[Model, Config]:
62+
) -> ResolvedModelAndConfig:
5363
"""Resolve a deployable model and the config revision to pair with it."""
54-
# 1 / 2. Full model path or raw model id → configs list by referenceModelId
64+
# 2. Full model path → keep the user's model; config from its base/reference.
5565
path_match = MODEL_PATH_RE.match(model_input)
5666
if path_match:
57-
_project_id, model_id = path_match.groups()
58-
return await _resolve_via_configs(config, model_id, config_id=config_id, model_input=model_input)
67+
project_id, model_id, revision_id = path_match.group(1), path_match.group(2), path_match.group(3)
68+
return await _resolve_explicit_model(
69+
config,
70+
model_id=model_id,
71+
project_id=project_id,
72+
config_id=config_id,
73+
model_input=model_input,
74+
revision_id=revision_id,
75+
)
5976

77+
# 1. Raw model id
6078
if "/" not in model_input:
79+
if config.project_id:
80+
try:
81+
model = await config.client.beta.models.retrieve(id=model_input, project_id=config.project_id)
82+
except NotFoundError:
83+
pass
84+
else:
85+
reference_model_id = model.base_model_id or model.id
86+
assert reference_model_id is not None
87+
return await _resolve_config_for_model(
88+
config,
89+
model,
90+
reference_model_id=reference_model_id,
91+
config_id=config_id,
92+
model_input=model_input,
93+
)
6194
return await _resolve_via_configs(config, model_input, config_id=config_id, model_input=model_input)
6295

6396
# 3. Named model (prefix/model-name)
@@ -70,32 +103,82 @@ async def resolve_model_and_config(
70103
reference_model_id = model.base_model_id or model.id
71104
assert reference_model_id is not None
72105
# Config comes from the base/reference model; deploy path stays the custom model.
73-
_base_model, selected_config = await _resolve_via_configs(
106+
return await _resolve_config_for_model(
74107
config,
75-
reference_model_id,
108+
model,
109+
reference_model_id=reference_model_id,
76110
config_id=config_id,
77111
model_input=model_input,
78112
)
79-
return model, selected_config
80113

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

83116

117+
async def _resolve_explicit_model(
118+
config: CLIConfigParameter,
119+
*,
120+
model_id: str,
121+
project_id: str,
122+
config_id: str | None,
123+
model_input: str,
124+
revision_id: str | None = None,
125+
) -> ResolvedModelAndConfig:
126+
"""Load the user-specified model and pair it with a compatible config."""
127+
try:
128+
model = await config.client.beta.models.retrieve(id=model_id, project_id=project_id)
129+
except NotFoundError:
130+
raise ValueError(f"Model {model_input} not found.") from None
131+
132+
reference_model_id = model.base_model_id or model.id
133+
assert reference_model_id is not None
134+
return await _resolve_config_for_model(
135+
config,
136+
model,
137+
reference_model_id=reference_model_id,
138+
config_id=config_id,
139+
model_input=model_input,
140+
revision_id=revision_id,
141+
)
142+
143+
144+
async def _resolve_config_for_model(
145+
config: CLIConfigParameter,
146+
model: Model,
147+
*,
148+
reference_model_id: str,
149+
config_id: str | None,
150+
model_input: str,
151+
revision_id: str | None = None,
152+
) -> ResolvedModelAndConfig:
153+
selected = resolve_config(
154+
await resolve_configs(config, reference_model_id),
155+
config_id,
156+
model=model_input,
157+
)
158+
selected = validate_requested_config(selected, config_id, model=model_input)
159+
return ResolvedModelAndConfig(model=model, config=selected, revision_id=revision_id)
160+
161+
84162
async def _resolve_via_configs(
85163
config: CLIConfigParameter,
86164
reference_model_id: str,
87165
*,
88166
config_id: str | None,
89167
model_input: str,
90-
) -> tuple[Model, Config]:
168+
) -> ResolvedModelAndConfig:
169+
"""Resolve a public/reference model id through the configs API.
170+
171+
The deploy target is the config's reference model — correct when the user
172+
passed a bare reference-model id that is not retrievable under --project.
173+
"""
91174
selected = resolve_config(
92175
await resolve_configs(config, reference_model_id),
93176
config_id,
94177
model=model_input,
95178
)
96179
selected = validate_requested_config(selected, config_id, model=model_input)
97180
model = await _retrieve_model_from_reference(config, selected, model_input=model_input)
98-
return model, selected
181+
return ResolvedModelAndConfig(model=model, config=selected)
99182

100183

101184
async def _retrieve_model_from_reference(
@@ -108,18 +191,16 @@ async def _retrieve_model_from_reference(
108191
path = selected.reference_model or ""
109192
match = MODEL_PATH_RE.match(path)
110193
if match:
111-
project_id, model_id = match.groups()
194+
project_id, model_id = match.group(1), match.group(2)
112195
elif selected.reference_model_id and selected.project_id:
113196
project_id, model_id = selected.project_id, selected.reference_model_id
114197
else:
115198
raise ValueError(f"Config {selected.id} has no usable reference model path.")
116199

117200
try:
118201
return await config.client.beta.models.retrieve(id=model_id, project_id=project_id)
119-
except APIError as e:
120-
if "not found" in e.message.lower():
121-
raise ValueError(f"Model {model_input} not found.") from None
122-
raise
202+
except NotFoundError:
203+
raise ValueError(f"Model {model_input} not found.") from None
123204

124205

125206
async def _find_private_model_by_name(config: CLIConfigParameter, name: str) -> Model:
@@ -236,7 +317,7 @@ async def _resolve_public_model_and_config(
236317
model_input: str,
237318
*,
238319
config_id: str | None = None,
239-
) -> tuple[Model, Config]:
320+
) -> ResolvedModelAndConfig:
240321
supported_models = await config.client.beta.models.list_supported(search=model_input)
241322
if not supported_models.data:
242323
raise ValueError(f"Model {model_input} not found.")
@@ -255,19 +336,22 @@ async def _resolve_public_model_and_config(
255336
match = MODEL_PATH_RE.match(profile.model or "")
256337
if not match:
257338
raise ValueError(f"Invalid model path: {profile.model}")
258-
project_id, model_id = match.groups()
339+
project_id, model_id, revision_id = match.group(1), match.group(2), match.group(3)
259340

260341
selected_config = validate_requested_config(
261342
config_from_profile(profile),
262343
config_id,
263344
model=model_input,
264345
)
265346
model = Model.construct(id=model_id, projectId=project_id, name=public_model.name or model_id)
266-
return model, selected_config
347+
return ResolvedModelAndConfig(model=model, config=selected_config, revision_id=revision_id)
267348

268349

269-
def construct_model_path(model: Model) -> str:
270-
return f"projects/{model.project_id}/models/{model.id}"
350+
def construct_model_path(model: Model, revision_id: str | None = None) -> str:
351+
path = f"projects/{model.project_id}/models/{model.id}"
352+
if revision_id:
353+
return f"{path}/revisions/{revision_id}"
354+
return path
271355

272356

273357
async def resolve_endpoint(config: CLIConfigParameter, endpoint_id_or_name: str) -> Endpoint:

src/together/lib/cli/api/beta/endpoints/ab.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ async def ab(
7474
)
7575
verify_control_receiving_traffic(endpoint, control)
7676

77-
resolved_model, config_value = await resolve_model_and_config(config, model, config_id=config_id)
77+
resolved = await resolve_model_and_config(config, model, config_id=config_id)
78+
resolved_model, config_value = resolved.model, resolved.config
7879

7980
autoscaling = build_autoscaling(
8081
min_replicas=1,
@@ -103,7 +104,7 @@ async def ab(
103104
endpoint_id=endpoint.id,
104105
enable_lora=enable_lora,
105106
name=name,
106-
model=construct_model_path(resolved_model),
107+
model=construct_model_path(resolved_model, resolved.revision_id),
107108
config=construct_config_path(config_value),
108109
autoscaling=autoscaling,
109110
),

src/together/lib/cli/api/beta/endpoints/deploy.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
PlacementProfile,
3131
)
3232
from together.lib.cli.api.beta.endpoints._utils._resolve_model import (
33+
MODEL_PATH_RE,
3334
construct_model_path,
3435
resolve_model_and_config,
3536
)
@@ -174,7 +175,18 @@ async def deploy(
174175
config: CLIConfigParameter,
175176
) -> None:
176177
"""Create a deployment on a new or existing dedicated inference endpoint."""
177-
resolved_model, config_value = await resolve_model_and_config(config, model, config_id=config_id)
178+
model_path_match = MODEL_PATH_RE.match(model)
179+
if model_revision is not None and model_path_match is not None and model_path_match.group(3) is not None:
180+
raise ValueError(
181+
"Do not pass --model-revision when --model already includes a revision. "
182+
"Specify the revision only in the fully qualified --model path."
183+
)
184+
185+
resolved = await resolve_model_and_config(config, model, config_id=config_id)
186+
resolved_model, config_value = resolved.model, resolved.config
187+
# Prefer revision pin from a fully-qualified model path; fall back to the
188+
# deprecated --model-revision flag.
189+
resolved_revision = resolved.revision_id or model_revision
178190

179191
autoscaling = build_autoscaling(
180192
min_replicas=min_replicas,
@@ -200,16 +212,18 @@ async def deploy(
200212
else:
201213
placement_value = placement.to_json()
202214

215+
model_path = construct_model_path(resolved_model, resolved_revision)
216+
203217
if not config.json:
204218
_print_deployment_preview(
205219
endpoint=endpoint_name_or_id,
206220
deployment_name=deployment_name,
207221
model=resolved_model,
222+
model_path=model_path,
208223
config_value=config_value,
209224
autoscaling=autoscaling,
210225
placement=placement_value,
211226
enable_lora=enable_lora,
212-
model_revision=model_revision,
213227
traffic_weight=traffic_weight,
214228
)
215229
await assert_explicit_project_id(config)
@@ -222,11 +236,12 @@ async def deploy(
222236
config.client.beta.endpoints.deployments.create(
223237
endpoint.id,
224238
name=deployment_name,
225-
model=construct_model_path(resolved_model),
239+
model=model_path,
226240
config=construct_config_path(config_value),
227241
autoscaling=autoscaling,
228242
enable_lora=enable_lora if enable_lora is not None else omit,
229-
model_revision_id=model_revision or omit,
243+
# Revision is already embedded in model_path when present.
244+
model_revision_id=omit,
230245
placement=placement_value or omit,
231246
),
232247
)
@@ -267,11 +282,11 @@ def _print_deployment_preview(
267282
endpoint: str,
268283
deployment_name: str,
269284
model: Model,
285+
model_path: str,
270286
config_value: Config,
271287
autoscaling: DeploymentAutoscalingParam,
272288
placement: Placement | None,
273289
enable_lora: bool | None,
274-
model_revision: str | None,
275290
traffic_weight: float | None,
276291
) -> None:
277292
table = Table(expand=True, show_header=False, show_edge=False, show_lines=False, box=None, pad_edge=False)
@@ -302,9 +317,6 @@ def add_row(flag: str, value: str) -> None:
302317
if percentile := metric.get("percentile"):
303318
add_row("--scaling-percentile", percentile)
304319

305-
if model_revision:
306-
add_row("--model-revision", model_revision)
307-
308320
if placement is not None:
309321
if "profile" in placement:
310322
add_row("--placement", placement["profile"]) # type: ignore[typeddict-item]
@@ -324,7 +336,7 @@ def add_row(flag: str, value: str) -> None:
324336
add_row("--enable-lora", "true" if enable_lora else "false")
325337
if traffic_weight is not None:
326338
add_row("--traffic-weight", str(traffic_weight))
327-
add_row("--model", model.name)
339+
add_row("--model", f"{model.name} ({model_path})")
328340
add_row("--config", config_value.id) # type: ignore
329341

330342
table.add_row("\n".join(args))

src/together/lib/cli/api/beta/endpoints/shadow.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ async def shadow(
9292
rate, target_qps = await resolve_rate_or_target_qps(rate, target_qps, config=config)
9393

9494
endpoint_id = (await resolve_endpoint(config, endpoint_id_or_name)).id
95-
resolved_model, config_value = await resolve_model_and_config(config, model, config_id=config_id)
95+
resolved = await resolve_model_and_config(config, model, config_id=config_id)
96+
resolved_model, config_value = resolved.model, resolved.config
9697

9798
autoscaling = build_autoscaling(
9899
min_replicas=1,
@@ -122,7 +123,7 @@ async def shadow(
122123
endpoint_id=endpoint_id,
123124
name=name,
124125
enable_lora=enable_lora,
125-
model=construct_model_path(resolved_model),
126+
model=construct_model_path(resolved_model, resolved.revision_id),
126127
config=construct_config_path(config_value),
127128
autoscaling=autoscaling,
128129
),

src/together/lib/cli/api/beta/models/create.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ async def preprompt(self, config: CLIConfig) -> None:
3030
for profile in model.deployment_profiles:
3131
match = MODEL_PATH_RE.match(profile.model)
3232
if match:
33-
_, model_id = match.groups()
33+
model_id = match.group(2)
3434
self.choices.append((f"{model.name} ({profile.quantization})", model_id))
3535

3636

0 commit comments

Comments
 (0)