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 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)
@@ -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"]