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 +36 -0
- envloop/cli.py +78 -20
- envloop/cli_errors.py +10 -0
- envloop/client.py +70 -42
- envloop/http.py +5 -17
- envloop/inference.py +35 -0
- envloop/profile_updates.py +23 -3
- envloop/resources.py +0 -4
- envloop/rsi_cli.py +49 -0
- envloop/train.py +25 -2
- envloop/train_runner_cli.py +64 -0
- envloop/training/__init__.py +3 -122
- envloop_cli-1.7.0.dist-info/METADATA +195 -0
- {envloop_cli-0.4.0.dist-info → envloop_cli-1.7.0.dist-info}/RECORD +16 -16
- envloop/training/credentials.py +0 -107
- envloop/training/sampling_client.py +0 -58
- envloop/training/service_client.py +0 -165
- envloop_cli-0.4.0.dist-info/METADATA +0 -122
- {envloop_cli-0.4.0.dist-info → envloop_cli-1.7.0.dist-info}/WHEEL +0 -0
- {envloop_cli-0.4.0.dist-info → envloop_cli-1.7.0.dist-info}/entry_points.txt +0 -0
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:
|
|
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
|
|
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
|
|
159
|
-
|
|
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="
|
|
237
|
-
put.add_argument("--runner", metavar="RUNNER", help="Bind
|
|
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
|
|
246
|
-
update.add_argument("--runner", metavar="RUNNER", help="Bind a Runner
|
|
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
|
-
"
|
|
378
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
503
|
-
|
|
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,
|
|
595
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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')
|
envloop/profile_updates.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|