envloop-cli 0.3.0__tar.gz → 1.4.0__tar.gz
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_cli-0.3.0 → envloop_cli-1.4.0}/.gitignore +3 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/PKG-INFO +2 -2
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/agent_runs.py +36 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/cli.py +50 -8
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/cli_errors.py +8 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/client.py +75 -10
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/http.py +5 -17
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/navigation_resources.py +2 -1
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/profile_updates.py +30 -3
- envloop_cli-1.4.0/envloop/rsi_cli.py +49 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/training/__init__.py +3 -1
- envloop_cli-1.4.0/envloop/training/credentials.py +107 -0
- envloop_cli-1.4.0/envloop/training/sampling_client.py +58 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/training/service_client.py +59 -2
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/pyproject.toml +2 -2
- envloop_cli-0.3.0/envloop/training/credentials.py +0 -56
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/README.pypi.md +0 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/__init__.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/auth_cli.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/auth_login.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/auth_workspace.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/cli_output.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/config.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/job_logs.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/lifecycle_cli.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/profile_secrets.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/resource_cli.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/resources.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/sandbox.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/train.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/train_log_cli.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/utils/__init__.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-1.4.0}/envloop/utils/logging.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: envloop-cli
|
|
3
|
-
Version:
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: EnvLoop CLI and Python SDK for jobs, training, checkpoints and inference
|
|
5
5
|
Project-URL: Homepage, https://envloop.ai
|
|
6
6
|
Project-URL: Documentation, https://docs.envloop.ai
|
|
@@ -8,7 +8,7 @@ Project-URL: Repository, https://github.com/EnvLoop/envloop-cli
|
|
|
8
8
|
Project-URL: Issues, https://github.com/EnvLoop/envloop-cli/issues
|
|
9
9
|
Project-URL: Agent instructions, https://docs.envloop.ai/skill.md
|
|
10
10
|
Requires-Python: >=3.11
|
|
11
|
-
Requires-Dist: tinker==0.
|
|
11
|
+
Requires-Dist: tinker==0.34.0
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
13
|
|
|
14
14
|
# envloop-cli: EnvLoop CLI and Python SDK
|
|
@@ -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})})
|
|
@@ -208,6 +208,27 @@ def parser():
|
|
|
208
208
|
runs_get = runs.add_parser("get", help="Show a run's rounds, Train Jobs and checkpoints")
|
|
209
209
|
runs_get.add_argument("run", metavar="RUN", help="run label: user_metadata.rsi_run or agent_run")
|
|
210
210
|
runs_get.add_argument("--json", action="store_true", help="Output the complete run as JSON (default: tables)")
|
|
211
|
+
runs_start = runs.add_parser("start", help="Start a new RSI loop run (paid; needs training.run)")
|
|
212
|
+
runs_start.add_argument("run", metavar="RUN", help="new run id: lower-case letters, digits and dashes")
|
|
213
|
+
brief = runs_start.add_mutually_exclusive_group(required=True)
|
|
214
|
+
brief.add_argument("--brief", help="the goal of the run (up to 4000 characters)")
|
|
215
|
+
brief.add_argument("--brief-file", metavar="PATH", help="read the goal from a file, or - for standard input")
|
|
216
|
+
runs_start.add_argument("--model-usd-cap", type=float, required=True, help="strong-model spend cap in USD (the hard stop)")
|
|
217
|
+
runs_start.add_argument("--usd-cap", type=float, help="cap in USD for model plus GPU training together")
|
|
218
|
+
runs_start.add_argument("--model-request-cap", type=int, help="cap on model requests")
|
|
219
|
+
runs_start.add_argument("--domain", help="task family (default: the service's default; an unknown value is rejected)")
|
|
220
|
+
runs_start.add_argument("--rounds", type=int, help="target rounds (default: the service's default)")
|
|
221
|
+
runs_start.add_argument("--dry-run", action="store_true", help="GPU-free run: trains nothing; exercises the loop, spends model budget only")
|
|
222
|
+
runs_start.add_argument("--student-model", help="gateway student model of a dry run")
|
|
223
|
+
runs_live = runs.add_parser("live", help="Show what a run does now; --follow streams new events")
|
|
224
|
+
runs_live.add_argument("run", metavar="RUN")
|
|
225
|
+
runs_live.add_argument("--since", type=int, help="only events after this cursor (the previous next_cursor)")
|
|
226
|
+
runs_live.add_argument("--follow", action="store_true", help="poll and print new events until the run ends; Ctrl-C exits without stopping it")
|
|
227
|
+
runs_steer = runs.add_parser("steer", help="Message the running operator now (needs training.run)")
|
|
228
|
+
runs_steer.add_argument("run", metavar="RUN")
|
|
229
|
+
runs_steer.add_argument("text", metavar="TEXT", help="1 to 4000 characters")
|
|
230
|
+
runs_stop = runs.add_parser("stop", help="Stop a run after its current round (needs training.run and training.stop)")
|
|
231
|
+
runs_stop.add_argument("run", metavar="RUN")
|
|
211
232
|
tasks = groups.add_parser("trail", help="Inspect and control existing trials").add_subparsers(dest="action", required=True)
|
|
212
233
|
listing = tasks.add_parser("list", help="List Agent, Sandbox, Train and Inference worker Trials")
|
|
213
234
|
listing.add_argument("--type", choices=("agent", "sandbox", "train", "inference"), help="Filter by Trial type")
|
|
@@ -230,10 +251,11 @@ def parser():
|
|
|
230
251
|
download.add_argument("trial_id")
|
|
231
252
|
download.add_argument("directory", type=Path)
|
|
232
253
|
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")
|
|
254
|
+
put = profiles.add_parser("create", help="Create an immutable Profile version from TOML or JSON")
|
|
234
255
|
put.add_argument("name")
|
|
235
256
|
put.add_argument("version")
|
|
236
|
-
put.add_argument("--file", type=Path, required=True, help="
|
|
257
|
+
put.add_argument("--file", type=Path, required=True, help="Profile TOML or JSON file")
|
|
258
|
+
put.add_argument("--runner", metavar="RUNNER", help="Bind a Runner revision to this version")
|
|
237
259
|
secret_input = put.add_mutually_exclusive_group()
|
|
238
260
|
secret_input.add_argument("--prompt-model-api-key", action="store_true", help="Read the model API key from a non-echoing prompt")
|
|
239
261
|
secret_input.add_argument("--model-api-key-stdin", action="store_true", help="Read the model API key from one line of standard input")
|
|
@@ -241,10 +263,16 @@ def parser():
|
|
|
241
263
|
update.add_argument("name")
|
|
242
264
|
update.add_argument("version", help="New version; existing versions cannot be overwritten")
|
|
243
265
|
update.add_argument("--from-version", help="Source version; omit to use the explicit default")
|
|
244
|
-
update.add_argument("--file", type=Path, required=True, help="JSON patch: merge
|
|
266
|
+
update.add_argument("--file", type=Path, required=True, help="TOML or JSON patch: merge params keys, replace supplied metadata; preserve omitted fields")
|
|
267
|
+
update.add_argument("--runner", metavar="RUNNER", help="Bind a Runner revision; v1 requires a full commit; omit to preserve the source binding")
|
|
245
268
|
update_secret = update.add_mutually_exclusive_group()
|
|
246
269
|
update_secret.add_argument("--prompt-model-api-key", action="store_true", help="Read an encrypted model connection key from a non-echoing prompt")
|
|
247
270
|
update_secret.add_argument("--model-api-key-stdin", action="store_true", help="Read an encrypted model connection key from one line of standard input")
|
|
271
|
+
fork = profiles.add_parser('fork', help='Fork an existing Profile into a new immutable identity')
|
|
272
|
+
fork.add_argument('source_name')
|
|
273
|
+
fork.add_argument('source_version')
|
|
274
|
+
fork.add_argument('name')
|
|
275
|
+
fork.add_argument('version')
|
|
248
276
|
get = profiles.add_parser("get")
|
|
249
277
|
get.add_argument("name")
|
|
250
278
|
get.add_argument("--version")
|
|
@@ -404,7 +432,8 @@ def main(argv=None):
|
|
|
404
432
|
args.runner = runner_selector(args.runner)
|
|
405
433
|
except ValueError as exc:
|
|
406
434
|
cli.error(str(exc))
|
|
407
|
-
|
|
435
|
+
selections = sum(value is not None for value in (args.dataset, args.dataset_option, args.source))
|
|
436
|
+
if selections != 1 and not (selections == 0 and args.profile):
|
|
408
437
|
cli.error("provide exactly one positional dataset, --dataset or --source")
|
|
409
438
|
args.dataset = args.dataset if args.dataset is not None else args.dataset_option
|
|
410
439
|
if args.source is not None and any(getattr(args, key) is not None for key in ("runner", "task_id", "config", "split", "revision")):
|
|
@@ -424,6 +453,13 @@ def main(argv=None):
|
|
|
424
453
|
with connection as client:
|
|
425
454
|
if args.group == "profile":
|
|
426
455
|
if args.action in {"create", "update"}:
|
|
456
|
+
from .profile_updates import read_profile_patch, with_runner
|
|
457
|
+
|
|
458
|
+
raw = args.file.read_bytes()
|
|
459
|
+
patch = read_profile_patch(raw)
|
|
460
|
+
if args.action == 'update' and args.runner is not None:
|
|
461
|
+
patch = with_runner(patch, args.runner)
|
|
462
|
+
raw = json.dumps(patch).encode('utf-8')
|
|
427
463
|
secrets = None
|
|
428
464
|
if args.prompt_model_api_key:
|
|
429
465
|
import getpass
|
|
@@ -437,14 +473,14 @@ def main(argv=None):
|
|
|
437
473
|
elif args.model_api_key_stdin:
|
|
438
474
|
secrets = {"model_api_key": sys.stdin.readline(4098).rstrip("\r\n")}
|
|
439
475
|
if args.action == "update":
|
|
440
|
-
from .profile_updates import read_profile_patch
|
|
441
|
-
|
|
442
476
|
result = client.profiles.update(args.name, args.version,
|
|
443
|
-
patch=
|
|
477
|
+
patch=patch, from_version=args.from_version, secrets=secrets)
|
|
444
478
|
else:
|
|
445
479
|
result = client.profiles.import_spec(
|
|
446
|
-
|
|
480
|
+
raw, name=args.name, version=args.version, secrets=secrets, runner=args.runner
|
|
447
481
|
)
|
|
482
|
+
elif args.action == 'fork':
|
|
483
|
+
result = client.profiles.fork(args.source_name, args.source_version, name=args.name, version=args.version)
|
|
448
484
|
elif args.action == "get":
|
|
449
485
|
result = client.profiles.get(args.name, args.version)
|
|
450
486
|
elif args.action == "list":
|
|
@@ -488,6 +524,12 @@ def main(argv=None):
|
|
|
488
524
|
from .train_log_cli import read_logs
|
|
489
525
|
|
|
490
526
|
return read_logs(client.jobs, args)
|
|
527
|
+
elif args.action == "run" and args.run_action in {"start", "live", "steer", "stop"}:
|
|
528
|
+
from .rsi_cli import run_rsi
|
|
529
|
+
|
|
530
|
+
result = run_rsi(client.agent_runs, args)
|
|
531
|
+
if result is None:
|
|
532
|
+
return 0
|
|
491
533
|
elif args.action == "run":
|
|
492
534
|
result = client.agent_runs.list() if args.run_action == "list" else client.agent_runs.get(args.run)
|
|
493
535
|
elif args.action == "list":
|
|
@@ -66,6 +66,9 @@ _register("AUTH_LOGIN_INVALID_RESPONSE INVALID_PROJECT_RESPONSE INVALID_WORKSPAC
|
|
|
66
66
|
_register("WORKSPACE_PAGINATION_UNSUPPORTED PROJECT_PAGINATION_UNSUPPORTED", "The complete workspace or project selection could not be loaded.", "Select the intended workspace/project by ID and verify its own API key; check client/server API version compatibility.")
|
|
67
67
|
_register("NO_WORKSPACES NO_PROJECTS", "No selectable workspace or project was found.", "Use an API key authorized for the intended workspace/project, or add a verified selection through '{program} auth config'.")
|
|
68
68
|
_register("PROFILE_DEFAULT_REQUIRED", "This Profile has no explicit default version.", "Specify org/name@VERSION, or set an authorized default with '{program} profile set-default'.")
|
|
69
|
+
_register("INVALID_PROFILE_RUNNER", "The Profile Runner binding is invalid.", "Use org/name@<full-commit> from '{program} runner list'; file and --runner must agree.")
|
|
70
|
+
_register("PROFILE_RUNNER_CONFLICT", "The explicit Runner conflicts with the Profile binding.", "Omit --runner, use the bound Revision, or publish a new Profile version.")
|
|
71
|
+
_register("PROFILE_RUNNER_UNSUPPORTED", "This submission path cannot execute the Profile-bound Runner.", "Use a catalog Dataset Job with this Profile; retain the existing training path for unbound Profiles.")
|
|
69
72
|
_register("PROFILE_ARCHIVED PROFILE_HASH_MISMATCH", "The selected Profile is archived or does not match its frozen version.", "Select an available immutable Profile version and resubmit with the verified reference.")
|
|
70
73
|
_register("CHECKPOINT_AMBIGUOUS", "More than one checkpoint matches this selection.", "Provide --job-id or --run-id and an unambiguous checkpoint path.")
|
|
71
74
|
_register("CHECKPOINT_FILES_UNAVAILABLE ARTIFACT_NOT_READY", "The requested download is not available yet.", "Inspect the Job result and artifact/checkpoint listing before retrying the download.")
|
|
@@ -79,6 +82,11 @@ _register("RUNNER_NOT_FOUND", "The Runner reference was not found or is not read
|
|
|
79
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.")
|
|
80
83
|
_register("INVALID_SANDBOX_IMAGE", "The Sandbox image reference is invalid.", "Use a Docker image reference name[:tag][@sha256:digest] and check '{command} --help'.")
|
|
81
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
|
+
|
|
82
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.")
|
|
83
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.")
|
|
84
92
|
|
|
@@ -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)}
|
|
@@ -535,7 +584,12 @@ class Jobs:
|
|
|
535
584
|
def stop(self, job_id):
|
|
536
585
|
return self._server.stop_job(job_id)
|
|
537
586
|
|
|
538
|
-
def wait(self, job_id, *, timeout=600, poll_interval=.1):
|
|
587
|
+
def wait(self, job_id, *, timeout=600, poll_interval=.1, max_poll_interval=5):
|
|
588
|
+
"""Poll until terminal; the interval grows 1.5x per poll up to ``max_poll_interval``.
|
|
589
|
+
|
|
590
|
+
Jobs that run for tens of minutes (large-model training) would otherwise
|
|
591
|
+
issue ten status requests a second for the whole wait.
|
|
592
|
+
"""
|
|
539
593
|
if not math.isfinite(timeout) or timeout < 0:
|
|
540
594
|
raise ValueError("timeout must be finite and non-negative")
|
|
541
595
|
if not math.isfinite(poll_interval) or poll_interval <= 0:
|
|
@@ -549,6 +603,7 @@ class Jobs:
|
|
|
549
603
|
if remaining <= 0:
|
|
550
604
|
raise TimeoutError("job did not reach a terminal state")
|
|
551
605
|
time.sleep(min(poll_interval, remaining))
|
|
606
|
+
poll_interval = max(poll_interval, min(poll_interval * 1.5, max_poll_interval))
|
|
552
607
|
|
|
553
608
|
|
|
554
609
|
class Client:
|
|
@@ -603,13 +658,23 @@ class Client:
|
|
|
603
658
|
|
|
604
659
|
if 'http_client' in options:
|
|
605
660
|
raise ValueError('Client.training_service manages its authenticated HTTP client')
|
|
661
|
+
if any(name in options for name in ('base_url', 'api_key', 'default_headers')):
|
|
662
|
+
raise ValueError('Client.training_service manages its connection and credential headers')
|
|
663
|
+
overrides = options.get('default_headers_overrides') or {}
|
|
664
|
+
protected_headers = {'authorization', 'x-api-key', 'cookie', 'x-workspace-id',
|
|
665
|
+
'x-project-id', 'x-envloop-temporary-scope'}
|
|
666
|
+
if any(name.lower() in protected_headers for name in overrides):
|
|
667
|
+
raise ValueError('Client.training_service manages authentication, project and temporary scope headers')
|
|
606
668
|
transport = self._transport
|
|
669
|
+
project_id = transport.headers.get("X-Project-ID", "")
|
|
670
|
+
if 'project_id' in options and options.pop('project_id') != project_id:
|
|
671
|
+
raise ValueError('Select the training project through Client.remote(project_id=...)')
|
|
607
672
|
token = transport.request("POST", "/v1/job/tinker/api/v1/auth/token", {})["jwt"]
|
|
608
673
|
headers = {name: value for name, value in transport.headers.items() if name in {"X-Workspace-ID", "X-Project-ID"}}
|
|
609
674
|
if temporary_scope_id is not None:
|
|
610
675
|
headers["X-EnvLoop-Temporary-Scope"] = temporary_scope_id
|
|
611
676
|
base_url = transport.base + "/v1/job/tinker"
|
|
612
|
-
return ServiceClient(base_url=base_url, api_key=token, default_headers=headers,
|
|
677
|
+
return ServiceClient(base_url=base_url, api_key=token, project_id=project_id, default_headers=headers,
|
|
613
678
|
http_client=TrainingHttpClient(base_url, transport.headers["Authorization"]), **options)
|
|
614
679
|
|
|
615
680
|
@classmethod
|
|
@@ -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))
|
|
@@ -167,7 +167,8 @@ def _checkpoint_matches(path, item):
|
|
|
167
167
|
return True
|
|
168
168
|
# The bare-session alias is resolved by the server for either kind.
|
|
169
169
|
return (parsed.netloc == item.get('job_id') and name in {
|
|
170
|
-
'weights/' + str(item.get('checkpoint_id')), 'sampler_weights/' + str(item.get('checkpoint_id'))
|
|
170
|
+
'weights/' + str(item.get('checkpoint_id')), 'sampler_weights/' + str(item.get('checkpoint_id')),
|
|
171
|
+
'external_weights/' + str(item.get('checkpoint_id'))})
|
|
171
172
|
|
|
172
173
|
|
|
173
174
|
def _checkpoint_target(directory, name):
|
|
@@ -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"}
|
|
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)
|
|
@@ -47,3 +55,22 @@ def checked_patch(patch, name, version):
|
|
|
47
55
|
except (ValueError, TypeError, OverflowError):
|
|
48
56
|
raise RemoteError("INVALID_PROFILE", 422) from None
|
|
49
57
|
return dict(patch)
|
|
58
|
+
|
|
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
|
+
|
|
72
|
+
def with_runner(value, runner):
|
|
73
|
+
"""Keep a file binding and explicit CLI binding unambiguous."""
|
|
74
|
+
if not isinstance(value, Mapping) or value.get('runner', runner) != runner:
|
|
75
|
+
raise RemoteError('INVALID_PROFILE_RUNNER', 422)
|
|
76
|
+
return dict(value, runner=runner)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""``el job run start|live|steer|stop``: thin wrappers over the platform's RSI routes."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
FINISHED = frozenset({"finished", "failed", "stopped", "unreadable"})
|
|
7
|
+
POLL_SECONDS = 3.0
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _brief(args):
|
|
11
|
+
if args.brief is not None:
|
|
12
|
+
return args.brief
|
|
13
|
+
if args.brief_file == "-":
|
|
14
|
+
return sys.stdin.read()
|
|
15
|
+
with open(args.brief_file, encoding="utf-8") as handle:
|
|
16
|
+
return handle.read()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _follow(runs, run, since):
|
|
20
|
+
"""Print new events as ``seq kind: text`` until the run ends; the run itself is left alone."""
|
|
21
|
+
while True:
|
|
22
|
+
live = runs.live(run, since)
|
|
23
|
+
for event in live.get("events") or []:
|
|
24
|
+
print(f"{event.get('seq')} {event.get('kind')}: {event.get('text') or ''}", flush=True)
|
|
25
|
+
if isinstance(live.get("next_cursor"), int):
|
|
26
|
+
since = live["next_cursor"]
|
|
27
|
+
if live.get("state") in FINISHED:
|
|
28
|
+
print(f"state: {live['state']}", flush=True)
|
|
29
|
+
return
|
|
30
|
+
time.sleep(POLL_SECONDS)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run_rsi(runs, args):
|
|
34
|
+
"""Run one action; returns the result to print, or None when it printed its own output."""
|
|
35
|
+
if args.run_action == "start":
|
|
36
|
+
return runs.start(args.run, _brief(args), model_usd_cap=args.model_usd_cap, usd_cap=args.usd_cap,
|
|
37
|
+
model_request_cap=args.model_request_cap, domain=args.domain, rounds=args.rounds,
|
|
38
|
+
dry_run=args.dry_run, student_model=args.student_model)
|
|
39
|
+
if args.run_action == "live":
|
|
40
|
+
if not args.follow:
|
|
41
|
+
return runs.live(args.run, args.since)
|
|
42
|
+
try:
|
|
43
|
+
_follow(runs, args.run, args.since)
|
|
44
|
+
except KeyboardInterrupt:
|
|
45
|
+
pass
|
|
46
|
+
return None
|
|
47
|
+
if args.run_action == "steer":
|
|
48
|
+
return runs.steer(args.run, args.text)
|
|
49
|
+
return runs.control(args.run, "stop")
|
|
@@ -44,7 +44,6 @@ from tinker import (
|
|
|
44
44
|
SampledSequence as SampledSequence,
|
|
45
45
|
SampleRequest as SampleRequest,
|
|
46
46
|
SampleResponse as SampleResponse,
|
|
47
|
-
SamplingClient as SamplingClient,
|
|
48
47
|
SamplingParams as SamplingParams,
|
|
49
48
|
StopReason as StopReason,
|
|
50
49
|
TensorData as TensorData,
|
|
@@ -59,7 +58,9 @@ from tinker import (
|
|
|
59
58
|
auth as auth,
|
|
60
59
|
types as types,
|
|
61
60
|
)
|
|
61
|
+
from tinker.types import ImageChunk as ImageChunk
|
|
62
62
|
|
|
63
|
+
from .sampling_client import SamplingClient as SamplingClient
|
|
63
64
|
from .service_client import RestClient as RestClient, ServiceClient as ServiceClient
|
|
64
65
|
|
|
65
66
|
__all__ = [
|
|
@@ -79,6 +80,7 @@ __all__ = [
|
|
|
79
80
|
"Datum",
|
|
80
81
|
"DmelChunk",
|
|
81
82
|
"EncodedTextChunk",
|
|
83
|
+
"ImageChunk",
|
|
82
84
|
"ForwardBackwardOutput",
|
|
83
85
|
"SampledProvenanceSpan",
|
|
84
86
|
"LoraConfig",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Per-service HTTP auth boundary for the pinned Tinker client's JWT renewals."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from tinker._base_client import DefaultAsyncHttpxClient
|
|
8
|
+
from tinker.lib.internal_client_holder import InternalClientHolder, _ShadowHolderConfig
|
|
9
|
+
|
|
10
|
+
_SCOPE_HEADERS = {'x-workspace-id', 'x-project-id', 'x-envloop-temporary-scope'}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _ProcessLocalShadowConfig(_ShadowHolderConfig):
|
|
14
|
+
"""Prevent native SamplingClient pickle from exporting the bootstrap JWT.
|
|
15
|
+
|
|
16
|
+
Upstream serializes shadow_config rather than the holder itself. Its handle
|
|
17
|
+
export first calls without_credentials(), which returns the official plain
|
|
18
|
+
config shape without this process boundary or any authentication headers.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __reduce__(self):
|
|
22
|
+
raise TypeError('EnvLoop training services are process-local; export a sampler handle instead')
|
|
23
|
+
|
|
24
|
+
def without_credentials(self):
|
|
25
|
+
fields = dataclasses.asdict(self)
|
|
26
|
+
fields['api_key'] = None
|
|
27
|
+
fields['default_headers_overrides'] = {
|
|
28
|
+
name: value for name, value in self.default_headers_overrides.items()
|
|
29
|
+
if name.lower() in _SCOPE_HEADERS
|
|
30
|
+
}
|
|
31
|
+
return _ShadowHolderConfig(**fields)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TrainingClientHolder(InternalClientHolder):
|
|
35
|
+
"""Instance-local transport options for the pinned Tinker 0.34 holder.
|
|
36
|
+
|
|
37
|
+
The high-level upstream constructor no longer forwards transport options.
|
|
38
|
+
Inject them before its first config request, so config, refresh, session and
|
|
39
|
+
REST pools all retain the same authenticated transport and timeout policy.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, *, transport_options, **kwargs):
|
|
43
|
+
self._envloop_transport_options = transport_options
|
|
44
|
+
super().__init__(**kwargs)
|
|
45
|
+
|
|
46
|
+
async def _fetch_client_config(self, auth):
|
|
47
|
+
self._constructor_kwargs.update(self._envloop_transport_options)
|
|
48
|
+
return await super()._fetch_client_config(auth)
|
|
49
|
+
|
|
50
|
+
def __reduce__(self):
|
|
51
|
+
raise TypeError('EnvLoop training services are process-local; create a new training_service in each process')
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def shadow_config(self):
|
|
55
|
+
return _ProcessLocalShadowConfig(**dataclasses.asdict(super().shadow_config))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _PlatformCredential(httpx.Auth):
|
|
59
|
+
def __init__(self, authorization):
|
|
60
|
+
self._authorization = authorization
|
|
61
|
+
|
|
62
|
+
def auth_flow(self, request):
|
|
63
|
+
# Never combine the original credential with Tinker's bootstrap JWT.
|
|
64
|
+
request.headers.pop('X-API-Key', None)
|
|
65
|
+
request.headers['Authorization'] = self._authorization
|
|
66
|
+
yield request
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TrainingHttpClient(DefaultAsyncHttpxClient):
|
|
70
|
+
"""Keep upstream JWT refresh, but authenticate each exchange with its source.
|
|
71
|
+
|
|
72
|
+
Only the exact configured config/exchange URLs receive the original key.
|
|
73
|
+
TrainingClientHolder forwards this through the pinned low-level client's
|
|
74
|
+
http_client option without global auth/environment hooks.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, base_url, authorization):
|
|
78
|
+
super().__init__(follow_redirects=False)
|
|
79
|
+
self._credential_urls = {httpx.URL(base_url + path) for path in
|
|
80
|
+
('/api/v1/client/config', '/api/v1/auth/token')}
|
|
81
|
+
self._platform_credential = _PlatformCredential(authorization)
|
|
82
|
+
|
|
83
|
+
async def send(self, request, **kwargs):
|
|
84
|
+
# X-API-Key is not stripped by HTTPX on cross-origin redirects. Both
|
|
85
|
+
# bootstrap exchanges and ordinary JWT requests must stay on their URL.
|
|
86
|
+
kwargs['follow_redirects'] = False
|
|
87
|
+
exchange = request.method == 'POST' and request.url in self._credential_urls
|
|
88
|
+
if exchange:
|
|
89
|
+
kwargs['auth'] = self._platform_credential
|
|
90
|
+
response = await super().send(request, **kwargs)
|
|
91
|
+
if exchange and not 200 <= response.status_code < 300:
|
|
92
|
+
# Upstream logs refresh failures. Retain a bounded code/status, never
|
|
93
|
+
# a provider's arbitrary response body or potentially secret detail.
|
|
94
|
+
await response.aread()
|
|
95
|
+
try:
|
|
96
|
+
code = response.json().get('code')
|
|
97
|
+
except (ValueError, AttributeError):
|
|
98
|
+
code = None
|
|
99
|
+
if not isinstance(code, str) or not re.fullmatch(r'[A-Z][A-Z0-9_]{0,127}', code):
|
|
100
|
+
code = 'TRAINING_AUTH_FAILED'
|
|
101
|
+
await response.aclose()
|
|
102
|
+
return httpx.Response(response.status_code, request=response.request,
|
|
103
|
+
json={'code': code, 'error': 'EnvLoop training authorization failed'})
|
|
104
|
+
return response
|
|
105
|
+
|
|
106
|
+
def __reduce__(self):
|
|
107
|
+
raise TypeError('EnvLoop training services are process-local; create a new training_service in each process')
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Restore official sampler handles using an explicit EnvLoop credential."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
from tinker import SamplingClient as TinkerSamplingClient
|
|
8
|
+
from tinker.lib.internal_client_holder import _ShadowHolderConfig
|
|
9
|
+
from tinker.lib.public_interfaces.sampling_client import _SamplingClientPickleState
|
|
10
|
+
|
|
11
|
+
from .credentials import TrainingClientHolder
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SamplingClient(TinkerSamplingClient):
|
|
15
|
+
@staticmethod
|
|
16
|
+
def from_sampler_handle(handle, *, api_key=None):
|
|
17
|
+
fields = json.loads(base64.urlsafe_b64decode(handle))
|
|
18
|
+
config = _ShadowHolderConfig(**fields.pop('holder'))
|
|
19
|
+
state = _SamplingClientPickleState(**fields, holder=config)
|
|
20
|
+
url = urlsplit(config.base_url)
|
|
21
|
+
if not url.path.rstrip('/').endswith('/v1/job/tinker'):
|
|
22
|
+
return TinkerSamplingClient.from_sampler_handle(handle, api_key=api_key)
|
|
23
|
+
if not isinstance(api_key, str) or not api_key.strip():
|
|
24
|
+
raise ValueError('EnvLoop sampler handles require an explicit original EnvLoop api_key')
|
|
25
|
+
if url.scheme not in {'http', 'https'} or not url.hostname or url.username or url.password or url.query or url.fragment:
|
|
26
|
+
raise ValueError('invalid EnvLoop sampler API URL')
|
|
27
|
+
if config.api_key is not None:
|
|
28
|
+
raise ValueError('EnvLoop sampler handles must not contain credentials')
|
|
29
|
+
headers = {name.lower(): value for name, value in config.default_headers_overrides.items()}
|
|
30
|
+
if set(headers) - {'x-workspace-id', 'x-project-id', 'x-envloop-temporary-scope'}:
|
|
31
|
+
raise ValueError('EnvLoop sampler handles may contain only workspace, project and temporary scope headers')
|
|
32
|
+
|
|
33
|
+
from envloop import Client
|
|
34
|
+
from envloop.http import HttpService
|
|
35
|
+
|
|
36
|
+
base_url = config.base_url.rstrip('/')
|
|
37
|
+
# A missing scope means key-selected scope, never this worker's ambient
|
|
38
|
+
# EnvLoop configuration or environment.
|
|
39
|
+
client = Client(HttpService(base_url.removesuffix('/v1/job/tinker'), api_key=api_key,
|
|
40
|
+
workspace_id=headers.get('x-workspace-id'),
|
|
41
|
+
project_id=headers.get('x-project-id'), config=False, environment=False))
|
|
42
|
+
try:
|
|
43
|
+
service = client.training_service(temporary_scope_id=headers.get('x-envloop-temporary-scope'))
|
|
44
|
+
# Fetch current server flags with the explicit original key. Never
|
|
45
|
+
# trust cached auth flags from a portable handle or create a new job.
|
|
46
|
+
holder = TrainingClientHolder(
|
|
47
|
+
session_id=state.session_id, base_url=service._base_url, api_key=service._api_key,
|
|
48
|
+
default_headers_overrides=service._default_headers_overrides,
|
|
49
|
+
transport_options=service._envloop_transport_options,
|
|
50
|
+
)
|
|
51
|
+
return SamplingClient(holder, sampling_session_id=state.sampling_session_id,
|
|
52
|
+
shadow=True, record_stability_info=state.record_stability_info)
|
|
53
|
+
finally:
|
|
54
|
+
client.close()
|
|
55
|
+
|
|
56
|
+
def close(self):
|
|
57
|
+
"""Release this restored client's local resources without finishing its job."""
|
|
58
|
+
return self.holder.run_coroutine_threadsafe(self.holder.aclose())
|
|
@@ -72,11 +72,32 @@ class RestClient(TinkerRestClient):
|
|
|
72
72
|
async def set_checkpoint_ttl_from_tinker_path_async(self, tinker_path_, ttl_seconds):
|
|
73
73
|
return await super().set_checkpoint_ttl_from_tinker_path_async(tinker_path(tinker_path_), ttl_seconds)
|
|
74
74
|
|
|
75
|
+
def copy_weights(self, path, destination_project_id=None, *, ttl_seconds=None, weights_access_token=None):
|
|
76
|
+
return super().copy_weights(tinker_path(path), destination_project_id,
|
|
77
|
+
ttl_seconds=ttl_seconds, weights_access_token=weights_access_token)
|
|
78
|
+
|
|
79
|
+
async def copy_weights_async(self, path, destination_project_id=None, *, ttl_seconds=None, weights_access_token=None):
|
|
80
|
+
return await super().copy_weights_async(tinker_path(path), destination_project_id,
|
|
81
|
+
ttl_seconds=ttl_seconds, weights_access_token=weights_access_token)
|
|
82
|
+
|
|
83
|
+
def get_external_weights_urls(self, path):
|
|
84
|
+
return super().get_external_weights_urls(self._external_path(path))
|
|
85
|
+
|
|
86
|
+
async def get_external_weights_urls_async(self, path):
|
|
87
|
+
return await super().get_external_weights_urls_async(self._external_path(path))
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _external_path(path):
|
|
91
|
+
if isinstance(path, str) and path.startswith("envloop://"):
|
|
92
|
+
match = _checked(path)
|
|
93
|
+
return f"tinker://{match['session']}/external_weights/{match['checkpoint']}"
|
|
94
|
+
return path
|
|
95
|
+
|
|
75
96
|
|
|
76
97
|
class ServiceClient(TinkerServiceClient):
|
|
77
98
|
"""Tinker ServiceClient accepting EnvLoop checkpoint paths.
|
|
78
99
|
|
|
79
|
-
Tinker
|
|
100
|
+
Tinker validates sampling paths in its shared session holder, and its
|
|
80
101
|
RestClient parses paths client-side, before sending HTTP. Adapt those boundaries
|
|
81
102
|
so ServiceClient, its TrainingClients and RestClients accept envloop:// without
|
|
82
103
|
replacing native futures, training clients, authentication, or globally patching
|
|
@@ -84,6 +105,13 @@ class ServiceClient(TinkerServiceClient):
|
|
|
84
105
|
"""
|
|
85
106
|
|
|
86
107
|
def __init__(self, *args, **kwargs):
|
|
108
|
+
# 0.34's public constructor silently ignores these previously supported
|
|
109
|
+
# options. Keep EnvLoop's per-service auth/timeout boundary explicit.
|
|
110
|
+
self._envloop_transport_options = {name: kwargs.pop(name) for name in
|
|
111
|
+
('http_client', 'timeout', 'max_retries') if name in kwargs}
|
|
112
|
+
headers = kwargs.pop('default_headers', None)
|
|
113
|
+
if headers is not None:
|
|
114
|
+
kwargs['default_headers_overrides'] = dict(headers) | dict(kwargs.get('default_headers_overrides') or {})
|
|
87
115
|
super().__init__(*args, **kwargs)
|
|
88
116
|
self._checkpoint_adapter_lock = threading.Lock()
|
|
89
117
|
self._checkpoint_adapter_holder = None
|
|
@@ -92,7 +120,7 @@ class ServiceClient(TinkerServiceClient):
|
|
|
92
120
|
return RestClient(self._get_rest_holder())
|
|
93
121
|
|
|
94
122
|
def _get_session_holder(self):
|
|
95
|
-
holder = super()._get_session_holder()
|
|
123
|
+
holder = self._envloop_holder(session=True) if self._envloop_transport_options else super()._get_session_holder()
|
|
96
124
|
with self._checkpoint_adapter_lock:
|
|
97
125
|
if self._checkpoint_adapter_holder is not holder:
|
|
98
126
|
create = holder._create_sampling_session
|
|
@@ -106,3 +134,32 @@ class ServiceClient(TinkerServiceClient):
|
|
|
106
134
|
holder._create_sampling_session = create_sampling_session
|
|
107
135
|
self._checkpoint_adapter_holder = holder
|
|
108
136
|
return holder
|
|
137
|
+
|
|
138
|
+
def _get_rest_holder(self):
|
|
139
|
+
return self._envloop_holder(session=False) if self._envloop_transport_options else super()._get_rest_holder()
|
|
140
|
+
|
|
141
|
+
def _envloop_holder(self, *, session):
|
|
142
|
+
from .credentials import TrainingClientHolder
|
|
143
|
+
|
|
144
|
+
attribute = '_session_holder' if session else '_rest_holder'
|
|
145
|
+
lock = self._session_holder_lock if session else self._rest_holder_lock
|
|
146
|
+
with self._lifecycle_lock:
|
|
147
|
+
if self._closed:
|
|
148
|
+
raise RuntimeError('ServiceClient is closed')
|
|
149
|
+
holder = getattr(self, attribute)
|
|
150
|
+
if holder is not None:
|
|
151
|
+
return holder
|
|
152
|
+
with lock:
|
|
153
|
+
with self._lifecycle_lock:
|
|
154
|
+
if self._closed:
|
|
155
|
+
raise RuntimeError('ServiceClient is closed')
|
|
156
|
+
holder = getattr(self, attribute)
|
|
157
|
+
if holder is None:
|
|
158
|
+
options = dict(user_metadata=self._user_metadata, project_id=self._project_id,
|
|
159
|
+
display_name=self._display_name) if session else {'_skip_session': True}
|
|
160
|
+
holder = TrainingClientHolder(
|
|
161
|
+
base_url=self._base_url, api_key=self._api_key,
|
|
162
|
+
default_headers_overrides=self._default_headers_overrides,
|
|
163
|
+
transport_options=self._envloop_transport_options, **options)
|
|
164
|
+
setattr(self, attribute, holder)
|
|
165
|
+
return holder
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "envloop-cli"
|
|
3
|
-
version = "
|
|
3
|
+
version = "1.4.0"
|
|
4
4
|
description = "EnvLoop CLI and Python SDK for jobs, training, checkpoints and inference"
|
|
5
5
|
requires-python = ">=3.11"
|
|
6
6
|
readme = "README.pypi.md"
|
|
7
|
-
dependencies = ["tinker==0.
|
|
7
|
+
dependencies = ["tinker==0.34.0"]
|
|
8
8
|
|
|
9
9
|
[project.scripts]
|
|
10
10
|
envloop = "envloop.cli:main"
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
"""Per-service HTTP auth boundary for the pinned Tinker client's JWT renewals."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
|
|
5
|
-
import httpx
|
|
6
|
-
from tinker._base_client import DefaultAsyncHttpxClient
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class _PlatformCredential(httpx.Auth):
|
|
10
|
-
def __init__(self, authorization):
|
|
11
|
-
self._authorization = authorization
|
|
12
|
-
|
|
13
|
-
def auth_flow(self, request):
|
|
14
|
-
# Never combine the original credential with Tinker's bootstrap JWT.
|
|
15
|
-
request.headers.pop('X-API-Key', None)
|
|
16
|
-
request.headers['Authorization'] = self._authorization
|
|
17
|
-
yield request
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class TrainingHttpClient(DefaultAsyncHttpxClient):
|
|
21
|
-
"""Keep upstream JWT refresh, but authenticate each exchange with its source.
|
|
22
|
-
|
|
23
|
-
Only the exact configured config/exchange URLs receive the original key.
|
|
24
|
-
This uses Tinker's supported http_client option; no global auth/environment
|
|
25
|
-
hooks or changes to the server token lifetime are required.
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
def __init__(self, base_url, authorization):
|
|
29
|
-
super().__init__()
|
|
30
|
-
self._credential_urls = {httpx.URL(base_url + path) for path in
|
|
31
|
-
('/api/v1/client/config', '/api/v1/auth/token')}
|
|
32
|
-
self._platform_credential = _PlatformCredential(authorization)
|
|
33
|
-
|
|
34
|
-
async def send(self, request, **kwargs):
|
|
35
|
-
exchange = request.method == 'POST' and request.url in self._credential_urls
|
|
36
|
-
if exchange:
|
|
37
|
-
kwargs['auth'] = self._platform_credential
|
|
38
|
-
kwargs['follow_redirects'] = False
|
|
39
|
-
response = await super().send(request, **kwargs)
|
|
40
|
-
if exchange and not 200 <= response.status_code < 300:
|
|
41
|
-
# Upstream logs refresh failures. Retain a bounded code/status, never
|
|
42
|
-
# a provider's arbitrary response body or potentially secret detail.
|
|
43
|
-
await response.aread()
|
|
44
|
-
try:
|
|
45
|
-
code = response.json().get('code')
|
|
46
|
-
except (ValueError, AttributeError):
|
|
47
|
-
code = None
|
|
48
|
-
if not isinstance(code, str) or not re.fullmatch(r'[A-Z][A-Z0-9_]{0,127}', code):
|
|
49
|
-
code = 'TRAINING_AUTH_FAILED'
|
|
50
|
-
await response.aclose()
|
|
51
|
-
return httpx.Response(response.status_code, request=response.request,
|
|
52
|
-
json={'code': code, 'error': 'EnvLoop training authorization failed'})
|
|
53
|
-
return response
|
|
54
|
-
|
|
55
|
-
def __reduce__(self):
|
|
56
|
-
raise TypeError('EnvLoop training services are process-local; create a new training_service in each process')
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|