envloop-cli 0.4.0__py3-none-any.whl → 1.7.0__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.
envloop/agent_runs.py CHANGED
@@ -7,6 +7,7 @@ using the same rules as the Console's agent runs page.
7
7
  """
8
8
 
9
9
  from datetime import datetime
10
+ from urllib.parse import quote, urlencode
10
11
 
11
12
  from .train import TrainJobs
12
13
 
@@ -206,3 +207,38 @@ class AgentRuns:
206
207
  if run not in runs:
207
208
  raise AgentRunError("AGENT_RUN_NOT_FOUND")
208
209
  return {**_detail(run, runs[run]), "observed_at": observed_at}
210
+
211
+ # The RSI loop service behind the platform (``/v1/agent-runs/rsi``). Starting spends money, so
212
+ # ``start`` needs a key with ``training.run``; ``control(stop)`` also needs ``training.stop``.
213
+ def _rsi(self, method: str, path: str, body=None) -> dict:
214
+ return self._train_jobs.transport.request(method, "/v1/agent-runs/rsi" + path, body)
215
+
216
+ def start(self, run: str, brief: str, *, model_usd_cap: float, usd_cap: float | None = None,
217
+ model_request_cap: int | None = None, domain: str | None = None, rounds: int | None = None,
218
+ dry_run: bool = False, student_model: str | None = None) -> dict:
219
+ """Start a new RSI run; the platform validates every field. ``dry_run`` uses no GPU."""
220
+ limits = {"model_usd_cap": model_usd_cap}
221
+ for key, value in (("usd_cap", usd_cap), ("model_request_cap", model_request_cap)):
222
+ if value is not None:
223
+ limits[key] = value
224
+ body = {"run": run, "brief": brief, "limits": limits}
225
+ for key, value in (("domain", domain), ("rounds", rounds), ("student_model", student_model)):
226
+ if value is not None:
227
+ body[key] = value
228
+ if dry_run:
229
+ body["dry_run"] = True
230
+ return self._rsi("POST", "/runs", body)
231
+
232
+ def live(self, run: str, since: int | None = None) -> dict:
233
+ """What a run does now; pass the previous ``next_cursor`` as ``since`` for newer events."""
234
+ query = "?" + urlencode({"since": since}) if since is not None else ""
235
+ return self._rsi("GET", f"/runs/{quote(run, safe='')}/live{query}")
236
+
237
+ def steer(self, run: str, text: str) -> dict:
238
+ """Queue a message for the running operator (1 to 4000 characters)."""
239
+ return self._rsi("POST", f"/runs/{quote(run, safe='')}/steer", {"text": text})
240
+
241
+ def control(self, run: str, action: str, rounds: int | None = None) -> dict:
242
+ """``extend`` (needs ``rounds``), ``stop`` (after the current round) or ``resume``."""
243
+ return self._rsi("POST", f"/runs/{quote(run, safe='')}/control",
244
+ {"action": action, **({} if rounds is None else {"rounds": rounds})})
envloop/cli.py CHANGED
@@ -133,11 +133,11 @@ def log_page_size(value):
133
133
 
134
134
  def runner_arguments(submit):
135
135
  submit.add_argument("--runner", metavar="NAME[@REVISION]",
136
- help="Agent: override the Dataset runner with NAME@REVISION; Sandbox: registered Runner name (for example base)")
136
+ help="Agent/Train: namespace/name@REVISION; Sandbox: registered Runner name (for example base)")
137
137
  submit.add_argument("--job-id", help="caller-selected Job ID")
138
138
  submit.add_argument("--tag", help="run label")
139
139
  submit.add_argument("--priority", type=int, help="waiting priority; larger values run first (default: 0)")
140
- submit.add_argument("--command", help="shell script override executed in the runner directory; preserves multiline text")
140
+ submit.add_argument("--command", help="Agent/Sandbox: shell override; Train: declared run_command parameter passed to the published Runner entrypoint; preserves multiline text")
141
141
 
142
142
 
143
143
  def parser():
@@ -153,10 +153,20 @@ def parser():
153
153
  from .auth_cli import add_auth_commands
154
154
 
155
155
  jobs = groups.add_parser("job", help="Submit and manage Agent, Sandbox, Train and Inference Jobs").add_subparsers(dest="action", required=True)
156
- job_submit = jobs.add_parser("submit", help="Submit an Agent, Sandbox, Train or Inference Job")
156
+ job_submit = jobs.add_parser("submit", help="Submit an Agent, Sandbox, Train or Inference Job", allow_abbrev=False)
157
157
  job_submit.add_argument("--type", choices=("agent", "sandbox", "train", "inference"), default="agent", help="Job type (default: agent)")
158
- job_submit.add_argument("--image", metavar="IMAGE", help="Sandbox Docker image override (server default: python:3.11)")
159
- job_submit.add_argument("--file", type=Path, help="Train or Inference Job request JSON (spec or Train trials array)")
158
+ job_submit.add_argument("--image", metavar="IMAGE", help="Sandbox image override (server default: python:3.11); Train Runner: public spelling of --docker")
159
+ from .train_runner_cli import memory_mebibytes, positive_integer
160
+
161
+ job_submit.add_argument("--docker", metavar="IMAGE", help="Train Runner Docker image override; omitted uses the Runner declaration")
162
+ job_submit.add_argument("--trails", type=positive_integer, metavar="COUNT", help="Train Runner: 1–16 independent Trials (default: 1)")
163
+ job_submit.add_argument("--gput-type", metavar="MODEL", help="Train Runner: GPU model for each Trial (for example H100)")
164
+ job_submit.add_argument("--gpu", type=positive_integer, metavar="COUNT", help="Train Runner: positive GPU count per Trial")
165
+ job_submit.add_argument("--cpu", type=positive_integer, metavar="COUNT", help="Train Runner: positive CPU count per Trial")
166
+ for option in ('memory', 'disk', 'shm-size'):
167
+ job_submit.add_argument('--' + option, type=memory_mebibytes, metavar="SIZE",
168
+ help="Train Runner: per-Trial size in Mi/Gi/Ti (for example 64Gi)")
169
+ job_submit.add_argument("--file", type=Path, help="Managed Train or Inference Job JSON (spec or Train trials array; interactive training is unsupported)")
160
170
  job_submit.add_argument("--source", type=Path, help="two-file directory for a single-task Job")
161
171
  job_submit.add_argument("--task-id", help="select one task within the dataset")
162
172
  job_submit.add_argument(
@@ -175,10 +185,11 @@ def parser():
175
185
  help="named runtime Profile; omitted version requires an explicit default")
176
186
  job_submit.add_argument("--seed", type=int)
177
187
  job_submit.add_argument("--timeout-sec", type=wait_seconds,
178
- help="Agent/Sandbox execution timeout (whole seconds); Train wait timeout (default: 600)")
188
+ help="Agent/Sandbox or Train Runner execution timeout (whole seconds); Train --file wait timeout (default: 600)")
179
189
  job_submit.add_argument("--delete-delay", type=nonnegative_seconds, metavar="SECONDS",
180
190
  help="keep each sandbox (E2B or Fly) after completion for SECONDS (default: 0); cancellation/timeout delete immediately")
181
191
  job_submit.add_argument("--wait", action="store_true")
192
+ job_submit.add_argument("--json", action="store_true", help="Output the complete response as JSON (the default for submit; accepted for consistency with list commands)")
182
193
  for action in ("get", "stop", "result", "pause", "resume", "wake", "event"):
183
194
  command = jobs.add_parser(action)
184
195
  command.add_argument("job_id")
@@ -208,6 +219,27 @@ def parser():
208
219
  runs_get = runs.add_parser("get", help="Show a run's rounds, Train Jobs and checkpoints")
209
220
  runs_get.add_argument("run", metavar="RUN", help="run label: user_metadata.rsi_run or agent_run")
210
221
  runs_get.add_argument("--json", action="store_true", help="Output the complete run as JSON (default: tables)")
222
+ runs_start = runs.add_parser("start", help="Start a new RSI loop run (paid; needs training.run)")
223
+ runs_start.add_argument("run", metavar="RUN", help="new run id: lower-case letters, digits and dashes")
224
+ brief = runs_start.add_mutually_exclusive_group(required=True)
225
+ brief.add_argument("--brief", help="the goal of the run (up to 4000 characters)")
226
+ brief.add_argument("--brief-file", metavar="PATH", help="read the goal from a file, or - for standard input")
227
+ runs_start.add_argument("--model-usd-cap", type=float, required=True, help="strong-model spend cap in USD (the hard stop)")
228
+ runs_start.add_argument("--usd-cap", type=float, help="cap in USD for model plus GPU training together")
229
+ runs_start.add_argument("--model-request-cap", type=int, help="cap on model requests")
230
+ runs_start.add_argument("--domain", help="task family (default: the service's default; an unknown value is rejected)")
231
+ runs_start.add_argument("--rounds", type=int, help="target rounds (default: the service's default)")
232
+ runs_start.add_argument("--dry-run", action="store_true", help="GPU-free run: trains nothing; exercises the loop, spends model budget only")
233
+ runs_start.add_argument("--student-model", help="gateway student model of a dry run")
234
+ runs_live = runs.add_parser("live", help="Show what a run does now; --follow streams new events")
235
+ runs_live.add_argument("run", metavar="RUN")
236
+ runs_live.add_argument("--since", type=int, help="only events after this cursor (the previous next_cursor)")
237
+ runs_live.add_argument("--follow", action="store_true", help="poll and print new events until the run ends; Ctrl-C exits without stopping it")
238
+ runs_steer = runs.add_parser("steer", help="Message the running operator now (needs training.run)")
239
+ runs_steer.add_argument("run", metavar="RUN")
240
+ runs_steer.add_argument("text", metavar="TEXT", help="1 to 4000 characters")
241
+ runs_stop = runs.add_parser("stop", help="Stop a run after its current round (needs training.run and training.stop)")
242
+ runs_stop.add_argument("run", metavar="RUN")
211
243
  tasks = groups.add_parser("trail", help="Inspect and control existing trials").add_subparsers(dest="action", required=True)
212
244
  listing = tasks.add_parser("list", help="List Agent, Sandbox, Train and Inference worker Trials")
213
245
  listing.add_argument("--type", choices=("agent", "sandbox", "train", "inference"), help="Filter by Trial type")
@@ -230,11 +262,11 @@ def parser():
230
262
  download.add_argument("trial_id")
231
263
  download.add_argument("directory", type=Path)
232
264
  profiles = groups.add_parser("profile", help="Manage platform runtime profiles").add_subparsers(dest="action", required=True)
233
- put = profiles.add_parser("create", help="Create an immutable Profile version from JSON")
265
+ put = profiles.add_parser("create", help="Create an immutable Profile version from TOML or JSON")
234
266
  put.add_argument("name")
235
267
  put.add_argument("version")
236
- put.add_argument("--file", type=Path, required=True, help="ProfileSpec JSON file")
237
- put.add_argument("--runner", metavar="RUNNER", help="Bind org/name@<full-commit> to this version")
268
+ put.add_argument("--file", type=Path, required=True, help="Profile TOML or JSON file")
269
+ put.add_argument("--runner", metavar="RUNNER", help="Bind a Runner revision to this version")
238
270
  secret_input = put.add_mutually_exclusive_group()
239
271
  secret_input.add_argument("--prompt-model-api-key", action="store_true", help="Read the model API key from a non-echoing prompt")
240
272
  secret_input.add_argument("--model-api-key-stdin", action="store_true", help="Read the model API key from one line of standard input")
@@ -242,11 +274,16 @@ def parser():
242
274
  update.add_argument("name")
243
275
  update.add_argument("version", help="New version; existing versions cannot be overwritten")
244
276
  update.add_argument("--from-version", help="Source version; omit to use the explicit default")
245
- update.add_argument("--file", type=Path, required=True, help="JSON patch: merge literal params keys, replace supplied metadata; preserve omitted fields")
246
- update.add_argument("--runner", metavar="RUNNER", help="Bind a Runner name@full-commit; omit to preserve the source binding")
277
+ update.add_argument("--file", type=Path, required=True, help="TOML or JSON patch: merge params keys, replace supplied metadata; preserve omitted fields")
278
+ update.add_argument("--runner", metavar="RUNNER", help="Bind a Runner revision; v1 requires a full commit; omit to preserve the source binding")
247
279
  update_secret = update.add_mutually_exclusive_group()
248
280
  update_secret.add_argument("--prompt-model-api-key", action="store_true", help="Read an encrypted model connection key from a non-echoing prompt")
249
281
  update_secret.add_argument("--model-api-key-stdin", action="store_true", help="Read an encrypted model connection key from one line of standard input")
282
+ fork = profiles.add_parser('fork', help='Fork an existing Profile into a new immutable identity')
283
+ fork.add_argument('source_name')
284
+ fork.add_argument('source_version')
285
+ fork.add_argument('name')
286
+ fork.add_argument('version')
250
287
  get = profiles.add_parser("get")
251
288
  get.add_argument("name")
252
289
  get.add_argument("--version")
@@ -368,16 +405,27 @@ def main(argv=None):
368
405
  print(format_error(exc, args=args, program=cli.prog), file=sys.stderr)
369
406
  return 1
370
407
  if args.group == "job" and args.action == "submit":
408
+ from .train_runner_cli import SHORTCUT_FIELDS, runner_request
409
+
410
+ runner_mode = args.type == 'train' and args.file is None
411
+ if not runner_mode and any(getattr(args, key) is not None for key in SHORTCUT_FIELDS):
412
+ cli.error('Train Runner shortcuts require --type train --runner and cannot be combined with --file')
371
413
  if args.type in {"train", "inference"}:
372
- if args.file is None:
414
+ if args.file is None and not runner_mode:
373
415
  cli.error(f"--type {args.type} requires --file REQUEST.json")
374
416
  if args.type == 'inference' and (args.wait or args.timeout_sec is not None):
375
417
  cli.error('Inference is a continuous service; --wait and --timeout-sec do not apply')
376
418
  agent_fields = ("dataset", "dataset_option", "source", "task_id", "config", "split", "revision",
377
- "runner", "job_id", "tag", "priority", "command", "attempts", "request_id",
378
- "profile", "seed", "delete_delay", "image")
419
+ "job_id", "tag", "priority", "attempts", "profile", "seed", "delete_delay")
420
+ if not runner_mode:
421
+ agent_fields += ('runner', 'command', 'request_id', 'image')
379
422
  if any(getattr(args, key) is not None for key in agent_fields):
380
- cli.error(f"--type {args.type} accepts its request through --file; Agent submission options cannot be combined")
423
+ cli.error(f"--type {args.type} cannot be combined with Agent submission options or mixed --file/Runner input")
424
+ if runner_mode:
425
+ try:
426
+ args.train_request = runner_request(args)
427
+ except ValueError as exc:
428
+ cli.error(str(exc))
381
429
  elif args.type == 'sandbox':
382
430
  from .sandbox import sandbox_runner_selector, validate_image
383
431
 
@@ -406,7 +454,8 @@ def main(argv=None):
406
454
  args.runner = runner_selector(args.runner)
407
455
  except ValueError as exc:
408
456
  cli.error(str(exc))
409
- if sum(value is not None for value in (args.dataset, args.dataset_option, args.source)) != 1:
457
+ selections = sum(value is not None for value in (args.dataset, args.dataset_option, args.source))
458
+ if selections != 1 and not (selections == 0 and args.profile):
410
459
  cli.error("provide exactly one positional dataset, --dataset or --source")
411
460
  args.dataset = args.dataset if args.dataset is not None else args.dataset_option
412
461
  if args.source is not None and any(getattr(args, key) is not None for key in ("runner", "task_id", "config", "split", "revision")):
@@ -430,7 +479,7 @@ def main(argv=None):
430
479
 
431
480
  raw = args.file.read_bytes()
432
481
  patch = read_profile_patch(raw)
433
- if args.runner is not None:
482
+ if args.action == 'update' and args.runner is not None:
434
483
  patch = with_runner(patch, args.runner)
435
484
  raw = json.dumps(patch).encode('utf-8')
436
485
  secrets = None
@@ -450,8 +499,10 @@ def main(argv=None):
450
499
  patch=patch, from_version=args.from_version, secrets=secrets)
451
500
  else:
452
501
  result = client.profiles.import_spec(
453
- raw, name=args.name, version=args.version, secrets=secrets
502
+ raw, name=args.name, version=args.version, secrets=secrets, runner=args.runner
454
503
  )
504
+ elif args.action == 'fork':
505
+ result = client.profiles.fork(args.source_name, args.source_version, name=args.name, version=args.version)
455
506
  elif args.action == "get":
456
507
  result = client.profiles.get(args.name, args.version)
457
508
  elif args.action == "list":
@@ -477,14 +528,15 @@ def main(argv=None):
477
528
  if args.type == 'inference':
478
529
  result = client.jobs.submit_inference(json.loads(args.file.read_text(encoding='utf-8')))
479
530
  elif args.type == "train":
480
- result = client.training_jobs.submit(json.loads(args.file.read_text(encoding="utf-8")))
531
+ request = args.train_request if args.file is None else json.loads(args.file.read_text(encoding="utf-8"))
532
+ result = client.training_jobs.submit(request)
481
533
  else:
482
534
  result = client.jobs.submit(args.dataset, type=args.type, source=args.source, task_id=args.task_id,
483
535
  attempts=args.attempts if args.attempts is not None else 1,
484
536
  request_id=args.request_id, config=args.config, split=args.split,
485
537
  revision=args.revision, **submission_options(args))
486
538
  if args.wait:
487
- wait_options = {"timeout": args.timeout_sec} if args.type == "train" and args.timeout_sec is not None else {}
539
+ wait_options = {"timeout": args.timeout_sec} if args.type == "train" and args.file is not None and args.timeout_sec is not None else {}
488
540
  result = client.jobs.wait(result["job_id"], **wait_options)
489
541
  elif args.action == 'event':
490
542
  result = client.jobs.events(args.job_id)
@@ -495,6 +547,12 @@ def main(argv=None):
495
547
  from .train_log_cli import read_logs
496
548
 
497
549
  return read_logs(client.jobs, args)
550
+ elif args.action == "run" and args.run_action in {"start", "live", "steer", "stop"}:
551
+ from .rsi_cli import run_rsi
552
+
553
+ result = run_rsi(client.agent_runs, args)
554
+ if result is None:
555
+ return 0
498
556
  elif args.action == "run":
499
557
  result = client.agent_runs.list() if args.run_action == "list" else client.agent_runs.get(args.run)
500
558
  elif args.action == "list":
envloop/cli_errors.py CHANGED
@@ -82,9 +82,19 @@ _register("RUNNER_NOT_FOUND", "The Runner reference was not found or is not read
82
82
  _register("INVALID_SANDBOX_RUNNER", "The Runner revision cannot execute as a standalone Sandbox Job.", "Check '{program} runner list' and select a frozen Runner revision whose environment supports execution without a Dataset.")
83
83
  _register("INVALID_SANDBOX_IMAGE", "The Sandbox image reference is invalid.", "Use a Docker image reference name[:tag][@sha256:digest] and check '{command} --help'.")
84
84
 
85
+ _register("RSI_RUN_NOT_FOUND", "No RSI run with this name is visible in the current project.", "Check the name with '{program} job run list' and the scope with '{program} auth config show'; a run you just started appears once the service has accepted it.")
86
+ _register("INVALID_RSI_REQUEST INVALID_STEER INVALID_DIRECTIVE", "The RSI service rejected the request fields.", "Check '{command} --help' and https://docs.envloop.ai/agent-docs/en/guides/agent-runs.md for the run id format, the allowed domain and rounds, and the required spend cap.")
87
+ _register("RSI_CONFLICT RSI_RUN_NOT_ACTIVE", "The run id exists, the run is not in a state that allows this action, or no loop machine is free.", "Pick a new run id for a start; read the run with '{program} job run live RUN' before steering or stopping it.")
88
+ _register("RSI_CONTROL_UNCONFIGURED RSI_CONTROL_UNAVAILABLE RSI_CONTROL_TIMEOUT", "The RSI loop service is not available through this platform right now.", _RETRY_HINT)
89
+
85
90
  _register("JOB_ACTION_UNSUPPORTED", "This Job type does not support the requested action.", "Check the Job type with '{program} job get JOB_ID' and use its supported actions.")
86
91
  _register("INFERENCE_ROUTE_RETIRED", "The global inference route has been retired.", "Use the owning Job's base URL: API_BASE/v1/job/JOB_ID/Inference, and a model deployed by that Job.")
87
92
 
93
+ _register("CHECKPOINT_UNAVAILABLE CHECKPOINT_NOT_DURABLE CHECKPOINT_FILES_MISSING", "The fixed inference input is unavailable or not durable.", "Inspect checkpoint publication, expiry, deletion and permissions. Do not retry with different weights under the same request ID.")
94
+ _register("CHECKPOINT_INTEGRITY_FAILED CHECKPOINT_FORMAT_UNSUPPORTED CHECKPOINT_BASE_MISMATCH", "The fixed input failed integrity or compatibility validation.", "Use a verified durable checkpoint matching the serving profile. A different input requires a new Job and request ID.")
95
+ _register("INFERENCE_AUTHORIZATION_DENIED INFERENCE_AUTHORIZATION_UNAVAILABLE", "The platform could not authorize a new inference worker.", "Check current project membership and Runner/input access before waking or resuming the Job.")
96
+ _register("INFERENCE_PROFILE_UNSUPPORTED INFERENCE_RUNNER_INVALID INFERENCE_MODEL_UNPINNED INFERENCE_RUNNER_IMAGE_MISMATCH INFERENCE_RUNNER_IMAGE_UNAVAILABLE INFERENCE_RUNTIME_UNAVAILABLE", "The selected Runner or frozen inference configuration is unavailable or unsupported.", "Discover a published compatible Runner and serving profile. Submit a new Job for changes to weights, base model or Runner.")
97
+
88
98
  # Public service identifiers verified against EnvPlatform. Keep unexpected codes
89
99
  # private even when they look uppercase: a server can echo a submitted secret in
90
100
  # that field. HTTP status still supplies useful recovery for newer service codes.
envloop/client.py CHANGED
@@ -343,16 +343,38 @@ class Profiles:
343
343
 
344
344
  Omitted fields and Secret markers are preserved. Supplied metadata,
345
345
  including secret_params, replaces that field. Parameter values replace
346
- whole values, including objects; keys are literal. The default is unchanged.
346
+ whole values, including objects. V1 keys are literal; v2 keys are case
347
+ insensitive and live in spec.params. The default is unchanged.
347
348
  """
348
- from .profile_updates import checked_patch
349
+ from .profile_updates import checked_patch, merge_v2_params
349
350
 
350
351
  patch = checked_patch(patch, name, version)
351
352
  original = self.get(name, from_version)["spec"]
352
- spec = {**original, **patch, "name": name, "version": version,
353
- "params": {**original.get("params", {}), **patch.get("params", {})}}
353
+ spec = {**original, **patch, "name": name, "version": version}
354
+ if original.get('schema_version') == 'envplatform.profile/v2':
355
+ if 'params' in patch:
356
+ from .http import RemoteError
357
+ raise RemoteError('INVALID_PROFILE', 422)
358
+ before, changes = original.get('spec', {}), patch.get('spec', {})
359
+ spec['spec'] = {**before, **changes, 'params': merge_v2_params(before.get('params', {}), changes.get('params', {}), spec.get('secret_params', []))}
360
+ else:
361
+ spec['params'] = {**original.get('params', {}), **patch.get('params', {})}
354
362
  return self.put(spec, secrets=secrets)
355
363
 
364
+ def fork(self, source_name, source_version, *, name, version, patch=None, secrets=None):
365
+ """Publish a new identity from an authorized source; never change its default."""
366
+ from .profile_updates import checked_patch, merge_v2_params
367
+
368
+ original = self.get(source_name, source_version)['spec']
369
+ changes = checked_patch({} if patch is None else patch, name, version)
370
+ result = {**original, **changes, 'name': name, 'version': version}
371
+ if original.get('schema_version') == 'envplatform.profile/v2':
372
+ before, updates = original.get('spec', {}), changes.get('spec', {})
373
+ result['spec'] = {**before, **updates, 'params': merge_v2_params(before.get('params', {}), updates.get('params', {}), result.get('secret_params', []))}
374
+ else:
375
+ result['params'] = {**original.get('params', {}), **changes.get('params', {})}
376
+ return self.put(result, secrets=secrets)
377
+
356
378
  def get(self, name: str, version: str | None = None) -> dict:
357
379
  return self._server.get_profile(name, version)
358
380
 
@@ -377,7 +399,31 @@ class Profiles:
377
399
  """Revoke model access for a scoped immutable secret revision."""
378
400
  return self._server.revoke_profile_secret(name, version)
379
401
 
380
- def import_spec(self, raw: bytes, *, name=None, version=None, secrets=None):
402
+ def import_spec(self, raw: bytes, *, name=None, version=None, secrets=None, runner=None):
403
+ import json
404
+
405
+ from .profile_updates import read_profile_patch, with_runner
406
+
407
+ value = read_profile_patch(raw)
408
+ if isinstance(value, dict) and value.get('schema_version') == 'envhub.runner/v1':
409
+ from .http import RemoteError
410
+
411
+ definition = value.get('runner')
412
+ declarations = value.get('params', {})
413
+ if (not isinstance(definition, dict) or not isinstance(definition.get('name'), str)
414
+ or not definition['name'] or not isinstance(declarations, dict)
415
+ or any(not isinstance(item, dict) for item in declarations.values())):
416
+ raise RemoteError('INVALID_PROFILE', 422)
417
+ if runner is not None and runner.partition('@')[0] != definition['name']:
418
+ raise RemoteError('INVALID_PROFILE_RUNNER', 422)
419
+ defaults = {key: declaration['default'] for key, declaration in declarations.items()
420
+ if 'default' in declaration}
421
+ value = {'schema_version': 'envplatform.profile/v2', 'name': name, 'version': version,
422
+ 'runner': runner if runner is not None else definition['name'] + '@main',
423
+ 'spec': {'params': defaults, **({'env': value['env']} if 'env' in value else {})}}
424
+ if runner is not None:
425
+ value = with_runner(value, runner)
426
+ raw = json.dumps(value, allow_nan=False, ensure_ascii=False).encode('utf-8')
381
427
  options = {} if secrets is None else {"secrets": secrets}
382
428
  return self._server.import_profile(raw, name=name, version=version, **options)
383
429
 
@@ -454,9 +500,10 @@ class Jobs:
454
500
  body['image'] = validate_image(image)
455
501
  validate_public_execution(body)
456
502
  return self._server.submit_job(body)
457
- if sum(value is not None for value in (dataset, source, task_ref)) != 1:
503
+ profile_only = all(value is None for value in (dataset, source, task_ref)) and (profile_id is not None or options.get("profile_name") is not None)
504
+ if sum(value is not None for value in (dataset, source, task_ref)) != 1 and not profile_only:
458
505
  raise ValueError("provide exactly one dataset, source or task_ref")
459
- if dataset is None and (runner is not None or any(value is not None for value in (task_id, namespace, config, split, revision))):
506
+ if dataset is None and not profile_only and (runner is not None or any(value is not None for value in (task_id, namespace, config, split, revision))):
460
507
  raise ValueError("dataset selection options require a dataset")
461
508
  if runner is not None:
462
509
  runner = runner_selector(runner)
@@ -464,7 +511,9 @@ class Jobs:
464
511
  options["command"] = validate_command(command)
465
512
  selection = {key: value for key, value in dict(task_id=task_id, namespace=namespace, config=config,
466
513
  split=split, revision=revision).items() if value is not None}
467
- if source is not None:
514
+ if profile_only:
515
+ source = {"dataset": selection} if selection else {}
516
+ elif source is not None:
468
517
  source = {"task": load_task(source)}
469
518
  elif task_ref is not None:
470
519
  source = {"task_ref": dict(task_ref)}
@@ -499,10 +548,8 @@ class Jobs:
499
548
 
500
549
  def submit_inference(self, body: dict):
501
550
  """Submit a continuous inference deployment, identified by its Job ID."""
502
- if not isinstance(body, dict) or set(body) - {'type', 'request_id', 'spec'} or body.get('type', 'inference') != 'inference':
503
- raise ValueError('inference submission requires spec and request_id')
504
- if not isinstance(body.get('spec'), dict):
505
- raise ValueError('inference submission requires a spec object')
551
+ from .inference import validate_submission
552
+ validate_submission(body)
506
553
  return self._server.submit_job({'request_id': str(uuid4()), **body, 'type': 'inference'})
507
554
 
508
555
  def pause(self, job_id):
@@ -535,7 +582,12 @@ class Jobs:
535
582
  def stop(self, job_id):
536
583
  return self._server.stop_job(job_id)
537
584
 
538
- def wait(self, job_id, *, timeout=600, poll_interval=.1):
585
+ def wait(self, job_id, *, timeout=600, poll_interval=.1, max_poll_interval=5):
586
+ """Poll until terminal; the interval grows 1.5x per poll up to ``max_poll_interval``.
587
+
588
+ Jobs that run for tens of minutes (large-model training) would otherwise
589
+ issue ten status requests a second for the whole wait.
590
+ """
539
591
  if not math.isfinite(timeout) or timeout < 0:
540
592
  raise ValueError("timeout must be finite and non-negative")
541
593
  if not math.isfinite(poll_interval) or poll_interval <= 0:
@@ -549,6 +601,7 @@ class Jobs:
549
601
  if remaining <= 0:
550
602
  raise TimeoutError("job did not reach a terminal state")
551
603
  time.sleep(min(poll_interval, remaining))
604
+ poll_interval = max(poll_interval, min(poll_interval * 1.5, max_poll_interval))
552
605
 
553
606
 
554
607
  class Client:
@@ -591,36 +644,11 @@ class Client:
591
644
 
592
645
  return TemporaryResources(self, **options)
593
646
 
594
- def training_service(self, *, temporary_scope_id=None, **options):
595
- """Use this EnvLoop connection for training without Tinker credentials/env.
647
+ def training_service(self, *args, **options):
648
+ """Removed: raise a pointer to managed Jobs."""
649
+ from .train import INTERACTIVE_TRAINING_REMOVED
596
650
 
597
- The platform exchanges its Key for the training protocol's short-lived
598
- token. Upstream token refresh follows the same platform URL and the
599
- gateway rechecks the underlying subject and credential on every request.
600
- """
601
- from .training import ServiceClient
602
- from .training.credentials import TrainingHttpClient
603
-
604
- if 'http_client' in options:
605
- raise ValueError('Client.training_service manages its authenticated HTTP client')
606
- if any(name in options for name in ('base_url', 'api_key', 'default_headers')):
607
- raise ValueError('Client.training_service manages its connection and credential headers')
608
- overrides = options.get('default_headers_overrides') or {}
609
- protected_headers = {'authorization', 'x-api-key', 'cookie', 'x-workspace-id',
610
- 'x-project-id', 'x-envloop-temporary-scope'}
611
- if any(name.lower() in protected_headers for name in overrides):
612
- raise ValueError('Client.training_service manages authentication, project and temporary scope headers')
613
- transport = self._transport
614
- project_id = transport.headers.get("X-Project-ID", "")
615
- if 'project_id' in options and options.pop('project_id') != project_id:
616
- raise ValueError('Select the training project through Client.remote(project_id=...)')
617
- token = transport.request("POST", "/v1/job/tinker/api/v1/auth/token", {})["jwt"]
618
- headers = {name: value for name, value in transport.headers.items() if name in {"X-Workspace-ID", "X-Project-ID"}}
619
- if temporary_scope_id is not None:
620
- headers["X-EnvLoop-Temporary-Scope"] = temporary_scope_id
621
- base_url = transport.base + "/v1/job/tinker"
622
- return ServiceClient(base_url=base_url, api_key=token, project_id=project_id, default_headers=headers,
623
- http_client=TrainingHttpClient(base_url, transport.headers["Authorization"]), **options)
651
+ raise RuntimeError(INTERACTIVE_TRAINING_REMOVED)
624
652
 
625
653
  @classmethod
626
654
  def remote(cls, api_url=None, *, timeout=30, api_key=None, workspace_id=None, project_id=None, config=True):
envloop/http.py CHANGED
@@ -307,22 +307,10 @@ class HttpService:
307
307
  sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
308
308
 
309
309
  def import_profile(self, raw, *, name=None, version=None, secrets=None):
310
- def unique_object(pairs):
311
- result = {}
312
- for key, value in pairs:
313
- if key in result:
314
- raise ValueError("duplicate key")
315
- result[key] = value
316
- return result
310
+ from .profile_updates import read_profile_patch
317
311
 
318
- def invalid_constant(value):
319
- raise ValueError("non-finite JSON value")
320
-
321
- try:
322
- spec = json.loads(raw, object_pairs_hook=unique_object, parse_constant=invalid_constant)
323
- if (not isinstance(spec, dict) or (name is not None and spec.get("name") != name)
324
- or (version is not None and spec.get("version") != version)):
325
- raise ValueError("Profile name/version mismatch")
326
- except (ValueError, UnicodeError, TypeError):
327
- raise RemoteError("INVALID_PROFILE", 422) from None
312
+ spec = read_profile_patch(raw)
313
+ if (not isinstance(spec, dict) or (name is not None and spec.get("name") != name)
314
+ or (version is not None and spec.get("version") != version)):
315
+ raise RemoteError("INVALID_PROFILE", 422)
328
316
  return self.put_profile(profile_payload(spec, secrets))
envloop/inference.py ADDED
@@ -0,0 +1,35 @@
1
+ """Public inference intent validation; resolution and admission remain server owned."""
2
+ import re
3
+
4
+ _FIELDS = {'name', 'runner', 'inputs', 'serving_profile', 'hardware', 'weights_format',
5
+ 'scaling', 'temporary_scope_id'}
6
+
7
+
8
+ def validate_submission(body):
9
+ if (not isinstance(body, dict) or set(body) - {'type', 'request_id', 'spec'}
10
+ or body.get('type', 'inference') != 'inference'):
11
+ raise ValueError('inference submission requires spec and request_id')
12
+ spec = body.get('spec')
13
+ if not isinstance(spec, dict):
14
+ raise ValueError('inference submission requires a spec object')
15
+ if 'request_id' in body and (not isinstance(body['request_id'], str)
16
+ or not body['request_id'].strip() or len(body['request_id']) > 128):
17
+ raise ValueError('request_id must be a non-empty string of at most 128 characters')
18
+ if any(key in spec for key in ('provider', 'executor', 'resolved_runner', 'runtime')):
19
+ raise ValueError('execution configuration and Runner resolution are owned by the server')
20
+ if 'runner' not in spec:
21
+ return # Explicit legacy compatibility: existing adapter specs still reach the API.
22
+ if set(spec) - _FIELDS:
23
+ raise ValueError('fixed-input inference cannot mix legacy or runtime fields')
24
+ runner = spec['runner']
25
+ if (not isinstance(runner, str)
26
+ or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*@[^\s@]+', runner)):
27
+ raise ValueError('runner requires namespace/name@revision')
28
+ if any(not isinstance(spec.get(key), str) or not spec[key].strip()
29
+ for key in ('name', 'serving_profile')):
30
+ raise ValueError('fixed-input inference requires name and serving_profile')
31
+ inputs = spec.get('inputs', {})
32
+ if (not isinstance(inputs, dict) or set(inputs) - {'checkpoint'}
33
+ or 'checkpoint' in inputs and (not isinstance(inputs['checkpoint'], str)
34
+ or not inputs['checkpoint'].startswith('envloop://') or not inputs['checkpoint'][10:])):
35
+ raise ValueError('inputs accepts one Checkpoint ANS URI')
@@ -1,5 +1,6 @@
1
1
  """Build new Profile versions without changing the source or its default."""
2
2
  import json
3
+ import tomllib
3
4
  from collections.abc import Mapping
4
5
 
5
6
  from .http import RemoteError
@@ -18,13 +19,19 @@ def read_profile_patch(raw):
18
19
  raise ValueError
19
20
 
20
21
  try:
21
- return json.loads(raw, object_pairs_hook=unique, parse_constant=invalid_constant)
22
+ text = raw.decode('utf-8') if isinstance(raw, bytes) else raw
23
+ try:
24
+ value = json.loads(text, object_pairs_hook=unique, parse_constant=invalid_constant)
25
+ except ValueError:
26
+ value = tomllib.loads(text)
27
+ json.dumps(value, allow_nan=False)
28
+ return value
22
29
  except (ValueError, UnicodeError, TypeError):
23
30
  raise RemoteError("INVALID_PROFILE", 422) from None
24
31
 
25
32
 
26
33
  def checked_patch(patch, name, version):
27
- allowed = {"schema_version", "name", "version", "params", "description", "created_for", "secret_params", "runner"}
34
+ allowed = {"schema_version", "name", "version", "params", "description", "created_for", "secret_params", "agent", "runner", "dataset", "dataset_mode", "task", "tags", "spec"}
28
35
 
29
36
  def string_keys(value):
30
37
  if isinstance(value, Mapping):
@@ -39,7 +46,8 @@ def checked_patch(patch, name, version):
39
46
  try:
40
47
  if (not isinstance(patch, Mapping) or set(patch) - allowed
41
48
  or patch.get("name", name) != name or patch.get("version", version) != version
42
- or not isinstance(patch.get("params", {}), Mapping)):
49
+ or not isinstance(patch.get("params", {}), Mapping)
50
+ or not isinstance(patch.get("spec", {}), Mapping)):
43
51
  raise ValueError
44
52
  # Reject non-finite numbers for SDK input as well as CLI JSON files.
45
53
  json.dumps(patch, allow_nan=False)
@@ -49,6 +57,18 @@ def checked_patch(patch, name, version):
49
57
  return dict(patch)
50
58
 
51
59
 
60
+ def merge_v2_params(base, override, secrets=()):
61
+ """Overlay parameter values using the Runner's case insensitive names."""
62
+ if not isinstance(base, Mapping) or not isinstance(override, Mapping):
63
+ raise RemoteError("INVALID_PROFILE", 422)
64
+ targets = [key.upper() for key in override]
65
+ if len(set(targets)) != len(targets):
66
+ raise RemoteError("INVALID_PROFILE", 422)
67
+ marked = {key.upper(): key for key in secrets}
68
+ changes = {marked.get(key.upper(), key): item for key, item in override.items()}
69
+ return {key: item for key, item in base.items() if key.upper() not in targets} | changes
70
+
71
+
52
72
  def with_runner(value, runner):
53
73
  """Keep a file binding and explicit CLI binding unambiguous."""
54
74
  if not isinstance(value, Mapping) or value.get('runner', runner) != runner:
envloop/resources.py CHANGED
@@ -183,10 +183,6 @@ class TemporaryResources:
183
183
  def get(self):
184
184
  return self.client._transport.request("GET", self.path)
185
185
 
186
- def training_service(self, **options):
187
- self.path # Fail before any authentication/session if the scope was not entered.
188
- return self.client.training_service(temporary_scope_id=self.scope_id, **options)
189
-
190
186
  def retain(self, resources):
191
187
  return self.client._transport.request("POST", self.path + "/retain", {"resources": resources})
192
188