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,2866 @@
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
+ """HTTP client for the Cortex Training REST API.
17
+
18
+ Single-purpose, production-shaped client:
19
+
20
+ - One auth path: Programmatic Access Token (PAT) — see ``CortexTrainingClient.from_pat``.
21
+ - One CreateJob shape: a list of typed :class:`SubJobConfig` (each carries either
22
+ a :class:`TrainingConfig` or an :class:`InferenceConfig`). The client-side
23
+ validators mirror the server's own required-field checks so an invalid job
24
+ fails locally instead of after a round trip.
25
+ - One wire format: ``{job_id?, sub_job_configs:[SubJobConfig]}`` with per-sub-job
26
+ ``training_config`` / ``inference_config`` blocks. See
27
+ ``docs/reference/rest-api.md`` for the full schema.
28
+
29
+ The server keeps the config blocks open (``additionalProperties``), so unknown
30
+ keys pass through unchanged. The strict, typed shape lives here in the client;
31
+ use the ``extra`` fields for anything the typed layer does not model yet.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import base64
37
+ import functools
38
+ import gzip
39
+ import hashlib
40
+ import inspect
41
+ import json
42
+ import logging
43
+ import os
44
+ import re
45
+ import shutil
46
+ import tempfile
47
+ import threading
48
+ import time
49
+ from collections.abc import Iterator
50
+ from collections.abc import Mapping
51
+ from dataclasses import dataclass
52
+ from dataclasses import field
53
+ from enum import Enum
54
+ from pathlib import Path
55
+ from pathlib import PurePosixPath
56
+ from typing import Any
57
+
58
+ import requests
59
+ from requests.adapters import HTTPAdapter
60
+ from tenacity import Retrying
61
+ from tenacity import retry_if_exception
62
+ from tenacity import stop_after_attempt
63
+ from tenacity import wait_exponential_jitter
64
+ from urllib3.exceptions import NewConnectionError
65
+
66
+ from cortex_training import wire
67
+ from cortex_training.telemetry import CachedSessionTokenProvider
68
+ from cortex_training.telemetry import OtlpMetricEmitter
69
+
70
+ logger = logging.getLogger(__name__)
71
+
72
+ _STDOUT_CHUNK_RE = re.compile(
73
+ r"^console\.(?P<timestamp>\d{8}-\d{6})\.(?P<suffix>[^./\\]+)\.gz$"
74
+ )
75
+ _GPU_METRICS_CHUNK_RE = re.compile(
76
+ r"^gpu\.(?P<timestamp>\d{8}-\d{6})\.(?P<suffix>[^./\\]+)\.gz$"
77
+ )
78
+ _STREAM_COPY_BUFFER_BYTES = 1024 * 1024
79
+
80
+ # Env var that unlocks create-job debug options. These are an internal-only
81
+ # capability and are deliberately not documented for external use: the client
82
+ # refuses to forward a create-job request carrying a `debug` block unless this
83
+ # is set to a truthy value. The server gates them independently.
84
+ DEBUG_OPTIONS_ENV = "CORTEX_TRAINING_ENABLE_DEBUG_OPTIONS"
85
+ DISABLE_TELEMETRY_ENV = "CORTEX_TRAINING_DISABLE_TELEMETRY"
86
+ ENABLE_SUCCESS_TELEMETRY_ENV = "CORTEX_TRAINING_ENABLE_SUCCESS_TELEMETRY"
87
+
88
+
89
+ def _env_flag_enabled(name: str) -> bool:
90
+ return os.environ.get(name, "").strip().lower() in {
91
+ "1",
92
+ "true",
93
+ "yes",
94
+ "on",
95
+ }
96
+
97
+
98
+ def _debug_options_enabled() -> bool:
99
+ """True when create-job debug options are explicitly enabled via env var."""
100
+ return _env_flag_enabled(DEBUG_OPTIONS_ENV)
101
+
102
+
103
+ def _telemetry_disabled() -> bool:
104
+ """True when OTLP client metrics are explicitly disabled via env var."""
105
+ return _env_flag_enabled(DISABLE_TELEMETRY_ENV)
106
+
107
+
108
+ def _success_telemetry_enabled() -> bool:
109
+ """True when successful automatic operation outcomes should be emitted."""
110
+ return _env_flag_enabled(ENABLE_SUCCESS_TELEMETRY_ENV)
111
+
112
+
113
+ # HTTP statuses worth retrying: the request was well-formed, so the same call
114
+ # may succeed once transient load/infra conditions clear.
115
+ # 429 Too Many Requests - rate limited; back off and retry
116
+ # 500 Internal Server Err - transient server-side failure
117
+ # 502 Bad Gateway - upstream/proxy hiccup in front of the backend
118
+ # 503 Service Unavailable - server temporarily overloaded or restarting
119
+ # 504 Gateway Timeout - upstream didn't respond in time
120
+ # 409 Conflict - the target zone is restarting
121
+ # 404 Not Found - a just-created job may not be visible yet, so a
122
+ # valid id can 404 briefly and succeed on retry.
123
+ # Note the trade-off on 404: a *mistyped* job id is also retried (up to
124
+ # `max_retries`, with backoff) before the error surfaces. Every other 4xx is
125
+ # excluded because it signals a client/config error that won't fix itself.
126
+ _TRANSIENT_STATUSES = {429, 500, 502, 503, 504, 404, 409}
127
+ _CHUNK_GROUP_RESTART_REQUIRED = "chunk_group_restart_required"
128
+ _CHUNK_GROUP_ERROR_CODES = {
129
+ _CHUNK_GROUP_RESTART_REQUIRED,
130
+ "chunk_group_conflict",
131
+ "chunk_group_missing_chunks",
132
+ }
133
+
134
+
135
+ class ChunkGroupError(requests.exceptions.HTTPError):
136
+ """Base class for structured chunk-group failures."""
137
+
138
+ def __init__(self, message: str, *, response: requests.Response, detail: dict):
139
+ super().__init__(
140
+ message,
141
+ response=response,
142
+ request=getattr(response, "request", None),
143
+ )
144
+ self.detail = detail
145
+
146
+
147
+ class ChunkGroupRestartError(ChunkGroupError):
148
+ """The server lost an incomplete group and the bounded replay also failed."""
149
+
150
+
151
+ class ChunkGroupConflictError(ChunkGroupError):
152
+ """The same chunk-group identity was reused inconsistently."""
153
+
154
+
155
+ def _is_transient(exc: BaseException) -> bool:
156
+ if isinstance(exc, (requests.exceptions.ConnectionError, requests.exceptions.Timeout)):
157
+ return True
158
+ if isinstance(exc, requests.exceptions.HTTPError):
159
+ resp = exc.response
160
+ return resp is not None and resp.status_code in _TRANSIENT_STATUSES
161
+ return False
162
+
163
+
164
+ def _iter_error_dicts(value: Any, *, depth: int = 0) -> Iterator[dict]:
165
+ """Yield dictionaries from direct or service-wrapped JSON error bodies."""
166
+ if depth > 8:
167
+ return
168
+ if isinstance(value, dict):
169
+ yield value
170
+ for child in value.values():
171
+ yield from _iter_error_dicts(child, depth=depth + 1)
172
+ return
173
+ if isinstance(value, list):
174
+ for child in value:
175
+ yield from _iter_error_dicts(child, depth=depth + 1)
176
+ return
177
+ if not isinstance(value, str):
178
+ return
179
+
180
+ decoder = json.JSONDecoder()
181
+ for index, char in enumerate(value):
182
+ if char not in "[{":
183
+ continue
184
+ try:
185
+ decoded, _ = decoder.raw_decode(value[index:])
186
+ except (TypeError, ValueError):
187
+ continue
188
+ yield from _iter_error_dicts(decoded, depth=depth + 1)
189
+
190
+
191
+ def _chunk_group_error_detail(response: requests.Response | None) -> dict | None:
192
+ if response is None:
193
+ return None
194
+ try:
195
+ body = response.json()
196
+ except Exception:
197
+ return None
198
+ for candidate in _iter_error_dicts(body):
199
+ if candidate.get("code") in _CHUNK_GROUP_ERROR_CODES:
200
+ return candidate
201
+ message = str(candidate.get("message") or "")
202
+ if candidate.get("chunk_group_id") and message == "request chunk group is missing chunks":
203
+ return {
204
+ **candidate,
205
+ "code": "chunk_group_missing_chunks",
206
+ }
207
+ return None
208
+
209
+
210
+ _METRIC_IDENTITY_FIELDS = (
211
+ "job_id",
212
+ "sub_job_id",
213
+ "sub_job_type",
214
+ "source_job_id",
215
+ "source_sub_job_id",
216
+ "target_sub_job_id",
217
+ "target_sub_job_ids",
218
+ "request_id",
219
+ "checkpoint_id",
220
+ )
221
+ _REQUEST_ID_RESULT_OPERATIONS = {
222
+ "forward_backward",
223
+ "forward",
224
+ "generate",
225
+ "generate_stream",
226
+ "step",
227
+ "save",
228
+ "load",
229
+ "weight_sync",
230
+ "bootstrap_router_replay",
231
+ "router_replay_discard",
232
+ "reset_prefix_cache",
233
+ "cancel_request",
234
+ }
235
+ _METRIC_ERROR_MESSAGE_LIMIT = 512
236
+ _SECRET_IN_ERROR_RE = re.compile(
237
+ r"(?i)(authorization[\"']?\s*[:=]\s*"
238
+ r"(?:bearer\s+|snowflake\s+token\s*=\s*)?|"
239
+ r"bearer\s+|snowflake\s+token\s*=\s*|"
240
+ r"(?:password|secret|credential|pat|api[_-]?key|private[_-]?key|"
241
+ r"access[_-]?token|refresh[_-]?token|session[_-]?token|token)"
242
+ r"[\"']?\s*[:=]\s*)"
243
+ r"(?:\"[^\"]*\"|'[^']*'|\S+)"
244
+ )
245
+
246
+
247
+ def _safe_metric_error_message(exc: BaseException) -> str:
248
+ """Return a bounded, single-line error message with obvious secrets redacted."""
249
+ message = " ".join(str(exc).split())
250
+ message = _SECRET_IN_ERROR_RE.sub(r"\1<redacted>", message)
251
+ return message[:_METRIC_ERROR_MESSAGE_LIMIT]
252
+
253
+
254
+ def _metric_error_attributes(exc: BaseException) -> dict[str, Any]:
255
+ attributes: dict[str, Any] = {"error.type": type(exc).__name__}
256
+ response = getattr(exc, "response", None)
257
+ if response is None:
258
+ return attributes
259
+ status_code = getattr(response, "status_code", None)
260
+ if isinstance(status_code, int):
261
+ attributes["error.http_status"] = status_code
262
+ headers = getattr(response, "headers", {}) or {}
263
+ request_id = headers.get("x-snowflake-request-id")
264
+ if isinstance(request_id, str) and request_id:
265
+ attributes["snowflake.request_id"] = request_id
266
+ try:
267
+ body = response.json()
268
+ except Exception:
269
+ return attributes
270
+ for candidate in _iter_error_dicts(body):
271
+ code = candidate.get("code") or candidate.get("error_code")
272
+ if code is not None:
273
+ attributes["error.code"] = str(code)
274
+ break
275
+ return attributes
276
+
277
+
278
+ def _metric_identity(
279
+ operation: str,
280
+ arguments: Mapping[str, Any],
281
+ result: Any = None,
282
+ ) -> dict[str, Any]:
283
+ attributes = {}
284
+ for field in _METRIC_IDENTITY_FIELDS:
285
+ value = arguments.get(field)
286
+ if value is not None:
287
+ attributes[field] = (
288
+ list(value) if isinstance(value, (list, tuple)) else value
289
+ )
290
+ if operation == "create_job" and "job_id" not in attributes:
291
+ if isinstance(result, str):
292
+ attributes["job_id"] = result
293
+ elif isinstance(result, dict) and result.get("job_id") is not None:
294
+ attributes["job_id"] = result["job_id"]
295
+ if operation in _REQUEST_ID_RESULT_OPERATIONS and "request_id" not in attributes:
296
+ if isinstance(result, str):
297
+ attributes["request_id"] = result
298
+ elif isinstance(result, dict) and result.get("request_id") is not None:
299
+ attributes["request_id"] = result["request_id"]
300
+ return attributes
301
+
302
+
303
+ def _track_operation(operation: str):
304
+ """Record one outcome metric around a public client operation."""
305
+
306
+ def decorate(method):
307
+ signature = inspect.signature(method)
308
+
309
+ @functools.wraps(method)
310
+ def wrapped(self, *args, **kwargs):
311
+ if getattr(self, "_metric_emitter", None) is None:
312
+ return method(self, *args, **kwargs)
313
+ state = getattr(self, "_operation_metric_state", None)
314
+ if state is None:
315
+ return method(self, *args, **kwargs)
316
+ active = getattr(state, "active", set())
317
+ if active:
318
+ return method(self, *args, **kwargs)
319
+ try:
320
+ bound = signature.bind(self, *args, **kwargs)
321
+ bound.apply_defaults()
322
+ except TypeError:
323
+ return method(self, *args, **kwargs)
324
+ except Exception:
325
+ logger.debug("client operation metric setup failed", exc_info=True)
326
+ return method(self, *args, **kwargs)
327
+ active = set()
328
+ active.add(operation)
329
+ state.active = active
330
+ state.attempt_count = 0
331
+ state.request_count = 0
332
+ started = time.monotonic()
333
+ try:
334
+ result = method(self, *args, **kwargs)
335
+ except Exception as exc:
336
+ self._emit_operation_outcome(
337
+ operation,
338
+ started,
339
+ bound.arguments,
340
+ error=exc,
341
+ )
342
+ raise
343
+ else:
344
+ self._emit_operation_outcome(
345
+ operation,
346
+ started,
347
+ bound.arguments,
348
+ result=result,
349
+ )
350
+ return result
351
+ finally:
352
+ state.active = set()
353
+ state.attempt_count = 0
354
+ state.request_count = 0
355
+
356
+ return wrapped
357
+
358
+ return decorate
359
+
360
+
361
+ def _is_chunk_post_transient(exc: BaseException) -> bool:
362
+ if isinstance(exc, requests.exceptions.HTTPError):
363
+ if _chunk_group_error_detail(exc.response) is not None:
364
+ return False
365
+ return _is_transient(exc)
366
+
367
+
368
+ def _is_connect_error(exc: BaseException) -> bool:
369
+ """Only failures proving the request never reached the server (safe for create_job)."""
370
+ if isinstance(exc, requests.exceptions.ConnectTimeout):
371
+ return True
372
+ if isinstance(exc, requests.exceptions.ConnectionError):
373
+ cause = exc.args[0] if exc.args else None
374
+ reason = getattr(cause, "reason", None)
375
+ return isinstance(cause, NewConnectionError) or isinstance(reason, NewConnectionError)
376
+ return False
377
+
378
+
379
+ # ─── Canonical types ─────────────────────────────────────────────────────
380
+
381
+
382
+ class JobType(str, Enum):
383
+ """Sub-job types supported by Cortex Training.
384
+
385
+ Matches the ``job_type`` enum in the REST schema; see
386
+ ``docs/reference/rest-api.md`` section 8.1.
387
+ """
388
+
389
+ TRAINING = "training"
390
+ SAMPLING = "sampling"
391
+ LOG_PROBABILITY = "log_probability"
392
+
393
+
394
+ class Hardware(str, Enum):
395
+ """GPU hardware for create job and capacity.
396
+
397
+ Wire values match the REST ``hardware`` field; see
398
+ ``docs/reference/rest-api.md`` sections 5.1 and 5.4. Omitting it defaults to
399
+ H200 on the server. Unknown values are rejected client-side.
400
+ """
401
+
402
+ H200 = "H200"
403
+ B200 = "B200"
404
+ B300 = "B300"
405
+
406
+
407
+ def _hardware_wire(hardware: Hardware | str) -> str:
408
+ """Return the wire spelling for ``hardware``."""
409
+ if isinstance(hardware, Hardware):
410
+ return hardware.value
411
+ try:
412
+ return Hardware(hardware).value
413
+ except ValueError as exc:
414
+ allowed = ", ".join(member.value for member in Hardware)
415
+ raise ValueError(f"hardware must be one of {allowed}, got {hardware!r}") from exc
416
+
417
+
418
+ def _effective_primerl_config(extra: dict) -> dict:
419
+ prime_rl = extra.get("prime_rl")
420
+ if isinstance(prime_rl, dict):
421
+ # The training runtime expands the nested prime_rl block over the training
422
+ # config, so validate the same effective values here.
423
+ return {**extra, **prime_rl}
424
+ return extra
425
+
426
+
427
+ def _validate_primerl_lm_head_config(extra: dict, *, location: str) -> None:
428
+ cfg = _effective_primerl_config(extra)
429
+ fused_cross_entropy = cfg.get("fused_cross_entropy", "liger")
430
+ if fused_cross_entropy is False:
431
+ return
432
+
433
+ token_chunk_size = cfg.get("fused_lm_head_token_chunk_size")
434
+ token_chunked_lm_head = isinstance(token_chunk_size, int) and not isinstance(token_chunk_size, bool)
435
+ if cfg.get("fp32_lm_head", False) or token_chunked_lm_head:
436
+ raise ValueError(
437
+ f"{location} cannot combine fused_cross_entropy with "
438
+ "fp32_lm_head or fused_lm_head_token_chunk_size. "
439
+ "Set fused_cross_entropy=false to use fp32/chunked LM head, "
440
+ "or remove fp32/chunked LM-head knobs to use fused CE."
441
+ )
442
+
443
+
444
+ @dataclass
445
+ class TrainingConfig:
446
+ """Training hyperparameters for a training sub-job.
447
+
448
+ Required fields mirror the server-side validator: ``max_seq_len > 0``,
449
+ ``train_batch_size > 0``, ``n_gpus > 0``, and a non-empty ``optimizer``.
450
+
451
+ ``extra`` carries any additional fields the training worker consumes. The
452
+ server keeps this block open (additionalProperties), so unknown keys flow
453
+ through unchanged. See ``docs/reference/rest-api.md`` section 8.2.
454
+ """
455
+
456
+ optimizer: dict
457
+ max_seq_len: int
458
+ train_batch_size: int
459
+ n_gpus: int
460
+ gradient_clipping: float | None = None
461
+ multiplex_job_id: str | None = None
462
+ # When False, resuming from a checkpoint loads weights only and starts the
463
+ # optimizer fresh. Required to change DP size (the DP-sharded optimizer
464
+ # cannot be resized); None leaves the server default (True).
465
+ load_optimizer_states: bool | None = None
466
+ extra: dict = field(default_factory=dict)
467
+
468
+ def validate(self) -> None:
469
+ if not isinstance(self.optimizer, dict) or not self.optimizer:
470
+ raise ValueError("training.optimizer is required and must be a non-empty dict")
471
+ if self.max_seq_len <= 0:
472
+ raise ValueError("training.max_seq_len must be > 0")
473
+ if self.train_batch_size <= 0:
474
+ raise ValueError("training.train_batch_size must be > 0")
475
+ if self.n_gpus <= 0:
476
+ raise ValueError("training.n_gpus must be > 0")
477
+ _validate_primerl_lm_head_config(self.extra, location="training.extra")
478
+ prime_rl = self.extra.get("prime_rl")
479
+ if isinstance(prime_rl, dict):
480
+ _validate_primerl_lm_head_config(prime_rl, location="training.extra.prime_rl")
481
+
482
+ def to_wire(self) -> dict:
483
+ out: dict = {
484
+ "optimizer": self.optimizer,
485
+ "max_seq_len": self.max_seq_len,
486
+ "train_batch_size": self.train_batch_size,
487
+ "n_gpus": self.n_gpus,
488
+ }
489
+ if self.gradient_clipping is not None:
490
+ out["gradient_clipping"] = self.gradient_clipping
491
+ if self.multiplex_job_id is not None:
492
+ out["multiplex_job_id"] = self.multiplex_job_id
493
+ if self.load_optimizer_states is not None:
494
+ out["load_optimizer_states"] = self.load_optimizer_states
495
+ for k, v in self.extra.items():
496
+ out.setdefault(k, v)
497
+ return out
498
+
499
+
500
+ @dataclass
501
+ class InferenceConfig:
502
+ """Sampling/log-probability config for an inference sub-job.
503
+
504
+ Required fields mirror the server-side validator: ``max_seq_len > 0`` and
505
+ ``n_gpus > 0``. ``extra`` carries vLLM-style passthrough keys (e.g.
506
+ ``gpu_memory_utilization``). See ``docs/reference/rest-api.md`` section 8.3.
507
+ """
508
+
509
+ max_seq_len: int
510
+ n_gpus: int
511
+ multiplex_job_id: str | None = None
512
+ extra: dict = field(default_factory=dict)
513
+
514
+ def validate(self) -> None:
515
+ if self.max_seq_len <= 0:
516
+ raise ValueError("sampling.max_seq_len must be > 0")
517
+ if self.n_gpus <= 0:
518
+ raise ValueError("sampling.n_gpus must be > 0")
519
+
520
+ def to_wire(self) -> dict:
521
+ out: dict = {"max_seq_len": self.max_seq_len, "n_gpus": self.n_gpus}
522
+ if self.multiplex_job_id is not None:
523
+ out["multiplex_job_id"] = self.multiplex_job_id
524
+ for k, v in self.extra.items():
525
+ out.setdefault(k, v)
526
+ return out
527
+
528
+
529
+ @dataclass
530
+ class SubJobConfig:
531
+ """One sub-job within a CreateJob request.
532
+
533
+ Matches the ``SubJobConfig`` schema in ``docs/reference/rest-api.md``
534
+ section 8.1.
535
+
536
+ Exactly one of ``training`` or ``sampling`` must be set, matching the
537
+ sub-job's ``job_type`` (see :meth:`validate`).
538
+ """
539
+
540
+ job_type: JobType
541
+ model_name: str
542
+ training: TrainingConfig | None = None
543
+ sampling: InferenceConfig | None = None
544
+ global_batch_size: int | None = None
545
+ dtype: str | None = None
546
+ seed: int | None = None
547
+ model_post_init: list[str] | None = None
548
+ source_checkpoint_info: dict | None = None
549
+
550
+ @classmethod
551
+ def training_job(
552
+ cls,
553
+ model_name: str,
554
+ *,
555
+ optimizer: dict,
556
+ max_seq_len: int,
557
+ train_batch_size: int,
558
+ n_gpus: int,
559
+ gradient_clipping: float | None = None,
560
+ multiplex_job_id: str | None = None,
561
+ load_optimizer_states: bool | None = None,
562
+ extra_training: dict | None = None,
563
+ global_batch_size: int | None = None,
564
+ dtype: str | None = None,
565
+ seed: int | None = None,
566
+ model_post_init: list[str] | None = None,
567
+ source_checkpoint_info: dict | None = None,
568
+ ) -> "SubJobConfig":
569
+ """Build a training :class:`SubJobConfig`. All training fields are explicit.
570
+
571
+ Args:
572
+ model_name: Model identifier (e.g., "Qwen/Qwen3-1.7B").
573
+ optimizer: Optimizer config dict (e.g., {"name": "AdamW", "lr": 0.0001}).
574
+ max_seq_len: Maximum sequence length for training.
575
+ train_batch_size: Per-device batch size.
576
+ n_gpus: Number of GPUs (DP size).
577
+ gradient_clipping: Optional gradient clipping threshold.
578
+ multiplex_job_id: Optional multiplex job identifier.
579
+ load_optimizer_states: Whether to load optimizer states from
580
+ checkpoints. Set to ``False`` when loading across different DP
581
+ sizes (``n_gpus``) since optimizer states are DP-sharded and
582
+ cannot be resized. When ``None``, uses server default (True).
583
+ **This is a creation-time setting and cannot be changed later.**
584
+ extra_training: Additional training config keys (passthrough).
585
+ global_batch_size: Global batch size across all GPUs.
586
+ dtype: Model dtype (e.g., "bfloat16").
587
+ seed: Random seed.
588
+ model_post_init: List of post-initialization operations.
589
+ source_checkpoint_info: Optional checkpoint to resume from at
590
+ creation time (use ``{"checkpoint_id": "...", "source_job_id": "..."}`).
591
+ """
592
+ return cls(
593
+ job_type=JobType.TRAINING,
594
+ model_name=model_name,
595
+ training=TrainingConfig(
596
+ optimizer=optimizer,
597
+ max_seq_len=max_seq_len,
598
+ train_batch_size=train_batch_size,
599
+ n_gpus=n_gpus,
600
+ gradient_clipping=gradient_clipping,
601
+ multiplex_job_id=multiplex_job_id,
602
+ load_optimizer_states=load_optimizer_states,
603
+ extra=dict(extra_training) if extra_training else {},
604
+ ),
605
+ global_batch_size=global_batch_size,
606
+ dtype=dtype,
607
+ seed=seed,
608
+ model_post_init=model_post_init,
609
+ source_checkpoint_info=source_checkpoint_info,
610
+ )
611
+
612
+ @classmethod
613
+ def sampling_job(
614
+ cls,
615
+ model_name: str,
616
+ *,
617
+ max_seq_len: int,
618
+ n_gpus: int,
619
+ multiplex_job_id: str | None = None,
620
+ extra_sampling: dict | None = None,
621
+ job_type: JobType = JobType.SAMPLING,
622
+ global_batch_size: int | None = None,
623
+ dtype: str | None = None,
624
+ seed: int | None = None,
625
+ model_post_init: list[str] | None = None,
626
+ source_checkpoint_info: dict | None = None,
627
+ ) -> "SubJobConfig":
628
+ """Build a sampling/log-probability :class:`SubJobConfig`."""
629
+ if job_type not in (JobType.SAMPLING, JobType.LOG_PROBABILITY):
630
+ raise ValueError(f"sampling_job() only accepts SAMPLING or LOG_PROBABILITY, got {job_type!r}")
631
+ return cls(
632
+ job_type=job_type,
633
+ model_name=model_name,
634
+ sampling=InferenceConfig(
635
+ max_seq_len=max_seq_len,
636
+ n_gpus=n_gpus,
637
+ multiplex_job_id=multiplex_job_id,
638
+ extra=dict(extra_sampling) if extra_sampling else {},
639
+ ),
640
+ global_batch_size=global_batch_size,
641
+ dtype=dtype,
642
+ seed=seed,
643
+ model_post_init=model_post_init,
644
+ source_checkpoint_info=source_checkpoint_info,
645
+ )
646
+
647
+ def validate(self) -> None:
648
+ # Mirrors the server-side CreateJob validation.
649
+ if not self.model_name:
650
+ raise ValueError("sub_job.model_name is required")
651
+ if self.training is not None and self.sampling is not None:
652
+ raise ValueError("sub_job.training and sub_job.sampling are mutually exclusive")
653
+ if self.job_type == JobType.TRAINING:
654
+ if self.training is None:
655
+ raise ValueError("training sub-job requires a `training` block")
656
+ self.training.validate()
657
+ elif self.job_type in (JobType.SAMPLING, JobType.LOG_PROBABILITY):
658
+ if self.sampling is None:
659
+ raise ValueError(f"{self.job_type.value} sub-job requires a `sampling` block")
660
+ self.sampling.validate()
661
+ else: # pragma: no cover - JobType enum is closed
662
+ raise ValueError(f"unknown job_type: {self.job_type!r}")
663
+
664
+ def to_wire(self) -> dict:
665
+ wire: dict = {
666
+ "job_type": self.job_type.value,
667
+ "model_name": self.model_name,
668
+ }
669
+ if self.global_batch_size is not None:
670
+ wire["global_batch_size"] = self.global_batch_size
671
+ if self.dtype is not None:
672
+ wire["dtype"] = self.dtype
673
+ if self.seed is not None:
674
+ wire["seed"] = self.seed
675
+ if self.model_post_init is not None:
676
+ wire["model_post_init"] = list(self.model_post_init)
677
+ if self.training is not None:
678
+ wire["training_config"] = self.training.to_wire()
679
+ if self.sampling is not None:
680
+ wire["inference_config"] = self.sampling.to_wire()
681
+ if self.source_checkpoint_info is not None:
682
+ wire["source_checkpoint_info"] = self.source_checkpoint_info
683
+ return wire
684
+
685
+
686
+ # ─── Forward-backward payload helpers ────────────────────────────────────
687
+
688
+
689
+ def _load_torch():
690
+ import torch
691
+
692
+ return torch
693
+
694
+
695
+ def _tensor_dtype(torch, dtype_name: str | None):
696
+ if dtype_name is None:
697
+ return torch.long
698
+ aliases = {
699
+ "bool": torch.bool,
700
+ "boolean": torch.bool,
701
+ "float": torch.float32,
702
+ "float32": torch.float32,
703
+ "float64": torch.float64,
704
+ "double": torch.float64,
705
+ "bfloat16": torch.bfloat16,
706
+ "bf16": torch.bfloat16,
707
+ "float16": torch.float16,
708
+ "fp16": torch.float16,
709
+ "int": torch.int32,
710
+ "int32": torch.int32,
711
+ "int64": torch.int64,
712
+ "long": torch.long,
713
+ }
714
+ try:
715
+ return aliases[dtype_name]
716
+ except KeyError as exc:
717
+ raise ValueError(f"unsupported tensor dtype: {dtype_name}") from exc
718
+
719
+
720
+ def _json_tensor(value: Any, *, default_dtype: str | None = "long"):
721
+ torch = _load_torch()
722
+ if isinstance(value, dict):
723
+ if "data" not in value:
724
+ raise ValueError("tensor object must contain a data field")
725
+ dtype_name = value.get("dtype", default_dtype)
726
+ value = value["data"]
727
+ else:
728
+ dtype_name = default_dtype
729
+ return torch.tensor(value, dtype=_tensor_dtype(torch, dtype_name))
730
+
731
+
732
+ def _read_fwd_bwd_args(payload: dict[str, Any]) -> tuple:
733
+ args = payload.get("args", [])
734
+ if args is None:
735
+ return ()
736
+ if not isinstance(args, list):
737
+ raise ValueError("fwd-bwd payload args must be a JSON list")
738
+ return tuple(args)
739
+
740
+
741
+ def _tensorize_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
742
+ out = {}
743
+ for key, value in kwargs.items():
744
+ if isinstance(value, dict) and "data" in value:
745
+ out[key] = _json_tensor(value)
746
+ elif isinstance(value, list):
747
+ out[key] = _json_tensor(value)
748
+ else:
749
+ out[key] = value
750
+ return out
751
+
752
+
753
+ def _require_bool(value: Any, name: str) -> bool:
754
+ if not isinstance(value, bool):
755
+ raise ValueError(f"{name} must be a boolean")
756
+ return value
757
+
758
+
759
+ def _positive_int(value: Any, name: str) -> int:
760
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
761
+ raise ValueError(f"{name} must be a positive integer")
762
+ return value
763
+
764
+
765
+ def _repeat_texts(texts: Any, batch_size: Any) -> list[str]:
766
+ if isinstance(texts, str):
767
+ text_list = [texts]
768
+ elif isinstance(texts, list) and texts and all(isinstance(x, str) for x in texts):
769
+ text_list = texts
770
+ else:
771
+ raise ValueError("fwd-bwd payload texts must be a non-empty string list")
772
+
773
+ if batch_size is None:
774
+ return text_list
775
+ batch = _positive_int(batch_size, "fwd-bwd payload batch_size")
776
+ return [text_list[i % len(text_list)] for i in range(batch)]
777
+
778
+
779
+ def _load_tokenizer(tokenizer_spec: Any):
780
+ try:
781
+ from transformers import AutoTokenizer
782
+ except ModuleNotFoundError as exc:
783
+ raise ModuleNotFoundError(
784
+ "No module named 'transformers'",
785
+ name="transformers",
786
+ ) from exc
787
+
788
+ if isinstance(tokenizer_spec, str):
789
+ model_name = tokenizer_spec
790
+ kwargs: dict[str, Any] = {}
791
+ elif isinstance(tokenizer_spec, dict):
792
+ model_name = tokenizer_spec.get("model_name") or tokenizer_spec.get("name") or tokenizer_spec.get("path")
793
+ if not isinstance(model_name, str) or not model_name:
794
+ raise ValueError("tokenizer.model_name is required")
795
+ kwargs = {}
796
+ for key in ("trust_remote_code", "use_fast", "revision"):
797
+ if key in tokenizer_spec:
798
+ kwargs[key] = tokenizer_spec[key]
799
+ else:
800
+ raise ValueError("fwd-bwd payload tokenizer must be a string or object")
801
+
802
+ tokenizer = AutoTokenizer.from_pretrained(model_name, **kwargs)
803
+ if getattr(tokenizer, "pad_token_id", None) is None:
804
+ eos_token = getattr(tokenizer, "eos_token", None)
805
+ if eos_token is not None:
806
+ tokenizer.pad_token = eos_token
807
+ return tokenizer
808
+
809
+
810
+ def _tokenize_payload(payload: dict[str, Any]) -> dict[str, Any]:
811
+ tokenizer_spec = payload.get("tokenizer", payload.get("model_name"))
812
+ if tokenizer_spec is None:
813
+ raise ValueError("fwd-bwd payload requires tokenizer or input_ids")
814
+
815
+ tokenizer = _load_tokenizer(tokenizer_spec)
816
+ texts = _repeat_texts(payload.get("texts"), payload.get("batch_size"))
817
+ padding = payload.get("padding", "max_length")
818
+ truncation = payload.get("truncation", True)
819
+ add_special_tokens = payload.get("add_special_tokens", True)
820
+ max_length = payload.get("max_length")
821
+ if padding == "max_length":
822
+ max_length = _positive_int(max_length, "fwd-bwd payload max_length")
823
+ elif max_length is not None:
824
+ max_length = _positive_int(max_length, "fwd-bwd payload max_length")
825
+ _require_bool(truncation, "fwd-bwd payload truncation")
826
+ _require_bool(add_special_tokens, "fwd-bwd payload add_special_tokens")
827
+
828
+ encode_kwargs = {
829
+ "return_tensors": "pt",
830
+ "padding": padding,
831
+ "truncation": truncation,
832
+ "add_special_tokens": add_special_tokens,
833
+ }
834
+ if max_length is not None:
835
+ encode_kwargs["max_length"] = max_length
836
+ encoded = tokenizer(texts, **encode_kwargs)
837
+ if "input_ids" not in encoded:
838
+ raise ValueError("tokenizer output did not include input_ids")
839
+ return encoded
840
+
841
+
842
+ def _ensure_rank2(tensor, name: str) -> None:
843
+ if len(tensor.shape) != 2:
844
+ raise ValueError(f"{name} must have shape [batch, seq_len]")
845
+
846
+
847
+ def _build_position_ids(input_ids, position_spec: Any):
848
+ torch = _load_torch()
849
+ if position_spec is None or position_spec is False:
850
+ return None
851
+ if position_spec is True or position_spec == "arange":
852
+ batch_size, seq_len = input_ids.shape
853
+ return torch.arange(seq_len, dtype=torch.long).unsqueeze(0).expand(batch_size, -1).contiguous()
854
+ if isinstance(position_spec, (dict, list)):
855
+ return _json_tensor(position_spec)
856
+ raise ValueError("fwd-bwd payload position_ids must be true, false, arange, or tensor data")
857
+
858
+
859
+ def _label_config(payload: dict[str, Any]) -> tuple[Any, int, bool]:
860
+ labels = payload.get("labels", payload.get("label_strategy", "next_token"))
861
+ if isinstance(labels, dict) and "data" in labels:
862
+ return labels, int(payload.get("ignore_index", -100)), True
863
+ if isinstance(labels, dict):
864
+ strategy = labels.get("strategy", "next_token")
865
+ ignore_index = int(labels.get("ignore_index", payload.get("ignore_index", -100)))
866
+ mask_padding = labels.get("mask_padding", payload.get("mask_padding", True))
867
+ return strategy, ignore_index, _require_bool(mask_padding, "labels.mask_padding")
868
+ return (
869
+ labels,
870
+ int(payload.get("ignore_index", -100)),
871
+ _require_bool(
872
+ payload.get("mask_padding", True),
873
+ "fwd-bwd payload mask_padding",
874
+ ),
875
+ )
876
+
877
+
878
+ def _build_labels(input_ids, attention_mask, payload: dict[str, Any]):
879
+ torch = _load_torch()
880
+ labels, ignore_index, mask_padding = _label_config(payload)
881
+ if labels is None or labels == "none":
882
+ return None
883
+ if isinstance(labels, (dict, list)):
884
+ return _json_tensor(labels)
885
+ if labels in ("next_token", "shifted_input_ids"):
886
+ out = torch.roll(input_ids, shifts=-1, dims=1)
887
+ out[:, -1] = ignore_index
888
+ if mask_padding and attention_mask is not None:
889
+ target_mask = torch.roll(attention_mask, shifts=-1, dims=1)
890
+ target_mask[:, -1] = 0
891
+ out = out.masked_fill(target_mask == 0, ignore_index)
892
+ return out
893
+ if labels in ("input_ids", "self"):
894
+ out = input_ids.clone()
895
+ if mask_padding and attention_mask is not None:
896
+ out = out.masked_fill(attention_mask == 0, ignore_index)
897
+ return out
898
+ raise ValueError("fwd-bwd labels strategy must be next_token, input_ids, none, or tensor data")
899
+
900
+
901
+ def build_forward_backward_kwargs(payload: dict[str, Any]) -> dict[str, Any]:
902
+ """Build tensor kwargs for a forward-backward training batch.
903
+
904
+ ``payload`` is intentionally JSON-shaped so CLI examples can stay readable:
905
+ callers may provide either tokenizable ``texts`` plus a ``tokenizer`` or
906
+ direct tensor data under ``input_ids`` / ``labels`` / ``position_ids``.
907
+ """
908
+ if not isinstance(payload, dict):
909
+ raise ValueError("fwd-bwd payload must be a JSON object")
910
+
911
+ if "kwargs" in payload:
912
+ kwargs = payload["kwargs"]
913
+ if not isinstance(kwargs, dict):
914
+ raise ValueError("fwd-bwd payload kwargs must be an object")
915
+ return _tensorize_kwargs(kwargs)
916
+
917
+ torch = _load_torch()
918
+ encoded = None
919
+ if "input_ids" in payload:
920
+ input_ids = _json_tensor(payload["input_ids"])
921
+ attention_mask = (
922
+ _json_tensor(payload["attention_mask"])
923
+ if isinstance(payload.get("attention_mask"), (dict, list))
924
+ else None
925
+ )
926
+ else:
927
+ encoded = _tokenize_payload(payload)
928
+ input_ids = encoded["input_ids"]
929
+ attention_mask = encoded.get("attention_mask")
930
+
931
+ _ensure_rank2(input_ids, "input_ids")
932
+ if attention_mask is not None:
933
+ _ensure_rank2(attention_mask, "attention_mask")
934
+ if attention_mask.shape != input_ids.shape:
935
+ raise ValueError("attention_mask must have the same shape as input_ids")
936
+
937
+ kwargs: dict[str, Any] = {"input_ids": input_ids.contiguous()}
938
+
939
+ include_attention = payload.get("include_attention_mask", False)
940
+ if "attention_mask" in payload and isinstance(payload["attention_mask"], bool):
941
+ include_attention = payload["attention_mask"]
942
+ include_attention = _require_bool(
943
+ include_attention,
944
+ "fwd-bwd payload include_attention_mask",
945
+ )
946
+ if include_attention:
947
+ if attention_mask is None:
948
+ attention_mask = torch.ones_like(input_ids, dtype=torch.long)
949
+ kwargs["attention_mask"] = attention_mask.contiguous()
950
+
951
+ position_spec = payload.get("position_ids")
952
+ if position_spec is None and payload.get("include_position_ids", False):
953
+ position_spec = "arange"
954
+ position_ids = _build_position_ids(input_ids, position_spec)
955
+ if position_ids is not None:
956
+ _ensure_rank2(position_ids, "position_ids")
957
+ if position_ids.shape != input_ids.shape:
958
+ raise ValueError("position_ids must have the same shape as input_ids")
959
+ kwargs["position_ids"] = position_ids.contiguous()
960
+
961
+ labels = _build_labels(input_ids, attention_mask, payload)
962
+ if labels is not None:
963
+ _ensure_rank2(labels, "labels")
964
+ if labels.shape != input_ids.shape:
965
+ raise ValueError("labels must have the same shape as input_ids")
966
+ kwargs["labels"] = labels.contiguous()
967
+
968
+ return kwargs
969
+
970
+
971
+ def serialize_forward_backward_args(args: tuple = (), kwargs: dict[str, Any] | None = None) -> bytes:
972
+ """Serialize ``{"args": args, "kwargs": kwargs}`` for Cortex Training fwd-bwd."""
973
+ if not isinstance(args, tuple):
974
+ raise ValueError("forward-backward args must be a tuple")
975
+ if kwargs is None:
976
+ kwargs = {}
977
+ if not isinstance(kwargs, dict):
978
+ raise ValueError("forward-backward kwargs must be a dict")
979
+
980
+ return wire.dumps(
981
+ {"args": args, "kwargs": kwargs},
982
+ metadata={"response_options": {"format": "dssst1", "delivery": "chunked"}},
983
+ )
984
+
985
+
986
+ def build_forward_backward_payload(spec: dict[str, Any]) -> bytes:
987
+ """Build a serialized fwd-bwd byte payload from readable JSON."""
988
+ if not isinstance(spec, dict):
989
+ raise ValueError("fwd-bwd JSON must be an object")
990
+ payload = spec.get("payload", spec)
991
+ if not isinstance(payload, dict):
992
+ raise ValueError("fwd-bwd payload must be an object")
993
+ args = _read_fwd_bwd_args(payload)
994
+ kwargs = build_forward_backward_kwargs(payload)
995
+ return serialize_forward_backward_args(args, kwargs)
996
+
997
+
998
+ def _sql_string_literal(value: str) -> str:
999
+ """Return ``value`` as a single-quoted Snowflake SQL string."""
1000
+ return "'" + value.replace("'", "''") + "'"
1001
+
1002
+
1003
+ def _validate_artifact_relative_path(path: str) -> str:
1004
+ """Reject paths that could escape or ambiguously address the run stage."""
1005
+ candidate = PurePosixPath(path)
1006
+ if (
1007
+ not path
1008
+ or path.startswith("/")
1009
+ or "\\" in path
1010
+ or any(part in {"", ".", ".."} for part in path.split("/"))
1011
+ or candidate.name in {"", ".", ".."}
1012
+ ):
1013
+ raise ValueError(f"unsafe experiment artifact path: {path!r}")
1014
+ return path
1015
+
1016
+
1017
+ # ─── HTTP client ─────────────────────────────────────────────────────────
1018
+
1019
+
1020
+ class CortexTrainingClient:
1021
+ """HTTP client for the Cortex Training REST API (``cortex-training``).
1022
+
1023
+ Construct with :meth:`from_pat`::
1024
+
1025
+ client = CortexTrainingClient.from_pat(
1026
+ host="ACCOUNT.snowflakecomputing.com",
1027
+ pat="...",
1028
+ database="CORTEX_TRAINING_DB",
1029
+ schema="PUBLIC",
1030
+ )
1031
+
1032
+ Against a local or otherwise compatible server, construct the client
1033
+ directly with an explicit ``base_url``; this skips PAT auth::
1034
+
1035
+ client = CortexTrainingClient(
1036
+ base_url="http://localhost:8084",
1037
+ database="MY_DB",
1038
+ schema="PUBLIC",
1039
+ )
1040
+
1041
+ Then build one or more sub-jobs and submit::
1042
+
1043
+ sub = SubJobConfig.training_job(
1044
+ model_name="Qwen/Qwen3-1.7B",
1045
+ optimizer={"name": "AdamW", "lr": 1e-5},
1046
+ max_seq_len=2048,
1047
+ train_batch_size=1,
1048
+ n_gpus=1,
1049
+ )
1050
+ job_id = client.create_job(sub_jobs=[sub])
1051
+ """
1052
+
1053
+ def __init__(
1054
+ self,
1055
+ base_url: str,
1056
+ database: str,
1057
+ schema: str,
1058
+ endpoint: str = "cortex-training",
1059
+ poll_interval: float = 0.5,
1060
+ poll_timeout: float = 1800.0,
1061
+ poll_backoff_multiplier: float = 1.25,
1062
+ poll_max_interval: float = 6.0,
1063
+ pool_maxsize: int = 1024,
1064
+ max_retries: int = 10,
1065
+ ):
1066
+ if poll_interval <= 0:
1067
+ raise ValueError("poll_interval must be > 0")
1068
+ if poll_timeout <= 0:
1069
+ raise ValueError("poll_timeout must be > 0")
1070
+ if poll_backoff_multiplier < 1.0:
1071
+ raise ValueError("poll_backoff_multiplier must be >= 1.0")
1072
+ if poll_max_interval < poll_interval:
1073
+ raise ValueError("poll_max_interval must be >= poll_interval")
1074
+ if pool_maxsize <= 0:
1075
+ raise ValueError("pool_maxsize must be > 0")
1076
+ if max_retries < 0:
1077
+ raise ValueError("max_retries must be >= 0")
1078
+ self.base_url = base_url.rstrip("/")
1079
+ self.database = database
1080
+ self.schema = schema
1081
+ self.endpoint = endpoint
1082
+ self.poll_interval = poll_interval
1083
+ self.poll_timeout = poll_timeout
1084
+ self.poll_backoff_multiplier = poll_backoff_multiplier
1085
+ self.poll_max_interval = poll_max_interval
1086
+ self.max_retries = max_retries
1087
+ self._fwd_bwd_send_count = 0
1088
+ self._fwd_bwd_request_debug: dict[str, dict[str, Any]] = {}
1089
+ self._generate_request_ids: set[str] = set()
1090
+ # Per-job cache of the sampling sub-job's ``inference_config.max_seq_len``
1091
+ # (the value the backend launches vLLM with as ``max_model_len``). Used
1092
+ # to validate generate prompt lengths client-side. A cached ``None``
1093
+ # means the job was fetched but exposes no sampling max_seq_len (skip
1094
+ # validation); a missing key means it has not been resolved yet (e.g. a
1095
+ # transient get_job failure), so a later call will retry.
1096
+ self._sampling_max_seq_len: dict[str, int | None] = {}
1097
+ self._artifact_connection_config: dict[str, str] | None = None
1098
+ self._metric_emitter: OtlpMetricEmitter | None = None
1099
+ self._operation_metric_state = threading.local()
1100
+ self._session = requests.Session()
1101
+ adapter = HTTPAdapter(pool_connections=pool_maxsize, pool_maxsize=pool_maxsize)
1102
+ self._session.mount("https://", adapter)
1103
+ self._session.mount("http://", adapter)
1104
+
1105
+ @classmethod
1106
+ def from_pat(
1107
+ cls,
1108
+ host: str,
1109
+ pat: str,
1110
+ database: str,
1111
+ schema: str,
1112
+ endpoint: str = "cortex-training",
1113
+ verify_ssl: bool = True,
1114
+ *,
1115
+ telemetry_timeout: float = 3.0,
1116
+ **kwargs: Any,
1117
+ ) -> "CortexTrainingClient":
1118
+ """Authenticate with a Programmatic Access Token (PAT).
1119
+
1120
+ Sends ``Authorization: Bearer <pat>`` plus the
1121
+ ``X-Snowflake-Authorization-Token-Type: PROGRAMMATIC_ACCESS_TOKEN`` header
1122
+ on every request. No login round-trip.
1123
+ """
1124
+ client = cls(
1125
+ base_url=f"https://{host}",
1126
+ database=database,
1127
+ schema=schema,
1128
+ endpoint=endpoint,
1129
+ **kwargs,
1130
+ )
1131
+ client._session.headers["Authorization"] = f"Bearer {pat}"
1132
+ client._session.headers["X-Snowflake-Authorization-Token-Type"] = "PROGRAMMATIC_ACCESS_TOKEN"
1133
+ client._session.verify = verify_ssl
1134
+ client._artifact_connection_config = {"host": host, "pat": pat}
1135
+ if not _telemetry_disabled():
1136
+ token_provider = CachedSessionTokenProvider(
1137
+ client.base_url,
1138
+ pat,
1139
+ verify_ssl=verify_ssl,
1140
+ timeout=telemetry_timeout,
1141
+ )
1142
+ client._metric_emitter = OtlpMetricEmitter(
1143
+ client.base_url,
1144
+ token_provider,
1145
+ verify_ssl=verify_ssl,
1146
+ timeout=telemetry_timeout,
1147
+ )
1148
+ return client
1149
+
1150
+ @property
1151
+ def _prefix(self) -> str:
1152
+ return f"{self.base_url}/api/v2/databases/{self.database}/schemas/{self.schema}/{self.endpoint}"
1153
+
1154
+ def emit_metric(
1155
+ self,
1156
+ operation: str,
1157
+ value: Any = 1,
1158
+ *,
1159
+ attributes: dict[str, Any] | None = None,
1160
+ ) -> None:
1161
+ """Emit a best-effort OTLP log record used as a client metric.
1162
+
1163
+ PAT clients lazily exchange the PAT for a cached session token on the
1164
+ first call. Local/mock clients, or clients constructed with
1165
+ ``CORTEX_TRAINING_DISABLE_TELEMETRY`` set, treat this method as a no-op.
1166
+ All authentication, discovery, and export errors are ignored.
1167
+ """
1168
+ try:
1169
+ if self._metric_emitter is not None:
1170
+ self._metric_emitter.emit(operation, value, attributes=attributes)
1171
+ except Exception:
1172
+ return
1173
+
1174
+ def close(self) -> None:
1175
+ """Flush telemetry briefly and close this client's HTTP sessions."""
1176
+ try:
1177
+ if self._metric_emitter is not None:
1178
+ self._metric_emitter.close()
1179
+ except Exception:
1180
+ logger.debug("client telemetry close failed", exc_info=True)
1181
+ finally:
1182
+ self._session.close()
1183
+
1184
+ def __enter__(self) -> "CortexTrainingClient":
1185
+ return self
1186
+
1187
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
1188
+ self.close()
1189
+
1190
+ def _emit_operation_outcome(
1191
+ self,
1192
+ operation: str,
1193
+ started: float,
1194
+ arguments: Mapping[str, Any],
1195
+ *,
1196
+ result: Any = None,
1197
+ error: BaseException | None = None,
1198
+ ) -> None:
1199
+ """Emit one structured outcome without changing the operation result."""
1200
+ if self._metric_emitter is None:
1201
+ return
1202
+ if error is None and not _success_telemetry_enabled():
1203
+ return
1204
+ try:
1205
+ attempt_count = int(
1206
+ getattr(self._operation_metric_state, "attempt_count", 0)
1207
+ )
1208
+ request_count = int(
1209
+ getattr(self._operation_metric_state, "request_count", 0)
1210
+ )
1211
+ value: dict[str, Any] = {
1212
+ "attempt_count": attempt_count,
1213
+ "duration_ms": round((time.monotonic() - started) * 1000, 3),
1214
+ "request_count": request_count,
1215
+ "retry_count": max(attempt_count - request_count, 0),
1216
+ "success": error is None,
1217
+ }
1218
+ attributes = _metric_identity(operation, arguments, result)
1219
+ if error is not None:
1220
+ value["error_message"] = _safe_metric_error_message(error)
1221
+ attributes.update(_metric_error_attributes(error))
1222
+ self.emit_metric(operation, value, attributes=attributes)
1223
+ except Exception:
1224
+ logger.debug("client operation metric construction failed", exc_info=True)
1225
+
1226
+ @staticmethod
1227
+ def _debug_json_summary(value: Any, *, limit: int = 2000) -> str:
1228
+ try:
1229
+ text = json.dumps(value, sort_keys=True, default=str)
1230
+ except TypeError:
1231
+ text = repr(value)
1232
+ if len(text) > limit:
1233
+ return f"{text[:limit]}...<truncated {len(text) - limit} chars>"
1234
+ return text
1235
+
1236
+ @classmethod
1237
+ def _debug_response_summary(cls, resp: requests.Response, *, limit: int = 2000) -> str:
1238
+ try:
1239
+ return cls._debug_json_summary(resp.json(), limit=limit)
1240
+ except ValueError:
1241
+ text = getattr(resp, "text", "")
1242
+ except Exception as exc:
1243
+ text = f"<failed to read response body: {type(exc).__name__}: {exc}>"
1244
+ text = str(text).replace("\n", "\\n")
1245
+ if len(text) > limit:
1246
+ return f"{text[:limit]}...<truncated {len(text) - limit} chars>"
1247
+ return text
1248
+
1249
+ @staticmethod
1250
+ def _debug_context_label(debug_context: dict[str, Any]) -> str:
1251
+ parts = ["[cortex-training fwd-bwd]"]
1252
+ for key in ("phase", "call", "job_id", "request_id", "payload_bytes"):
1253
+ value = debug_context.get(key)
1254
+ if value is not None:
1255
+ parts.append(f"{key}={value}")
1256
+ return " ".join(parts)
1257
+
1258
+ def _send(self, method: str, url: str, *, retry_on=_is_transient, **kwargs) -> requests.Response:
1259
+ fn = getattr(self._session, method.lower())
1260
+ debug_context = kwargs.pop("debug_context", None)
1261
+ debug_label = self._debug_context_label(debug_context) if debug_context is not None else None
1262
+ attempt_no = 0
1263
+ max_attempts = 1 + self.max_retries
1264
+ try:
1265
+ state = self._operation_metric_state
1266
+ if getattr(state, "active", set()):
1267
+ state.request_count = getattr(state, "request_count", 0) + 1
1268
+ except Exception:
1269
+ logger.debug("client operation request counting failed", exc_info=True)
1270
+
1271
+ def attempt() -> requests.Response:
1272
+ nonlocal attempt_no
1273
+ attempt_no += 1
1274
+ try:
1275
+ state = self._operation_metric_state
1276
+ if getattr(state, "active", set()):
1277
+ state.attempt_count = getattr(state, "attempt_count", 0) + 1
1278
+ except Exception:
1279
+ logger.debug("client operation attempt counting failed", exc_info=True)
1280
+ if debug_label is not None:
1281
+ logger.debug(
1282
+ "%s sending %s %s attempt=%d/%d",
1283
+ debug_label,
1284
+ method.upper(),
1285
+ url,
1286
+ attempt_no,
1287
+ max_attempts,
1288
+ )
1289
+ try:
1290
+ resp = fn(url, **kwargs)
1291
+ except Exception as exc:
1292
+ if debug_label is not None:
1293
+ logger.debug(
1294
+ "%s request exception %s %s attempt=%d/%d: %s: %s",
1295
+ debug_label,
1296
+ method.upper(),
1297
+ url,
1298
+ attempt_no,
1299
+ max_attempts,
1300
+ type(exc).__name__,
1301
+ exc,
1302
+ )
1303
+ raise
1304
+ status_code = getattr(resp, "status_code", None)
1305
+ headers = getattr(resp, "headers", {}) or {}
1306
+ sf_request_id = headers.get("x-snowflake-request-id")
1307
+ if not isinstance(sf_request_id, str) or not sf_request_id:
1308
+ sf_request_id = None
1309
+ if sf_request_id:
1310
+ req = getattr(resp, "request", None)
1311
+ logger.debug(
1312
+ "snowflake request_id=%s %s %s status=%d",
1313
+ sf_request_id,
1314
+ getattr(req, "method", method.upper()),
1315
+ getattr(req, "path_url", url),
1316
+ status_code,
1317
+ )
1318
+ if debug_label is not None:
1319
+ try:
1320
+ status_int = int(status_code)
1321
+ except (TypeError, ValueError):
1322
+ status_int = None
1323
+ outcome = (
1324
+ "successful response" if status_int is not None and 200 <= status_int < 400 else "failed response"
1325
+ )
1326
+ snowflake = f" snowflake_request_id={sf_request_id}" if sf_request_id else ""
1327
+ logger.debug(
1328
+ "%s %s %s %s attempt=%d/%d status=%s%s body=%s",
1329
+ debug_label,
1330
+ outcome,
1331
+ method.upper(),
1332
+ url,
1333
+ attempt_no,
1334
+ max_attempts,
1335
+ status_code,
1336
+ snowflake,
1337
+ self._debug_response_summary(resp),
1338
+ )
1339
+ resp.raise_for_status()
1340
+ return resp
1341
+
1342
+ retryer = Retrying(
1343
+ retry=retry_if_exception(retry_on),
1344
+ stop=stop_after_attempt(1 + self.max_retries),
1345
+ wait=wait_exponential_jitter(initial=0.5, max=10.0),
1346
+ reraise=True,
1347
+ )
1348
+ return retryer(attempt)
1349
+
1350
+ # ─── Job management ──────────────────────────────────────────────────
1351
+
1352
+ @_track_operation("create_job")
1353
+ def create_job(
1354
+ self,
1355
+ sub_jobs: list[SubJobConfig],
1356
+ job_id: str | None = None,
1357
+ experiment_name: str | None = None,
1358
+ hardware: Hardware | str | None = None,
1359
+ idle_timeout_seconds: int | None = None,
1360
+ pending_timeout_seconds: int | None = None,
1361
+ ) -> str:
1362
+ """Create a job from a list of sub-jobs and return its server job_id.
1363
+
1364
+ Each :class:`SubJobConfig` is validated client-side before the request
1365
+ is sent (see :meth:`SubJobConfig.validate`). A job supports zero or one
1366
+ ``training`` sub-job and any number of ``sampling`` /
1367
+ ``log_probability`` sub-jobs. ``job_id`` is optional;
1368
+ when omitted the server generates one. ``experiment_name`` is optional;
1369
+ when omitted the server auto-creates an experiment for the job.
1370
+ ``hardware`` is optional (:class:`Hardware`.H200 / .B200 / .B300); when
1371
+ set, all sub-jobs in the job run on that GPU type. Omitted defaults to
1372
+ H200.
1373
+ ``idle_timeout_seconds`` bounds how long the job may sit idle before the
1374
+ server reclaims it: ``0`` disables reclamation, any other value must be
1375
+ between 300 and 604,800. ``pending_timeout_seconds`` bounds how long the
1376
+ job may wait for capacity before failing with ``pending_timeout``: between
1377
+ 300 and 604,800, with no disable value. Omit either to use the server
1378
+ default, currently 30 minutes for idle and 24 hours for pending.
1379
+ """
1380
+ if not sub_jobs:
1381
+ raise ValueError("create_job requires a non-empty sub_jobs list")
1382
+ if idle_timeout_seconds is not None:
1383
+ if isinstance(idle_timeout_seconds, bool) or not isinstance(
1384
+ idle_timeout_seconds, int
1385
+ ):
1386
+ raise ValueError("idle_timeout_seconds must be an integer")
1387
+ if idle_timeout_seconds != 0 and not (
1388
+ 300 <= idle_timeout_seconds <= 604_800
1389
+ ):
1390
+ raise ValueError(
1391
+ "idle_timeout_seconds must be 0 or between 300 and 604800"
1392
+ )
1393
+ if pending_timeout_seconds is not None:
1394
+ if isinstance(pending_timeout_seconds, bool) or not isinstance(
1395
+ pending_timeout_seconds, int
1396
+ ):
1397
+ raise ValueError("pending_timeout_seconds must be an integer")
1398
+ if not (300 <= pending_timeout_seconds <= 604_800):
1399
+ raise ValueError(
1400
+ "pending_timeout_seconds must be between 300 and 604800"
1401
+ )
1402
+ for sj in sub_jobs:
1403
+ sj.validate()
1404
+ body: dict = {"sub_job_configs": [sj.to_wire() for sj in sub_jobs]}
1405
+ if job_id is not None:
1406
+ body["job_id"] = job_id
1407
+ if experiment_name is not None:
1408
+ body["experiment_name"] = experiment_name
1409
+ if hardware is not None:
1410
+ body["hardware"] = _hardware_wire(hardware)
1411
+ if idle_timeout_seconds is not None:
1412
+ body["idle_timeout_seconds"] = idle_timeout_seconds
1413
+ if pending_timeout_seconds is not None:
1414
+ body["pending_timeout_seconds"] = pending_timeout_seconds
1415
+ return self.create_job_from_body(body)["job_id"]
1416
+
1417
+ @_track_operation("create_job")
1418
+ def create_job_from_body(self, body: dict) -> dict:
1419
+ """Create a job from a raw REST CreateJob request body.
1420
+
1421
+ This is useful for tooling that already has the REST JSON payload,
1422
+ while :meth:`create_job` remains the typed path for Python callers.
1423
+
1424
+ A job supports zero or one ``training`` sub-job and any number of
1425
+ ``sampling`` / ``log_probability`` sub-jobs; a second training sub-job
1426
+ raises :class:`ValueError` before the request is sent.
1427
+ """
1428
+ if not isinstance(body, dict):
1429
+ raise ValueError("create_job_from_body requires a JSON object")
1430
+ sub_job_configs = body.get("sub_job_configs")
1431
+ if not isinstance(sub_job_configs, list) or not sub_job_configs:
1432
+ raise ValueError("create_job_from_body requires a non-empty sub_job_configs list")
1433
+ training_sub_jobs = sum(
1434
+ 1
1435
+ for cfg in sub_job_configs
1436
+ if isinstance(cfg, dict)
1437
+ and str(cfg.get("job_type") or "").strip().lower() == JobType.TRAINING.value
1438
+ )
1439
+ if training_sub_jobs > 1:
1440
+ raise ValueError("at most one training sub-job is supported per job")
1441
+ if body.get("debug") and not _debug_options_enabled():
1442
+ raise ValueError(
1443
+ "create-job debug options are an internal-only capability; set "
1444
+ f"{DEBUG_OPTIONS_ENV}=1 to send a request with a `debug` block"
1445
+ )
1446
+ resp = self._send("POST", self._prefix, json=body, retry_on=_is_connect_error)
1447
+ return resp.json()
1448
+
1449
+ @staticmethod
1450
+ def _normalize_job_status(status) -> str:
1451
+ """Normalize full enum names (``JOB_STATE_RUNNING``) to short form (``running``).
1452
+
1453
+ Coerces to ``str`` first: a backend running ahead of the API layer can
1454
+ return a JobState enum value the API layer doesn't yet know (e.g. a
1455
+ newly added state), which is then rendered as a raw integer rather than
1456
+ a name. Tolerating it keeps :meth:`wait_for_job` polling — an unknown
1457
+ status is neither ``running`` nor terminal, so it stays in-progress —
1458
+ instead of crashing on ``int.lower()``.
1459
+ """
1460
+ return str(status or "").lower().removeprefix("job_state_")
1461
+
1462
+ def _sleep_with_backoff(self, delay: float, deadline: float) -> float:
1463
+ remaining = deadline - time.monotonic()
1464
+ if remaining <= 0:
1465
+ return delay
1466
+ time.sleep(min(delay, remaining))
1467
+ return min(delay * self.poll_backoff_multiplier, self.poll_max_interval)
1468
+
1469
+ @_track_operation("wait_for_job")
1470
+ def wait_for_job(self, job_id: str) -> dict:
1471
+ """Poll until the job reaches ``running``. Raises on terminal states or timeout."""
1472
+ deadline = time.monotonic() + self.poll_timeout
1473
+ delay = self.poll_interval
1474
+ while time.monotonic() < deadline:
1475
+ job = self.get_job(job_id)
1476
+ status = self._normalize_job_status(job.get("status", ""))
1477
+ if status == "running":
1478
+ return job
1479
+ if status in ("failed", "done", "cancelled", "canceled"):
1480
+ reason = job.get("reason", "")
1481
+ raise RuntimeError(f"Job {job_id} reached terminal state '{status}': {reason}")
1482
+ delay = self._sleep_with_backoff(delay, deadline)
1483
+ raise TimeoutError(f"Job {job_id} did not become running within {self.poll_timeout}s")
1484
+
1485
+ @_track_operation("get_job")
1486
+ def get_job(self, job_id: str) -> dict:
1487
+ resp = self._send("GET", f"{self._prefix}/{job_id}")
1488
+ return resp.json()
1489
+
1490
+ @_track_operation("list_jobs")
1491
+ def list_jobs(self, status: str | None = None) -> list:
1492
+ params = {}
1493
+ if status is not None:
1494
+ params["status"] = status
1495
+ resp = self._send("GET", self._prefix, params=params)
1496
+ return resp.json().get("jobs", [])
1497
+
1498
+ @_track_operation("cancel_job")
1499
+ def cancel_job(self, job_id: str) -> None:
1500
+ # The REST API uses colon-action syntax: /{jobId}:cancel
1501
+ self._send("POST", f"{self._prefix}/{job_id}:cancel")
1502
+
1503
+ @_track_operation("get_capacity")
1504
+ def get_capacity(self, hardware: Hardware | str | None = None) -> dict:
1505
+ """Return the calling account's GPU ceiling and current usage.
1506
+
1507
+ Backed by the account-scoped endpoint ``/cortex-training/capacity``
1508
+ (not under ``/{job_id}``). The account is resolved server-side from the
1509
+ caller's session — never from a request field — so a caller can only
1510
+ ever read its own account's capacity. Optional ``hardware``
1511
+ (:class:`Hardware`.H200 / .B200 / .B300) is sent as a query parameter and
1512
+ scopes the numbers to that GPU type; omitted defaults to H200.
1513
+
1514
+ The returned dict always carries all six fields. The server emits
1515
+ proto3 JSON, which omits zero/false fields (an account holding nothing
1516
+ under a zero ceiling is literally ``{}``), so we fill in the documented
1517
+ defaults:
1518
+
1519
+ - ``has_reservation`` (bool): whether ``max_total_gpus`` is a
1520
+ *guaranteed* commitment rather than a best-effort per-account cap.
1521
+ When ``False`` the account draws on the shared pool, so
1522
+ ``available_gpus`` is an upper bound.
1523
+ - ``max_total_gpus`` (int): the account's ceiling — its commitment when
1524
+ ``has_reservation``, else its per-account cap. ``-1`` means no
1525
+ ceiling, ``0`` a real quota of zero, ``> 0`` the limit. This is the
1526
+ canonical ceiling; prefer it over ``reserved_gpus``.
1527
+ - ``reserved_gpus`` (int, **deprecated**): mirrors ``max_total_gpus``
1528
+ when ``has_reservation`` is ``True``, else 0.
1529
+ - ``in_use_gpus`` (int): GPUs the account holds — jobs in ``placing``,
1530
+ ``initializing``, or ``running``. Queued work is *not* counted here;
1531
+ it is in ``pending_gpus``.
1532
+ - ``pending_gpus`` (int): GPUs requested by jobs still waiting for
1533
+ capacity (``pending``). It claims quota, so it reduces
1534
+ ``available_gpus``.
1535
+ - ``available_gpus`` (int): what a new job could start with right now —
1536
+ ceiling headroom capped by what is schedulable. ``0`` does not mean
1537
+ blocked: a submit within the ceiling is still accepted and queued.
1538
+
1539
+ See ``docs/reference/rest-api.md`` section 5.4 for the authoritative
1540
+ field list.
1541
+ """
1542
+ params = {}
1543
+ if hardware is not None:
1544
+ params["hardware"] = _hardware_wire(hardware)
1545
+ resp = self._send(
1546
+ "GET",
1547
+ f"{self._prefix}/capacity",
1548
+ **({"params": params} if params else {}),
1549
+ )
1550
+ body = resp.json()
1551
+ return {
1552
+ "has_reservation": bool(body.get("has_reservation", False)),
1553
+ "max_total_gpus": int(body.get("max_total_gpus", 0)),
1554
+ "reserved_gpus": int(body.get("reserved_gpus", 0)),
1555
+ "in_use_gpus": int(body.get("in_use_gpus", 0)),
1556
+ "pending_gpus": int(body.get("pending_gpus", 0)),
1557
+ "available_gpus": int(body.get("available_gpus", 0)),
1558
+ }
1559
+
1560
+ @_track_operation("get_experiment_run")
1561
+ def get_experiment_run(self, job_id: str) -> dict[str, str]:
1562
+ """Return ``{experiment_name, experiment_run_name}`` for ``job_id``.
1563
+
1564
+ Backed by the endpoint ``/cortex-training/{job_id}/experiment-run``.
1565
+ """
1566
+ resp = self._send("GET", f"{self._prefix}/{job_id}/experiment-run")
1567
+ return resp.json()
1568
+
1569
+ def _query_sql_row(self, statement: str) -> list[Any]:
1570
+ """Execute a synchronous SQL statement and return its first row."""
1571
+ resp = self._send(
1572
+ "POST",
1573
+ f"{self.base_url}/api/v2/statements",
1574
+ json={"statement": statement, "database": self.database, "schema": self.schema},
1575
+ )
1576
+ rows = resp.json().get("data")
1577
+ if not isinstance(rows, list) or not rows or not isinstance(rows[0], list) or not rows[0]:
1578
+ raise ValueError(f"SQL query returned no rows: {statement}")
1579
+ return rows[0]
1580
+
1581
+ def _experiment_run_uri(self, job_id: str) -> tuple[str, str]:
1582
+ """Return the experiment run's ``snow://`` URI and run name."""
1583
+ run = self.get_experiment_run(job_id)
1584
+ try:
1585
+ experiment_name = run["experiment_name"]
1586
+ run_name = run["experiment_run_name"]
1587
+ except KeyError as exc:
1588
+ raise ValueError(f"experiment-run response missing field: {exc.args[0]}") from exc
1589
+ if not isinstance(experiment_name, str) or not experiment_name:
1590
+ raise ValueError("experiment-run response has invalid experiment_name")
1591
+ if not isinstance(run_name, str) or not run_name:
1592
+ raise ValueError("experiment-run response has invalid experiment_run_name")
1593
+ return (
1594
+ f"snow://experiment/{experiment_name}/versions/{run_name}/",
1595
+ run_name,
1596
+ )
1597
+
1598
+ def _open_experiment_artifact_connection(self) -> Any:
1599
+ """Open a connector session for Snowflake experiment artifact LIST/GET."""
1600
+ config = self._artifact_connection_config
1601
+ if config is None:
1602
+ raise RuntimeError(
1603
+ "experiment artifact download requires a PAT-authenticated client"
1604
+ )
1605
+ user, account, role = self._query_sql_row(
1606
+ "SELECT CURRENT_USER(), CURRENT_ACCOUNT_NAME(), CURRENT_ROLE()"
1607
+ )
1608
+ if not isinstance(user, str) or not user:
1609
+ raise ValueError("SQL identity response missing current user")
1610
+ if not isinstance(account, str) or not account:
1611
+ raise ValueError("SQL identity response missing current account")
1612
+
1613
+ import snowflake.connector
1614
+
1615
+ kwargs: dict[str, Any] = {
1616
+ "host": config["host"],
1617
+ "account": account,
1618
+ "user": user,
1619
+ "authenticator": "PROGRAMMATIC_ACCESS_TOKEN",
1620
+ "token": config["pat"],
1621
+ "database": self.database,
1622
+ "schema": self.schema,
1623
+ }
1624
+ if isinstance(role, str) and role:
1625
+ kwargs["role"] = role
1626
+ return snowflake.connector.connect(**kwargs)
1627
+
1628
+ @staticmethod
1629
+ def _list_experiment_artifacts(
1630
+ connection: Any,
1631
+ run_uri: str,
1632
+ run_name: str,
1633
+ artifact_root: str,
1634
+ ) -> list[str]:
1635
+ """List safe run-relative file paths below ``artifact_root``."""
1636
+ root = artifact_root.strip("/") + "/"
1637
+ _validate_artifact_relative_path(root.removesuffix("/"))
1638
+ cursor = connection.cursor()
1639
+ try:
1640
+ rows = cursor.execute(
1641
+ f"LIST {_sql_string_literal(run_uri + root)}"
1642
+ ).fetchall()
1643
+ finally:
1644
+ cursor.close()
1645
+
1646
+ run_prefix = f"/versions/{run_name}/"
1647
+ paths: list[str] = []
1648
+ for row in rows:
1649
+ name = row[0] if row else None
1650
+ if not isinstance(name, str) or not name.startswith(run_prefix):
1651
+ raise ValueError(
1652
+ f"experiment artifact LIST returned an unexpected path: {name!r}"
1653
+ )
1654
+ relative_path = _validate_artifact_relative_path(
1655
+ name.removeprefix(run_prefix)
1656
+ )
1657
+ if not relative_path.startswith(root):
1658
+ raise ValueError(
1659
+ "experiment artifact LIST returned a file outside the "
1660
+ f"requested root {root!r}: {relative_path!r}"
1661
+ )
1662
+ paths.append(relative_path)
1663
+ return sorted(set(paths))
1664
+
1665
+ @staticmethod
1666
+ def _download_experiment_artifact(
1667
+ connection: Any,
1668
+ run_uri: str,
1669
+ relative_path: str,
1670
+ target_dir: Path,
1671
+ ) -> Path:
1672
+ """Download one exact experiment artifact and return its local path."""
1673
+ relative_path = _validate_artifact_relative_path(relative_path)
1674
+ target_dir.mkdir(parents=True, exist_ok=True)
1675
+ target_uri = target_dir.resolve().as_uri() + "/"
1676
+ cursor = connection.cursor()
1677
+ try:
1678
+ cursor.execute(
1679
+ "GET "
1680
+ f"{_sql_string_literal(run_uri + relative_path)} "
1681
+ f"{_sql_string_literal(target_uri)}"
1682
+ ).fetchall()
1683
+ finally:
1684
+ cursor.close()
1685
+ downloaded = target_dir / PurePosixPath(relative_path).name
1686
+ if not downloaded.is_file():
1687
+ raise RuntimeError(
1688
+ f"Snowflake GET did not create expected artifact {downloaded}"
1689
+ )
1690
+ return downloaded
1691
+
1692
+ @_track_operation("fetch_execution_logs")
1693
+ def fetch_execution_logs(self, job_id: str) -> list[dict[str, str]]:
1694
+ """Download every log file under the job's experiment run stage.
1695
+
1696
+ Returns a list of
1697
+ ``{"sub_job_id", "filename", "artifact_uri", "content"}``
1698
+ entries, one per object found under a ``_logs/<sub_job_id>/`` subtree
1699
+ in the run's stage. Filenames such as ``execution.jsonl`` and
1700
+ ``server.log`` are both included; bodies are decoded as UTF-8.
1701
+
1702
+ ``artifact_uri`` is the logical ``snow://`` URI. Files are retrieved
1703
+ through Snowflake's supported experiment artifact ``LIST``/``GET``
1704
+ interface; no object-store credentials are exposed to the caller.
1705
+ """
1706
+ run_uri, run_name = self._experiment_run_uri(job_id)
1707
+ connection = self._open_experiment_artifact_connection()
1708
+ try:
1709
+ paths: list[str] = []
1710
+ for artifact_root in ("_logs", "checkpoints/_logs"):
1711
+ paths.extend(
1712
+ self._list_experiment_artifacts(
1713
+ connection, run_uri, run_name, artifact_root
1714
+ )
1715
+ )
1716
+
1717
+ results: list[dict[str, str]] = []
1718
+ with tempfile.TemporaryDirectory(prefix="cortex-training-execution-logs-") as temp:
1719
+ temp_root = Path(temp)
1720
+ for relative_path in sorted(set(paths)):
1721
+ if relative_path.startswith("_logs/"):
1722
+ after_logs = relative_path.removeprefix("_logs/")
1723
+ elif relative_path.startswith("checkpoints/_logs/"):
1724
+ after_logs = relative_path.removeprefix("checkpoints/_logs/")
1725
+ else:
1726
+ continue
1727
+ sub_job_id, separator, filename = after_logs.partition("/")
1728
+ if (
1729
+ not separator
1730
+ or not filename
1731
+ or not (
1732
+ sub_job_id == job_id
1733
+ or sub_job_id.startswith(f"{job_id}:")
1734
+ )
1735
+ or sub_job_id in {".", ".."}
1736
+ or "\\" in sub_job_id
1737
+ ):
1738
+ continue
1739
+ downloaded = self._download_experiment_artifact(
1740
+ connection,
1741
+ run_uri,
1742
+ relative_path,
1743
+ temp_root,
1744
+ )
1745
+ artifact_uri = run_uri + relative_path
1746
+ try:
1747
+ content = downloaded.read_text(encoding="utf-8")
1748
+ finally:
1749
+ downloaded.unlink(missing_ok=True)
1750
+ results.append(
1751
+ {
1752
+ "sub_job_id": sub_job_id,
1753
+ "filename": filename,
1754
+ "artifact_uri": artifact_uri,
1755
+ "content": content,
1756
+ }
1757
+ )
1758
+ return results
1759
+ finally:
1760
+ connection.close()
1761
+
1762
+ def _download_gzip_artifacts(
1763
+ self,
1764
+ job_id: str,
1765
+ output_dir: str | os.PathLike[str],
1766
+ *,
1767
+ artifact_name: str,
1768
+ chunk_pattern: re.Pattern[str],
1769
+ destination_name: str,
1770
+ temporary_prefix: str,
1771
+ ) -> list[dict[str, Any]]:
1772
+ """Reconstruct staged gzip chunks into one atomic file per sub-job."""
1773
+ run_uri, run_name = self._experiment_run_uri(job_id)
1774
+ connection = self._open_experiment_artifact_connection()
1775
+ root = Path(output_dir).expanduser()
1776
+ downloads: dict[str, dict[str, Any]] = {}
1777
+ artifact_roots = (f"_{artifact_name}", f"checkpoints/_{artifact_name}")
1778
+ try:
1779
+ paths_by_root = {
1780
+ artifact_root: self._list_experiment_artifacts(
1781
+ connection, run_uri, run_name, artifact_root
1782
+ )
1783
+ for artifact_root in artifact_roots
1784
+ }
1785
+ chunks_by_sub_job: dict[str, dict[str, Any]] = {}
1786
+ for artifact_root, paths in paths_by_root.items():
1787
+ prefix = artifact_root + "/"
1788
+ for relative_path in paths:
1789
+ after_root = relative_path.removeprefix(prefix)
1790
+ sub_job_id, separator, filename = after_root.partition("/")
1791
+ if (
1792
+ not separator
1793
+ or sub_job_id in {"", ".", ".."}
1794
+ or not (
1795
+ sub_job_id == job_id
1796
+ or sub_job_id.startswith(f"{job_id}:")
1797
+ )
1798
+ or "/" in filename
1799
+ or "\\" in sub_job_id
1800
+ or chunk_pattern.fullmatch(filename) is None
1801
+ ):
1802
+ continue
1803
+ chunks = chunks_by_sub_job.get(sub_job_id)
1804
+ if chunks is None:
1805
+ chunks = {"artifact_root": artifact_root, "paths": []}
1806
+ chunks_by_sub_job[sub_job_id] = chunks
1807
+ elif chunks["artifact_root"] != artifact_root:
1808
+ raise ValueError(
1809
+ f"{artifact_name} chunks for {sub_job_id!r} appear "
1810
+ "under multiple artifact roots"
1811
+ )
1812
+ chunks["paths"].append(relative_path)
1813
+
1814
+ with tempfile.TemporaryDirectory(
1815
+ prefix=f"cortex-training-{artifact_name}-artifacts-"
1816
+ ) as temp:
1817
+ artifact_temp = Path(temp)
1818
+ for sub_job_id in sorted(chunks_by_sub_job):
1819
+ chunk_paths = sorted(chunks_by_sub_job[sub_job_id]["paths"])
1820
+ destination = root / sub_job_id / destination_name
1821
+ destination.parent.mkdir(parents=True, exist_ok=True)
1822
+ output = tempfile.NamedTemporaryFile(
1823
+ mode="wb",
1824
+ dir=str(destination.parent),
1825
+ prefix=temporary_prefix,
1826
+ suffix=".tmp",
1827
+ delete=False,
1828
+ )
1829
+ state = {
1830
+ "destination": destination,
1831
+ "temporary": Path(output.name),
1832
+ "output": output,
1833
+ "chunk_count": 0,
1834
+ "first_path": chunk_paths[0],
1835
+ "last_path": chunk_paths[-1],
1836
+ }
1837
+ downloads[sub_job_id] = state
1838
+ for relative_path in chunk_paths:
1839
+ downloaded = self._download_experiment_artifact(
1840
+ connection,
1841
+ run_uri,
1842
+ relative_path,
1843
+ artifact_temp,
1844
+ )
1845
+ try:
1846
+ with downloaded.open("rb") as compressed:
1847
+ with gzip.GzipFile(fileobj=compressed, mode="rb") as source:
1848
+ shutil.copyfileobj(
1849
+ source,
1850
+ output,
1851
+ length=_STREAM_COPY_BUFFER_BYTES,
1852
+ )
1853
+ finally:
1854
+ downloaded.unlink(missing_ok=True)
1855
+ state["chunk_count"] += 1
1856
+
1857
+ results: list[dict[str, Any]] = []
1858
+ for sub_job_id in sorted(downloads):
1859
+ state = downloads[sub_job_id]
1860
+ output = state["output"]
1861
+ output.flush()
1862
+ os.fsync(output.fileno())
1863
+ output.close()
1864
+ os.replace(state["temporary"], state["destination"])
1865
+ results.append(
1866
+ {
1867
+ "sub_job_id": sub_job_id,
1868
+ "filename": state["destination"].name,
1869
+ "saved_path": str(state["destination"]),
1870
+ "chunk_count": state["chunk_count"],
1871
+ "first_artifact_uri": run_uri + state["first_path"],
1872
+ "last_artifact_uri": run_uri + state["last_path"],
1873
+ }
1874
+ )
1875
+ return results
1876
+ except BaseException:
1877
+ for state in downloads.values():
1878
+ output = state["output"]
1879
+ if not output.closed:
1880
+ output.close()
1881
+ state["temporary"].unlink(missing_ok=True)
1882
+ raise
1883
+ finally:
1884
+ connection.close()
1885
+
1886
+ def download_stdout_logs(
1887
+ self, job_id: str, output_dir: str | os.PathLike[str]
1888
+ ) -> list[dict[str, Any]]:
1889
+ """Reconstruct sealed console artifacts into one file per sub-job."""
1890
+ return self._download_gzip_artifacts(
1891
+ job_id,
1892
+ output_dir,
1893
+ artifact_name="stdout",
1894
+ chunk_pattern=_STDOUT_CHUNK_RE,
1895
+ destination_name="stdout.log",
1896
+ temporary_prefix=".stdout-",
1897
+ )
1898
+
1899
+ def download_metrics(
1900
+ self, job_id: str, output_dir: str | os.PathLike[str]
1901
+ ) -> list[dict[str, Any]]:
1902
+ """Reconstruct staged GPU metric chunks into one JSONL file per sub-job."""
1903
+ return self._download_gzip_artifacts(
1904
+ job_id,
1905
+ output_dir,
1906
+ artifact_name="metrics",
1907
+ chunk_pattern=_GPU_METRICS_CHUNK_RE,
1908
+ destination_name="gpu.jsonl",
1909
+ temporary_prefix=".gpu-metrics-",
1910
+ )
1911
+
1912
+ # ─── Data-plane async operations ─────────────────────────────────────
1913
+
1914
+ # Maximum payload size for forward-backward requests.
1915
+ # Payloads beyond this limit will fail fast with a message.
1916
+ _MAX_FWD_BWD_BYTES = 60 * 1024 * 1024 # 60 MB
1917
+
1918
+ # Maximum payload size for generate / generate_stream request bodies.
1919
+ # The encoded JSON body must stay under this cap; oversized requests fail
1920
+ # fast client-side with a clear message.
1921
+ _MAX_GENERATE_BYTES = 60 * 1024 * 1024 # 60 MB
1922
+
1923
+ def _check_generate_payload(self, op: str, body: bytes) -> None:
1924
+ if len(body) > self._MAX_GENERATE_BYTES:
1925
+ size_mb = len(body) / (1024 * 1024)
1926
+ raise ValueError(
1927
+ f"{op} payload is {size_mb:.1f} MB, which exceeds the "
1928
+ "maximum allowed size of "
1929
+ f"{self._MAX_GENERATE_BYTES / (1024 * 1024):.0f} MB. "
1930
+ "Reduce the number of prompts or shorten each prompt to "
1931
+ "bring the serialized request under the limit."
1932
+ )
1933
+
1934
+ def _resolve_sampling_max_seq_len(self, job_id: str) -> int | None:
1935
+ """Return the sampling sub-job's ``max_seq_len``, or ``None`` if unknown.
1936
+
1937
+ The value is read from the job's sampling ``inference_config`` (the same
1938
+ number the backend launches vLLM with as ``max_model_len``) and cached
1939
+ per ``job_id``. A successful lookup — including a definitive "no sampling
1940
+ sub-job" miss — is cached; a transient ``get_job`` failure is not, so a
1941
+ later call retries rather than disabling validation permanently.
1942
+ """
1943
+ if job_id in self._sampling_max_seq_len:
1944
+ return self._sampling_max_seq_len[job_id]
1945
+ try:
1946
+ job = self.get_job(job_id)
1947
+ except Exception:
1948
+ # Don't let a transient lookup failure break generate, and don't
1949
+ # cache the miss — the real request will surface any hard error.
1950
+ return None
1951
+ max_seq_len: int | None = None
1952
+ for sub in job.get("sub_jobs", []) or []:
1953
+ if not isinstance(sub, dict):
1954
+ continue
1955
+ cfg = sub.get("inference_config")
1956
+ if not isinstance(cfg, dict):
1957
+ continue
1958
+ raw = cfg.get("max_seq_len")
1959
+ # Proto numbers can arrive as floats (e.g. 2048.0); coerce to int.
1960
+ if isinstance(raw, (int, float)) and raw > 0:
1961
+ max_seq_len = int(raw)
1962
+ break
1963
+ self._sampling_max_seq_len[job_id] = max_seq_len
1964
+ return max_seq_len
1965
+
1966
+ def _check_prompt_lengths(self, op: str, job_id: str, prompts: list[str | list[int]]) -> None:
1967
+ """Fail fast when a pre-tokenized prompt cannot fit the sampling window.
1968
+
1969
+ Mirrors vLLM's decoder-prompt check (``vllm/v1/engine/input_processor``):
1970
+ a generation prompt must be strictly shorter than ``max_model_len`` so
1971
+ at least one output token fits, i.e. it is rejected when
1972
+ ``len(prompt_token_ids) >= max_seq_len``. Only pre-tokenized prompts
1973
+ (``list[int]``) are validated here, since their token count is exact;
1974
+ string prompts would require replicating the server tokenizer and are
1975
+ left to fail server-side.
1976
+ """
1977
+ if not any(isinstance(p, list) for p in prompts):
1978
+ return
1979
+ max_seq_len = self._resolve_sampling_max_seq_len(job_id)
1980
+ if max_seq_len is None:
1981
+ return
1982
+ for i, prompt in enumerate(prompts):
1983
+ if not isinstance(prompt, list):
1984
+ continue
1985
+ n = len(prompt)
1986
+ if n >= max_seq_len:
1987
+ raise ValueError(
1988
+ f"{op} prompt at index {i} has {n} tokens, which does not "
1989
+ f"fit the sampling job's max_seq_len of {max_seq_len}: a "
1990
+ "prompt must be shorter than max_seq_len to leave room for "
1991
+ "at least one generated token (vLLM rejects "
1992
+ "len(prompt) >= max_model_len). Shorten the prompt to at "
1993
+ f"most {max_seq_len - 1} tokens, or recreate the job with a "
1994
+ "larger max_seq_len."
1995
+ )
1996
+
1997
+ def _post_octet_request_chunks(
1998
+ self,
1999
+ *,
2000
+ job_id: str,
2001
+ path_suffix: str,
2002
+ operation: str,
2003
+ frame: bytes,
2004
+ max_bytes: int,
2005
+ force_chunk: bool = False,
2006
+ allow_group_restart: bool = False,
2007
+ debug_context: dict[str, Any] | None = None,
2008
+ ) -> dict:
2009
+ chunks = wire.encode_byte_chunks(
2010
+ frame,
2011
+ kind="request",
2012
+ operation=operation,
2013
+ max_bytes=max_bytes,
2014
+ force_chunk=force_chunk,
2015
+ )
2016
+ group_metadata = wire.read_byte_chunk_metadata(chunks[0])
2017
+ chunk_group_id = (
2018
+ str(group_metadata["chunk_group_id"])
2019
+ if group_metadata is not None and group_metadata.get("chunk_group_id")
2020
+ else None
2021
+ )
2022
+ group_restarts = 0
2023
+ idx = 0
2024
+ final_body: dict | None = None
2025
+ while idx < len(chunks):
2026
+ chunk = chunks[idx]
2027
+ try:
2028
+ resp = self._send(
2029
+ "POST",
2030
+ f"{self._prefix}/{job_id}/{path_suffix}",
2031
+ data=chunk,
2032
+ headers={"Content-Type": "application/octet-stream"},
2033
+ retry_on=(_is_chunk_post_transient if allow_group_restart else _is_transient),
2034
+ debug_context=debug_context,
2035
+ )
2036
+ except requests.exceptions.HTTPError as exc:
2037
+ detail = _chunk_group_error_detail(exc.response)
2038
+ if detail is None:
2039
+ raise
2040
+ code = detail.get("code")
2041
+ if allow_group_restart and code == _CHUNK_GROUP_RESTART_REQUIRED and group_restarts < 1:
2042
+ group_restarts += 1
2043
+ idx = 0
2044
+ final_body = None
2045
+ logger.warning(
2046
+ "%s chunk group %s lost server state; replaying once from chunk 0",
2047
+ operation,
2048
+ chunk_group_id,
2049
+ )
2050
+ continue
2051
+ error_type = (
2052
+ ChunkGroupRestartError if code == _CHUNK_GROUP_RESTART_REQUIRED else ChunkGroupConflictError
2053
+ )
2054
+ raise error_type(
2055
+ str(detail.get("message") or code),
2056
+ response=exc.response,
2057
+ detail=detail,
2058
+ ) from exc
2059
+
2060
+ body = resp.json()
2061
+ if idx < len(chunks) - 1:
2062
+ if isinstance(body, dict) and body.get("request_id"):
2063
+ raise RuntimeError(f"{operation} chunk {idx} unexpectedly returned request_id")
2064
+ else:
2065
+ final_body = body
2066
+ idx += 1
2067
+ if final_body is None:
2068
+ raise RuntimeError(f"{operation} produced no request body")
2069
+ return final_body
2070
+
2071
+ @_track_operation("forward_backward")
2072
+ def forward_backward(self, job_id: str, data: bytes) -> str:
2073
+ """Submit a forward+backward pass. Returns request_id."""
2074
+ self._fwd_bwd_send_count += 1
2075
+ call = self._fwd_bwd_send_count
2076
+ debug_context = {
2077
+ "phase": "submit",
2078
+ "call": call,
2079
+ "job_id": job_id,
2080
+ "payload_bytes": len(data),
2081
+ }
2082
+ debug_label = self._debug_context_label(debug_context)
2083
+ logger.debug("%s payload total bytes: %d", debug_label, len(data))
2084
+ body = self._post_octet_request_chunks(
2085
+ job_id=job_id,
2086
+ path_suffix="forward-backward",
2087
+ operation="fwd-bwd",
2088
+ frame=data,
2089
+ max_bytes=self._MAX_FWD_BWD_BYTES,
2090
+ force_chunk=True,
2091
+ allow_group_restart=True,
2092
+ debug_context=debug_context,
2093
+ )
2094
+ request_id = body["request_id"]
2095
+ request_debug_context = {
2096
+ "phase": "poll",
2097
+ "call": call,
2098
+ "job_id": job_id,
2099
+ "request_id": request_id,
2100
+ "payload_bytes": len(data),
2101
+ }
2102
+ self._fwd_bwd_request_debug[request_id] = request_debug_context
2103
+ logger.debug(
2104
+ "%s submitted request_id=%s body=%s",
2105
+ debug_label,
2106
+ request_id,
2107
+ self._debug_json_summary(body),
2108
+ )
2109
+ return request_id
2110
+
2111
+ @_track_operation("generate")
2112
+ def generate(
2113
+ self,
2114
+ job_id: str,
2115
+ prompts: list[str | list[int]],
2116
+ sampling_params: dict | list[dict | None] | None = None,
2117
+ routing_key: str | list[str | None] | None = None,
2118
+ strict: bool | None = None,
2119
+ ) -> str:
2120
+ """Submit a generate request to a sampling sub-job. Returns request_id.
2121
+
2122
+ The typed fields are serialized as a DSSST1 safetensors frame and sent
2123
+ as ``application/octet-stream``. Oversized frames are byte-chunked
2124
+ across multiple POSTs; the final POST returns the pollable request id.
2125
+ Poll with :meth:`poll_request`.
2126
+
2127
+ Prompt-length validation is asymmetric by input type:
2128
+
2129
+ * **Pre-tokenized prompts** (``list[int]``) are checked client-side
2130
+ against the sampling sub-job's ``max_seq_len`` and fail fast with a
2131
+ ``ValueError`` when ``len(prompt) >= max_seq_len`` — the token count
2132
+ is exact, so this never rejects a prompt vLLM would accept.
2133
+ * **String prompts** are *not* validated here. Counting their tokens
2134
+ would require replicating the server tokenizer (including special
2135
+ tokens), so an over-long string instead fails server-side in vLLM
2136
+ with a ``maximum model length`` error rather than client-side.
2137
+ """
2138
+ payload: dict = {"prompts": prompts}
2139
+ if sampling_params is not None:
2140
+ payload["sampling_params"] = sampling_params
2141
+ if routing_key is not None:
2142
+ payload["routing_key"] = routing_key
2143
+ if strict is not None:
2144
+ payload["strict"] = strict
2145
+ self._check_prompt_lengths("generate", job_id, prompts)
2146
+ body = wire.dumps(
2147
+ payload,
2148
+ metadata={"response_options": {"format": "dssst1", "delivery": "chunked"}},
2149
+ )
2150
+ response = self._post_octet_request_chunks(
2151
+ job_id=job_id,
2152
+ path_suffix="generate",
2153
+ operation="generate",
2154
+ frame=body,
2155
+ max_bytes=self._MAX_GENERATE_BYTES,
2156
+ )
2157
+ request_id = response["request_id"]
2158
+ self._generate_request_ids.add(request_id)
2159
+ return request_id
2160
+
2161
+ @_track_operation("generate_stream")
2162
+ def generate_stream(
2163
+ self,
2164
+ job_id: str,
2165
+ prompts: list[str | list[int]],
2166
+ sampling_params: dict | list[dict | None] | None = None,
2167
+ routing_key: str | list[str | None] | None = None,
2168
+ strict: bool | None = None,
2169
+ ) -> dict:
2170
+ """Start a streaming generate request. Returns the response body.
2171
+
2172
+ Progress is read with :meth:`get_request_status` using the returned
2173
+ ``request_id``. Cancellation uses :meth:`cancel_request`.
2174
+
2175
+ Prompt-length validation matches :meth:`generate`: pre-tokenized
2176
+ (``list[int]``) prompts are checked client-side against the sampling
2177
+ sub-job's ``max_seq_len`` and fail fast, while over-long string
2178
+ prompts are left to fail server-side in vLLM (see :meth:`generate`).
2179
+ """
2180
+ payload: dict = {"prompts": prompts}
2181
+ if sampling_params is not None:
2182
+ payload["sampling_params"] = sampling_params
2183
+ if routing_key is not None:
2184
+ payload["routing_key"] = routing_key
2185
+ if strict is not None:
2186
+ payload["strict"] = strict
2187
+ self._check_prompt_lengths("generate_stream", job_id, prompts)
2188
+ body = json.dumps(payload).encode("utf-8")
2189
+ self._check_generate_payload("generate_stream", body)
2190
+ resp = self._send(
2191
+ "POST",
2192
+ f"{self._prefix}/{job_id}/generate-stream",
2193
+ data=body,
2194
+ headers={"Content-Type": "application/octet-stream"},
2195
+ )
2196
+ return resp.json()
2197
+
2198
+ @_track_operation("step")
2199
+ def step(self, job_id: str, learning_rate: float | None = None) -> str:
2200
+ """Submit an optimizer step. Returns request_id."""
2201
+ body: dict = {}
2202
+ if learning_rate is not None:
2203
+ body["learning_rate"] = learning_rate
2204
+ resp = self._send("POST", f"{self._prefix}/{job_id}/step", json=body)
2205
+ return resp.json()["request_id"]
2206
+
2207
+ @_track_operation("save")
2208
+ def save(
2209
+ self,
2210
+ job_id: str,
2211
+ checkpoint_id: str | None = None,
2212
+ checkpoint_type: str | None = None,
2213
+ ) -> str:
2214
+ """Submit a checkpoint save. Returns request_id."""
2215
+ body: dict = {}
2216
+ if checkpoint_id is not None:
2217
+ body["checkpoint_id"] = checkpoint_id
2218
+ if checkpoint_type is not None:
2219
+ normalized_type = checkpoint_type.lower()
2220
+ if normalized_type not in ("resumable", "weights-only"):
2221
+ raise ValueError("checkpoint_type must be 'resumable' or 'weights-only'")
2222
+ body["checkpoint_type"] = normalized_type
2223
+ resp = self._send("POST", f"{self._prefix}/{job_id}/save", json=body)
2224
+ return resp.json()["request_id"]
2225
+
2226
+ @_track_operation("load")
2227
+ def load(
2228
+ self,
2229
+ job_id: str,
2230
+ checkpoint_id: str,
2231
+ source_job_id: str | None = None,
2232
+ *,
2233
+ target_sub_job_id: str | None = None,
2234
+ ) -> str:
2235
+ """Submit a checkpoint load into an existing job. Returns request_id.
2236
+
2237
+ Args:
2238
+ job_id: The job to load the checkpoint into.
2239
+ checkpoint_id: Checkpoint identifier (from a prior save).
2240
+ source_job_id: Optional. Load from another job's checkpoint store.
2241
+ target_sub_job_id: Optional. Route the load to a specific training
2242
+ sub-job. Format: ``"{job_id}:training:{index}"``. Omit to use
2243
+ the session's default training sub-job.
2244
+
2245
+ Returns:
2246
+ request_id for polling via ``poll_request()``.
2247
+
2248
+ **When to use target_sub_job_id:**
2249
+
2250
+ A job has at most one training sub-job, so omitting this routes to that
2251
+ sub-job. Pass it when you want explicit routing rather than relying on
2252
+ the server's default resolution.
2253
+
2254
+ **Discovering sub-job IDs:**
2255
+
2256
+ .. code-block:: python
2257
+
2258
+ job = client.get_job(job_id)
2259
+ for sj in job["sub_jobs"]:
2260
+ if sj["job_type"] == "training":
2261
+ sub_job_id = sj["sub_job_id"]
2262
+ n_gpus = sj["training_config"]["n_gpus"]
2263
+ print(f"Training sub-job {sub_job_id}: {n_gpus} GPUs")
2264
+
2265
+ **DP size compatibility:**
2266
+
2267
+ If the target sub-job has a different DP size (``n_gpus``) than the
2268
+ checkpoint was saved from, you **must** have created the job with
2269
+ ``load_optimizer_states=False`` in the ``SubJobConfig``. The optimizer
2270
+ states are DP-sharded and cannot be resized. This setting is configured
2271
+ at job creation time and cannot be changed at runtime.
2272
+
2273
+ **Server validation:**
2274
+
2275
+ The server validates that ``target_sub_job_id`` belongs to this session
2276
+ (returns 400 if not) and is a training sub-job (returns 501 for
2277
+ non-training sub-jobs like sampling).
2278
+ """
2279
+ body: dict = {"checkpoint_id": checkpoint_id}
2280
+ if source_job_id is not None:
2281
+ body["source_job_id"] = source_job_id
2282
+ if target_sub_job_id is not None:
2283
+ body["target_sub_job_id"] = target_sub_job_id
2284
+ resp = self._send("POST", f"{self._prefix}/{job_id}/load", json=body)
2285
+ return resp.json()["request_id"]
2286
+
2287
+ def _operation(
2288
+ self,
2289
+ job_id: str,
2290
+ operation_type: str,
2291
+ *,
2292
+ payload: dict | bytes | bytearray | memoryview | None = None,
2293
+ sub_job_id: str | None = None,
2294
+ sub_job_type: str | None = None,
2295
+ ) -> dict:
2296
+ """Submit a generic data-plane operation to the routed sub-job."""
2297
+ body: dict = {"operation_type": operation_type}
2298
+ if sub_job_id is not None:
2299
+ body["sub_job_id"] = sub_job_id
2300
+ if sub_job_type is not None:
2301
+ body["sub_job_type"] = sub_job_type
2302
+ if isinstance(payload, (bytes, bytearray, memoryview)):
2303
+ payload = {
2304
+ "payload_b64": base64.b64encode(bytes(payload)).decode("ascii"),
2305
+ "content_type": "application/octet-stream",
2306
+ }
2307
+ if payload is not None:
2308
+ body["payload"] = payload
2309
+ print(f"***** POST operation {self._prefix}/{job_id}/operation {body=}")
2310
+ resp = self._send("POST", f"{self._prefix}/{job_id}/operation", json=body)
2311
+ return resp.json()
2312
+
2313
+ @_track_operation("forward")
2314
+ def forward(
2315
+ self,
2316
+ job_id: str,
2317
+ payload: dict | bytes | bytearray | memoryview | None = None,
2318
+ *,
2319
+ sub_job_id: str | None = None,
2320
+ sub_job_type: str | None = None,
2321
+ ) -> dict:
2322
+ """Submit a generic forward operation. Returns the response body."""
2323
+ if isinstance(payload, (bytes, bytearray, memoryview)) and len(payload) > self._MAX_FWD_BWD_BYTES:
2324
+ size_mb = len(payload) / (1024 * 1024)
2325
+ raise ValueError(
2326
+ f"forward payload is {size_mb:.1f} MB, which exceeds the "
2327
+ "maximum allowed size of "
2328
+ f"{self._MAX_FWD_BWD_BYTES / (1024 * 1024):.0f} MB. "
2329
+ "Reduce the batch size or sequence length in your training "
2330
+ "configuration to bring the serialized input batch under "
2331
+ "the limit."
2332
+ )
2333
+ return self._operation(
2334
+ job_id,
2335
+ "forward",
2336
+ payload=payload,
2337
+ sub_job_id=sub_job_id,
2338
+ sub_job_type=sub_job_type,
2339
+ )
2340
+
2341
+ def fwd(
2342
+ self,
2343
+ job_id: str,
2344
+ payload: dict | bytes | bytearray | memoryview | None = None,
2345
+ *,
2346
+ sub_job_id: str | None = None,
2347
+ sub_job_type: str | None = None,
2348
+ ) -> dict:
2349
+ """Compatibility alias for :meth:`forward`."""
2350
+ return self.forward(
2351
+ job_id,
2352
+ payload=payload,
2353
+ sub_job_id=sub_job_id,
2354
+ sub_job_type=sub_job_type,
2355
+ )
2356
+
2357
+ def fwd_no_grad(
2358
+ self,
2359
+ job_id: str,
2360
+ payload: dict | bytes | bytearray | memoryview | None = None,
2361
+ *,
2362
+ sub_job_id: str | None = None,
2363
+ sub_job_type: str | None = None,
2364
+ ) -> dict:
2365
+ """Compatibility alias for :meth:`forward`."""
2366
+ return self.forward(
2367
+ job_id,
2368
+ payload=payload,
2369
+ sub_job_id=sub_job_id,
2370
+ sub_job_type=sub_job_type,
2371
+ )
2372
+
2373
+ @_track_operation("weight_sync")
2374
+ def weight_sync(
2375
+ self,
2376
+ job_id: str,
2377
+ source_sub_job_id: str,
2378
+ target_sub_job_ids: list[str],
2379
+ *,
2380
+ weight_format: str | None = None,
2381
+ sub_job_id: str | None = None,
2382
+ sub_job_type: str | None = None,
2383
+ ) -> str:
2384
+ """Sync model weights from a training sub-job into one or more sampling
2385
+ sub-jobs (used in RL training loops). Returns request_id.
2386
+
2387
+ Multi-sub-job sessions require the operation envelope to include a
2388
+ routing hint; by default the operation is routed through the source
2389
+ training sub-job.
2390
+
2391
+ Args:
2392
+ job_id: The job owning both sub-jobs.
2393
+ source_sub_job_id: Training sub-job to read weights from, e.g.
2394
+ ``f"{job_id}:training:0"``.
2395
+ target_sub_job_ids: Sampling sub-jobs to update, e.g.
2396
+ ``[f"{job_id}:sampling:0"]``.
2397
+ weight_format: ``"vllm"`` (server default) or ``"hf"`` for full
2398
+ weights, or ``"lora"`` to broadcast only the adapter tensors.
2399
+
2400
+ Example::
2401
+
2402
+ request_id = client.weight_sync(
2403
+ job_id,
2404
+ source_sub_job_id=f"{job_id}:training:0",
2405
+ target_sub_job_ids=[f"{job_id}:sampling:0"],
2406
+ weight_format="lora",
2407
+ )
2408
+ client.poll_request(job_id, request_id)
2409
+ """
2410
+ body = {
2411
+ "source_sub_job_id": source_sub_job_id, # training
2412
+ "target_sub_job_ids": list(target_sub_job_ids), # sampling
2413
+ }
2414
+ if weight_format is not None:
2415
+ # "lora" broadcasts only the trained adapter tensors.
2416
+ body["weight_format"] = weight_format
2417
+ return self._operation(
2418
+ job_id,
2419
+ "weight-sync",
2420
+ payload=body,
2421
+ sub_job_id=sub_job_id or source_sub_job_id, # training
2422
+ sub_job_type=(sub_job_type if sub_job_type is not None else "training"),
2423
+ )["request_id"]
2424
+
2425
+ @_track_operation("bootstrap_router_replay")
2426
+ def bootstrap_router_replay(
2427
+ self,
2428
+ job_id: str,
2429
+ source_sub_job_id: str,
2430
+ target_sub_job_id: str,
2431
+ *,
2432
+ max_cache_bytes: int | None = None,
2433
+ sub_job_id: str | None = None,
2434
+ sub_job_type: str | None = None,
2435
+ ) -> dict:
2436
+ """Bootstrap router replay from one sub-job to another."""
2437
+ payload: dict = {
2438
+ "source_sub_job_id": source_sub_job_id,
2439
+ "target_sub_job_id": target_sub_job_id,
2440
+ }
2441
+ if max_cache_bytes is not None:
2442
+ payload["max_cache_bytes"] = max_cache_bytes
2443
+ return self._operation(
2444
+ job_id,
2445
+ "bootstrap-router-replay",
2446
+ payload=payload,
2447
+ sub_job_id=sub_job_id,
2448
+ sub_job_type=sub_job_type,
2449
+ )
2450
+
2451
+ @_track_operation("router_replay_discard")
2452
+ def router_replay_discard(
2453
+ self,
2454
+ job_id: str,
2455
+ sample_ids: list[str] | None = None,
2456
+ *,
2457
+ sub_job_id: str | None = None,
2458
+ sub_job_type: str | None = None,
2459
+ extra: dict | None = None,
2460
+ ) -> dict:
2461
+ """Discard router replay samples on a routed sub-job."""
2462
+ payload = dict(extra or {})
2463
+ payload.setdefault("sample_ids", list(sample_ids or []))
2464
+ return self._operation(
2465
+ job_id,
2466
+ "router-replay-discard",
2467
+ payload=payload,
2468
+ sub_job_id=sub_job_id,
2469
+ sub_job_type=sub_job_type,
2470
+ )
2471
+
2472
+ @_track_operation("reset_prefix_cache")
2473
+ def reset_prefix_cache(
2474
+ self,
2475
+ job_id: str,
2476
+ *,
2477
+ sub_job_id: str | None = None,
2478
+ sub_job_type: str | None = "sampling",
2479
+ drain: bool = True,
2480
+ timeout_s: float = 60.0,
2481
+ retry_interval_s: float = 0.1,
2482
+ extra: dict | None = None,
2483
+ ) -> dict:
2484
+ """Reset the sampling prefix cache for a Cortex Training job.
2485
+
2486
+ Operation-specific fields are carried inside the opaque operation
2487
+ payload. ``extra`` allows new reset options to flow through without a
2488
+ cortex-training release.
2489
+ """
2490
+ payload = dict(extra or {})
2491
+ payload.setdefault("drain", drain)
2492
+ payload.setdefault("timeout_s", timeout_s)
2493
+ payload.setdefault("retry_interval_s", retry_interval_s)
2494
+ return self._operation(
2495
+ job_id,
2496
+ "reset-prefix-cache",
2497
+ payload=payload,
2498
+ sub_job_id=sub_job_id,
2499
+ sub_job_type=sub_job_type,
2500
+ )
2501
+
2502
+ # ─── Async request polling ────────────────────────────────────────────
2503
+
2504
+ @staticmethod
2505
+ def _normalize_request_status(status: str) -> str:
2506
+ """Normalize full enum names (``REQUEST_STATE_DONE``) to short form (``done``)."""
2507
+ return status.lower().removeprefix("request_state_")
2508
+
2509
+ @staticmethod
2510
+ def _decode_result_payload(result: dict) -> dict | None:
2511
+ if not isinstance(result, dict):
2512
+ return None
2513
+ if result.get("wire_format") != wire.WIRE_FORMAT_VERSION:
2514
+ return None
2515
+ if result.get("encoding") != "base64":
2516
+ raise RuntimeError("DSSST1 result payload must use base64 encoding")
2517
+ raw = result.get("payload_b64")
2518
+ if not isinstance(raw, str) or not raw:
2519
+ raise RuntimeError("DSSST1 result payload missing payload_b64")
2520
+ payload = base64.b64decode(raw)
2521
+ decoded = wire.loads(payload)
2522
+ if not isinstance(decoded, dict):
2523
+ raise RuntimeError("DSSST1 result payload did not decode to a dict")
2524
+ return decoded
2525
+
2526
+ @staticmethod
2527
+ def _decode_result_chunk_event(event: dict) -> bytes | None:
2528
+ if not isinstance(event, dict) or event.get("type") != "result_chunk":
2529
+ return None
2530
+ raw = event.get("payload_b64")
2531
+ if not isinstance(raw, str) or not raw:
2532
+ raise RuntimeError("result_chunk event missing payload_b64")
2533
+ payload = base64.b64decode(raw)
2534
+ expected = event.get("payload_sha256")
2535
+ if expected is not None and hashlib.sha256(payload).hexdigest() != expected:
2536
+ raise RuntimeError("result_chunk payload_sha256 mismatch")
2537
+ return payload
2538
+
2539
+ @staticmethod
2540
+ def _restore_generate_result_lists(value: Any, torch_module: Any | None = None) -> Any:
2541
+ torch_module = _load_torch() if torch_module is None else torch_module
2542
+ if torch_module.is_tensor(value):
2543
+ return value.cpu().tolist()
2544
+ if isinstance(value, dict):
2545
+ return {
2546
+ key: CortexTrainingClient._restore_generate_result_lists(item, torch_module)
2547
+ for key, item in value.items()
2548
+ }
2549
+ if isinstance(value, list):
2550
+ return [CortexTrainingClient._restore_generate_result_lists(item, torch_module) for item in value]
2551
+ if isinstance(value, tuple):
2552
+ return tuple(CortexTrainingClient._restore_generate_result_lists(item, torch_module) for item in value)
2553
+ return value
2554
+
2555
+ def _normalize_generate_result_if_needed(self, request_id: str, result: dict) -> dict:
2556
+ if request_id not in self._generate_request_ids:
2557
+ return result
2558
+ self._generate_request_ids.discard(request_id)
2559
+ if isinstance(result, dict) and "results" in result:
2560
+ result = dict(result)
2561
+ result["results"] = self._restore_generate_result_lists(result["results"])
2562
+ return result
2563
+
2564
+ @staticmethod
2565
+ def _decode_stream_result_event(event: Any) -> Any:
2566
+ if not isinstance(event, dict) or event.get("type") != "result":
2567
+ return event
2568
+ result = event.get("result")
2569
+ if not isinstance(result, dict):
2570
+ return event
2571
+ decoded = CortexTrainingClient._decode_result_payload(result)
2572
+ if decoded is None:
2573
+ return event
2574
+ event = dict(event)
2575
+ event["result"] = CortexTrainingClient._restore_generate_result_lists(decoded)
2576
+ return event
2577
+
2578
+ @staticmethod
2579
+ def _decode_stream_result_events(status: dict) -> dict:
2580
+ events = status.get("events")
2581
+ if not isinstance(events, list):
2582
+ return status
2583
+ decoded = [CortexTrainingClient._decode_stream_result_event(event) for event in events]
2584
+ if decoded == events:
2585
+ return status
2586
+ status = dict(status)
2587
+ status["events"] = decoded
2588
+ return status
2589
+
2590
+ @_track_operation("poll_request")
2591
+ def poll_request(self, job_id: str, request_id: str) -> dict:
2592
+ """Poll until the async request completes. Returns the result dict."""
2593
+ deadline = time.monotonic() + self.poll_timeout
2594
+ delay = self.poll_interval
2595
+ debug_context = self._fwd_bwd_request_debug.get(request_id)
2596
+ debug_label = self._debug_context_label(debug_context) if debug_context is not None else None
2597
+ result_chunks: list[bytes] = []
2598
+ cursor: str | None = None
2599
+ while time.monotonic() < deadline:
2600
+ status = self.get_request_status(job_id, request_id, cursor=cursor)
2601
+ state = self._normalize_request_status(status.get("status", ""))
2602
+ events = status.get("events") or []
2603
+ received_chunk = False
2604
+ for event in events:
2605
+ chunk = self._decode_result_chunk_event(event)
2606
+ if chunk is not None:
2607
+ result_chunks.append(chunk)
2608
+ received_chunk = True
2609
+ next_cursor = status.get("next_cursor")
2610
+ if isinstance(next_cursor, str) and next_cursor:
2611
+ cursor = next_cursor
2612
+ # Drain available result chunks without backing off.
2613
+ continue
2614
+ if state in ("completed", "done", "succeeded"):
2615
+ if result_chunks:
2616
+ result = wire.decode_result_chunks(result_chunks)
2617
+ else:
2618
+ result = status.get("result") or {}
2619
+ decoded = self._decode_result_payload(result)
2620
+ if decoded is not None:
2621
+ result = decoded
2622
+ result = self._normalize_generate_result_if_needed(request_id, result)
2623
+ if debug_label is not None:
2624
+ logger.debug(
2625
+ "%s completed state=%s result=%s",
2626
+ debug_label,
2627
+ state,
2628
+ self._debug_json_summary(result),
2629
+ )
2630
+ self._fwd_bwd_request_debug.pop(request_id, None)
2631
+ return result
2632
+ if state in ("failed", "cancelled", "canceled"):
2633
+ error = status.get("error", "")
2634
+ if debug_label is not None:
2635
+ logger.debug(
2636
+ "%s failed state=%s error=%s body=%s",
2637
+ debug_label,
2638
+ state,
2639
+ error,
2640
+ self._debug_json_summary(status),
2641
+ )
2642
+ self._fwd_bwd_request_debug.pop(request_id, None)
2643
+ self._generate_request_ids.discard(request_id)
2644
+ raise RuntimeError(f"Request {request_id} ended with state '{state}': {error}")
2645
+ if received_chunk:
2646
+ # Defensive: if the server returns a chunk without next_cursor
2647
+ # before terminal status, poll again immediately rather than
2648
+ # sleeping.
2649
+ continue
2650
+ delay = self._sleep_with_backoff(delay, deadline)
2651
+ if debug_label is not None:
2652
+ logger.debug("%s timed out after %ss", debug_label, self.poll_timeout)
2653
+ self._fwd_bwd_request_debug.pop(request_id, None)
2654
+ self._generate_request_ids.discard(request_id)
2655
+ raise TimeoutError(f"Request {request_id} did not complete within {self.poll_timeout}s")
2656
+
2657
+ @_track_operation("get_request_status")
2658
+ def get_request_status(
2659
+ self,
2660
+ job_id: str,
2661
+ request_id: str,
2662
+ *,
2663
+ max_events: int | None = None,
2664
+ cursor: str | None = None,
2665
+ ) -> dict:
2666
+ params = {}
2667
+ if max_events is not None:
2668
+ params["max_events"] = max_events
2669
+ if cursor:
2670
+ params["cursor"] = cursor
2671
+ debug_context = self._fwd_bwd_request_debug.get(request_id)
2672
+ resp = self._send(
2673
+ "GET",
2674
+ f"{self._prefix}/{job_id}/requests/{request_id}",
2675
+ params=params or None,
2676
+ debug_context=debug_context,
2677
+ )
2678
+ return self._decode_stream_result_events(resp.json())
2679
+
2680
+ @_track_operation("cancel_request")
2681
+ def cancel_request(
2682
+ self,
2683
+ job_id: str,
2684
+ request_id: str,
2685
+ *,
2686
+ sub_job_id: str | None = None,
2687
+ sub_job_type: str | None = None,
2688
+ ) -> dict:
2689
+ return self._operation(
2690
+ job_id,
2691
+ "cancel-request",
2692
+ payload={"request_id": request_id},
2693
+ sub_job_id=sub_job_id,
2694
+ sub_job_type=sub_job_type,
2695
+ )
2696
+
2697
+ # ─── Logs (read-only) ────────────────────────────────────────────────
2698
+
2699
+ def tail_logs(
2700
+ self,
2701
+ job_id: str,
2702
+ *,
2703
+ cursor: str | None = None,
2704
+ max_lines: int | None = None,
2705
+ sub_job_id: str | None = None,
2706
+ sub_job_type: str | None = None,
2707
+ ) -> dict:
2708
+ """Fetch one page of a sub-job's logs from ``cursor`` (read-only).
2709
+
2710
+ The target is the sub-job's zone-manager (Ray head) pod, whose stdout
2711
+ already includes worker output (Ray ``log_to_driver``); identify it via
2712
+ ``sub_job_id`` (or ``sub_job_type`` when unambiguous). Returns
2713
+ ``{"entries": [...], "next_cursor": str, "eof": bool}``. Re-call with
2714
+ ``next_cursor`` to continue; an empty ``cursor`` reads from the start.
2715
+ """
2716
+ payload: dict = {}
2717
+ if cursor is not None:
2718
+ payload["cursor"] = cursor
2719
+ if max_lines is not None:
2720
+ payload["max_lines"] = max_lines
2721
+ return self._operation(
2722
+ job_id,
2723
+ "tail-logs",
2724
+ payload=payload,
2725
+ sub_job_id=sub_job_id,
2726
+ sub_job_type=sub_job_type,
2727
+ )
2728
+
2729
+ def _stream_pages(
2730
+ self,
2731
+ fetch,
2732
+ entries_key: str,
2733
+ *,
2734
+ follow: bool,
2735
+ cursor: str | None = None,
2736
+ poll_interval: float | None = None,
2737
+ ) -> Iterator[dict]:
2738
+ """Drive a cursor-paged tail: call ``fetch(cursor)`` for a page, yield
2739
+ its entries (under ``entries_key``), advance the cursor, and poll with
2740
+ backoff. With ``follow`` False, stop once a page reports ``eof``.
2741
+ """
2742
+ base_delay = poll_interval or self.poll_interval
2743
+ delay = base_delay
2744
+ while True:
2745
+ page = fetch(cursor)
2746
+ cursor = page.get("next_cursor", cursor)
2747
+ entries = page.get(entries_key) or []
2748
+ for entry in entries:
2749
+ yield entry
2750
+ if entries:
2751
+ delay = base_delay # made progress; poll again promptly
2752
+ continue
2753
+ if page.get("eof", False) and not follow:
2754
+ return
2755
+ time.sleep(delay)
2756
+ delay = min(delay * self.poll_backoff_multiplier, self.poll_max_interval)
2757
+
2758
+ def stream_logs(
2759
+ self,
2760
+ job_id: str,
2761
+ *,
2762
+ sub_job_id: str | None = None,
2763
+ sub_job_type: str | None = None,
2764
+ cursor: str | None = None,
2765
+ max_lines: int | None = None,
2766
+ poll_interval: float | None = None,
2767
+ follow: bool = True,
2768
+ ) -> Iterator[dict]:
2769
+ """Yield a sub-job's log entries, polling with backoff.
2770
+
2771
+ Drains the log then, with ``follow=True`` (the default), keeps polling
2772
+ for new entries until the caller stops iterating — suitable for a live
2773
+ tail. With ``follow=False`` it stops once the log reports ``eof``.
2774
+ """
2775
+ return self._stream_pages(
2776
+ lambda cur: self.tail_logs(
2777
+ job_id,
2778
+ cursor=cur,
2779
+ max_lines=max_lines,
2780
+ sub_job_id=sub_job_id,
2781
+ sub_job_type=sub_job_type,
2782
+ ),
2783
+ "entries",
2784
+ follow=follow,
2785
+ cursor=cursor,
2786
+ poll_interval=poll_interval,
2787
+ )
2788
+
2789
+ # ─── Zone scheduling events (read-only) ───────────────────────────────
2790
+
2791
+ def tail_events(
2792
+ self,
2793
+ job_id: str,
2794
+ *,
2795
+ cursor: str | None = None,
2796
+ max_events: int | None = None,
2797
+ sub_job_id: str | None = None,
2798
+ sub_job_type: str | None = None,
2799
+ ) -> dict:
2800
+ """Fetch one page of zone scheduling events for the session.
2801
+
2802
+ Returns ``{"events": [...], "next_cursor": str, "eof": bool}``. Served
2803
+ by the zone dispatcher itself, not routed to a Zone Manager.
2804
+
2805
+ Note: the server does not currently accept this operation; see
2806
+ ``docs/reference/rest-api.md`` section 7.9.
2807
+ """
2808
+ payload: dict = {}
2809
+ if cursor is not None:
2810
+ payload["cursor"] = cursor
2811
+ if max_events is not None:
2812
+ payload["max_events"] = max_events
2813
+ return self._operation(
2814
+ job_id,
2815
+ "zmd-events",
2816
+ payload=payload or None,
2817
+ sub_job_id=sub_job_id,
2818
+ sub_job_type=sub_job_type,
2819
+ )
2820
+
2821
+ def stream_events(
2822
+ self,
2823
+ job_id: str,
2824
+ *,
2825
+ cursor: str | None = None,
2826
+ max_events: int | None = None,
2827
+ sub_job_id: str | None = None,
2828
+ sub_job_type: str | None = None,
2829
+ poll_interval: float | None = None,
2830
+ follow: bool = True,
2831
+ ) -> Iterator[dict]:
2832
+ """Yield zone scheduling events for the session, polling with backoff."""
2833
+ return self._stream_pages(
2834
+ lambda cur: self.tail_events(
2835
+ job_id,
2836
+ cursor=cur,
2837
+ max_events=max_events,
2838
+ sub_job_id=sub_job_id,
2839
+ sub_job_type=sub_job_type,
2840
+ ),
2841
+ "events",
2842
+ follow=follow,
2843
+ cursor=cursor,
2844
+ poll_interval=poll_interval,
2845
+ )
2846
+
2847
+ # ─── Checkpoints ─────────────────────────────────────────────────────
2848
+
2849
+ @_track_operation("list_checkpoints")
2850
+ def list_checkpoints(self, job_id: str) -> list:
2851
+ resp = self._send("GET", f"{self._prefix}/{job_id}/checkpoints")
2852
+ return resp.json().get("checkpoints", [])
2853
+
2854
+ @_track_operation("export_checkpoint")
2855
+ def export_checkpoint(self, job_id: str, checkpoint_id: str) -> dict:
2856
+ # The REST API uses colon-action syntax: /{jobId}/checkpoints/{cpId}:export
2857
+ resp = self._send(
2858
+ "POST",
2859
+ f"{self._prefix}/{job_id}/checkpoints/{checkpoint_id}:export",
2860
+ )
2861
+ return resp.json()
2862
+
2863
+ @_track_operation("delete_checkpoint")
2864
+ def delete_checkpoint(self, job_id: str, checkpoint_id: str) -> None:
2865
+ """Delete a checkpoint. Returns None on success."""
2866
+ self._send("DELETE", f"{self._prefix}/{job_id}/checkpoints/{checkpoint_id}")