Skip to content

Commit 9ae958b

Browse files
committed
feat(jig): port over jig cli code
1 parent e630210 commit 9ae958b

File tree

5 files changed

+1084
-0
lines changed

5 files changed

+1084
-0
lines changed

src/together/lib/cli/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import together
99
from together._version import __version__
1010
from together._constants import DEFAULT_TIMEOUT
11+
from together.lib.cli.api.jig import jig
1112
from together.lib.cli.api.beta import beta
1213
from together.lib.cli.api.evals import evals
1314
from together.lib.cli.api.files import files
@@ -68,6 +69,7 @@ def main(
6869
main.add_command(endpoints)
6970
main.add_command(evals)
7071
main.add_command(beta)
72+
main.add_command(jig)
7173

7274
if __name__ == "__main__":
7375
main()
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
"""Jig - Simple deployment tool for Together AI."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import os
7+
import shutil
8+
import subprocess
9+
import sys
10+
from pathlib import Path
11+
from typing import Any
12+
13+
import click
14+
import httpx
15+
16+
from ._client import API_URL, REGISTRY_URL, APIClient
17+
from ._config import Config
18+
from ._helpers import (
19+
GENERATE_DOCKERFILE,
20+
AppContext,
21+
do_dockerfile,
22+
get_image,
23+
get_image_with_digest,
24+
set_secret,
25+
watch_job_status,
26+
)
27+
from ._state import State
28+
from .secrets import secrets
29+
from .volumes import volumes
30+
31+
32+
@click.group()
33+
@click.pass_context
34+
@click.option("--config", "config_path", type=str, help="Configuration file path")
35+
def jig(ctx: click.Context, config_path: str | None) -> None:
36+
"""jig - Simple deployment tool for Together AI"""
37+
# Skip initialization for 'init' command
38+
if ctx.invoked_subcommand == "init":
39+
return
40+
41+
# Skip initialization when showing help
42+
if "--help" in sys.argv:
43+
return
44+
45+
cfg = Config.find(config_path)
46+
state = State.load(cfg._path.parent)
47+
48+
api_key = os.getenv("TOGETHER_API_KEY", "")
49+
if not api_key:
50+
click.echo("ERROR: TOGETHER_API_KEY must be set", err=True)
51+
ctx.exit(1)
52+
53+
client = APIClient(api_key)
54+
if not state.username:
55+
state.username = client.get_username()
56+
state.save()
57+
58+
ctx.obj = AppContext(config=cfg, state=state, client=client, api_key=api_key)
59+
60+
61+
# --- Top-level Commands ---
62+
63+
64+
@jig.command()
65+
def init() -> None:
66+
"""Initialize jig configuration"""
67+
pyproject = Path("pyproject.toml")
68+
if pyproject.exists():
69+
click.echo("pyproject.toml already exists")
70+
return
71+
72+
content = """[project]
73+
name = "my-model"
74+
version = "0.1.0"
75+
dependencies = ["torch", "transformers"]
76+
77+
[tool.jig.image]
78+
python_version = "3.11"
79+
system_packages = ["git", "libglib2.0-0"]
80+
cmd = "python app.py"
81+
82+
[tool.jig.deploy]
83+
description = "My model deployment"
84+
gpu_type = "h100-80gb"
85+
gpu_count = 1
86+
"""
87+
with open(pyproject, "w") as f:
88+
f.write(content)
89+
click.echo("\N{CHECK MARK} Created pyproject.toml")
90+
click.echo(" Edit the configuration and run 'together jig deploy'")
91+
92+
93+
@jig.command()
94+
@click.pass_context
95+
def dockerfile(ctx: click.Context) -> None:
96+
"""Generate Dockerfile"""
97+
app_ctx: AppContext = ctx.obj
98+
do_dockerfile(app_ctx.config)
99+
100+
101+
@jig.command()
102+
@click.pass_context
103+
@click.option("--tag", default="latest", help="Image tag")
104+
def build(ctx: click.Context, tag: str) -> None:
105+
"""Build container image"""
106+
app_ctx: AppContext = ctx.obj
107+
image = get_image(app_ctx, tag)
108+
109+
if GENERATE_DOCKERFILE:
110+
dockerfile_path = Path(app_ctx.config.dockerfile)
111+
if (
112+
app_ctx.config._path
113+
and app_ctx.config._path.exists()
114+
and dockerfile_path.exists()
115+
and app_ctx.config._path.stat().st_mtime > dockerfile_path.stat().st_mtime
116+
):
117+
click.echo(f"\N{INFORMATION SOURCE} {app_ctx.config._path} has changed, regenerating Dockerfile")
118+
do_dockerfile(app_ctx.config)
119+
120+
if not dockerfile_path.exists():
121+
do_dockerfile(app_ctx.config)
122+
123+
# Copy sprocket worker if it exists
124+
build_dir_worker_path = Path("./.sprocket.py")
125+
dst = Path(__file__).parent / "sprocket" / "sprocket.py"
126+
try:
127+
shutil.copy(dst, build_dir_worker_path)
128+
except FileNotFoundError:
129+
pass
130+
131+
click.echo(f"Building {image}")
132+
cmd = ["docker", "build", "--platform", "linux/amd64", "-t", image, "."]
133+
if app_ctx.config.dockerfile != "Dockerfile":
134+
cmd.extend(["-f", app_ctx.config.dockerfile])
135+
136+
if subprocess.run(cmd).returncode != 0:
137+
raise click.ClickException("Build failed")
138+
139+
build_dir_worker_path.unlink(missing_ok=True)
140+
click.echo("\N{CHECK MARK} Built")
141+
142+
143+
@jig.command()
144+
@click.pass_context
145+
@click.option("--tag", default="latest", help="Image tag")
146+
def push(ctx: click.Context, tag: str) -> None:
147+
"""Push image to registry"""
148+
app_ctx: AppContext = ctx.obj
149+
image = get_image(app_ctx, tag)
150+
151+
# Login
152+
login_cmd = f"echo {app_ctx.api_key} | docker login {REGISTRY_URL} --username user --password-stdin"
153+
if subprocess.run(login_cmd, shell=True, capture_output=True).returncode != 0:
154+
raise click.ClickException("Registry login failed")
155+
156+
click.echo(f"Pushing {image}")
157+
if subprocess.run(["docker", "push", image]).returncode != 0:
158+
raise click.ClickException("Push failed")
159+
click.echo("\N{CHECK MARK} Pushed")
160+
161+
162+
@jig.command()
163+
@click.pass_context
164+
@click.option("--tag", default="latest", help="Image tag")
165+
@click.option("--build-only", is_flag=True, help="Build and push only")
166+
@click.option("--image", "existing_image", help="Use existing image (skip build/push)")
167+
def deploy(ctx: click.Context, tag: str, build_only: bool, existing_image: str | None) -> dict[str, Any] | None:
168+
"""Deploy model"""
169+
app_ctx: AppContext = ctx.obj
170+
171+
if existing_image:
172+
deployment_image = existing_image
173+
else:
174+
# Build and push
175+
ctx.invoke(build, tag=tag)
176+
ctx.invoke(push, tag=tag)
177+
deployment_image = get_image_with_digest(app_ctx, tag)
178+
179+
if build_only:
180+
click.echo("\N{CHECK MARK} Build complete (--build-only)")
181+
return None
182+
183+
deploy_data: dict[str, Any] = {
184+
"name": app_ctx.config.model_name,
185+
"description": app_ctx.config.deploy.description,
186+
"image": deployment_image,
187+
"min_replicas": app_ctx.config.deploy.min_replicas,
188+
"max_replicas": app_ctx.config.deploy.max_replicas,
189+
"port": app_ctx.config.deploy.port,
190+
"gpu_type": app_ctx.config.deploy.gpu_type,
191+
"gpu_count": app_ctx.config.deploy.gpu_count,
192+
"cpu": app_ctx.config.deploy.cpu,
193+
"memory": app_ctx.config.deploy.memory,
194+
"autoscaling": app_ctx.config.deploy.autoscaling,
195+
}
196+
197+
if app_ctx.config.deploy.health_check_path:
198+
deploy_data["health_check_path"] = app_ctx.config.deploy.health_check_path
199+
if app_ctx.config.deploy.command:
200+
deploy_data["command"] = app_ctx.config.deploy.command
201+
202+
# Add environment variables
203+
env_vars = [{"name": k, "value": v} for k, v in app_ctx.config.deploy.environment_variables.items()]
204+
env_vars.append({"name": "TOGETHER_API_BASE_URL", "value": API_URL})
205+
206+
if "TOGETHER_API_KEY" not in app_ctx.state.secrets:
207+
set_secret(app_ctx, "TOGETHER_API_KEY", app_ctx.api_key, "Auth key for queue API")
208+
209+
for name, secret_id in app_ctx.state.secrets.items():
210+
env_vars.append({"name": name, "value_from_secret": secret_id})
211+
212+
deploy_data["environment_variables"] = env_vars
213+
214+
# Add volumes
215+
volume_list = []
216+
for volume_name, mount_path in app_ctx.state.volumes.items():
217+
volume_list.append({"name": volume_name, "mount_path": mount_path})
218+
if volume_list:
219+
deploy_data["volumes"] = volume_list
220+
221+
click.echo(json.dumps(deploy_data, indent=2))
222+
click.echo(f"Deploying model: {app_ctx.config.model_name}")
223+
224+
# Try to update first, fallback to create if not found
225+
try:
226+
response = app_ctx.client.request(
227+
"PATCH",
228+
f"/v1/deployments/{app_ctx.config.model_name}",
229+
json=deploy_data,
230+
)
231+
click.echo("\N{CHECK MARK} Updated deployment")
232+
except httpx.HTTPStatusError as e:
233+
if e.response.status_code != 404:
234+
raise
235+
click.echo("\N{ROCKET} Creating new deployment")
236+
response = app_ctx.client.request("POST", "/v1/deployments", json=deploy_data)
237+
click.echo(f"\N{CHECK MARK} Deployed: {app_ctx.config.model_name}")
238+
239+
return response
240+
241+
242+
@jig.command()
243+
@click.pass_context
244+
def status(ctx: click.Context) -> None:
245+
"""Get deployment status"""
246+
app_ctx: AppContext = ctx.obj
247+
response = app_ctx.client.request("GET", f"/v1/deployments/{app_ctx.config.model_name}")
248+
click.echo(json.dumps(response, indent=2))
249+
250+
251+
@jig.command()
252+
@click.pass_context
253+
@click.option("--follow", is_flag=True, help="Follow log output")
254+
def logs(ctx: click.Context, follow: bool) -> None:
255+
"""Get deployment logs"""
256+
app_ctx: AppContext = ctx.obj
257+
258+
if not follow:
259+
response = app_ctx.client.request("GET", f"/v1/deployments/{app_ctx.config.model_name}/logs")
260+
if response and "lines" in response:
261+
for log_line in response["lines"]:
262+
click.echo(log_line)
263+
else:
264+
click.echo("No logs available")
265+
return
266+
267+
url = f"https://{API_URL}/v1/deployments/{app_ctx.config.model_name}/logs?follow=true"
268+
try:
269+
with httpx.Client(headers={"Authorization": f"Bearer {app_ctx.api_key}"}, timeout=None) as http_client:
270+
with http_client.stream("GET", url) as resp:
271+
resp.raise_for_status()
272+
for line in resp.iter_lines():
273+
if line:
274+
for log_line in json.loads(line).get("lines", []):
275+
click.echo(log_line)
276+
except KeyboardInterrupt:
277+
click.echo("\nStopped following logs")
278+
except Exception as e:
279+
click.echo(f"\nConnection ended: {e}")
280+
281+
282+
@jig.command()
283+
@click.pass_context
284+
def destroy(ctx: click.Context) -> None:
285+
"""Destroy deployment"""
286+
app_ctx: AppContext = ctx.obj
287+
app_ctx.client.request("DELETE", f"/v1/deployments/{app_ctx.config.model_name}")
288+
click.echo(f"\N{WASTEBASKET} Destroyed {app_ctx.config.model_name}")
289+
290+
291+
@jig.command()
292+
@click.pass_context
293+
@click.option("--prompt", help="Job prompt")
294+
@click.option("--payload", help="Job payload JSON")
295+
@click.option("--watch", is_flag=True, help="Watch job status until completion")
296+
def submit(ctx: click.Context, prompt: str | None, payload: str | None, watch: bool) -> None:
297+
"""Submit a job to the deployment"""
298+
app_ctx: AppContext = ctx.obj
299+
300+
if not prompt and not payload:
301+
raise click.ClickException("Either --prompt or --payload required")
302+
303+
request_data = {
304+
"model": app_ctx.config.model_name,
305+
"payload": json.loads(payload) if payload else {"prompt": prompt},
306+
"priority": 1,
307+
}
308+
309+
response = app_ctx.client.request("POST", "/v1/videos/generations", json=request_data)
310+
click.echo("\N{CHECK MARK} Submitted job")
311+
click.echo(json.dumps(response, indent=2))
312+
313+
if watch and response and "requestId" in response:
314+
click.echo(f"\nWatching job {response['requestId']}...")
315+
watch_job_status(app_ctx, response["requestId"])
316+
317+
318+
@jig.command("job-status")
319+
@click.pass_context
320+
@click.option("--request-id", required=True, help="Job request ID")
321+
def job_status(ctx: click.Context, request_id: str) -> None:
322+
"""Get status of a specific video job"""
323+
app_ctx: AppContext = ctx.obj
324+
response = app_ctx.client.request(
325+
"GET",
326+
f"/v1/videos/status?request_id={request_id}&model={app_ctx.config.model_name}",
327+
)
328+
click.echo(json.dumps(response, indent=2))
329+
330+
331+
@jig.command("queue-status")
332+
@click.pass_context
333+
def queue_status(ctx: click.Context) -> None:
334+
"""Get queue status for the deployment"""
335+
app_ctx: AppContext = ctx.obj
336+
response = app_ctx.client.request("GET", f"/internal/v1/queue/status?model={app_ctx.config.model_name}")
337+
click.echo(json.dumps(response, indent=2))
338+
339+
340+
# Add subcommand groups
341+
jig.add_command(secrets)
342+
jig.add_command(volumes)

0 commit comments

Comments
 (0)