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.
- platform_client/billing.py +159 -0
- platform_client/boards.py +24 -0
- platform_client/catalog.py +62 -0
- platform_client/client.py +336 -0
- platform_client/config.py +147 -0
- platform_client/device_flow.py +506 -0
- platform_client/enums.py +240 -0
- platform_client/errors.py +136 -0
- platform_client/eval_plan.py +713 -0
- platform_client/identity_providers.py +104 -0
- platform_client/ids.py +95 -0
- platform_client/model_config.py +13 -0
- platform_client/policy_container.py +97 -0
- platform_client/policy_images.py +50 -0
- platform_client/py.typed +0 -0
- platform_client/requests.py +106 -0
- platform_client/responses.py +565 -0
- platform_client/routes.py +30 -0
- platform_client/tasks.py +33 -0
- platform_client/tests/test_client.py +992 -0
- platform_client/tests/test_config.py +148 -0
- platform_client/tests/test_device_flow.py +1167 -0
- platform_client/tests/test_enums.py +169 -0
- platform_client/tests/test_eval_plan.py +961 -0
- platform_client/tests/test_ids.py +129 -0
- platform_client/tests/test_models.py +1206 -0
- platform_client/tests/test_policy_container.py +117 -0
- platform_client/tests/test_policy_images.py +77 -0
- platform_client/tests/test_tasks.py +31 -0
- positronic_platform_client-0.37.1.dist-info/METADATA +500 -0
- positronic_platform_client-0.37.1.dist-info/RECORD +33 -0
- positronic_platform_client-0.37.1.dist-info/WHEEL +4 -0
- positronic_platform_client-0.37.1.dist-info/entry_points.txt +2 -0
|
@@ -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()
|