positronic-platform-client 0.37.1__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.
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from typing import Annotated, Self
5
+
6
+ from platform_client.enums import BillingMode, BillingRole, BillingState
7
+ from platform_client.ids import INT64_MAX as INT64_MAX
8
+ from platform_client.ids import OrgSlug, PackageId, PurchaseId, UserId
9
+ from positronic_evals.slug import Slugged
10
+ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, computed_field, model_validator
11
+
12
+ CREDIT_SCALE = 60_000_000_000
13
+ NANOSECONDS_PER_MINUTE = 60_000_000_000
14
+ NANOSECONDS_PER_SECOND = 1_000_000_000
15
+ CreditUnits = Annotated[int, Field(strict=True, ge=0, le=INT64_MAX)]
16
+
17
+
18
+ class Tariff(BaseModel):
19
+ model_config = ConfigDict(extra='forbid', frozen=True)
20
+
21
+ version: str
22
+ episode_units: CreditUnits
23
+ minute_units: CreditUnits
24
+ duration_rounding_sec: int = Field(default=0, strict=True, ge=0, le=INT64_MAX // NANOSECONDS_PER_SECOND)
25
+
26
+ @staticmethod
27
+ def content_hash(episode_units: int, minute_units: int, duration_rounding_sec: int = 0) -> str:
28
+ content = f'v1:{CREDIT_SCALE}:{episode_units}:{minute_units}'
29
+ if duration_rounding_sec:
30
+ content = f'v2:{CREDIT_SCALE}:{episode_units}:{minute_units}:{duration_rounding_sec}'
31
+ return hashlib.sha256(content.encode()).hexdigest()
32
+
33
+ @classmethod
34
+ def for_rates(cls, episode_units: int, minute_units: int, *, duration_rounding_sec: int) -> Tariff:
35
+ return cls(
36
+ version=cls.content_hash(episode_units, minute_units, duration_rounding_sec),
37
+ episode_units=episode_units,
38
+ minute_units=minute_units,
39
+ duration_rounding_sec=duration_rounding_sec,
40
+ )
41
+
42
+ def charge_units(self, duration_ns: int) -> int:
43
+ if type(duration_ns) is not int or not 0 <= duration_ns <= INT64_MAX:
44
+ raise ValueError('duration must be an integer nanosecond count within the storage limit')
45
+ if self.duration_rounding_sec:
46
+ step_ns = self.duration_rounding_sec * NANOSECONDS_PER_SECOND
47
+ duration_ns = (duration_ns + step_ns - 1) // step_ns * step_ns
48
+ units = self.episode_units + duration_ns * self.minute_units // NANOSECONDS_PER_MINUTE
49
+ if units > INT64_MAX:
50
+ raise ValueError('episode credits exceed the storage limit')
51
+ return units
52
+
53
+ @model_validator(mode='after')
54
+ def _version_binds_the_rates(self) -> Self:
55
+ if self.version != self.content_hash(self.episode_units, self.minute_units, self.duration_rounding_sec):
56
+ raise ValueError('tariff version does not match its rates')
57
+ return self
58
+
59
+
60
+ class QuoteLine(BaseModel):
61
+ model_config = ConfigDict(extra='forbid', frozen=True)
62
+
63
+ task_pos: int = Field(strict=True, ge=0, le=INT64_MAX)
64
+ endpoint: str = Field(min_length=1)
65
+ count: int = Field(strict=True, ge=1, le=INT64_MAX)
66
+ cap_ns: int = Field(strict=True, ge=1, le=INT64_MAX)
67
+ max_units: CreditUnits
68
+
69
+
70
+ class CreditQuote(BaseModel):
71
+ model_config = ConfigDict(extra='forbid', frozen=True)
72
+
73
+ terms: Tariff
74
+ lines: tuple[QuoteLine, ...] = Field(min_length=1)
75
+ total_units: CreditUnits
76
+
77
+ @model_validator(mode='after')
78
+ def _lines_match_the_terms(self) -> Self:
79
+ bindings = [(line.task_pos, line.endpoint) for line in self.lines]
80
+ if len(set(bindings)) != len(bindings):
81
+ raise ValueError('quote repeats a task endpoint')
82
+ for line in self.lines:
83
+ maximum = line.count * self.terms.charge_units(line.cap_ns)
84
+ if line.max_units != maximum:
85
+ raise ValueError('quote line does not match its tariff')
86
+ if self.total_units != sum(line.max_units for line in self.lines):
87
+ raise ValueError('quote total does not match its lines')
88
+ return self
89
+
90
+
91
+ class RequestBilling(BaseModel):
92
+ model_config = ConfigDict(extra='forbid', frozen=True)
93
+
94
+ mode: Slugged[BillingMode]
95
+ quote: CreditQuote | None = None
96
+ state: Slugged[BillingState]
97
+
98
+ @model_validator(mode='after')
99
+ def _mode_has_the_matching_quote(self) -> Self:
100
+ if (self.mode is BillingMode.pay_as_you_go) != (self.quote is not None):
101
+ raise ValueError('a pay_as_you_go request requires a quote, and a packaged request cannot carry one')
102
+ if self.mode is BillingMode.packaged and self.state is not BillingState.settled:
103
+ raise ValueError('a packaged request holds no credits')
104
+ return self
105
+
106
+
107
+ class CreditBalance(BaseModel):
108
+ posted_units: CreditUnits
109
+ reserved_units: CreditUnits
110
+
111
+ @computed_field
112
+ @property
113
+ def available_units(self) -> int:
114
+ return self.posted_units - self.reserved_units
115
+
116
+ @model_validator(mode='after')
117
+ def _holds_fit_the_balance(self) -> Self:
118
+ if self.available_units < 0:
119
+ raise ValueError('reserved credits exceed posted credits')
120
+ return self
121
+
122
+
123
+ class CreditPackage(BaseModel):
124
+ model_config = ConfigDict(extra='forbid', frozen=True)
125
+
126
+ id: PackageId = Field(min_length=1)
127
+ credit_units: CreditUnits = Field(gt=0)
128
+ amount_minor: int = Field(strict=True, gt=0, le=INT64_MAX)
129
+ currency: str = Field(pattern=r'^[a-z]{3}$')
130
+
131
+
132
+ class BillingAccount(BaseModel):
133
+ org: OrgSlug
134
+ mode: Slugged[BillingMode]
135
+ billing_role: Slugged[BillingRole]
136
+ balance: CreditBalance
137
+ tariff: Tariff
138
+ packages: tuple[CreditPackage, ...]
139
+
140
+
141
+ class PurchaseView(BaseModel):
142
+ id: PurchaseId = Field(min_length=1)
143
+ package: CreditPackage
144
+ initiated_by: UserId
145
+ created_at: AwareDatetime
146
+ checkout_url: str | None = Field(default=None, min_length=1)
147
+ granted_at: AwareDatetime | None = None
148
+ review_reason: str | None = Field(default=None, min_length=1)
149
+
150
+ @model_validator(mode='after')
151
+ def _payable_link_matches_lifecycle(self) -> Self:
152
+ if self.checkout_url is not None and (self.granted_at is not None or self.review_reason is not None):
153
+ raise ValueError('a reviewed or credited purchase cannot carry a payable link')
154
+ return self
155
+
156
+
157
+ class PurchaseListResponse(BaseModel):
158
+ purchases: list[PurchaseView]
159
+ next: PurchaseId | None
@@ -0,0 +1,24 @@
1
+ """The slug a ranking board is read by. `rankings.list` names every slug the caller can read.
2
+
3
+ The platform owns the set, so a board this client has never heard of still reaches the server.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pydantic import GetCoreSchemaHandler
9
+ from pydantic_core import core_schema
10
+
11
+
12
+ class BoardRef(str):
13
+ """The slug of a board the platform publishes, as a validated `str`."""
14
+
15
+ __slots__ = ()
16
+
17
+ def __new__(cls, value: str) -> BoardRef:
18
+ if not value or any(c.isspace() for c in value):
19
+ raise ValueError(f'not a board slug: {value!r}')
20
+ return super().__new__(cls, value)
21
+
22
+ @classmethod
23
+ def __get_pydantic_core_schema__(cls, source: type, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
24
+ return core_schema.no_info_after_validator_function(cls, core_schema.str_schema())
@@ -0,0 +1,62 @@
1
+ """What the platform offers: the evals a caller may name, and the tasks a caller may compose into one.
2
+
3
+ Both lists depend on the caller's grant. Every registered user sees the evals a submission
4
+ names, and a customer grant adds the rig's evals and tasks.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from platform_client.enums import CameraVantage, Placement, StartPose
10
+ from platform_client.eval_plan import Clutter
11
+ from platform_client.tasks import TaskRef
12
+ from positronic_evals.evals import EvalRef
13
+ from positronic_evals.slug import Slugged
14
+ from pydantic import BaseModel, Field
15
+
16
+
17
+ class TaskSummary(BaseModel):
18
+ """`catalog.tasks` — one task the caller may put in a plan, as the catalogue states it.
19
+
20
+ `tote_placement` and `external_cameras` list the sides a plan may choose, or ask `random` to
21
+ draw from. `external_cameras` is keyed by the mount name the task defines.
22
+ `default_cap_per_episode_sec` applies when the plan states no cap. `clutter` applies when the
23
+ plan states none; a task carrying neither draws no clutter objects. `start_pose` applies when
24
+ the plan states none.
25
+ """
26
+
27
+ id: TaskRef
28
+ embodiment: str
29
+ task: str
30
+ setup: str = ''
31
+ total_items: int | None = None
32
+ objects: list[str] = Field(default_factory=list)
33
+ tote_placement: list[Slugged[Placement]] = Field(default_factory=list)
34
+ camera_vantage: Slugged[CameraVantage] | None = None
35
+ external_cameras: dict[str, list[Slugged[Placement]]] = Field(default_factory=dict)
36
+ default_cap_per_episode_sec: int | None = None
37
+ clutter: Clutter | None = None
38
+ start_pose: Slugged[StartPose] = StartPose.nominal
39
+
40
+
41
+ class EvalSummary(BaseModel):
42
+ """`catalog.evals` — one eval `submissions.create` accepts by name, with a policy image.
43
+
44
+ `tasks` names what the eval runs, in the embodiment's own spelling. `composable` is true for an
45
+ eval built from catalogue tasks: a caller may compose a plan over the same tasks. A pinned eval
46
+ fixes its trials and accepts no `tasks` from the caller.
47
+ """
48
+
49
+ id: EvalRef
50
+ embodiment: str
51
+ tasks: list[str] = Field(default_factory=list)
52
+ composable: bool
53
+ primary_metric: str | None = None
54
+ description: str = ''
55
+
56
+
57
+ class TaskListResponse(BaseModel):
58
+ tasks: list[TaskSummary] = Field(default_factory=list)
59
+
60
+
61
+ class EvalListResponse(BaseModel):
62
+ evals: list[EvalSummary] = Field(default_factory=list)
@@ -0,0 +1,336 @@
1
+ """`PlatformClient` — one method per gateway endpoint, request model in, response model out.
2
+
3
+ with PlatformClient() as client:
4
+ # keeps the key it returns; the timeout covers the gateway's check of the credential
5
+ client.register(RegisterRequest(credential=..., alias=...), timeout=client.register_timeout())
6
+ plan = EvalPlan(request_type=PrivateEval(org=...), tasks=..., endpoints=..., episodes_per_endpoint=...)
7
+ client.create_submission(plan)
8
+
9
+ The platform is `base_url`, else `POSITRONIC_PLATFORM_URL`, else production; the key is `api_key`,
10
+ else `POSITRONIC_PLATFORM_API_KEY`, else whatever `register` came back with. A non-2xx raises
11
+ `PlatformError` carrying the error envelope.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from enum import Enum, auto
18
+ from types import TracebackType
19
+ from typing import Any, ClassVar, Self, TypeVar
20
+
21
+ import httpx
22
+ from platform_client import routes
23
+ from platform_client.billing import BillingAccount, PurchaseListResponse, PurchaseView
24
+ from platform_client.boards import BoardRef
25
+ from platform_client.catalog import EvalListResponse, TaskListResponse
26
+ from platform_client.errors import PlatformError
27
+ from platform_client.eval_plan import REVEAL_REGISTRY_PASSWORD, EvalPlan
28
+ from platform_client.identity_providers import longest_register_timeout
29
+ from platform_client.ids import ApiKey, OrgSlug, PurchaseId, SubmissionId
30
+ from platform_client.requests import (
31
+ DEFAULT_PURCHASE_PAGE_SIZE,
32
+ BillingOrgQuery,
33
+ BillingPurchaseCreateRequest,
34
+ BillingPurchaseGetQuery,
35
+ BillingPurchaseListQuery,
36
+ CancelRequest,
37
+ RankingsQuery,
38
+ RegisterRequest,
39
+ SubmissionArtifactsQuery,
40
+ SubmissionGetQuery,
41
+ SubmissionListQuery,
42
+ )
43
+ from platform_client.responses import (
44
+ ArtifactListResponse,
45
+ AuthConfig,
46
+ BoardListResponse,
47
+ CancelResponse,
48
+ MeResponse,
49
+ RankingsResponse,
50
+ RegisterResponse,
51
+ ResolvedPlan,
52
+ SubmissionCreateResponse,
53
+ SubmissionListResponse,
54
+ SubmissionView,
55
+ )
56
+ from pydantic import BaseModel, TypeAdapter
57
+
58
+ M = TypeVar('M', bound=BaseModel)
59
+
60
+ DEFAULT_TIMEOUT_S = 30.0
61
+
62
+ # The platform a caller reaches with no configuration at all.
63
+ DEFAULT_PLATFORM_URL = 'https://platform.positronic.ro'
64
+
65
+ # What an authenticated call sends, what `users.register` must not, and what a caller's own client
66
+ # may therefore not default — three places that have to agree.
67
+ AUTH_HEADER = 'Authorization'
68
+
69
+ API_URL_ENV = 'POSITRONIC_PLATFORM_URL'
70
+ API_KEY_ENV = 'POSITRONIC_PLATFORM_API_KEY'
71
+ CREDENTIAL_ENV = 'POSITRONIC_PLATFORM_CREDENTIAL'
72
+
73
+
74
+ def resolve_api_key(api_key: ApiKey | None = None) -> ApiKey | None:
75
+ """The key to authenticate with: what the caller passed, else the environment, else none."""
76
+ if api_key is not None:
77
+ return api_key
78
+ from_env = os.environ.get(API_KEY_ENV)
79
+ return ApiKey(from_env) if from_env else None
80
+
81
+
82
+ def require_absolute_url(url: str | httpx.URL, source: str) -> None:
83
+ """Refuse a base URL that does not parse or names no host, naming the value and what it has to be.
84
+
85
+ Every endpoint sends a path relative to the client's base URL, so one that names no host — empty,
86
+ or a bare path — resolves against nothing and the request never leaves for a platform.
87
+ """
88
+ try:
89
+ parsed = httpx.URL(url)
90
+ except httpx.InvalidURL as exc:
91
+ raise ValueError(f'{source} is {str(url)!r}, which does not parse as a URL: {exc}') from exc
92
+ if parsed.is_absolute_url:
93
+ return
94
+ raise ValueError(
95
+ f'{source} is {str(url)!r}, which names no host: give an absolute URL, scheme and host, naming the platform'
96
+ )
97
+
98
+
99
+ def resolve_base_url(base_url: str | None = None) -> str:
100
+ """The platform to talk to: what the caller passed, else the environment, else the default."""
101
+ for value, source in ((base_url, 'base_url'), (os.environ.get(API_URL_ENV), API_URL_ENV)):
102
+ if value is None:
103
+ continue
104
+ # Empty is a misconfiguration, not a request for the default: `--platform-url=` reads as a
105
+ # platform the caller named, and falling through would send that run to the production one.
106
+ if not value.strip():
107
+ raise ValueError(f'{source} is empty; name a platform, or leave it unset for {DEFAULT_PLATFORM_URL}')
108
+ require_absolute_url(value, source)
109
+ return value
110
+ return DEFAULT_PLATFORM_URL
111
+
112
+
113
+ class Auth(Enum):
114
+ """What an endpoint does with the client's API key."""
115
+
116
+ REQUIRED = auto()
117
+ # Sent when one is set: a public board answers either way, a tenant's board only with a key.
118
+ OPTIONAL = auto()
119
+ NONE = auto()
120
+
121
+
122
+ class PlatformClient:
123
+ """A caller's handle on one platform.
124
+
125
+ Pass an `httpx.Client` instead of a `base_url` when the transport matters (an in-process ASGI
126
+ transport, a proxy, a retry policy); it carries its own base URL, so the two are exclusive.
127
+ """
128
+
129
+ def __init__(
130
+ self,
131
+ base_url: str | None = None,
132
+ *,
133
+ api_key: ApiKey | None = None,
134
+ timeout: float = DEFAULT_TIMEOUT_S,
135
+ client: httpx.Client | None = None,
136
+ ) -> None:
137
+ self._owns_client = client is None
138
+ if client is None:
139
+ url = resolve_base_url(base_url)
140
+ # An environment proxy would carry a plain-http key or token off the machine.
141
+ client = httpx.Client(base_url=url, timeout=timeout, trust_env=httpx.URL(url).scheme == 'https')
142
+ else:
143
+ if base_url is not None:
144
+ raise ValueError('a client of your own already carries its base URL; pass one or the other')
145
+ require_absolute_url(client.base_url, "the supplied client's base_url")
146
+ if AUTH_HEADER in client.headers:
147
+ # httpx MERGES client-level headers into every request, so a default here would reach
148
+ # `users.register`, which this module declares unauthenticated and the gateway reads
149
+ # as a registration by whoever that credential names.
150
+ raise ValueError(f'the supplied client carries a default {AUTH_HEADER} header; pass the key as api_key')
151
+ if client.auth is not None:
152
+ # An auth flow reaches the same requests by another route: httpx runs it on every
153
+ # one, so it would sign `users.register` too.
154
+ raise ValueError(
155
+ f'the supplied client carries an auth flow, which sets {AUTH_HEADER} on every '
156
+ f'request including `users.register`; pass the key as api_key'
157
+ )
158
+ self._client = client
159
+ self.api_key = resolve_api_key(api_key)
160
+
161
+ @property
162
+ def base_url(self) -> str:
163
+ """The platform URL this client resolved and reaches."""
164
+ return str(self._client.base_url)
165
+
166
+ # --- endpoints ---------------------------------------------------------------------------
167
+
168
+ def auth_config(self) -> AuthConfig:
169
+ """The sign-ins this platform takes."""
170
+ return self._get(routes.AUTH_CONFIG, AuthConfig, auth=Auth.NONE)
171
+
172
+ def register_timeout(self) -> httpx.Timeout:
173
+ """The bound on `users.register` for a credential of any sign-in this platform takes, from `auth.config`.
174
+
175
+ The gateway checks the credential with the identity provider inside the call, and a caller that gives
176
+ up first leaves a key behind that it can never read.
177
+ """
178
+ return longest_register_timeout(self.auth_config())
179
+
180
+ # `users.register` checks the credential with its identity provider before it answers.
181
+ DEFAULT_REGISTER_TIMEOUT: ClassVar[httpx.Timeout] = httpx.Timeout(90.0)
182
+
183
+ def register(
184
+ self, request: RegisterRequest, *, timeout: httpx.Timeout = DEFAULT_REGISTER_TIMEOUT
185
+ ) -> RegisterResponse:
186
+ """Create-or-return a user, KEEPING any key it returns — the next call is authenticated.
187
+
188
+ A key comes back only on a first registration or a `rotate`; a repeat registration answers
189
+ `existing` with none, and the client goes on holding whatever it already had. Without `timeout`
190
+ it waits `DEFAULT_REGISTER_TIMEOUT`; `register_timeout()` gives the bound `auth.config` implies.
191
+ """
192
+ response = self._post(routes.USERS_REGISTER, request, RegisterResponse, auth=Auth.NONE, timeout=timeout)
193
+ if response.api_key is not None:
194
+ self.api_key = response.api_key
195
+ return response
196
+
197
+ def me(self) -> MeResponse:
198
+ return self._get(routes.USERS_ME, MeResponse)
199
+
200
+ def billing_account(self, org: OrgSlug) -> BillingAccount:
201
+ return self._get(routes.BILLING_ACCOUNT, BillingAccount, query=BillingOrgQuery(org=org))
202
+
203
+ def create_purchase(self, request: BillingPurchaseCreateRequest) -> PurchaseView:
204
+ return self._post(routes.BILLING_PURCHASES_CREATE, request, PurchaseView)
205
+
206
+ def get_purchase(self, purchase_id: PurchaseId) -> PurchaseView:
207
+ return self._get(routes.BILLING_PURCHASES_GET, PurchaseView, query=BillingPurchaseGetQuery(id=purchase_id))
208
+
209
+ def list_purchases(
210
+ self, org: OrgSlug, *, after: PurchaseId | None = None, limit: int = DEFAULT_PURCHASE_PAGE_SIZE
211
+ ) -> PurchaseListResponse:
212
+ """Read one history page. Pass its `next` as `after` until `next` is None."""
213
+ query = BillingPurchaseListQuery(org=org, after=after, limit=limit)
214
+ return self._get(routes.BILLING_PURCHASES_LIST, PurchaseListResponse, query=query)
215
+
216
+ def create_submission(self, plan: EvalPlan) -> SubmissionCreateResponse:
217
+ """Run one plan. A plan that states its own tasks needs a customer grant: a key without one
218
+ is refused `forbidden`."""
219
+ return self._post(routes.SUBMISSIONS_CREATE, plan, SubmissionCreateResponse)
220
+
221
+ def resolve_plan(self, plan: EvalPlan) -> ResolvedPlan:
222
+ """The plan as it would run, with nothing filed."""
223
+ return self._post(routes.SUBMISSIONS_RESOLVE, plan, ResolvedPlan)
224
+
225
+ def list_submissions(
226
+ self, *, after: SubmissionId | None = None, limit: int | None = None
227
+ ) -> SubmissionListResponse:
228
+ """One page of the caller's submissions, oldest first — or every user's, for an admin or
229
+ service principal. Pass a page's `next` as `after` for the page after it."""
230
+ query = SubmissionListQuery(after=after, limit=limit)
231
+ return self._get(routes.SUBMISSIONS_LIST, SubmissionListResponse, query=query)
232
+
233
+ # The view is a discriminated union rather than a model, so it validates through an adapter.
234
+ # Built once, because building one costs more than the validation it then does.
235
+ _SUBMISSION_VIEW: ClassVar[TypeAdapter[SubmissionView]] = TypeAdapter(SubmissionView)
236
+
237
+ def get_submission(self, submission_id: SubmissionId) -> SubmissionView:
238
+ query = SubmissionGetQuery(id=submission_id)
239
+ return self._SUBMISSION_VIEW.validate_json(self._send('GET', routes.SUBMISSIONS_GET, query=query).content)
240
+
241
+ def list_artifacts(
242
+ self,
243
+ submission_id: SubmissionId,
244
+ *,
245
+ prefix: str | None = None,
246
+ after: str | None = None,
247
+ limit: int | None = None,
248
+ ) -> ArtifactListResponse:
249
+ """One page of the objects a finished submission wrote, in key order.
250
+
251
+ `prefix` keeps the page to one directory under the submission, and a page's `next` is the
252
+ `after` of the page behind it. A submission short of finished is refused `bad_request`.
253
+ """
254
+ query = SubmissionArtifactsQuery(id=submission_id, prefix=prefix, after=after, limit=limit)
255
+ return self._get(routes.SUBMISSIONS_ARTIFACTS, ArtifactListResponse, query=query)
256
+
257
+ def cancel_submission(self, request: CancelRequest) -> CancelResponse:
258
+ return self._post(routes.SUBMISSIONS_CANCEL, request, CancelResponse)
259
+
260
+ def rankings(self, *, board: BoardRef) -> RankingsResponse:
261
+ """One board by slug."""
262
+ query = RankingsQuery(board=board)
263
+ return self._get(routes.RANKINGS_GET, RankingsResponse, query=query, auth=Auth.OPTIONAL)
264
+
265
+ def list_boards(self) -> BoardListResponse:
266
+ """The boards this caller can read: the public ones, plus their tenant's once a key is set."""
267
+ return self._get(routes.RANKINGS_LIST, BoardListResponse, auth=Auth.OPTIONAL)
268
+
269
+ def catalog_evals(self) -> EvalListResponse:
270
+ """The evals a plan names, for this key's grant. The catalogue expands one into its tasks."""
271
+ return self._get(routes.CATALOG_EVALS, EvalListResponse)
272
+
273
+ def catalog_tasks(self) -> TaskListResponse:
274
+ """The tasks a plan may compose, for this key's grant. A key with no customer grant is refused `forbidden`."""
275
+ return self._get(routes.CATALOG_TASKS, TaskListResponse)
276
+
277
+ # --- plumbing ----------------------------------------------------------------------------
278
+
279
+ # Validated from the raw bytes, never from a decoded object: an id is hex text on the wire and
280
+ # only the JSON schema enforces that, so decoding first would take a number as well.
281
+ def _get(self, path: str, model: type[M], *, query: BaseModel | None = None, auth: Auth = Auth.REQUIRED) -> M:
282
+ return model.model_validate_json(self._send('GET', path, query=query, auth=auth).content)
283
+
284
+ def _post(
285
+ self,
286
+ path: str,
287
+ request: BaseModel,
288
+ model: type[M],
289
+ *,
290
+ auth: Auth = Auth.REQUIRED,
291
+ timeout: httpx.Timeout | None = None,
292
+ ) -> M:
293
+ # This dump reveals the registry password. Every other dump masks it.
294
+ body = request.model_dump(mode='json', context={REVEAL_REGISTRY_PASSWORD: True})
295
+ # None keeps the client's own timeout.
296
+ sent = self._send(
297
+ 'POST', path, json=body, auth=auth, timeout=httpx.USE_CLIENT_DEFAULT if timeout is None else timeout
298
+ )
299
+ return model.model_validate_json(sent.content)
300
+
301
+ def _send(
302
+ self, method: str, path: str, *, query: BaseModel | None = None, auth: Auth = Auth.REQUIRED, **kwargs: Any
303
+ ) -> httpx.Response:
304
+ # `exclude_none` is what makes an unset parameter absent from the URL rather than an empty
305
+ # value, which the gateway would have to tell apart from a caller that meant the empty string.
306
+ if query is not None:
307
+ kwargs['params'] = query.model_dump(mode='json', exclude_none=True)
308
+ headers: dict[str, str] = {}
309
+ if auth is Auth.REQUIRED and self.api_key is None:
310
+ raise ValueError(f'{path} needs an API key: pass api_key=, set {API_KEY_ENV}, or call register() first')
311
+ if auth is not Auth.NONE and self.api_key is not None:
312
+ headers[AUTH_HEADER] = f'Bearer {self.api_key}'
313
+ response = self._client.request(method, path, headers=headers, **kwargs)
314
+ # Not `>= 400`: httpx follows no redirect by default, so a 3xx arrives here with a body that
315
+ # is not an envelope and would surface as a parse error instead of the promised PlatformError.
316
+ if not 200 <= response.status_code < 300:
317
+ try:
318
+ payload: object = response.json()
319
+ except ValueError:
320
+ # A failure need not carry an envelope at all: a proxy's HTML 502 arrives here too.
321
+ payload = response.text
322
+ raise PlatformError.from_payload(response.status_code, payload)
323
+ return response
324
+
325
+ def close(self) -> None:
326
+ """Close the underlying transport, unless the caller supplied its own client."""
327
+ if self._owns_client:
328
+ self._client.close()
329
+
330
+ def __enter__(self) -> Self:
331
+ return self
332
+
333
+ def __exit__(
334
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
335
+ ) -> None:
336
+ self.close()