cortex-training 0.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1050 @@
1
+ # Copyright 2025 Snowflake Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """Command-line implementation for Cortex Training job management."""
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import sys
24
+ from datetime import datetime
25
+ from datetime import timezone
26
+ from pathlib import Path
27
+ from typing import Any
28
+ from typing import Callable
29
+ from typing import TextIO
30
+
31
+ _CONFIG_KEYS = {
32
+ "base_url",
33
+ "host",
34
+ "pat",
35
+ "database",
36
+ "schema",
37
+ "endpoint",
38
+ "poll_interval",
39
+ "poll_timeout",
40
+ "no_verify_ssl",
41
+ "verify_ssl",
42
+ }
43
+
44
+ _CONFIG_ALIASES = {
45
+ "url": "base_url",
46
+ "db": "database",
47
+ }
48
+
49
+ _LOGIN_STATE_ENV = "CORTEX_TRAINING_LOGIN_FILE"
50
+
51
+
52
+ def _env(*names: str) -> str | None:
53
+ for name in names:
54
+ value = os.environ.get(name)
55
+ if value:
56
+ return value
57
+ return None
58
+
59
+
60
+ def _normalize_host(host: str) -> str:
61
+ for prefix in ("https://", "http://"):
62
+ if host.startswith(prefix):
63
+ host = host[len(prefix) :]
64
+ return host.rstrip("/")
65
+
66
+
67
+ def _has_url_scheme(url: str) -> bool:
68
+ return url.startswith("http://") or url.startswith("https://")
69
+
70
+
71
+ def _load_cortex_training_client_class():
72
+ from .client import CortexTrainingClient
73
+
74
+ return CortexTrainingClient
75
+
76
+
77
+ def _load_forward_backward_payload_builder():
78
+ from .client import build_forward_backward_payload
79
+
80
+ return build_forward_backward_payload
81
+
82
+
83
+ def _hardware_choices() -> list[str]:
84
+ from .client import Hardware
85
+
86
+ return [member.value for member in Hardware]
87
+
88
+
89
+ def _created_epoch(raw: Any) -> float | None:
90
+ if raw is None:
91
+ return None
92
+ created_at = str(raw).strip()
93
+ if not created_at:
94
+ return None
95
+ if created_at.endswith("Z"):
96
+ created_at = created_at[:-1] + "+00:00"
97
+ try:
98
+ created = datetime.fromisoformat(created_at)
99
+ except ValueError:
100
+ return None
101
+ if created.tzinfo is None:
102
+ created = created.replace(tzinfo=timezone.utc)
103
+ return created.timestamp()
104
+
105
+
106
+ def _jobs_latest_last(jobs: list[Any]) -> list[Any]:
107
+ """Render jobs oldest-first so the newest entries land at the terminal."""
108
+ dated_jobs = [
109
+ (
110
+ _created_epoch(job.get("created_at")) if isinstance(job, dict) else None,
111
+ index,
112
+ job,
113
+ )
114
+ for index, job in enumerate(jobs)
115
+ ]
116
+ if any(created is not None for created, _, _ in dated_jobs):
117
+ return [
118
+ job
119
+ for _, _, job in sorted(
120
+ dated_jobs,
121
+ key=lambda item: (
122
+ item[0] is None,
123
+ item[0] if item[0] is not None else float("inf"),
124
+ item[1],
125
+ ),
126
+ )
127
+ ]
128
+ return list(reversed(jobs))
129
+
130
+
131
+ def build_parser(
132
+ *,
133
+ prog: str = "cortex-training",
134
+ include_tui: bool = False,
135
+ ) -> argparse.ArgumentParser:
136
+ parser = argparse.ArgumentParser(
137
+ prog=prog,
138
+ description="Manage Cortex Training jobs through the Cortex Training REST endpoint.",
139
+ )
140
+ parser.add_argument(
141
+ "--config",
142
+ default=_env("CORTEX_TRAINING_CONFIG"),
143
+ help="Path to a reusable Cortex Training CLI config JSON file.",
144
+ )
145
+ parser.add_argument(
146
+ "--base-url",
147
+ help="Base URL for a local or otherwise compatible server. Skips PAT auth.",
148
+ )
149
+ parser.add_argument(
150
+ "--host",
151
+ help="Snowflake account host for PAT auth.",
152
+ )
153
+ parser.add_argument(
154
+ "--pat",
155
+ help="Programmatic access token.",
156
+ )
157
+ parser.add_argument(
158
+ "--database",
159
+ help="Database containing the cortex-training endpoint.",
160
+ )
161
+ parser.add_argument(
162
+ "--schema",
163
+ help="Schema containing the cortex-training endpoint. Defaults to PUBLIC.",
164
+ )
165
+ parser.add_argument(
166
+ "--endpoint",
167
+ help="REST endpoint name. Defaults to cortex-training.",
168
+ )
169
+ parser.add_argument(
170
+ "--no-verify-ssl",
171
+ action="store_true",
172
+ help="Disable SSL certificate verification for PAT-authenticated requests.",
173
+ )
174
+ parser.add_argument("--poll-interval", type=float)
175
+ parser.add_argument("--poll-timeout", type=float)
176
+ parser.add_argument(
177
+ "--compact",
178
+ action="store_true",
179
+ help="Print compact JSON instead of pretty JSON.",
180
+ )
181
+ parser.add_argument(
182
+ "--job",
183
+ "--job-id",
184
+ dest="job",
185
+ help="Job id for data-plane commands such as fwd-bwd.",
186
+ )
187
+
188
+ subparsers = parser.add_subparsers(dest="command", required=True)
189
+
190
+ submit = subparsers.add_parser(
191
+ "submit",
192
+ help="Submit a Cortex Training job from a CreateJob JSON file.",
193
+ )
194
+ submit.add_argument(
195
+ "json_file",
196
+ help="Path to CreateJob JSON, or '-' for stdin.",
197
+ )
198
+ submit.add_argument("--job-id", help="Set or override the top-level job_id.")
199
+ submit.add_argument(
200
+ "--dry-run",
201
+ action="store_true",
202
+ help="Validate and print the CreateJob body without sending it.",
203
+ )
204
+ submit.add_argument(
205
+ "--wait",
206
+ action="store_true",
207
+ help="Wait until the submitted job reaches running.",
208
+ )
209
+
210
+ get = subparsers.add_parser("get", help="Fetch one Cortex Training job.")
211
+ get.add_argument("job_id")
212
+
213
+ checkpoints = subparsers.add_parser(
214
+ "checkpoints",
215
+ help="List checkpoints for one Cortex Training job.",
216
+ )
217
+ checkpoints.add_argument("job_id")
218
+
219
+ list_jobs = subparsers.add_parser("list", help="List Cortex Training jobs.")
220
+ list_jobs.add_argument("--status", help="Optional status filter.")
221
+
222
+ capacity = subparsers.add_parser(
223
+ "capacity",
224
+ help="Show reserved GPU capacity and current usage for the caller account.",
225
+ )
226
+ capacity.add_argument(
227
+ "--hardware",
228
+ choices=_hardware_choices(),
229
+ help=(
230
+ "Show only this GPU hardware. "
231
+ "Omit to show capacity for every hardware type."
232
+ ),
233
+ )
234
+
235
+ cancel = subparsers.add_parser("cancel", help="Cancel one Cortex Training job.")
236
+ cancel.add_argument("job_id")
237
+
238
+ wait = subparsers.add_parser("wait", help="Wait until one job reaches running.")
239
+ wait.add_argument("job_id")
240
+
241
+ fwd_bwd = subparsers.add_parser(
242
+ "fwd-bwd",
243
+ help="Run one forward-backward request from a readable payload JSON file.",
244
+ )
245
+ fwd_bwd.add_argument(
246
+ "json_file",
247
+ help="Path to fwd-bwd JSON, or '-' for stdin.",
248
+ )
249
+
250
+ step = subparsers.add_parser(
251
+ "step",
252
+ help="Run one optimizer step for a training job.",
253
+ )
254
+ step.add_argument(
255
+ "--lr",
256
+ type=float,
257
+ default=1e-4,
258
+ help="Learning rate for the optimizer step. Defaults to 1e-4.",
259
+ )
260
+
261
+ load = subparsers.add_parser(
262
+ "load",
263
+ help="Load a checkpoint into an already-created Cortex Training job.",
264
+ )
265
+ load.add_argument("checkpoint_id", help="Checkpoint id/tag to load.")
266
+ load.add_argument(
267
+ "--source-job-id",
268
+ help="Load the checkpoint from another job's checkpoint store.",
269
+ )
270
+ load.add_argument(
271
+ "--target-sub-job-id",
272
+ dest="target_sub_job_id",
273
+ help=(
274
+ "Training sub-job to load the checkpoint into, e.g. JOB_ID:training:0. "
275
+ "Use this when you need explicit routing control; a job has at most one "
276
+ "training sub-job, so omitting it uses that sub-job. "
277
+ "Use 'cortex-training get JOB_ID' to discover available sub-job IDs."
278
+ ),
279
+ )
280
+ load.add_argument(
281
+ "--no-poll",
282
+ action="store_true",
283
+ help="Print the request id without polling for completion.",
284
+ )
285
+
286
+ generate = subparsers.add_parser(
287
+ "generate",
288
+ help="Run one generate request from a readable payload JSON file.",
289
+ )
290
+ generate.add_argument(
291
+ "json_file",
292
+ help="Path to generate JSON, or '-' for stdin.",
293
+ )
294
+
295
+ weight_sync = subparsers.add_parser(
296
+ "weight-sync",
297
+ help="Sync weights from a training sub-job to one or more sampling sub-jobs.",
298
+ )
299
+ weight_sync.add_argument(
300
+ "--source-sub-job-id",
301
+ help="Training sub-job id. Defaults to JOB_ID:training:0.",
302
+ )
303
+ weight_sync.add_argument(
304
+ "--target-sub-job-id",
305
+ action="append",
306
+ dest="target_sub_job_ids",
307
+ help="Sampling sub-job id. Can be repeated. Defaults to JOB_ID:sampling:0.",
308
+ )
309
+ weight_sync.add_argument(
310
+ "--operation-sub-job-id",
311
+ help="Sub-job id used to route the operation envelope. Defaults to the source training sub-job id.",
312
+ )
313
+ weight_sync.add_argument(
314
+ "--operation-sub-job-type",
315
+ help="Sub-job type used to route the operation envelope.",
316
+ )
317
+ weight_sync.add_argument(
318
+ "--weight-format",
319
+ choices=("vllm", "hf", "lora"),
320
+ help="Weight sync payload format. Use 'lora' for adapter-only sync.",
321
+ )
322
+ weight_sync.add_argument(
323
+ "--no-poll",
324
+ action="store_true",
325
+ help="Print the request id without polling for completion.",
326
+ )
327
+
328
+ for command, job_parser in (
329
+ ("fwd-bwd", fwd_bwd),
330
+ ("step", step),
331
+ ("load", load),
332
+ ("generate", generate),
333
+ ("weight-sync", weight_sync),
334
+ ):
335
+ job_parser.prog = f"{prog} --job JOB_ID {command}"
336
+ job_parser.epilog = (
337
+ "Required global option: --job JOB_ID (alias: --job-id JOB_ID). "
338
+ "Place it before the subcommand."
339
+ )
340
+
341
+ download_log = subparsers.add_parser(
342
+ "download-log",
343
+ help="Download all log files for a Cortex Training job's experiment run.",
344
+ )
345
+ download_log.add_argument("job_id")
346
+ download_log.add_argument(
347
+ "--log-type",
348
+ choices=("execution", "stdout"),
349
+ default="execution",
350
+ help=(
351
+ "Log source to download: execution artifacts (default), or the "
352
+ "reconstructed head-pod stdout/stderr console."
353
+ ),
354
+ )
355
+ download_log.add_argument(
356
+ "--output-dir",
357
+ dest="output_dir",
358
+ help=(
359
+ "Directory to write log files into, grouped as "
360
+ "<output_dir>/<sub_job_id>/<filename>. Created if missing. "
361
+ "Defaults to the current working directory."
362
+ ),
363
+ )
364
+
365
+ download_metrics = subparsers.add_parser(
366
+ "download-metrics",
367
+ help="Download and reconstruct GPU metrics for a Cortex Training job.",
368
+ )
369
+ download_metrics.add_argument("job_id")
370
+ download_metrics.add_argument(
371
+ "--output-dir",
372
+ dest="output_dir",
373
+ help=(
374
+ "Directory to write <sub_job_id>/gpu.jsonl files. Created if "
375
+ "missing. Defaults to the current working directory."
376
+ ),
377
+ )
378
+
379
+ login = subparsers.add_parser(
380
+ "login",
381
+ usage="%(prog)s [-h] (config | --config config)",
382
+ help="Remember a Cortex Training config file for future commands.",
383
+ )
384
+ login_config = login.add_mutually_exclusive_group(required=True)
385
+ login_config.add_argument(
386
+ "login_config",
387
+ nargs="?",
388
+ metavar="config",
389
+ help="Path to the Cortex Training CLI config JSON file to remember.",
390
+ )
391
+ login_config.add_argument(
392
+ "--config",
393
+ dest="login_config_option",
394
+ metavar="config",
395
+ help="Alternative to the positional config path.",
396
+ )
397
+
398
+ if include_tui:
399
+ subparsers.add_parser("tui", help="Open the read-only Cortex Training log TUI.")
400
+
401
+ return parser
402
+
403
+
404
+ def _login_state_path() -> Path:
405
+ override = _env(_LOGIN_STATE_ENV)
406
+ if override:
407
+ return Path(override).expanduser()
408
+ config_home = _env("XDG_CONFIG_HOME")
409
+ base = Path(config_home).expanduser() if config_home else Path.home() / ".config"
410
+ return base / "cortex-training" / "login.json"
411
+
412
+
413
+ def _read_login_config_path() -> str | None:
414
+ path = _login_state_path()
415
+ if not path.exists():
416
+ return None
417
+ try:
418
+ parsed = json.loads(path.read_text(encoding="utf-8"))
419
+ except json.JSONDecodeError as exc:
420
+ raise ValueError(f"invalid cortex-training login state {path}: {exc}") from exc
421
+ if not isinstance(parsed, dict):
422
+ raise ValueError(f"invalid cortex-training login state {path}: expected object")
423
+ config_path = parsed.get("config_path")
424
+ if not isinstance(config_path, str) or not config_path:
425
+ raise ValueError(f"invalid cortex-training login state {path}: missing config_path")
426
+ return config_path
427
+
428
+
429
+ def _write_login_config_path(config_path: str) -> str:
430
+ path = Path(config_path).expanduser()
431
+ _load_config(str(path))
432
+ saved_path = str(path.resolve())
433
+ state_path = _login_state_path()
434
+ state_path.parent.mkdir(parents=True, exist_ok=True)
435
+ payload = {"config_path": saved_path}
436
+ state_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
437
+ return saved_path
438
+
439
+
440
+ def _load_config(path: str | None) -> dict[str, Any]:
441
+ if not path:
442
+ return {}
443
+ path_obj = Path(path).expanduser()
444
+ try:
445
+ parsed = json.loads(path_obj.read_text(encoding="utf-8"))
446
+ except json.JSONDecodeError as exc:
447
+ raise ValueError(f"invalid JSON in config {path}: {exc}") from exc
448
+ if not isinstance(parsed, dict):
449
+ raise ValueError("config JSON must be an object")
450
+
451
+ if "connection" in parsed:
452
+ connection = parsed["connection"]
453
+ if not isinstance(connection, dict):
454
+ raise ValueError("config connection must be an object")
455
+ parsed = connection
456
+
457
+ config: dict[str, Any] = {}
458
+ unknown = []
459
+ for key, value in parsed.items():
460
+ normalized = _CONFIG_ALIASES.get(key, key)
461
+ if normalized not in _CONFIG_KEYS:
462
+ unknown.append(key)
463
+ continue
464
+ config[normalized] = value
465
+ if unknown:
466
+ names = ", ".join(sorted(unknown))
467
+ raise ValueError(f"unknown config key(s): {names}")
468
+ return config
469
+
470
+
471
+ def _coalesce(*values: Any) -> Any:
472
+ for value in values:
473
+ if value is not None:
474
+ return value
475
+ return None
476
+
477
+
478
+ def _config_str(config: dict[str, Any], key: str) -> str | None:
479
+ if key not in config or config[key] is None:
480
+ return None
481
+ if not isinstance(config[key], str):
482
+ raise ValueError(f"config {key} must be a string")
483
+ return config[key]
484
+
485
+
486
+ def _config_float(config: dict[str, Any], key: str) -> float | None:
487
+ if key not in config or config[key] is None:
488
+ return None
489
+ if isinstance(config[key], bool) or not isinstance(config[key], (int, float)):
490
+ raise ValueError(f"config {key} must be a number")
491
+ return float(config[key])
492
+
493
+
494
+ def _config_bool(config: dict[str, Any], key: str) -> bool | None:
495
+ if key not in config or config[key] is None:
496
+ return None
497
+ if not isinstance(config[key], bool):
498
+ raise ValueError(f"config {key} must be a boolean")
499
+ return config[key]
500
+
501
+
502
+ def _has_connection(
503
+ base_url: str | None,
504
+ host: str | None,
505
+ pat: str | None,
506
+ database: str | None,
507
+ ) -> bool:
508
+ return bool(database and (base_url or (host and pat)))
509
+
510
+
511
+ def _select_config(args: argparse.Namespace, *, load_login: bool) -> dict[str, Any]:
512
+ if args.config:
513
+ return _load_config(args.config)
514
+
515
+ if not load_login:
516
+ return {}
517
+
518
+ base_url = _coalesce(args.base_url, _env("CORTEX_TRAINING_BASE_URL"))
519
+ host = _coalesce(args.host, _env("CORTEX_TRAINING_HOST", "SNOWFLAKE_HOST"))
520
+ pat = _coalesce(args.pat, _env("CORTEX_TRAINING_PAT", "SNOWFLAKE_PAT"))
521
+ database = _coalesce(
522
+ args.database,
523
+ _env("CORTEX_TRAINING_DATABASE", "SNOWFLAKE_DATABASE"),
524
+ )
525
+ if _has_connection(base_url, host, pat, database):
526
+ return {}
527
+
528
+ login_config = _read_login_config_path()
529
+ if login_config is None:
530
+ return {}
531
+ args.config = login_config
532
+ return _load_config(login_config)
533
+
534
+
535
+ def _resolve_args(
536
+ args: argparse.Namespace,
537
+ *,
538
+ load_login: bool = True,
539
+ ) -> argparse.Namespace:
540
+ config = _select_config(args, load_login=load_login)
541
+ args.base_url = _coalesce(
542
+ args.base_url,
543
+ _config_str(config, "base_url"),
544
+ _env("CORTEX_TRAINING_BASE_URL"),
545
+ )
546
+ args.host = _coalesce(
547
+ args.host,
548
+ _config_str(config, "host"),
549
+ _env("CORTEX_TRAINING_HOST", "SNOWFLAKE_HOST"),
550
+ )
551
+ args.pat = _coalesce(
552
+ args.pat,
553
+ _config_str(config, "pat"),
554
+ _env("CORTEX_TRAINING_PAT", "SNOWFLAKE_PAT"),
555
+ )
556
+ args.database = _coalesce(
557
+ args.database,
558
+ _config_str(config, "database"),
559
+ _env("CORTEX_TRAINING_DATABASE", "SNOWFLAKE_DATABASE"),
560
+ )
561
+ args.schema = _coalesce(
562
+ args.schema,
563
+ _config_str(config, "schema"),
564
+ _env("CORTEX_TRAINING_SCHEMA", "SNOWFLAKE_SCHEMA"),
565
+ "PUBLIC",
566
+ )
567
+ args.endpoint = _coalesce(
568
+ args.endpoint,
569
+ _config_str(config, "endpoint"),
570
+ _env("CORTEX_TRAINING_ENDPOINT"),
571
+ "cortex-training",
572
+ )
573
+ args.poll_interval = _coalesce(
574
+ args.poll_interval,
575
+ _config_float(config, "poll_interval"),
576
+ 0.5,
577
+ )
578
+ args.poll_timeout = _coalesce(
579
+ args.poll_timeout,
580
+ _config_float(config, "poll_timeout"),
581
+ 1800.0,
582
+ )
583
+ if not args.no_verify_ssl:
584
+ no_verify_ssl = _config_bool(config, "no_verify_ssl")
585
+ verify_ssl = _config_bool(config, "verify_ssl")
586
+ if no_verify_ssl is not None:
587
+ args.no_verify_ssl = no_verify_ssl
588
+ elif verify_ssl is not None:
589
+ args.no_verify_ssl = not verify_ssl
590
+ return args
591
+
592
+
593
+ def _normalize_connection_args(args: argparse.Namespace) -> argparse.Namespace:
594
+ if args.base_url is None or _has_url_scheme(args.base_url):
595
+ return args
596
+ if args.pat and args.host is None:
597
+ args.host = args.base_url
598
+ args.base_url = None
599
+ return args
600
+ raise ValueError(
601
+ "base_url must start with http:// or https://. For Snowflake PAT auth, use host instead of base_url."
602
+ )
603
+
604
+
605
+ def parse_args(
606
+ argv: list[str] | None = None,
607
+ *,
608
+ prog: str = "cortex-training",
609
+ include_tui: bool = False,
610
+ ) -> argparse.Namespace:
611
+ parser = build_parser(prog=prog, include_tui=include_tui)
612
+ args = parser.parse_args(argv)
613
+ if args.command == "login":
614
+ args.login_config = args.login_config or args.login_config_option
615
+ return args
616
+
617
+ dry_run = args.command == "submit" and args.dry_run
618
+ args = _resolve_args(args, load_login=not dry_run)
619
+ if dry_run:
620
+ return args
621
+ args = _normalize_connection_args(args)
622
+ if not args.database:
623
+ parser.error("provide --database or set CORTEX_TRAINING_DATABASE/SNOWFLAKE_DATABASE")
624
+ if args.base_url is None and (args.host is None or args.pat is None):
625
+ parser.error("provide --base-url for local/mock use, or both --host and --pat")
626
+ return args
627
+
628
+
629
+ def build_client(args: argparse.Namespace, cortex_training_client_cls):
630
+ kwargs = {
631
+ "database": args.database,
632
+ "schema": args.schema,
633
+ "endpoint": args.endpoint,
634
+ "poll_interval": args.poll_interval,
635
+ "poll_timeout": args.poll_timeout,
636
+ }
637
+ if args.base_url:
638
+ return cortex_training_client_cls(base_url=args.base_url, **kwargs)
639
+ return cortex_training_client_cls.from_pat(
640
+ host=_normalize_host(args.host),
641
+ pat=args.pat,
642
+ verify_ssl=not args.no_verify_ssl,
643
+ **kwargs,
644
+ )
645
+
646
+
647
+ def _read_json_object(path: str, stdin: TextIO) -> dict[str, Any]:
648
+ if path == "-":
649
+ raw = stdin.read()
650
+ else:
651
+ raw = Path(path).read_text(encoding="utf-8")
652
+ try:
653
+ parsed = json.loads(raw)
654
+ except json.JSONDecodeError as exc:
655
+ raise ValueError(f"invalid JSON in {path}: {exc}") from exc
656
+ if not isinstance(parsed, dict):
657
+ raise ValueError("job JSON must be an object")
658
+ return parsed
659
+
660
+
661
+ def _validate_create_job_body(body: dict[str, Any]) -> None:
662
+ sub_job_configs = body.get("sub_job_configs")
663
+ if not isinstance(sub_job_configs, list) or not sub_job_configs:
664
+ raise ValueError("job JSON must contain a non-empty sub_job_configs list")
665
+ # Mirrors client.create_job_from_body; submit --dry-run never builds a client.
666
+ training_sub_jobs = sum(
667
+ 1
668
+ for cfg in sub_job_configs
669
+ if isinstance(cfg, dict) and str(cfg.get("job_type") or "").strip().lower() == "training"
670
+ )
671
+ if training_sub_jobs > 1:
672
+ raise ValueError("at most one training sub-job is supported per job")
673
+
674
+
675
+ def _print_json(value: Any, stdout: TextIO, *, compact: bool) -> None:
676
+ if compact:
677
+ json.dump(value, stdout, separators=(",", ":"), sort_keys=True)
678
+ else:
679
+ json.dump(value, stdout, indent=2, sort_keys=True)
680
+ stdout.write("\n")
681
+
682
+
683
+ def _format_error(exc: BaseException) -> str:
684
+ response = getattr(exc, "response", None)
685
+ if response is None:
686
+ return str(exc)
687
+
688
+ parts = [str(exc)]
689
+ request_id = getattr(response, "headers", {}).get("x-snowflake-request-id")
690
+ if request_id:
691
+ parts.append(f"snowflake request id: {request_id}")
692
+
693
+ body = (getattr(response, "text", "") or "").strip()
694
+ if body:
695
+ if len(body) > 4000:
696
+ body = body[:4000] + "...<truncated>"
697
+ parts.append(f"response body: {body}")
698
+ return "\n".join(parts)
699
+
700
+
701
+ def _cmd_submit(
702
+ args: argparse.Namespace,
703
+ client,
704
+ stdout: TextIO,
705
+ stdin: TextIO,
706
+ ) -> int:
707
+ body = _read_json_object(args.json_file, stdin)
708
+ _validate_create_job_body(body)
709
+ if args.job_id is not None:
710
+ body = dict(body)
711
+ body["job_id"] = args.job_id
712
+
713
+ if args.dry_run:
714
+ _print_json(body, stdout, compact=args.compact)
715
+ return 0
716
+
717
+ response = client.create_job_from_body(body)
718
+ if args.wait:
719
+ job_id = response.get("job_id") or body.get("job_id")
720
+ if not job_id:
721
+ raise ValueError("submit --wait requires the create response to include job_id")
722
+ response = client.wait_for_job(str(job_id))
723
+ _print_json(response, stdout, compact=args.compact)
724
+ return 0
725
+
726
+
727
+ def _cmd_login(args: argparse.Namespace, stdout: TextIO) -> int:
728
+ config_path = _write_login_config_path(args.login_config)
729
+ _print_json(
730
+ {"config_path": config_path, "logged_in": True},
731
+ stdout,
732
+ compact=args.compact,
733
+ )
734
+ return 0
735
+
736
+
737
+ def _json_bool(value: Any, name: str) -> bool:
738
+ if not isinstance(value, bool):
739
+ raise ValueError(f"{name} must be a boolean")
740
+ return value
741
+
742
+
743
+ def _cmd_fwd_bwd(
744
+ args: argparse.Namespace,
745
+ client,
746
+ stdout: TextIO,
747
+ stdin: TextIO,
748
+ ) -> int:
749
+ if not args.job:
750
+ raise ValueError("provide --job JOB_ID for fwd-bwd")
751
+
752
+ spec = _read_json_object(args.json_file, stdin)
753
+ poll = _json_bool(spec.get("poll", True), "fwd-bwd poll")
754
+ build_payload = _load_forward_backward_payload_builder()
755
+ payload = build_payload(spec)
756
+
757
+ request_id = client.forward_backward(args.job, payload)
758
+ response = {
759
+ "job_id": args.job,
760
+ "payload_size_bytes": len(payload),
761
+ "request_id": request_id,
762
+ }
763
+ if poll:
764
+ response["result"] = client.poll_request(args.job, request_id)
765
+
766
+ _print_json(response, stdout, compact=args.compact)
767
+ return 0
768
+
769
+
770
+ def _cmd_step(args: argparse.Namespace, client, stdout: TextIO) -> int:
771
+ if not args.job:
772
+ raise ValueError("provide --job-id JOB_ID for step")
773
+
774
+ request_id = client.step(args.job, learning_rate=args.lr)
775
+ response = {
776
+ "job_id": args.job,
777
+ "learning_rate": args.lr,
778
+ "request_id": request_id,
779
+ "result": client.poll_request(args.job, request_id),
780
+ }
781
+ _print_json(response, stdout, compact=args.compact)
782
+ return 0
783
+
784
+
785
+ def _cmd_load(args: argparse.Namespace, client, stdout: TextIO) -> int:
786
+ if not args.job:
787
+ raise ValueError("provide --job-id JOB_ID for load")
788
+
789
+ request_id = client.load(
790
+ args.job,
791
+ checkpoint_id=args.checkpoint_id,
792
+ source_job_id=args.source_job_id,
793
+ target_sub_job_id=args.target_sub_job_id,
794
+ )
795
+ response = {
796
+ "checkpoint_id": args.checkpoint_id,
797
+ "job_id": args.job,
798
+ "request_id": request_id,
799
+ }
800
+ if args.source_job_id is not None:
801
+ response["source_job_id"] = args.source_job_id
802
+ if args.target_sub_job_id is not None:
803
+ response["target_sub_job_id"] = args.target_sub_job_id
804
+ if not args.no_poll:
805
+ response["result"] = client.poll_request(args.job, request_id)
806
+
807
+ _print_json(response, stdout, compact=args.compact)
808
+ return 0
809
+
810
+
811
+ def _cmd_generate(
812
+ args: argparse.Namespace,
813
+ client,
814
+ stdout: TextIO,
815
+ stdin: TextIO,
816
+ ) -> int:
817
+ if not args.job:
818
+ raise ValueError("provide --job-id JOB_ID for generate")
819
+
820
+ spec = _read_json_object(args.json_file, stdin)
821
+ payload = spec.get("payload", spec)
822
+ if not isinstance(payload, dict):
823
+ raise ValueError("generate payload must be an object")
824
+
825
+ poll = _json_bool(spec.get("poll", True), "generate poll")
826
+ prompts = payload.get("prompts")
827
+ if not isinstance(prompts, list) or not prompts:
828
+ raise ValueError("generate JSON must contain a non-empty prompts list")
829
+
830
+ sampling_params = payload.get("sampling_params")
831
+ if sampling_params is not None:
832
+ if isinstance(sampling_params, list):
833
+ if len(sampling_params) != len(prompts):
834
+ raise ValueError("generate sampling_params list length must match prompts length")
835
+ if any(item is not None and not isinstance(item, dict) for item in sampling_params):
836
+ raise ValueError("generate sampling_params list items must be objects or null")
837
+ elif not isinstance(sampling_params, dict):
838
+ raise ValueError("generate sampling_params must be an object or list")
839
+
840
+ strict = payload.get("strict")
841
+ if strict is not None:
842
+ strict = _json_bool(strict, "generate strict")
843
+
844
+ request_id = client.generate(
845
+ args.job,
846
+ prompts=prompts,
847
+ sampling_params=sampling_params,
848
+ routing_key=payload.get("routing_key"),
849
+ strict=strict,
850
+ )
851
+ response = {
852
+ "job_id": args.job,
853
+ "prompt_count": len(prompts),
854
+ "request_id": request_id,
855
+ }
856
+ if poll:
857
+ response["result"] = client.poll_request(args.job, request_id)
858
+
859
+ _print_json(response, stdout, compact=args.compact)
860
+ return 0
861
+
862
+
863
+ def _cmd_weight_sync(args: argparse.Namespace, client, stdout: TextIO) -> int:
864
+ if not args.job:
865
+ raise ValueError("provide --job-id JOB_ID for weight-sync")
866
+
867
+ source_sub_job_id = args.source_sub_job_id or f"{args.job}:training:0"
868
+ target_sub_job_ids = args.target_sub_job_ids or [f"{args.job}:sampling:0"]
869
+ request_id = client.weight_sync(
870
+ args.job,
871
+ source_sub_job_id=source_sub_job_id,
872
+ target_sub_job_ids=target_sub_job_ids,
873
+ weight_format=args.weight_format,
874
+ sub_job_id=args.operation_sub_job_id,
875
+ sub_job_type=args.operation_sub_job_type,
876
+ )
877
+ response = {
878
+ "job_id": args.job,
879
+ "request_id": request_id,
880
+ "source_sub_job_id": source_sub_job_id,
881
+ "target_sub_job_ids": target_sub_job_ids,
882
+ }
883
+ if args.weight_format is not None:
884
+ response["weight_format"] = args.weight_format
885
+ if not args.no_poll:
886
+ response["result"] = client.poll_request(args.job, request_id)
887
+
888
+ _print_json(response, stdout, compact=args.compact)
889
+ return 0
890
+
891
+
892
+ def _cmd_download_log(args: argparse.Namespace, client, stdout: TextIO) -> int:
893
+ out_dir = Path(args.output_dir).expanduser() if args.output_dir else Path.cwd()
894
+ if args.log_type == "stdout":
895
+ logs = client.download_stdout_logs(args.job_id, out_dir)
896
+ _print_json(
897
+ {"job_id": args.job_id, "logs": logs},
898
+ stdout,
899
+ compact=args.compact,
900
+ )
901
+ return 0
902
+
903
+ logs = client.fetch_execution_logs(args.job_id)
904
+ saved = []
905
+ for log in logs:
906
+ file_path = out_dir / (log["sub_job_id"] or "unknown") / log["filename"]
907
+ file_path.parent.mkdir(parents=True, exist_ok=True)
908
+ file_path.write_text(log["content"], encoding="utf-8")
909
+ saved.append(
910
+ {
911
+ "sub_job_id": log["sub_job_id"],
912
+ "filename": log["filename"],
913
+ "artifact_uri": log["artifact_uri"],
914
+ "saved_path": str(file_path),
915
+ }
916
+ )
917
+ _print_json({"job_id": args.job_id, "logs": saved}, stdout, compact=args.compact)
918
+ return 0
919
+
920
+
921
+ def _cmd_download_metrics(
922
+ args: argparse.Namespace, client, stdout: TextIO
923
+ ) -> int:
924
+ out_dir = Path(args.output_dir).expanduser() if args.output_dir else Path.cwd()
925
+ metrics = client.download_metrics(args.job_id, out_dir)
926
+ _print_json(
927
+ {"job_id": args.job_id, "metrics": metrics},
928
+ stdout,
929
+ compact=args.compact,
930
+ )
931
+ return 0
932
+
933
+
934
+ def _run(
935
+ args: argparse.Namespace,
936
+ client_factory: Callable[[argparse.Namespace], Any],
937
+ stdout: TextIO,
938
+ stdin: TextIO,
939
+ ) -> int:
940
+ if args.command == "login":
941
+ return _cmd_login(args, stdout)
942
+
943
+ if args.command == "submit" and args.dry_run:
944
+ return _cmd_submit(args, None, stdout, stdin)
945
+
946
+ client = client_factory(args)
947
+ if args.command == "submit":
948
+ return _cmd_submit(args, client, stdout, stdin)
949
+ if args.command == "get":
950
+ _print_json(client.get_job(args.job_id), stdout, compact=args.compact)
951
+ return 0
952
+ if args.command == "checkpoints":
953
+ _print_json(
954
+ {"checkpoints": client.list_checkpoints(args.job_id)},
955
+ stdout,
956
+ compact=args.compact,
957
+ )
958
+ return 0
959
+ if args.command == "list":
960
+ jobs = client.list_jobs(status=args.status)
961
+ _print_json({"jobs": _jobs_latest_last(jobs)}, stdout, compact=args.compact)
962
+ return 0
963
+ if args.command == "capacity":
964
+ if args.hardware is not None:
965
+ capacity = client.get_capacity(hardware=args.hardware)
966
+ else:
967
+ capacity = {
968
+ "capacity_by_hardware": {
969
+ hardware: client.get_capacity(hardware=hardware)
970
+ for hardware in _hardware_choices()
971
+ }
972
+ }
973
+ _print_json(capacity, stdout, compact=args.compact)
974
+ return 0
975
+ if args.command == "cancel":
976
+ client.cancel_job(args.job_id)
977
+ _print_json(
978
+ {"cancelled": True, "job_id": args.job_id},
979
+ stdout,
980
+ compact=args.compact,
981
+ )
982
+ return 0
983
+ if args.command == "wait":
984
+ _print_json(client.wait_for_job(args.job_id), stdout, compact=args.compact)
985
+ return 0
986
+ if args.command == "fwd-bwd":
987
+ return _cmd_fwd_bwd(args, client, stdout, stdin)
988
+ if args.command == "step":
989
+ return _cmd_step(args, client, stdout)
990
+ if args.command == "load":
991
+ return _cmd_load(args, client, stdout)
992
+ if args.command == "generate":
993
+ return _cmd_generate(args, client, stdout, stdin)
994
+ if args.command == "weight-sync":
995
+ return _cmd_weight_sync(args, client, stdout)
996
+ if args.command == "download-log":
997
+ return _cmd_download_log(args, client, stdout)
998
+ if args.command == "download-metrics":
999
+ return _cmd_download_metrics(args, client, stdout)
1000
+ raise ValueError(f"unknown command: {args.command}")
1001
+
1002
+
1003
+ def main(
1004
+ argv: list[str] | None = None,
1005
+ *,
1006
+ prog: str = "cortex-training",
1007
+ include_tui: bool = False,
1008
+ client_factory: Callable[[argparse.Namespace], Any] | None = None,
1009
+ stdout: TextIO | None = None,
1010
+ stderr: TextIO | None = None,
1011
+ stdin: TextIO | None = None,
1012
+ ) -> int:
1013
+ stdout = stdout or sys.stdout
1014
+ stderr = stderr or sys.stderr
1015
+ stdin = stdin or sys.stdin
1016
+
1017
+ try:
1018
+ args = parse_args(argv, prog=prog, include_tui=include_tui)
1019
+ dry_run = args.command == "submit" and args.dry_run
1020
+ no_client = dry_run or args.command == "login"
1021
+ if client_factory is None and not no_client:
1022
+ cortex_training_client_cls = _load_cortex_training_client_class()
1023
+
1024
+ def default_client_factory(parsed_args: argparse.Namespace) -> Any:
1025
+ return build_client(parsed_args, cortex_training_client_cls)
1026
+
1027
+ client_factory = default_client_factory
1028
+ elif client_factory is None:
1029
+
1030
+ def empty_client_factory(_parsed_args: argparse.Namespace) -> None:
1031
+ return None
1032
+
1033
+ client_factory = empty_client_factory
1034
+ return _run(args, client_factory, stdout, stdin)
1035
+ except (OSError, ValueError, RuntimeError) as exc:
1036
+ print(f"error: {_format_error(exc)}", file=stderr)
1037
+ return 1
1038
+ except ModuleNotFoundError as exc:
1039
+ missing = exc.name or "a required package"
1040
+ print(
1041
+ f"error: missing Python dependency '{missing}'. "
1042
+ "Install the missing package, or install this repo first, "
1043
+ "for example: python3 -m pip install -e .",
1044
+ file=stderr,
1045
+ )
1046
+ return 1
1047
+
1048
+
1049
+ if __name__ == "__main__":
1050
+ raise SystemExit(main())