hivekit 0.8.2__tar.gz → 0.9.1__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.
- {hivekit-0.8.2 → hivekit-0.9.1}/PKG-INFO +1 -1
- {hivekit-0.8.2 → hivekit-0.9.1}/pyproject.toml +1 -1
- hivekit-0.9.1/src/cli/auth/device_flow.py +204 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/auth/session_manager.py +41 -3
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/config.py +76 -9
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/experiment.py +2 -2
- hivekit-0.9.1/src/cli/experiment_download.py +131 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/http_client.py +107 -85
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/main.py +116 -14
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/skills/hive-setup/SKILL.md +2 -4
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/skills/hive-setup/references/configuration.md +8 -7
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/skills/hive-setup/references/gpu-hardware.md +4 -15
- hivekit-0.9.1/src/cli/utils/environment.py +29 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/url_utils.py +11 -8
- {hivekit-0.8.2 → hivekit-0.9.1}/src/hivekit.egg-info/PKG-INFO +1 -1
- {hivekit-0.8.2 → hivekit-0.9.1}/src/hivekit.egg-info/SOURCES.txt +3 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_config.py +54 -15
- {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_experiment.py +9 -4
- {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_http_client.py +28 -0
- hivekit-0.9.1/tests/test_login.py +255 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_main.py +398 -7
- hivekit-0.8.2/tests/test_login.py +0 -121
- {hivekit-0.8.2 → hivekit-0.9.1}/LICENSE +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/README.md +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/setup.cfg +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/__init__.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/auth/__init__.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/auth/auth_utils.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/auth/credential_store.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/auth/login_page.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/auth/logo.svg +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/auth/oidc_flow.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/auth/token_revoker.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/completers.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/skills/hive-setup/references/multi-evaluator.md +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/skills_install.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/update_check.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/__init__.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/archive.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/attachments.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/config_paths.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/config_sync.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/docker.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/logger.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/terminal.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/time.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/upload.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/version.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/hivekit.egg-info/dependency_links.txt +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/hivekit.egg-info/entry_points.txt +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/hivekit.egg-info/requires.txt +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/src/hivekit.egg-info/top_level.txt +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_attachments.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_completers.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_config_sync.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_logout.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_overrides.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_push_image.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_time_utils.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_update_check.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_upload.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_version.py +0 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Copyright (C) 2026 Hiverge
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
OIDC device authorization login flow (RFC 8628).
|
|
7
|
+
|
|
8
|
+
Used on headless machines (such as VMs) where no browser is
|
|
9
|
+
available to receive a redirect. The user is shown a URL containing a code
|
|
10
|
+
and the CLI polls the token endpoint until they have authorized.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Callable, Dict, Optional, Protocol
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
|
|
20
|
+
from cli.utils.url_utils import build_oidc_endpoints
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("hivekit")
|
|
23
|
+
|
|
24
|
+
_OIDC_CLIENT_ID = "hiverge"
|
|
25
|
+
_SCOPE = "openid offline_access"
|
|
26
|
+
_DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
|
27
|
+
_REQUEST_TIMEOUT = 30.0
|
|
28
|
+
|
|
29
|
+
# RFC 8628 section 3.5: on a slow_down response the client must increase the
|
|
30
|
+
# polling interval by five seconds.
|
|
31
|
+
_SLOW_DOWN_INCREMENT = 5
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DeviceAuthorizationError(Exception):
|
|
35
|
+
"""
|
|
36
|
+
Raised when the device authorization flow fails or is denied.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _PostCallable(Protocol):
|
|
41
|
+
"""
|
|
42
|
+
Protocol matching the signature of `requests.post` for dependency injection.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __call__(
|
|
46
|
+
self,
|
|
47
|
+
url: str,
|
|
48
|
+
data: Dict[str, str],
|
|
49
|
+
timeout: float,
|
|
50
|
+
verify: bool,
|
|
51
|
+
) -> requests.Response:
|
|
52
|
+
"""
|
|
53
|
+
Make an HTTP POST request and return the response.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class OidcDeviceLoginFlow:
|
|
58
|
+
"""
|
|
59
|
+
Orchestrates the OIDC device authorization login flow.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
identity_base_url: str,
|
|
65
|
+
organization_id: str,
|
|
66
|
+
post: _PostCallable = requests.post,
|
|
67
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
68
|
+
console: Optional[Console] = None,
|
|
69
|
+
insecure: bool = False,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Initialize the device authorization login flow.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
identity_base_url: The base URL for the identity provider.
|
|
76
|
+
organization_id: The organization (Keycloak realm) to authenticate against.
|
|
77
|
+
post: Callable used to make HTTP POST requests (injected for testability).
|
|
78
|
+
sleep: Callable used to wait between polls (injected for testability).
|
|
79
|
+
console: Rich console for output (defaults to a new Console).
|
|
80
|
+
insecure: If True, disable SSL certificate verification on requests.
|
|
81
|
+
"""
|
|
82
|
+
self._identity_base_url = identity_base_url
|
|
83
|
+
self._organization_id = organization_id
|
|
84
|
+
self._post = post
|
|
85
|
+
self._sleep = sleep
|
|
86
|
+
self._console = console or Console()
|
|
87
|
+
self._insecure = insecure
|
|
88
|
+
|
|
89
|
+
def login(self) -> Dict[str, Any]:
|
|
90
|
+
"""
|
|
91
|
+
Run the full device authorization login flow.
|
|
92
|
+
|
|
93
|
+
Requests a device code, prompts the user to visit the verification URL
|
|
94
|
+
and enter the user code, then polls the token endpoint until the user
|
|
95
|
+
authorizes the device and returns the token dict.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
DeviceAuthorizationError: If the request is denied, the device code
|
|
99
|
+
expires, or the identity provider returns an unexpected error.
|
|
100
|
+
"""
|
|
101
|
+
endpoints = build_oidc_endpoints(
|
|
102
|
+
identity_base_url=self._identity_base_url,
|
|
103
|
+
organization_id=self._organization_id,
|
|
104
|
+
)
|
|
105
|
+
authorization = self._request_device_code(
|
|
106
|
+
device_authorization_endpoint=endpoints["device_authorization_endpoint"],
|
|
107
|
+
)
|
|
108
|
+
self._display_instructions(authorization=authorization)
|
|
109
|
+
return self._poll_for_token(
|
|
110
|
+
token_endpoint=endpoints["token_endpoint"],
|
|
111
|
+
authorization=authorization,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _request_device_code(self, device_authorization_endpoint: str) -> Dict[str, Any]:
|
|
115
|
+
"""
|
|
116
|
+
Request a device and user code from the device authorization endpoint.
|
|
117
|
+
"""
|
|
118
|
+
response = self._post(
|
|
119
|
+
url=device_authorization_endpoint,
|
|
120
|
+
data={"client_id": _OIDC_CLIENT_ID, "scope": _SCOPE},
|
|
121
|
+
timeout=_REQUEST_TIMEOUT,
|
|
122
|
+
verify=not self._insecure,
|
|
123
|
+
)
|
|
124
|
+
if not response.ok:
|
|
125
|
+
raise DeviceAuthorizationError(
|
|
126
|
+
f"Device authorization request to {device_authorization_endpoint} failed "
|
|
127
|
+
f"with status {response.status_code}: {response.text}"
|
|
128
|
+
)
|
|
129
|
+
return response.json()
|
|
130
|
+
|
|
131
|
+
def _display_instructions(self, authorization: Dict[str, Any]) -> None:
|
|
132
|
+
"""
|
|
133
|
+
Print the verification URL and user code with instructions for the user.
|
|
134
|
+
|
|
135
|
+
When the identity provider supplies ``verification_uri_complete`` the URL
|
|
136
|
+
embeds the user code, so the user only needs to visit it and the code is
|
|
137
|
+
not shown separately. Otherwise the user is asked to enter the code on
|
|
138
|
+
the plain verification page.
|
|
139
|
+
"""
|
|
140
|
+
verification_uri_complete = authorization.get("verification_uri_complete")
|
|
141
|
+
if verification_uri_complete is not None:
|
|
142
|
+
self._console.print(
|
|
143
|
+
"\n[yellow]To log in, visit the following URL on any device:[/yellow]"
|
|
144
|
+
)
|
|
145
|
+
self._console.print(
|
|
146
|
+
verification_uri_complete,
|
|
147
|
+
style=f"link {verification_uri_complete}",
|
|
148
|
+
soft_wrap=True,
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
verification_uri = authorization["verification_uri"]
|
|
152
|
+
self._console.print(
|
|
153
|
+
"\n[yellow]To log in, visit the following URL on any device and enter the "
|
|
154
|
+
"code below:[/yellow]"
|
|
155
|
+
)
|
|
156
|
+
self._console.print(verification_uri, style=f"link {verification_uri}", soft_wrap=True)
|
|
157
|
+
self._console.print(f"[bold]Code:[/bold] {authorization['user_code']}")
|
|
158
|
+
|
|
159
|
+
def _poll_for_token(self, token_endpoint: str, authorization: Dict[str, Any]) -> Dict[str, Any]:
|
|
160
|
+
"""
|
|
161
|
+
Poll the token endpoint until the user authorizes the device.
|
|
162
|
+
"""
|
|
163
|
+
device_code = authorization["device_code"]
|
|
164
|
+
interval = authorization.get("interval", 5)
|
|
165
|
+
while True:
|
|
166
|
+
self._sleep(interval)
|
|
167
|
+
response = self._post(
|
|
168
|
+
url=token_endpoint,
|
|
169
|
+
data={
|
|
170
|
+
"grant_type": _DEVICE_CODE_GRANT_TYPE,
|
|
171
|
+
"device_code": device_code,
|
|
172
|
+
"client_id": _OIDC_CLIENT_ID,
|
|
173
|
+
},
|
|
174
|
+
timeout=_REQUEST_TIMEOUT,
|
|
175
|
+
verify=not self._insecure,
|
|
176
|
+
)
|
|
177
|
+
if response.ok:
|
|
178
|
+
return response.json()
|
|
179
|
+
interval = self._handle_poll_error(response=response, interval=interval)
|
|
180
|
+
|
|
181
|
+
def _handle_poll_error(self, response: requests.Response, interval: int) -> int:
|
|
182
|
+
"""
|
|
183
|
+
Interpret an error response while polling and return the next interval.
|
|
184
|
+
|
|
185
|
+
Returns the (possibly increased) polling interval when the user has not
|
|
186
|
+
yet authorized. Raises when the authorization is denied, has expired, or
|
|
187
|
+
the error is otherwise unrecoverable.
|
|
188
|
+
"""
|
|
189
|
+
error = response.json().get("error")
|
|
190
|
+
if error == "authorization_pending":
|
|
191
|
+
return interval
|
|
192
|
+
if error == "slow_down":
|
|
193
|
+
return interval + _SLOW_DOWN_INCREMENT
|
|
194
|
+
if error == "access_denied":
|
|
195
|
+
raise DeviceAuthorizationError(
|
|
196
|
+
"Access was denied. Please contact your Hiverge administrator."
|
|
197
|
+
)
|
|
198
|
+
if error == "expired_token":
|
|
199
|
+
raise DeviceAuthorizationError(
|
|
200
|
+
"The login code expired before it was used. Please run `hive login` again."
|
|
201
|
+
)
|
|
202
|
+
raise DeviceAuthorizationError(
|
|
203
|
+
f"Device authorization failed with status {response.status_code}: {response.text}"
|
|
204
|
+
)
|
|
@@ -11,12 +11,13 @@ and re-authenticating when tokens expire.
|
|
|
11
11
|
|
|
12
12
|
import logging
|
|
13
13
|
import os
|
|
14
|
-
from typing import Any, Dict
|
|
14
|
+
from typing import Any, Dict, Protocol
|
|
15
15
|
|
|
16
16
|
from authlib.integrations.requests_client import OAuth2Session
|
|
17
17
|
|
|
18
18
|
from cli.auth.auth_utils import get_machine_id
|
|
19
19
|
from cli.auth.credential_store import CredentialStore, create_credential_store
|
|
20
|
+
from cli.auth.device_flow import OidcDeviceLoginFlow
|
|
20
21
|
from cli.auth.oidc_flow import OidcLoginFlow
|
|
21
22
|
from cli.utils.config_paths import get_config_dir
|
|
22
23
|
from cli.utils.url_utils import build_oidc_endpoints, derive_identity_base_url
|
|
@@ -26,6 +27,17 @@ logger = logging.getLogger("hivekit")
|
|
|
26
27
|
_OIDC_CLIENT_ID = "hiverge"
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
class LoginFlow(Protocol):
|
|
31
|
+
"""
|
|
32
|
+
Interface for an interactive OIDC login flow that yields a token.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def login(self) -> Dict[str, Any]:
|
|
36
|
+
"""
|
|
37
|
+
Run the login flow and return the resulting token dict.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
29
41
|
class OidcSessionManager:
|
|
30
42
|
"""
|
|
31
43
|
Creates and refreshes OAuth2 sessions using OIDC.
|
|
@@ -35,7 +47,7 @@ class OidcSessionManager:
|
|
|
35
47
|
self,
|
|
36
48
|
organization_id: str,
|
|
37
49
|
credential_store: CredentialStore,
|
|
38
|
-
login_flow:
|
|
50
|
+
login_flow: LoginFlow,
|
|
39
51
|
token_endpoint: str,
|
|
40
52
|
insecure: bool = False,
|
|
41
53
|
) -> None:
|
|
@@ -114,6 +126,7 @@ def create_session_manager(
|
|
|
114
126
|
organization_id: str,
|
|
115
127
|
base_url: str,
|
|
116
128
|
insecure: bool = False,
|
|
129
|
+
use_device_flow: bool = False,
|
|
117
130
|
) -> OidcSessionManager:
|
|
118
131
|
"""
|
|
119
132
|
Create an OidcSessionManager with all required dependencies.
|
|
@@ -125,6 +138,8 @@ def create_session_manager(
|
|
|
125
138
|
organization_id: The organization (Keycloak realm) to authenticate against.
|
|
126
139
|
base_url: The API base URL, used to derive the identity provider URL.
|
|
127
140
|
insecure: If True, disable SSL certificate verification.
|
|
141
|
+
use_device_flow: If True, use the device authorization flow (for headless
|
|
142
|
+
machines) instead of the browser-based flow.
|
|
128
143
|
|
|
129
144
|
Returns:
|
|
130
145
|
A configured OidcSessionManager.
|
|
@@ -134,10 +149,11 @@ def create_session_manager(
|
|
|
134
149
|
clients_dir=os.path.join(get_config_dir(), "clients"),
|
|
135
150
|
machine_id_func=get_machine_id,
|
|
136
151
|
)
|
|
137
|
-
login_flow =
|
|
152
|
+
login_flow = _create_login_flow(
|
|
138
153
|
identity_base_url=identity_base_url,
|
|
139
154
|
organization_id=organization_id,
|
|
140
155
|
insecure=insecure,
|
|
156
|
+
use_device_flow=use_device_flow,
|
|
141
157
|
)
|
|
142
158
|
endpoints = build_oidc_endpoints(
|
|
143
159
|
identity_base_url=identity_base_url,
|
|
@@ -150,3 +166,25 @@ def create_session_manager(
|
|
|
150
166
|
token_endpoint=endpoints["token_endpoint"],
|
|
151
167
|
insecure=insecure,
|
|
152
168
|
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _create_login_flow(
|
|
172
|
+
identity_base_url: str,
|
|
173
|
+
organization_id: str,
|
|
174
|
+
insecure: bool,
|
|
175
|
+
use_device_flow: bool,
|
|
176
|
+
) -> LoginFlow:
|
|
177
|
+
"""
|
|
178
|
+
Create the browser-based or device authorization login flow.
|
|
179
|
+
"""
|
|
180
|
+
if use_device_flow:
|
|
181
|
+
return OidcDeviceLoginFlow(
|
|
182
|
+
identity_base_url=identity_base_url,
|
|
183
|
+
organization_id=organization_id,
|
|
184
|
+
insecure=insecure,
|
|
185
|
+
)
|
|
186
|
+
return OidcLoginFlow(
|
|
187
|
+
identity_base_url=identity_base_url,
|
|
188
|
+
organization_id=organization_id,
|
|
189
|
+
insecure=insecure,
|
|
190
|
+
)
|
|
@@ -30,12 +30,30 @@ class BaseModel(pydantic.BaseModel):
|
|
|
30
30
|
model_config = ConfigDict(extra="forbid", coerce_numbers_to_str=True)
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
33
|
+
# Pods created by a Job or Deployment are named "<prefix><5 random chars>".
|
|
34
|
+
# Our pod prefixes are:
|
|
35
|
+
# coordinator: "<name>-coordinator-"
|
|
36
|
+
# sandbox: "<name>-sandbox-<rs-hash>-"
|
|
37
|
+
#
|
|
38
|
+
# The AWS network policy agent identifies a pod by its name minus the last
|
|
39
|
+
# '-' segment. If the coordinator and sandbox pods end up with the same
|
|
40
|
+
# identity, the coordinator will inherit the sandbox's internet-only
|
|
41
|
+
# egress policy (so it cannot reach any in-cluster service, like the database).
|
|
42
|
+
# Reserving room for "-coordinator-", the longer of the two, keeps every pod's
|
|
43
|
+
# role intact.
|
|
44
|
+
#
|
|
45
|
+
# For example, a 51-char <name> leaves only 7 chars for the role:
|
|
46
|
+
# coordinator: "<name>-coordi" + "6sftc" -> identity "<name>"
|
|
47
|
+
# sandbox: "<name>-sandbo" + "4nxt7" -> identity "<name>" (same, broken)
|
|
48
|
+
# while a 45-char <name> keeps both roles:
|
|
49
|
+
# coordinator: "<name>-coordinator-" + "abcde" -> identity "<name>-coordinator"
|
|
50
|
+
# sandbox: "<name>-sandbox-69df" + "abcde" -> identity "<name>-sandbox"
|
|
36
51
|
K8S_LABEL_MAX_LENGTH = 63
|
|
37
|
-
|
|
38
|
-
|
|
52
|
+
K8S_GENERATED_NAME_SUFFIX_LENGTH = 5
|
|
53
|
+
RESERVED_NAME_SUFFIX_LENGTH = len("-coordinator-")
|
|
54
|
+
MAX_EXPERIMENT_NAME_LENGTH = (
|
|
55
|
+
K8S_LABEL_MAX_LENGTH - K8S_GENERATED_NAME_SUFFIX_LENGTH - RESERVED_NAME_SUFFIX_LENGTH
|
|
56
|
+
)
|
|
39
57
|
# A name ending in '-' gets a random uniqueness suffix appended (see
|
|
40
58
|
# unique_suffix), which eats into the budget the user actually controls (their
|
|
41
59
|
# typed base name).
|
|
@@ -77,11 +95,14 @@ KNOWN_MODELS = frozenset(
|
|
|
77
95
|
"anthropic.claude-opus-4-7",
|
|
78
96
|
"anthropic.claude-opus-4-8",
|
|
79
97
|
"anthropic.claude-opus-5",
|
|
98
|
+
"anthropic.claude-opus-5-5",
|
|
80
99
|
"anthropic.claude-sonnet-4-6",
|
|
81
100
|
"anthropic.claude-sonnet-5",
|
|
82
101
|
"moonshotai.kimi-k2.5",
|
|
83
102
|
"nvidia.nemotron-super-3-120b",
|
|
84
103
|
"openai.gpt-6-astra",
|
|
104
|
+
"openai.gpt-6-luna",
|
|
105
|
+
"openai.gpt-6-sol",
|
|
85
106
|
"openai.gpt-5.6-luna",
|
|
86
107
|
"openai.gpt-5.6-sol",
|
|
87
108
|
"openai.gpt-5.6-terra",
|
|
@@ -244,10 +265,6 @@ class SandboxConfig(BaseModel):
|
|
|
244
265
|
|
|
245
266
|
|
|
246
267
|
class PromptConfig(BaseModel):
|
|
247
|
-
enable_evolution: bool = Field(
|
|
248
|
-
default=False,
|
|
249
|
-
description="Whether to enable evolution for the experiment. Default to False.",
|
|
250
|
-
)
|
|
251
268
|
context: Optional[str] = Field(
|
|
252
269
|
default=None,
|
|
253
270
|
description="Some useful experiment-specific context to provide to the Hive.",
|
|
@@ -266,6 +283,20 @@ class PromptConfig(BaseModel):
|
|
|
266
283
|
),
|
|
267
284
|
)
|
|
268
285
|
|
|
286
|
+
@model_validator(mode="before")
|
|
287
|
+
@classmethod
|
|
288
|
+
def drop_enable_evolution(cls, values):
|
|
289
|
+
"""Accept and ignore the removed enable_evolution field without mutating the input."""
|
|
290
|
+
if not isinstance(values, dict) or "enable_evolution" not in values:
|
|
291
|
+
return values
|
|
292
|
+
values = dict(values)
|
|
293
|
+
values.pop("enable_evolution")
|
|
294
|
+
logger.warning(
|
|
295
|
+
"prompt.enable_evolution is deprecated and has no effect. "
|
|
296
|
+
"Remove it from your config to silence this warning."
|
|
297
|
+
)
|
|
298
|
+
return values
|
|
299
|
+
|
|
269
300
|
@model_validator(mode="before")
|
|
270
301
|
@classmethod
|
|
271
302
|
def migrate_arxiv_ids(cls, values):
|
|
@@ -513,6 +544,42 @@ class ProviderConfig(BaseModel):
|
|
|
513
544
|
"non-interruptible capacity only. Unset (the default): no constraint, "
|
|
514
545
|
"the cheapest available capacity is used.",
|
|
515
546
|
)
|
|
547
|
+
regions: Optional[list[str]] = Field(
|
|
548
|
+
default=None,
|
|
549
|
+
description="The regions the sandbox may be placed in: us, or all for "
|
|
550
|
+
"every region the platform offers, given as a single value or a list. "
|
|
551
|
+
"Unset (the default): your default regions.",
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
@field_validator("regions", mode="before")
|
|
555
|
+
@classmethod
|
|
556
|
+
def wrap_single_region(cls, v):
|
|
557
|
+
# `regions: all` or `regions: us` reads better than a one-item list.
|
|
558
|
+
if isinstance(v, str):
|
|
559
|
+
return [v]
|
|
560
|
+
return v
|
|
561
|
+
|
|
562
|
+
@field_validator("regions")
|
|
563
|
+
@classmethod
|
|
564
|
+
def validate_regions(cls, v):
|
|
565
|
+
# Only the format is checked here; which regions exist is up to the
|
|
566
|
+
# backend, so the list can grow without a CLI release.
|
|
567
|
+
if v is None:
|
|
568
|
+
return v
|
|
569
|
+
if not v:
|
|
570
|
+
raise ValueError("regions must not be empty; omit it to allow any region")
|
|
571
|
+
regions = []
|
|
572
|
+
for region in v:
|
|
573
|
+
region = region.strip().lower()
|
|
574
|
+
if not re.fullmatch(r"[a-z0-9_-]+", region):
|
|
575
|
+
raise ValueError(
|
|
576
|
+
f"invalid region '{region}', must contain only letters, digits, '-' and '_'"
|
|
577
|
+
)
|
|
578
|
+
# The backend treats regions as a set, so drop repeats rather than
|
|
579
|
+
# have it reject them.
|
|
580
|
+
if region not in regions:
|
|
581
|
+
regions.append(region)
|
|
582
|
+
return regions
|
|
516
583
|
|
|
517
584
|
|
|
518
585
|
class AgentModelConfig(BaseModel):
|
|
@@ -114,6 +114,8 @@ def build_experiment_crd(config: HiveConfig) -> Dict[str, Any]:
|
|
|
114
114
|
provider["name"] = config.provider.name
|
|
115
115
|
if config.provider.spot is not None:
|
|
116
116
|
provider["spot"] = config.provider.spot
|
|
117
|
+
if config.provider.regions:
|
|
118
|
+
provider["regions"] = config.provider.regions
|
|
117
119
|
if provider:
|
|
118
120
|
experiment["spec"]["provider"] = provider
|
|
119
121
|
|
|
@@ -124,8 +126,6 @@ def build_experiment_crd(config: HiveConfig) -> Dict[str, Any]:
|
|
|
124
126
|
experiment["spec"]["prompt"]["context"] = config.prompt.context
|
|
125
127
|
if config.prompt.ideas:
|
|
126
128
|
experiment["spec"]["prompt"]["ideas"] = config.prompt.ideas
|
|
127
|
-
if config.prompt.enable_evolution:
|
|
128
|
-
experiment["spec"]["prompt"]["enableEvolution"] = config.prompt.enable_evolution
|
|
129
129
|
|
|
130
130
|
# Add optional agent selection
|
|
131
131
|
if config.agents:
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Copyright (C) 2026 Hiverge
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Choose export destinations and save complete, validated downloads using bounded memory."""
|
|
6
|
+
|
|
7
|
+
import gzip
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from email.message import Message
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from cli.http_client import DOWNLOAD_SYSTEM_ERROR, DownloadError
|
|
17
|
+
|
|
18
|
+
_CHUNK_SIZE = 1 << 16
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_download_output(
|
|
22
|
+
output: str | None, *, directory_only: bool = False
|
|
23
|
+
) -> tuple[Path, str | None]:
|
|
24
|
+
"""Return the destination directory and optional filename override."""
|
|
25
|
+
if output is None:
|
|
26
|
+
return Path.cwd(), None
|
|
27
|
+
if not output:
|
|
28
|
+
raise DownloadError("--output must name a file or directory.")
|
|
29
|
+
path = Path(output)
|
|
30
|
+
if path.is_dir():
|
|
31
|
+
return path, None
|
|
32
|
+
if output.endswith(("/", os.sep)):
|
|
33
|
+
if path.exists():
|
|
34
|
+
raise DownloadError(f"Output is not a directory: {path}")
|
|
35
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
return path, None
|
|
37
|
+
if directory_only:
|
|
38
|
+
raise DownloadError("Full Export requires a directory. Use -o 'DIRECTORY/'.")
|
|
39
|
+
if output.replace("\\", "/").rsplit("/", 1)[-1] in {".", ".."}:
|
|
40
|
+
raise DownloadError("--output must name a file or directory.")
|
|
41
|
+
_check_destination(path)
|
|
42
|
+
return path.parent, path.name
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _check_distinct(main: Path, reports: Path) -> None:
|
|
46
|
+
resolved_main, resolved_reports = main.resolve(), reports.resolve()
|
|
47
|
+
same = resolved_main == resolved_reports or (
|
|
48
|
+
main.exists() and reports.exists() and main.samefile(reports)
|
|
49
|
+
)
|
|
50
|
+
if same:
|
|
51
|
+
raise DownloadError("The main output and evolution reports must have different filenames.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _check_destination(destination: Path) -> None:
|
|
55
|
+
if destination.is_dir():
|
|
56
|
+
raise DownloadError(f"Output is a directory: {destination}")
|
|
57
|
+
if not destination.parent.is_dir():
|
|
58
|
+
raise DownloadError(f"Output directory does not exist: {destination.parent}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _response_filename(response: requests.Response) -> str:
|
|
62
|
+
disposition = Message()
|
|
63
|
+
disposition["Content-Disposition"] = response.headers.get("Content-Disposition", "")
|
|
64
|
+
filename = disposition.get_filename()
|
|
65
|
+
# AlphaTrion owns sanitization. Reject malformed filenames rather than altering them.
|
|
66
|
+
if (
|
|
67
|
+
not filename
|
|
68
|
+
or filename in {".", ".."}
|
|
69
|
+
or "/" in filename
|
|
70
|
+
or "\\" in filename
|
|
71
|
+
or any(ord(char) < 32 or ord(char) == 127 for char in filename)
|
|
72
|
+
):
|
|
73
|
+
raise DownloadError(DOWNLOAD_SYSTEM_ERROR)
|
|
74
|
+
return filename
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class ExportOutput:
|
|
79
|
+
directory: Path
|
|
80
|
+
output: str | None
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def prepare(cls, name: str, full_export: bool, output: str | None) -> "ExportOutput":
|
|
84
|
+
"""Validate the output path before authentication or download."""
|
|
85
|
+
if not name:
|
|
86
|
+
raise DownloadError("Experiment name must be non-empty.")
|
|
87
|
+
directory, filename = resolve_download_output(output, directory_only=full_export)
|
|
88
|
+
return cls(directory, filename)
|
|
89
|
+
|
|
90
|
+
def destination(self, response: requests.Response, *, source: Path | None = None) -> Path:
|
|
91
|
+
"""Honor server filenames and check actual destinations before consuming bytes."""
|
|
92
|
+
filename = _response_filename(response)
|
|
93
|
+
destination = self.directory / (self.output if source is None and self.output else filename)
|
|
94
|
+
_check_destination(destination)
|
|
95
|
+
if source is not None:
|
|
96
|
+
_check_distinct(source, destination)
|
|
97
|
+
return destination
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def save_export(
|
|
101
|
+
response: requests.Response, destination: Path, *, suppress_empty: bool = False
|
|
102
|
+
) -> bool:
|
|
103
|
+
"""Publish only a complete gzip file; return False for suppressed empty reports.
|
|
104
|
+
|
|
105
|
+
The caller owns the response context. Temporary output lives beside the
|
|
106
|
+
destination so replacement cannot cross filesystem boundaries.
|
|
107
|
+
"""
|
|
108
|
+
temporary: Path | None = None
|
|
109
|
+
try:
|
|
110
|
+
with tempfile.NamedTemporaryFile(
|
|
111
|
+
mode="wb", dir=destination.parent, prefix=".hive-export-", suffix=".tmp", delete=False
|
|
112
|
+
) as output:
|
|
113
|
+
temporary = Path(output.name)
|
|
114
|
+
# Preserve AlphaTrion's compressed bytes, even if a proxy adds Content-Encoding.
|
|
115
|
+
for chunk in response.raw.stream(_CHUNK_SIZE, decode_content=False):
|
|
116
|
+
output.write(chunk)
|
|
117
|
+
with temporary.open("rb") as compressed:
|
|
118
|
+
# gzip accepts a zero-byte input, but a completed export must contain a frame.
|
|
119
|
+
if compressed.read(2) != b"\x1f\x8b":
|
|
120
|
+
raise DownloadError(DOWNLOAD_SYSTEM_ERROR)
|
|
121
|
+
has_content = False
|
|
122
|
+
with gzip.open(temporary, "rb") as decoded:
|
|
123
|
+
while decoded.read(_CHUNK_SIZE):
|
|
124
|
+
has_content = True
|
|
125
|
+
if suppress_empty and not has_content:
|
|
126
|
+
return False
|
|
127
|
+
os.replace(temporary, destination)
|
|
128
|
+
return True
|
|
129
|
+
finally:
|
|
130
|
+
if temporary is not None:
|
|
131
|
+
temporary.unlink(missing_ok=True)
|