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