runbios-sdk 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bios/__init__.py +172 -0
- bios/_client.py +238 -0
- bios/_gpu_priorities.py +114 -0
- bios/_types.py +2900 -0
- bios/datasets.py +354 -0
- bios/errors.py +271 -0
- bios/gpu.py +142 -0
- bios/inference.py +1234 -0
- bios/integrations.py +36 -0
- bios/loop.py +1870 -0
- bios/models.py +182 -0
- bios/py.typed +1 -0
- bios/training.py +668 -0
- bios/wallet.py +44 -0
- runbios/__init__.py +29 -0
- runbios_sdk-0.2.2.dist-info/METADATA +625 -0
- runbios_sdk-0.2.2.dist-info/RECORD +19 -0
- runbios_sdk-0.2.2.dist-info/WHEEL +4 -0
- runbios_sdk-0.2.2.dist-info/licenses/LICENSE +21 -0
bios/__init__.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
bios -- Official Python SDK for the Run BiOS fine-tuning platform.
|
|
3
|
+
|
|
4
|
+
Usage::
|
|
5
|
+
|
|
6
|
+
from bios import RunBiOS
|
|
7
|
+
|
|
8
|
+
client = RunBiOS(api_key="bios-...")
|
|
9
|
+
|
|
10
|
+
# Search for models
|
|
11
|
+
models = client.models.search(query="llama")
|
|
12
|
+
|
|
13
|
+
# Create a training job
|
|
14
|
+
job = client.training.create(
|
|
15
|
+
model="meta-llama/Llama-3.1-8B-Instruct",
|
|
16
|
+
dataset_id="ds_abc123",
|
|
17
|
+
method="sft",
|
|
18
|
+
adapter="lora",
|
|
19
|
+
)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from ._client import ApiError, HttpClient, VERSION
|
|
26
|
+
from ._types import ApiKeyIntrospection
|
|
27
|
+
from .errors import (
|
|
28
|
+
CAPACITY_UNAVAILABLE_CODE,
|
|
29
|
+
COMING_SOON_CODES,
|
|
30
|
+
GPU_REJECTION_CODE_BY_REASON,
|
|
31
|
+
GPU_REJECTION_CODES,
|
|
32
|
+
PERMANENT_GPU_CODES,
|
|
33
|
+
PERMANENT_GPU_REASONS,
|
|
34
|
+
CapacityUnavailableError,
|
|
35
|
+
ComingSoonError,
|
|
36
|
+
MinGpuCountError,
|
|
37
|
+
gpu_rejection_code_for_reason,
|
|
38
|
+
is_permanent_gpu_code,
|
|
39
|
+
is_permanent_gpu_reason,
|
|
40
|
+
)
|
|
41
|
+
from .models import Models
|
|
42
|
+
from .datasets import Datasets
|
|
43
|
+
from .integrations import Integrations
|
|
44
|
+
from .training import Training
|
|
45
|
+
from .wallet import Wallet
|
|
46
|
+
from .gpu import GPU
|
|
47
|
+
from .loop import Loop
|
|
48
|
+
from .inference import Inference
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__version__ = VERSION
|
|
52
|
+
__all__ = [
|
|
53
|
+
"RunBiOS",
|
|
54
|
+
"BiOS",
|
|
55
|
+
"ApiError",
|
|
56
|
+
"CapacityUnavailableError",
|
|
57
|
+
"ComingSoonError",
|
|
58
|
+
"MinGpuCountError",
|
|
59
|
+
"CAPACITY_UNAVAILABLE_CODE",
|
|
60
|
+
"COMING_SOON_CODES",
|
|
61
|
+
"PERMANENT_GPU_CODES",
|
|
62
|
+
"PERMANENT_GPU_REASONS",
|
|
63
|
+
"GPU_REJECTION_CODES",
|
|
64
|
+
"GPU_REJECTION_CODE_BY_REASON",
|
|
65
|
+
"is_permanent_gpu_code",
|
|
66
|
+
"is_permanent_gpu_reason",
|
|
67
|
+
"gpu_rejection_code_for_reason",
|
|
68
|
+
"VERSION",
|
|
69
|
+
"Models",
|
|
70
|
+
"Datasets",
|
|
71
|
+
"Integrations",
|
|
72
|
+
"Training",
|
|
73
|
+
"Wallet",
|
|
74
|
+
"GPU",
|
|
75
|
+
"Loop",
|
|
76
|
+
"Inference",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RunBiOS:
|
|
81
|
+
"""Main client for the Run BiOS API.
|
|
82
|
+
|
|
83
|
+
Create an instance with either an API key or a JWT access token,
|
|
84
|
+
then use the resource properties to interact with the API. When
|
|
85
|
+
``api_key``, ``base_url``, or ``inference_key`` is omitted, the
|
|
86
|
+
``RUNBIOS_API_KEY``, ``RUNBIOS_BASE_URL``, and ``RUNBIOS_INFERENCE_KEY``
|
|
87
|
+
environment variables are used as defaults (the legacy ``BIOS_API_KEY``,
|
|
88
|
+
``BIOS_BASE_URL``, and ``BIOS_INFERENCE_KEY`` names are still accepted).
|
|
89
|
+
``inference_key`` falls back to ``api_key`` last, so a platform key with
|
|
90
|
+
the serverless scope calls ``/v1`` without being passed twice.
|
|
91
|
+
|
|
92
|
+
Example with API key::
|
|
93
|
+
|
|
94
|
+
client = RunBiOS(api_key="bios-...")
|
|
95
|
+
|
|
96
|
+
Example with JWT::
|
|
97
|
+
|
|
98
|
+
client = RunBiOS(access_token="eyJhbG...", org_id="org_abc123")
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
api_key: Optional[str] = None,
|
|
104
|
+
access_token: Optional[str] = None,
|
|
105
|
+
org_id: Optional[str] = None,
|
|
106
|
+
workspace_id: Optional[str] = None,
|
|
107
|
+
base_url: Optional[str] = None,
|
|
108
|
+
timeout: Optional[float] = None,
|
|
109
|
+
inference_key: Optional[str] = None,
|
|
110
|
+
inference_base_url: Optional[str] = None,
|
|
111
|
+
inference_timeout: Optional[float] = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
http = HttpClient(
|
|
114
|
+
api_key=api_key,
|
|
115
|
+
access_token=access_token,
|
|
116
|
+
org_id=org_id,
|
|
117
|
+
workspace_id=workspace_id,
|
|
118
|
+
base_url=base_url,
|
|
119
|
+
timeout=timeout,
|
|
120
|
+
)
|
|
121
|
+
self._http = http
|
|
122
|
+
|
|
123
|
+
self.models: Models = Models(http)
|
|
124
|
+
"""Search models, fetch configs, check adapter compatibility."""
|
|
125
|
+
|
|
126
|
+
self.datasets: Datasets = Datasets(http)
|
|
127
|
+
"""Upload, import, preview, and manage training datasets."""
|
|
128
|
+
|
|
129
|
+
self.integrations: Integrations = Integrations(http)
|
|
130
|
+
|
|
131
|
+
self.training: Training = Training(http)
|
|
132
|
+
"""Create, monitor, stop, and resume fine-tuning jobs."""
|
|
133
|
+
|
|
134
|
+
self.wallet: Wallet = Wallet(http)
|
|
135
|
+
"""View wallet balance and transaction history."""
|
|
136
|
+
|
|
137
|
+
self.gpu: GPU = GPU(http)
|
|
138
|
+
#: The Conscious Loop. Capture what your model was asked and answered,
|
|
139
|
+
#: record whether it was right, and turn those judgements into training
|
|
140
|
+
#: data. Nothing is captured until you turn it on for a source.
|
|
141
|
+
self.loop: Loop = Loop(http)
|
|
142
|
+
"""View GPU pricing and get hardware recommendations."""
|
|
143
|
+
|
|
144
|
+
# Inference-key resolution, most specific first:
|
|
145
|
+
# 1. an explicit inference_key (a per-deployment sk-bios-... key)
|
|
146
|
+
# 2. RUNBIOS_INFERENCE_KEY (legacy BIOS_INFERENCE_KEY) from the environment
|
|
147
|
+
# 3. the control-plane api_key / RUNBIOS_API_KEY (legacy BIOS_API_KEY)
|
|
148
|
+
# (3) exists because a workspace platform key carrying the serverless
|
|
149
|
+
# scope calls /v1 directly, so RunBiOS(api_key=K).inference.chat_completions()
|
|
150
|
+
# must work without passing the same key twice.
|
|
151
|
+
self.inference: Inference = Inference(
|
|
152
|
+
inference_key=(
|
|
153
|
+
inference_key
|
|
154
|
+
or os.environ.get("RUNBIOS_INFERENCE_KEY")
|
|
155
|
+
or os.environ.get("BIOS_INFERENCE_KEY")
|
|
156
|
+
or api_key
|
|
157
|
+
or os.environ.get("RUNBIOS_API_KEY")
|
|
158
|
+
or os.environ.get("BIOS_API_KEY")
|
|
159
|
+
),
|
|
160
|
+
base_url=inference_base_url or base_url,
|
|
161
|
+
timeout=inference_timeout,
|
|
162
|
+
http=http,
|
|
163
|
+
)
|
|
164
|
+
"""Validate, create, monitor, stop, and delete model deployments, and
|
|
165
|
+
call OpenAI-compatible endpoints without automatic retries."""
|
|
166
|
+
|
|
167
|
+
def introspect(self) -> ApiKeyIntrospection:
|
|
168
|
+
"""Introspect the current API key to discover its permissions."""
|
|
169
|
+
return self._http.get("/api/api-keys/introspect")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
BiOS = RunBiOS # deprecated alias
|
bios/_client.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""HTTP client for the Run BiOS API."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Dict, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
VERSION = "0.2.2"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ApiError(Exception):
|
|
12
|
+
"""Typed error raised when the API returns a non-2xx status."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, status: int, body: Dict[str, Any]) -> None:
|
|
15
|
+
err = body.get("error")
|
|
16
|
+
nested = err if isinstance(err, dict) else None
|
|
17
|
+
msg = (
|
|
18
|
+
body.get("detail")
|
|
19
|
+
or (
|
|
20
|
+
str(nested.get("message") or nested.get("detail") or "")
|
|
21
|
+
if nested
|
|
22
|
+
else ""
|
|
23
|
+
)
|
|
24
|
+
or (err if isinstance(err, str) else "")
|
|
25
|
+
or body.get("message")
|
|
26
|
+
or "API error {}".format(status)
|
|
27
|
+
)
|
|
28
|
+
super().__init__(msg)
|
|
29
|
+
self.status: int = status
|
|
30
|
+
self.code: Optional[str] = (
|
|
31
|
+
str(nested["code"]) if nested and nested.get("code") else body.get("code")
|
|
32
|
+
) or None
|
|
33
|
+
self.request_id: Optional[str] = body.get("request_id")
|
|
34
|
+
self.message: str = msg
|
|
35
|
+
# The full parsed error body: structured fields (available_gpus,
|
|
36
|
+
# minimum_requirement, checked_at, ...) must survive for typed
|
|
37
|
+
# subclasses and for callers that need more than code/message.
|
|
38
|
+
self.body: Dict[str, Any] = body
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _error_code(body: Dict[str, Any]) -> Optional[str]:
|
|
42
|
+
err = body.get("error")
|
|
43
|
+
nested = err if isinstance(err, dict) else None
|
|
44
|
+
return (
|
|
45
|
+
(str(nested["code"]) if nested and nested.get("code") else body.get("code"))
|
|
46
|
+
or None
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _build_api_error(status: int, body: Dict[str, Any]) -> ApiError:
|
|
51
|
+
"""Map a non-2xx body to the most specific typed exception available.
|
|
52
|
+
|
|
53
|
+
The service is the authority: every GPU-rejection code becomes the typed
|
|
54
|
+
:class:`~bios.errors.CapacityUnavailableError` so callers can read its
|
|
55
|
+
``.alternatives`` / ``.minimum_requirement`` without string-matching. That
|
|
56
|
+
is the transient ``CAPACITY_UNAVAILABLE`` 409 AND the four permanent 400s
|
|
57
|
+
(``GPU_TYPE_TOO_SMALL``, ``GPU_COUNT_BELOW_MINIMUM``, ``GPU_COUNT_INVALID``,
|
|
58
|
+
``GPU_TYPE_UNSUPPORTED``), which carry the same body and the same recovery
|
|
59
|
+
data, so one ``except`` still catches all of them. Read ``.permanent`` to
|
|
60
|
+
tell "wait for stock" apart from "pick a different GPU type or count".
|
|
61
|
+
Everything else stays ``ApiError``.
|
|
62
|
+
"""
|
|
63
|
+
code = _error_code(body)
|
|
64
|
+
if code is not None:
|
|
65
|
+
# Lazy import avoids a circular dependency at module load time.
|
|
66
|
+
from .errors import (
|
|
67
|
+
COMING_SOON_CODES,
|
|
68
|
+
GPU_REJECTION_CODES,
|
|
69
|
+
CapacityUnavailableError,
|
|
70
|
+
ComingSoonError,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if code in GPU_REJECTION_CODES:
|
|
74
|
+
return CapacityUnavailableError(status, body)
|
|
75
|
+
if code in COMING_SOON_CODES:
|
|
76
|
+
return ComingSoonError(status, body)
|
|
77
|
+
return ApiError(status, body)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class HttpClient:
|
|
81
|
+
"""Shared HTTP transport used by resource modules via composition."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
api_key: Optional[str] = None,
|
|
86
|
+
access_token: Optional[str] = None,
|
|
87
|
+
org_id: Optional[str] = None,
|
|
88
|
+
workspace_id: Optional[str] = None,
|
|
89
|
+
base_url: Optional[str] = None,
|
|
90
|
+
timeout: Optional[float] = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
# Config wins; otherwise the RUNBIOS_* environment variables supply
|
|
93
|
+
# defaults (the legacy BIOS_* names are still accepted), then the
|
|
94
|
+
# canonical production hostname.
|
|
95
|
+
self._base_url = (
|
|
96
|
+
base_url
|
|
97
|
+
or os.environ.get("RUNBIOS_BASE_URL")
|
|
98
|
+
or os.environ.get("BIOS_BASE_URL")
|
|
99
|
+
# api.runbios.ai DNS does not exist yet; planned cutover target.
|
|
100
|
+
or "https://api.runbios.ai"
|
|
101
|
+
).rstrip("/")
|
|
102
|
+
self._api_key = (
|
|
103
|
+
api_key
|
|
104
|
+
or os.environ.get("RUNBIOS_API_KEY")
|
|
105
|
+
or os.environ.get("BIOS_API_KEY")
|
|
106
|
+
or None
|
|
107
|
+
)
|
|
108
|
+
self._access_token = access_token
|
|
109
|
+
self._org_id = org_id
|
|
110
|
+
self._workspace_id = workspace_id
|
|
111
|
+
self._timeout = timeout if timeout is not None else 30.0
|
|
112
|
+
|
|
113
|
+
if not self._api_key and not self._access_token:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
"Run BiOS: either api_key or access_token is required "
|
|
116
|
+
"(or set the RUNBIOS_API_KEY environment variable; "
|
|
117
|
+
"the legacy BIOS_API_KEY name is still accepted)"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
self._session = requests.Session()
|
|
121
|
+
self._session.headers.update(self._build_base_headers())
|
|
122
|
+
|
|
123
|
+
def _build_base_headers(self) -> Dict[str, str]:
|
|
124
|
+
headers: Dict[str, str] = {
|
|
125
|
+
"User-Agent": "bios-python/{}".format(VERSION),
|
|
126
|
+
"Accept": "application/json",
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if self._api_key:
|
|
130
|
+
headers["X-API-Key"] = self._api_key
|
|
131
|
+
elif self._access_token:
|
|
132
|
+
headers["Authorization"] = "Bearer {}".format(self._access_token)
|
|
133
|
+
|
|
134
|
+
if self._org_id:
|
|
135
|
+
headers["X-Org-ID"] = self._org_id
|
|
136
|
+
if self._workspace_id:
|
|
137
|
+
headers["X-Workspace-ID"] = self._workspace_id
|
|
138
|
+
|
|
139
|
+
return headers
|
|
140
|
+
|
|
141
|
+
def _handle_response(self, response: requests.Response) -> Any:
|
|
142
|
+
if not response.ok:
|
|
143
|
+
try:
|
|
144
|
+
body = response.json()
|
|
145
|
+
except (ValueError, RuntimeError):
|
|
146
|
+
body = {
|
|
147
|
+
"error": response.text
|
|
148
|
+
or "API error {}".format(response.status_code)
|
|
149
|
+
}
|
|
150
|
+
raise _build_api_error(response.status_code, body)
|
|
151
|
+
|
|
152
|
+
if response.status_code == 204 or not response.content:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
return response.json()
|
|
157
|
+
except (ValueError, RuntimeError):
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
def request(
|
|
161
|
+
self,
|
|
162
|
+
method: str,
|
|
163
|
+
path: str,
|
|
164
|
+
body: Optional[Any] = None,
|
|
165
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
166
|
+
) -> Any:
|
|
167
|
+
"""Send a JSON request and parse the response."""
|
|
168
|
+
url = "{}{}".format(self._base_url, path)
|
|
169
|
+
headers = {"Content-Type": "application/json"}
|
|
170
|
+
if extra_headers:
|
|
171
|
+
headers.update(extra_headers)
|
|
172
|
+
|
|
173
|
+
response = self._session.request(
|
|
174
|
+
method=method,
|
|
175
|
+
url=url,
|
|
176
|
+
json=body,
|
|
177
|
+
headers=headers,
|
|
178
|
+
timeout=self._timeout,
|
|
179
|
+
)
|
|
180
|
+
return self._handle_response(response)
|
|
181
|
+
|
|
182
|
+
def get(self, path: str, extra_headers: Optional[Dict[str, str]] = None) -> Any:
|
|
183
|
+
"""Send a GET request."""
|
|
184
|
+
url = "{}{}".format(self._base_url, path)
|
|
185
|
+
response = self._session.get(url, headers=extra_headers, timeout=self._timeout)
|
|
186
|
+
return self._handle_response(response)
|
|
187
|
+
|
|
188
|
+
def post(
|
|
189
|
+
self,
|
|
190
|
+
path: str,
|
|
191
|
+
body: Optional[Any] = None,
|
|
192
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
193
|
+
) -> Any:
|
|
194
|
+
"""Send a POST request with a JSON body."""
|
|
195
|
+
return self.request("POST", path, body, extra_headers)
|
|
196
|
+
|
|
197
|
+
def patch(
|
|
198
|
+
self,
|
|
199
|
+
path: str,
|
|
200
|
+
body: Optional[Any] = None,
|
|
201
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
202
|
+
) -> Any:
|
|
203
|
+
"""Send a PATCH request with a JSON body."""
|
|
204
|
+
return self.request("PATCH", path, body, extra_headers)
|
|
205
|
+
|
|
206
|
+
def put(
|
|
207
|
+
self,
|
|
208
|
+
path: str,
|
|
209
|
+
body: Optional[Any] = None,
|
|
210
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
211
|
+
) -> Any:
|
|
212
|
+
"""Send a PUT request with a JSON body."""
|
|
213
|
+
return self.request("PUT", path, body, extra_headers)
|
|
214
|
+
|
|
215
|
+
def delete(
|
|
216
|
+
self,
|
|
217
|
+
path: str,
|
|
218
|
+
body: Optional[Any] = None,
|
|
219
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
220
|
+
) -> Any:
|
|
221
|
+
"""Send a DELETE request."""
|
|
222
|
+
return self.request("DELETE", path, body, extra_headers)
|
|
223
|
+
|
|
224
|
+
def upload(
|
|
225
|
+
self,
|
|
226
|
+
path: str,
|
|
227
|
+
files: Dict[str, Tuple[str, Any, str]],
|
|
228
|
+
data: Optional[Dict[str, str]] = None,
|
|
229
|
+
) -> Any:
|
|
230
|
+
"""Upload files via multipart/form-data."""
|
|
231
|
+
url = "{}{}".format(self._base_url, path)
|
|
232
|
+
response = self._session.post(
|
|
233
|
+
url,
|
|
234
|
+
files=files,
|
|
235
|
+
data=data,
|
|
236
|
+
timeout=self._timeout,
|
|
237
|
+
)
|
|
238
|
+
return self._handle_response(response)
|
bios/_gpu_priorities.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Canonical ranked GPU placement validation shared by training/deployments."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def normalize_gpu_priorities(
|
|
7
|
+
raw: Optional[List[Dict[str, Any]]],
|
|
8
|
+
*,
|
|
9
|
+
queue_enabled: bool,
|
|
10
|
+
gpu_type: Optional[str],
|
|
11
|
+
gpu_count: Optional[int],
|
|
12
|
+
queue_field: str,
|
|
13
|
+
ranked_immediate: bool = False,
|
|
14
|
+
) -> Tuple[Optional[List[Dict[str, Any]]], Optional[str], Optional[int]]:
|
|
15
|
+
"""Validate ordering/cardinality and return canonical snake-case choices.
|
|
16
|
+
|
|
17
|
+
``ranked_immediate=True`` (training) lets a launch without the queue carry
|
|
18
|
+
1 to 5 choices ranked best first; the extras are used as backups when the
|
|
19
|
+
first choice is not free at launch. With ``ranked_immediate=False``
|
|
20
|
+
(deployments) a launch without the queue still needs exactly one choice.
|
|
21
|
+
Queueing always needs 3 to 5 choices.
|
|
22
|
+
"""
|
|
23
|
+
if raw is None:
|
|
24
|
+
if queue_enabled:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
"Run BiOS: {}=true requires 3 to 5 gpu_priorities".format(
|
|
27
|
+
queue_field
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
return None, gpu_type, gpu_count
|
|
31
|
+
|
|
32
|
+
if not isinstance(raw, list) or not raw:
|
|
33
|
+
raise ValueError("Run BiOS: gpu_priorities must contain at least one choice")
|
|
34
|
+
if len(raw) > 5:
|
|
35
|
+
raise ValueError("Run BiOS: gpu_priorities accepts at most 5 choices")
|
|
36
|
+
if queue_enabled and len(raw) < 3:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
"Run BiOS: {}=true requires 3 to 5 gpu_priorities".format(queue_field)
|
|
39
|
+
)
|
|
40
|
+
if not queue_enabled and not ranked_immediate and len(raw) != 1:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
"Run BiOS: gpu_priorities must contain exactly one choice when {} is false".format(
|
|
43
|
+
queue_field
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
normalized: List[Dict[str, Any]] = []
|
|
48
|
+
seen = set()
|
|
49
|
+
for index, choice in enumerate(raw):
|
|
50
|
+
if not isinstance(choice, dict):
|
|
51
|
+
raise ValueError("Run BiOS: gpu_priorities entries must be objects")
|
|
52
|
+
choice_type = str(
|
|
53
|
+
choice.get("gpu_type") or choice.get("gpuType") or ""
|
|
54
|
+
).strip()
|
|
55
|
+
choice_count = choice.get("gpu_count")
|
|
56
|
+
if choice_count is None:
|
|
57
|
+
choice_count = choice.get("gpuCount")
|
|
58
|
+
if not choice_type:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
"Run BiOS: gpu_priorities[{}].gpu_type is required".format(index)
|
|
61
|
+
)
|
|
62
|
+
if (
|
|
63
|
+
not isinstance(choice_count, int)
|
|
64
|
+
or isinstance(choice_count, bool)
|
|
65
|
+
or not 1 <= choice_count <= 8
|
|
66
|
+
):
|
|
67
|
+
raise ValueError(
|
|
68
|
+
"Run BiOS: gpu_priorities[{}].gpu_count must be an integer between 1 and 8".format(
|
|
69
|
+
index
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
provider = str(choice.get("provider") or "").strip().casefold()
|
|
73
|
+
region = str(choice.get("region") or "").strip().casefold()
|
|
74
|
+
tier = str(choice.get("tier") or "").strip().casefold()
|
|
75
|
+
if tier and tier != "secure":
|
|
76
|
+
raise ValueError(
|
|
77
|
+
"Run BiOS: gpu_priorities[{}].tier must be secure".format(index)
|
|
78
|
+
)
|
|
79
|
+
# Dedup key only (the provider is NEVER emitted below unless the caller
|
|
80
|
+
# set it). Default to the neutral platform alias so two omitted-provider
|
|
81
|
+
# choices collapse under "bios-cloud", matching the console/server key
|
|
82
|
+
# convention.
|
|
83
|
+
canonical_key = "{}|{}|{}|{}".format(
|
|
84
|
+
provider or "bios-cloud",
|
|
85
|
+
region or "global",
|
|
86
|
+
tier or "secure",
|
|
87
|
+
choice_type.casefold(),
|
|
88
|
+
)
|
|
89
|
+
if canonical_key in seen:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
"Run BiOS: gpu_priorities provider/region/tier/GPU placements must be distinct"
|
|
92
|
+
)
|
|
93
|
+
seen.add(canonical_key)
|
|
94
|
+
entry = {"gpu_type": choice_type, "gpu_count": choice_count}
|
|
95
|
+
if provider:
|
|
96
|
+
entry["provider"] = provider
|
|
97
|
+
if region:
|
|
98
|
+
entry["region"] = region
|
|
99
|
+
entry["tier"] = "secure"
|
|
100
|
+
normalized.append(entry)
|
|
101
|
+
|
|
102
|
+
first = normalized[0]
|
|
103
|
+
if gpu_type is not None and str(gpu_type).strip() != first["gpu_type"]:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
"Run BiOS: gpu_type/gpu_count must match the first gpu_priorities choice"
|
|
106
|
+
)
|
|
107
|
+
if gpu_count is not None and gpu_count != first["gpu_count"]:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"Run BiOS: gpu_type/gpu_count must match the first gpu_priorities choice"
|
|
110
|
+
)
|
|
111
|
+
return normalized, first["gpu_type"], first["gpu_count"]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
__all__ = ["normalize_gpu_priorities"]
|