envloop-cli 0.3.0__tar.gz → 0.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-0.4.0}/PKG-INFO +2 -2
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/cli.py +11 -4
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/cli_errors.py +3 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/client.py +11 -1
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/navigation_resources.py +2 -1
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/profile_updates.py +8 -1
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/training/__init__.py +3 -1
- envloop_cli-0.4.0/envloop/training/credentials.py +107 -0
- envloop_cli-0.4.0/envloop/training/sampling_client.py +58 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/training/service_client.py +59 -2
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/pyproject.toml +2 -2
- envloop_cli-0.3.0/envloop/training/credentials.py +0 -56
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/.gitignore +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/README.pypi.md +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/__init__.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/agent_runs.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/auth_cli.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/auth_login.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/auth_workspace.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/cli_output.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/config.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/http.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/job_logs.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/lifecycle_cli.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/profile_secrets.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/resource_cli.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/resources.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/sandbox.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/train.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/train_log_cli.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.4.0}/envloop/utils/__init__.py +0 -0
- {envloop_cli-0.3.0 → envloop_cli-0.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: 0.
|
|
3
|
+
Version: 0.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
|
|
@@ -234,6 +234,7 @@ def parser():
|
|
|
234
234
|
put.add_argument("name")
|
|
235
235
|
put.add_argument("version")
|
|
236
236
|
put.add_argument("--file", type=Path, required=True, help="ProfileSpec JSON file")
|
|
237
|
+
put.add_argument("--runner", metavar="RUNNER", help="Bind org/name@<full-commit> to this version")
|
|
237
238
|
secret_input = put.add_mutually_exclusive_group()
|
|
238
239
|
secret_input.add_argument("--prompt-model-api-key", action="store_true", help="Read the model API key from a non-echoing prompt")
|
|
239
240
|
secret_input.add_argument("--model-api-key-stdin", action="store_true", help="Read the model API key from one line of standard input")
|
|
@@ -242,6 +243,7 @@ def parser():
|
|
|
242
243
|
update.add_argument("version", help="New version; existing versions cannot be overwritten")
|
|
243
244
|
update.add_argument("--from-version", help="Source version; omit to use the explicit default")
|
|
244
245
|
update.add_argument("--file", type=Path, required=True, help="JSON patch: merge literal params keys, replace supplied metadata; preserve omitted fields")
|
|
246
|
+
update.add_argument("--runner", metavar="RUNNER", help="Bind a Runner name@full-commit; omit to preserve the source binding")
|
|
245
247
|
update_secret = update.add_mutually_exclusive_group()
|
|
246
248
|
update_secret.add_argument("--prompt-model-api-key", action="store_true", help="Read an encrypted model connection key from a non-echoing prompt")
|
|
247
249
|
update_secret.add_argument("--model-api-key-stdin", action="store_true", help="Read an encrypted model connection key from one line of standard input")
|
|
@@ -424,6 +426,13 @@ def main(argv=None):
|
|
|
424
426
|
with connection as client:
|
|
425
427
|
if args.group == "profile":
|
|
426
428
|
if args.action in {"create", "update"}:
|
|
429
|
+
from .profile_updates import read_profile_patch, with_runner
|
|
430
|
+
|
|
431
|
+
raw = args.file.read_bytes()
|
|
432
|
+
patch = read_profile_patch(raw)
|
|
433
|
+
if args.runner is not None:
|
|
434
|
+
patch = with_runner(patch, args.runner)
|
|
435
|
+
raw = json.dumps(patch).encode('utf-8')
|
|
427
436
|
secrets = None
|
|
428
437
|
if args.prompt_model_api_key:
|
|
429
438
|
import getpass
|
|
@@ -437,13 +446,11 @@ def main(argv=None):
|
|
|
437
446
|
elif args.model_api_key_stdin:
|
|
438
447
|
secrets = {"model_api_key": sys.stdin.readline(4098).rstrip("\r\n")}
|
|
439
448
|
if args.action == "update":
|
|
440
|
-
from .profile_updates import read_profile_patch
|
|
441
|
-
|
|
442
449
|
result = client.profiles.update(args.name, args.version,
|
|
443
|
-
patch=
|
|
450
|
+
patch=patch, from_version=args.from_version, secrets=secrets)
|
|
444
451
|
else:
|
|
445
452
|
result = client.profiles.import_spec(
|
|
446
|
-
|
|
453
|
+
raw, name=args.name, version=args.version, secrets=secrets
|
|
447
454
|
)
|
|
448
455
|
elif args.action == "get":
|
|
449
456
|
result = client.profiles.get(args.name, args.version)
|
|
@@ -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.")
|
|
@@ -603,13 +603,23 @@ class Client:
|
|
|
603
603
|
|
|
604
604
|
if 'http_client' in options:
|
|
605
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')
|
|
606
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=...)')
|
|
607
617
|
token = transport.request("POST", "/v1/job/tinker/api/v1/auth/token", {})["jwt"]
|
|
608
618
|
headers = {name: value for name, value in transport.headers.items() if name in {"X-Workspace-ID", "X-Project-ID"}}
|
|
609
619
|
if temporary_scope_id is not None:
|
|
610
620
|
headers["X-EnvLoop-Temporary-Scope"] = temporary_scope_id
|
|
611
621
|
base_url = transport.base + "/v1/job/tinker"
|
|
612
|
-
return ServiceClient(base_url=base_url, api_key=token, default_headers=headers,
|
|
622
|
+
return ServiceClient(base_url=base_url, api_key=token, project_id=project_id, default_headers=headers,
|
|
613
623
|
http_client=TrainingHttpClient(base_url, transport.headers["Authorization"]), **options)
|
|
614
624
|
|
|
615
625
|
@classmethod
|
|
@@ -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):
|
|
@@ -24,7 +24,7 @@ def read_profile_patch(raw):
|
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
def checked_patch(patch, name, version):
|
|
27
|
-
allowed = {"schema_version", "name", "version", "params", "description", "created_for", "secret_params"}
|
|
27
|
+
allowed = {"schema_version", "name", "version", "params", "description", "created_for", "secret_params", "runner"}
|
|
28
28
|
|
|
29
29
|
def string_keys(value):
|
|
30
30
|
if isinstance(value, Mapping):
|
|
@@ -47,3 +47,10 @@ def checked_patch(patch, name, version):
|
|
|
47
47
|
except (ValueError, TypeError, OverflowError):
|
|
48
48
|
raise RemoteError("INVALID_PROFILE", 422) from None
|
|
49
49
|
return dict(patch)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def with_runner(value, runner):
|
|
53
|
+
"""Keep a file binding and explicit CLI binding unambiguous."""
|
|
54
|
+
if not isinstance(value, Mapping) or value.get('runner', runner) != runner:
|
|
55
|
+
raise RemoteError('INVALID_PROFILE_RUNNER', 422)
|
|
56
|
+
return dict(value, runner=runner)
|
|
@@ -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 = "0.
|
|
3
|
+
version = "0.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|