manifold-cli 0.0.39__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.
- manifold_cli/__init__.py +3 -0
- manifold_cli/__main__.py +4 -0
- manifold_cli/bridge.py +70 -0
- manifold_cli/cli.py +54 -0
- manifold_cli/clients/__init__.py +0 -0
- manifold_cli/clients/auth_flow.py +169 -0
- manifold_cli/clients/credentials.py +53 -0
- manifold_cli/clients/docker_engine.py +43 -0
- manifold_cli/clients/image.py +130 -0
- manifold_cli/clients/manifold.py +1288 -0
- manifold_cli/clients/runner_home.py +165 -0
- manifold_cli/clients/workos.py +99 -0
- manifold_cli/commands/__init__.py +0 -0
- manifold_cli/commands/auth.py +101 -0
- manifold_cli/commands/benchmark.py +539 -0
- manifold_cli/commands/organization.py +154 -0
- manifold_cli/commands/policy.py +749 -0
- manifold_cli/commands/run.py +337 -0
- manifold_cli/commands/run_log.py +291 -0
- manifold_cli/commands/runner.py +852 -0
- manifold_cli/commands/table.py +101 -0
- manifold_cli/commands/token.py +144 -0
- manifold_cli/dependencies.py +116 -0
- manifold_cli/models/__init__.py +0 -0
- manifold_cli/models/auth.py +49 -0
- manifold_cli/models/benchmark.py +87 -0
- manifold_cli/models/catalog.py +189 -0
- manifold_cli/models/credentials.py +19 -0
- manifold_cli/models/errors.py +121 -0
- manifold_cli/models/log.py +114 -0
- manifold_cli/models/observability.py +121 -0
- manifold_cli/models/organization.py +19 -0
- manifold_cli/models/pagination.py +59 -0
- manifold_cli/models/policy.py +49 -0
- manifold_cli/models/run.py +381 -0
- manifold_cli/models/runner.py +186 -0
- manifold_cli/models/runner_home.py +69 -0
- manifold_cli/models/task_queue.py +40 -0
- manifold_cli/models/token.py +39 -0
- manifold_cli/replay/__init__.py +68 -0
- manifold_cli/replay/log.py +814 -0
- manifold_cli/runner/__init__.py +72 -0
- manifold_cli/runner/api_retry.py +36 -0
- manifold_cli/runner/benchmark/__init__.py +800 -0
- manifold_cli/runner/benchmark/live_view.py +248 -0
- manifold_cli/runner/benchmark/preassigned_scheduling.py +325 -0
- manifold_cli/runner/benchmark/recording.py +1208 -0
- manifold_cli/runner/benchmark/scheduling.py +256 -0
- manifold_cli/runner/benchmark/task_queue_scheduling.py +717 -0
- manifold_cli/runner/benchmark/task_server.py +123 -0
- manifold_cli/runner/benchmark/video.py +124 -0
- manifold_cli/runner/benchmark_steps.py +178 -0
- manifold_cli/runner/cancellation.py +22 -0
- manifold_cli/runner/capture.py +282 -0
- manifold_cli/runner/container.py +370 -0
- manifold_cli/runner/current_dispatch.py +25 -0
- manifold_cli/runner/daemon.py +520 -0
- manifold_cli/runner/dispatch.py +38 -0
- manifold_cli/runner/gpu.py +182 -0
- manifold_cli/runner/heartbeat.py +202 -0
- manifold_cli/runner/loopback.py +102 -0
- manifold_cli/runner/loopback_bench.py +183 -0
- manifold_cli/runner/loopback_policy.py +39 -0
- manifold_cli/runner/output.py +218 -0
- manifold_cli/runner/pairing_acceptance.py +57 -0
- manifold_cli/runner/plan.py +369 -0
- manifold_cli/runner/policy.py +350 -0
- manifold_cli/runner/policy_steps.py +164 -0
- manifold_cli/runner/preparation.py +47 -0
- manifold_cli/runner/process.py +219 -0
- manifold_cli/runner/ram.py +17 -0
- manifold_cli/runner/resource_usage.py +265 -0
- manifold_cli/runner/served.py +218 -0
- manifold_cli/runner/service.py +120 -0
- manifold_cli/runner/side_failure.py +68 -0
- manifold_cli/runner/signature.py +98 -0
- manifold_cli/runner/source.py +66 -0
- manifold_cli/runner/stages.py +241 -0
- manifold_cli/runner/step_timing.py +229 -0
- manifold_cli/runner/worker.py +534 -0
- manifold_cli/runner/workload.py +401 -0
- manifold_cli/services/__init__.py +0 -0
- manifold_cli/services/auth.py +89 -0
- manifold_cli/services/benchmark.py +357 -0
- manifold_cli/services/organization.py +22 -0
- manifold_cli/services/policy.py +234 -0
- manifold_cli/services/run.py +304 -0
- manifold_cli/services/runner.py +207 -0
- manifold_cli/services/runner_daemon.py +146 -0
- manifold_cli/services/token.py +52 -0
- manifold_cli/settings.py +30 -0
- manifold_cli/transport/__init__.py +31 -0
- manifold_cli/transport/diagnostics.py +72 -0
- manifold_cli/transport/interfaces.py +69 -0
- manifold_cli/transport/iroh.py +317 -0
- manifold_cli/transport/registry.py +34 -0
- manifold_cli/transport/tcp.py +146 -0
- manifold_cli-0.0.39.dist-info/METADATA +406 -0
- manifold_cli-0.0.39.dist-info/RECORD +102 -0
- manifold_cli-0.0.39.dist-info/WHEEL +4 -0
- manifold_cli-0.0.39.dist-info/entry_points.txt +2 -0
- manifold_cli-0.0.39.dist-info/licenses/LICENSE +373 -0
|
@@ -0,0 +1,1288 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from http import HTTPStatus
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from pydantic import TypeAdapter
|
|
15
|
+
|
|
16
|
+
from manifold_cli.models.auth import CliConfig, User
|
|
17
|
+
from manifold_cli.models.benchmark import (
|
|
18
|
+
Benchmark,
|
|
19
|
+
BenchmarkNotFoundError,
|
|
20
|
+
BenchmarkVersion,
|
|
21
|
+
RubricContent,
|
|
22
|
+
)
|
|
23
|
+
from manifold_cli.models.catalog import Adapter, CatalogRegisterError, Visibility
|
|
24
|
+
from manifold_cli.models.errors import (
|
|
25
|
+
ManifoldApiError,
|
|
26
|
+
error_detail,
|
|
27
|
+
raw_error_detail,
|
|
28
|
+
)
|
|
29
|
+
from manifold_cli.models.log import (
|
|
30
|
+
LogBatch,
|
|
31
|
+
LogLine,
|
|
32
|
+
LogMatch,
|
|
33
|
+
LogProducer,
|
|
34
|
+
LogSource,
|
|
35
|
+
RunLogStream,
|
|
36
|
+
)
|
|
37
|
+
from manifold_cli.models.observability import (
|
|
38
|
+
ReportBenchmarkMetricsRequest,
|
|
39
|
+
ReportPolicyMetricsRequest,
|
|
40
|
+
ReportResourceUsageRequest,
|
|
41
|
+
)
|
|
42
|
+
from manifold_cli.models.organization import Organization, OrganizationNotFoundError
|
|
43
|
+
from manifold_cli.models.pagination import Page, SortOrder
|
|
44
|
+
from manifold_cli.models.policy import Policy, PolicyNotFoundError, PolicyVersion
|
|
45
|
+
from manifold_cli.models.run import (
|
|
46
|
+
Assignment,
|
|
47
|
+
AssignmentDispatch,
|
|
48
|
+
AssignmentNotAssignedToRunnerError,
|
|
49
|
+
Episode,
|
|
50
|
+
EpisodeReport,
|
|
51
|
+
EpisodesRejectedError,
|
|
52
|
+
PublishedLiveView,
|
|
53
|
+
ReplayRejectedError,
|
|
54
|
+
ReplayUploadSlot,
|
|
55
|
+
Run,
|
|
56
|
+
RunBridge,
|
|
57
|
+
RunCancelRejectedError,
|
|
58
|
+
RunDeleteRejectedError,
|
|
59
|
+
RunNotFoundError,
|
|
60
|
+
RunSideRefusedError,
|
|
61
|
+
RunStatus,
|
|
62
|
+
RunSubmitError,
|
|
63
|
+
SchedulingType,
|
|
64
|
+
SideHeartbeat,
|
|
65
|
+
SideOutcome,
|
|
66
|
+
)
|
|
67
|
+
from manifold_cli.models.runner import (
|
|
68
|
+
GpuModel,
|
|
69
|
+
InstanceOverrideType,
|
|
70
|
+
ListedRunner,
|
|
71
|
+
MintedRunner,
|
|
72
|
+
Runner,
|
|
73
|
+
RunnerGpuDevice,
|
|
74
|
+
RunnerNameTakenError,
|
|
75
|
+
RunnerNotFoundError,
|
|
76
|
+
RunnerProvider,
|
|
77
|
+
RunnerSource,
|
|
78
|
+
)
|
|
79
|
+
from manifold_cli.models.task_queue import (
|
|
80
|
+
TaskClaim,
|
|
81
|
+
TaskQueueLeaseLostError,
|
|
82
|
+
TaskResult,
|
|
83
|
+
)
|
|
84
|
+
from manifold_cli.models.token import (
|
|
85
|
+
AccessToken,
|
|
86
|
+
AccessTokenNotFoundError,
|
|
87
|
+
CreatedAccessToken,
|
|
88
|
+
Scope,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
logger = logging.getLogger(__name__)
|
|
92
|
+
|
|
93
|
+
# The recording's own media type, signed into the slot, so the PUT has to send it.
|
|
94
|
+
RRD_MIME = "application/vnd.rerun.rrd"
|
|
95
|
+
|
|
96
|
+
# A recording runs to megabytes and the upload is a single request, so it gets
|
|
97
|
+
# its own generous timeout rather than the client's default.
|
|
98
|
+
REPLAY_PUT_TIMEOUT = 120.0
|
|
99
|
+
HEARTBEAT_TIMEOUT_SECONDS = 5.0
|
|
100
|
+
# An episode report is a write the server keeps whether or not the reply is
|
|
101
|
+
# read, so it waits well past httpx's five-second default before giving up.
|
|
102
|
+
EPISODE_REPORT_TIMEOUT_SECONDS = 30.0
|
|
103
|
+
|
|
104
|
+
_ACCESS_TOKENS_ADAPTER = TypeAdapter(list[AccessToken])
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AssignmentHeartbeatClient:
|
|
108
|
+
def __init__(self, base_url: str, client: httpx.AsyncClient):
|
|
109
|
+
self._base_url = base_url
|
|
110
|
+
self._client = client
|
|
111
|
+
|
|
112
|
+
async def send(self, assignment: Assignment, *, serving: bool) -> SideHeartbeat:
|
|
113
|
+
"""Report this assignment's side alive, and serving when `serving` is set.
|
|
114
|
+
|
|
115
|
+
`assignment` is the attempt returned by the dispatch. Including it in
|
|
116
|
+
the request prevents the server from updating a later attempt if the
|
|
117
|
+
request completes after the first attempt ends.
|
|
118
|
+
|
|
119
|
+
A heartbeat that is not serving is the daemon's liveness until the
|
|
120
|
+
policy has accepted the benchmark's signature: the server marks the
|
|
121
|
+
runner seen without changing its preparation or startup phase.
|
|
122
|
+
The first serving heartbeat sets the side to `running`.
|
|
123
|
+
|
|
124
|
+
The answer carries how the peer finished, which is the other instruction
|
|
125
|
+
a side gets: a policy container serves until it is stopped, so its own
|
|
126
|
+
worker ending does not mean the run is over.
|
|
127
|
+
|
|
128
|
+
Raises `AssignmentNotAssignedToRunnerError` when the attempt is no longer this
|
|
129
|
+
runner's. That refusal is the instruction to tear the worker down.
|
|
130
|
+
"""
|
|
131
|
+
async with asyncio.timeout(HEARTBEAT_TIMEOUT_SECONDS):
|
|
132
|
+
response = await self._client.post(
|
|
133
|
+
f"{self._base_url}/v0/runs/{assignment.run_id}"
|
|
134
|
+
f"/attempts/{assignment.id}/heartbeat",
|
|
135
|
+
params={"serving": serving},
|
|
136
|
+
)
|
|
137
|
+
if response.status_code == HTTPStatus.CONFLICT:
|
|
138
|
+
raise _heartbeat_refusal(response, assignment.id)
|
|
139
|
+
if response.is_error:
|
|
140
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
141
|
+
return SideHeartbeat.model_validate(response.json())
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ManifoldApiClient:
|
|
145
|
+
def __init__(self, base_url: str, auth: httpx.Auth | None = None):
|
|
146
|
+
self._base_url = base_url.rstrip("/")
|
|
147
|
+
self._auth = auth
|
|
148
|
+
self._client = httpx.Client(auth=auth)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def base_url(self) -> str:
|
|
152
|
+
"""The deployment this client sends its requests to.
|
|
153
|
+
|
|
154
|
+
The policy side reads it to work out which of its own addresses a peer
|
|
155
|
+
dials: the interface that routes to this URL is the one a peer reaches.
|
|
156
|
+
"""
|
|
157
|
+
return self._base_url
|
|
158
|
+
|
|
159
|
+
def close(self) -> None:
|
|
160
|
+
self._client.close()
|
|
161
|
+
|
|
162
|
+
def get_cli_config(self) -> CliConfig:
|
|
163
|
+
# Unauthenticated bootstrap: this endpoint needs no token, and it is
|
|
164
|
+
# also what the auth flow fetches to refresh one — routing it through
|
|
165
|
+
# auth would recurse. auth=None opts this request out of the client's
|
|
166
|
+
# auth flow entirely.
|
|
167
|
+
response = self._client.get(f"{self._base_url}/auth/cli/config", auth=None)
|
|
168
|
+
if response.is_error:
|
|
169
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
170
|
+
return CliConfig.model_validate(response.json())
|
|
171
|
+
|
|
172
|
+
def get_me(self) -> User:
|
|
173
|
+
response = self._client.get(f"{self._base_url}/auth/me")
|
|
174
|
+
if response.is_error:
|
|
175
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
176
|
+
return User.model_validate(response.json())
|
|
177
|
+
|
|
178
|
+
def list_organizations(self) -> list[Organization]:
|
|
179
|
+
response = self._client.get(f"{self._base_url}/organization")
|
|
180
|
+
if response.is_error:
|
|
181
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
182
|
+
return [Organization.model_validate(item) for item in response.json()]
|
|
183
|
+
|
|
184
|
+
def list_policies(
|
|
185
|
+
self, *, limit: int | None = None, cursor: str | None = None
|
|
186
|
+
) -> Page[Policy]:
|
|
187
|
+
response = self._client.get(
|
|
188
|
+
f"{self._base_url}/v0/policies", params=_page_params(limit, cursor)
|
|
189
|
+
)
|
|
190
|
+
if response.is_error:
|
|
191
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
192
|
+
return Page[Policy].model_validate(response.json())
|
|
193
|
+
|
|
194
|
+
def list_benchmarks(
|
|
195
|
+
self, *, limit: int | None = None, cursor: str | None = None
|
|
196
|
+
) -> Page[Benchmark]:
|
|
197
|
+
response = self._client.get(
|
|
198
|
+
f"{self._base_url}/v0/benchmarks", params=_page_params(limit, cursor)
|
|
199
|
+
)
|
|
200
|
+
if response.is_error:
|
|
201
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
202
|
+
return Page[Benchmark].model_validate(response.json())
|
|
203
|
+
|
|
204
|
+
def list_policy_versions(self, slug: str, organization: str) -> list[PolicyVersion]:
|
|
205
|
+
response = self._client.get(
|
|
206
|
+
f"{self._base_url}/v0/policies/{slug}/versions",
|
|
207
|
+
params={"organization": organization},
|
|
208
|
+
)
|
|
209
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
210
|
+
raise PolicyNotFoundError(
|
|
211
|
+
error_detail(response.text) or f"'{slug}' was not found"
|
|
212
|
+
)
|
|
213
|
+
if response.is_error:
|
|
214
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
215
|
+
return [PolicyVersion.model_validate(item) for item in response.json()]
|
|
216
|
+
|
|
217
|
+
def list_benchmark_versions(
|
|
218
|
+
self, slug: str, organization: str
|
|
219
|
+
) -> list[BenchmarkVersion]:
|
|
220
|
+
response = self._client.get(
|
|
221
|
+
f"{self._base_url}/v0/benchmarks/{slug}/versions",
|
|
222
|
+
params={"organization": organization},
|
|
223
|
+
)
|
|
224
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
225
|
+
raise BenchmarkNotFoundError(
|
|
226
|
+
error_detail(response.text) or f"'{slug}' was not found"
|
|
227
|
+
)
|
|
228
|
+
if response.is_error:
|
|
229
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
230
|
+
return [BenchmarkVersion.model_validate(item) for item in response.json()]
|
|
231
|
+
|
|
232
|
+
def upsert_policy(
|
|
233
|
+
self,
|
|
234
|
+
slug: str,
|
|
235
|
+
organization: str,
|
|
236
|
+
*,
|
|
237
|
+
display_name: str,
|
|
238
|
+
visibility: Visibility,
|
|
239
|
+
) -> None:
|
|
240
|
+
response = self._client.put(
|
|
241
|
+
f"{self._base_url}/v0/policies/{slug}",
|
|
242
|
+
json={
|
|
243
|
+
"organization": organization,
|
|
244
|
+
"display_name": display_name,
|
|
245
|
+
"visibility": visibility.value,
|
|
246
|
+
},
|
|
247
|
+
)
|
|
248
|
+
_raise_for_register_rejection(response, slug)
|
|
249
|
+
if response.is_error:
|
|
250
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
251
|
+
|
|
252
|
+
def register_policy_version(
|
|
253
|
+
self,
|
|
254
|
+
slug: str,
|
|
255
|
+
version: str,
|
|
256
|
+
organization: str,
|
|
257
|
+
*,
|
|
258
|
+
adapter: Adapter,
|
|
259
|
+
config: dict[str, Any],
|
|
260
|
+
minimum_gpu_memory_gb: int,
|
|
261
|
+
minimum_driver_major: int | None,
|
|
262
|
+
maximum_driver_major: int | None,
|
|
263
|
+
minimum_gpu_count: int | None = None,
|
|
264
|
+
) -> PolicyVersion:
|
|
265
|
+
body = {
|
|
266
|
+
"organization": organization,
|
|
267
|
+
"adapter": adapter.value,
|
|
268
|
+
"config": config,
|
|
269
|
+
"minimum_gpu_memory_gb": minimum_gpu_memory_gb,
|
|
270
|
+
}
|
|
271
|
+
if minimum_driver_major is not None:
|
|
272
|
+
body["minimum_driver_major"] = minimum_driver_major
|
|
273
|
+
if maximum_driver_major is not None:
|
|
274
|
+
body["maximum_driver_major"] = maximum_driver_major
|
|
275
|
+
if minimum_gpu_count is not None:
|
|
276
|
+
body["minimum_gpu_count"] = minimum_gpu_count
|
|
277
|
+
response = self._client.put(
|
|
278
|
+
f"{self._base_url}/v0/policies/{slug}/versions/{version}",
|
|
279
|
+
json=body,
|
|
280
|
+
)
|
|
281
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
282
|
+
raise PolicyNotFoundError(
|
|
283
|
+
error_detail(response.text) or f"'{slug}' was not found"
|
|
284
|
+
)
|
|
285
|
+
_raise_for_register_rejection(response, slug)
|
|
286
|
+
if response.is_error:
|
|
287
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
288
|
+
return PolicyVersion.model_validate(response.json())
|
|
289
|
+
|
|
290
|
+
def delete_policy(self, slug: str, organization: str) -> None:
|
|
291
|
+
response = self._client.delete(
|
|
292
|
+
f"{self._base_url}/v0/policies/{quote(slug, safe='')}",
|
|
293
|
+
params={"organization": organization},
|
|
294
|
+
)
|
|
295
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
296
|
+
raise PolicyNotFoundError(
|
|
297
|
+
error_detail(response.text) or f"'{slug}' was not found"
|
|
298
|
+
)
|
|
299
|
+
if response.is_error:
|
|
300
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
301
|
+
|
|
302
|
+
def delete_policy_version(self, slug: str, version: str, organization: str) -> None:
|
|
303
|
+
response = self._client.delete(
|
|
304
|
+
f"{self._base_url}/v0/policies/{quote(slug, safe='')}"
|
|
305
|
+
f"/versions/{quote(version, safe='')}",
|
|
306
|
+
params={"organization": organization},
|
|
307
|
+
)
|
|
308
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
309
|
+
raise PolicyNotFoundError(
|
|
310
|
+
error_detail(response.text) or f"'{slug}:{version}' was not found"
|
|
311
|
+
)
|
|
312
|
+
if response.is_error:
|
|
313
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
314
|
+
|
|
315
|
+
def upsert_benchmark(
|
|
316
|
+
self,
|
|
317
|
+
slug: str,
|
|
318
|
+
organization: str,
|
|
319
|
+
*,
|
|
320
|
+
display_name: str,
|
|
321
|
+
visibility: Visibility,
|
|
322
|
+
) -> None:
|
|
323
|
+
response = self._client.put(
|
|
324
|
+
f"{self._base_url}/v0/benchmarks/{slug}",
|
|
325
|
+
json={
|
|
326
|
+
"organization": organization,
|
|
327
|
+
"display_name": display_name,
|
|
328
|
+
"visibility": visibility.value,
|
|
329
|
+
},
|
|
330
|
+
)
|
|
331
|
+
_raise_for_register_rejection(response, slug)
|
|
332
|
+
if response.is_error:
|
|
333
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
334
|
+
|
|
335
|
+
def register_benchmark_version(
|
|
336
|
+
self,
|
|
337
|
+
slug: str,
|
|
338
|
+
version: str,
|
|
339
|
+
organization: str,
|
|
340
|
+
*,
|
|
341
|
+
adapter: Adapter,
|
|
342
|
+
config: dict[str, Any],
|
|
343
|
+
episodes: int,
|
|
344
|
+
shards: int,
|
|
345
|
+
shards_per_gpu: int | None,
|
|
346
|
+
minimum_gpu_memory_gb: int,
|
|
347
|
+
minimum_driver_major: int | None,
|
|
348
|
+
maximum_driver_major: int | None,
|
|
349
|
+
minimum_gpu_count: int | None = None,
|
|
350
|
+
scheduling_type: SchedulingType,
|
|
351
|
+
allow_preemptible: bool,
|
|
352
|
+
) -> BenchmarkVersion:
|
|
353
|
+
body = {
|
|
354
|
+
"organization": organization,
|
|
355
|
+
"adapter": adapter.value,
|
|
356
|
+
"config": config,
|
|
357
|
+
"episodes": episodes,
|
|
358
|
+
"shards": shards,
|
|
359
|
+
"minimum_gpu_memory_gb": minimum_gpu_memory_gb,
|
|
360
|
+
"scheduling_type": scheduling_type.value,
|
|
361
|
+
"allow_preemptible": allow_preemptible,
|
|
362
|
+
"shards_per_gpu": shards_per_gpu,
|
|
363
|
+
}
|
|
364
|
+
if minimum_driver_major is not None:
|
|
365
|
+
body["minimum_driver_major"] = minimum_driver_major
|
|
366
|
+
if maximum_driver_major is not None:
|
|
367
|
+
body["maximum_driver_major"] = maximum_driver_major
|
|
368
|
+
if minimum_gpu_count is not None:
|
|
369
|
+
body["minimum_gpu_count"] = minimum_gpu_count
|
|
370
|
+
response = self._client.put(
|
|
371
|
+
f"{self._base_url}/v0/benchmarks/{slug}/versions/{version}",
|
|
372
|
+
json=body,
|
|
373
|
+
)
|
|
374
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
375
|
+
raise BenchmarkNotFoundError(
|
|
376
|
+
error_detail(response.text) or f"'{slug}' was not found"
|
|
377
|
+
)
|
|
378
|
+
_raise_for_register_rejection(response, slug)
|
|
379
|
+
if response.is_error:
|
|
380
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
381
|
+
return BenchmarkVersion.model_validate(response.json())
|
|
382
|
+
|
|
383
|
+
def publish_benchmark_rubric(
|
|
384
|
+
self,
|
|
385
|
+
slug: str,
|
|
386
|
+
version: str,
|
|
387
|
+
organization: str,
|
|
388
|
+
*,
|
|
389
|
+
rubric: RubricContent,
|
|
390
|
+
) -> None:
|
|
391
|
+
"""Publish `rubric` as the benchmark version's rubric.
|
|
392
|
+
|
|
393
|
+
The client sends the groups and tasks exactly as it read them from the
|
|
394
|
+
image. The server replaces any stored rubric and checks each group and
|
|
395
|
+
task.
|
|
396
|
+
"""
|
|
397
|
+
response = self._client.put(
|
|
398
|
+
f"{self._base_url}/v0/benchmarks/{slug}/versions/{version}/rubric",
|
|
399
|
+
json={
|
|
400
|
+
"organization": organization,
|
|
401
|
+
"groups": rubric.groups,
|
|
402
|
+
"tasks": rubric.tasks,
|
|
403
|
+
"grading": rubric.grading,
|
|
404
|
+
},
|
|
405
|
+
)
|
|
406
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
407
|
+
raise BenchmarkNotFoundError(
|
|
408
|
+
error_detail(response.text) or f"'{slug}' was not found"
|
|
409
|
+
)
|
|
410
|
+
_raise_for_register_rejection(response, slug)
|
|
411
|
+
if response.is_error:
|
|
412
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
413
|
+
|
|
414
|
+
def delete_benchmark(self, slug: str, organization: str) -> None:
|
|
415
|
+
response = self._client.delete(
|
|
416
|
+
f"{self._base_url}/v0/benchmarks/{quote(slug, safe='')}",
|
|
417
|
+
params={"organization": organization},
|
|
418
|
+
)
|
|
419
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
420
|
+
raise BenchmarkNotFoundError(
|
|
421
|
+
error_detail(response.text) or f"'{slug}' was not found"
|
|
422
|
+
)
|
|
423
|
+
if response.is_error:
|
|
424
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
425
|
+
|
|
426
|
+
def delete_benchmark_version(
|
|
427
|
+
self, slug: str, version: str, organization: str
|
|
428
|
+
) -> None:
|
|
429
|
+
response = self._client.delete(
|
|
430
|
+
f"{self._base_url}/v0/benchmarks/{quote(slug, safe='')}"
|
|
431
|
+
f"/versions/{quote(version, safe='')}",
|
|
432
|
+
params={"organization": organization},
|
|
433
|
+
)
|
|
434
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
435
|
+
raise BenchmarkNotFoundError(
|
|
436
|
+
error_detail(response.text) or f"'{slug}:{version}' was not found"
|
|
437
|
+
)
|
|
438
|
+
if response.is_error:
|
|
439
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
440
|
+
|
|
441
|
+
def submit_run(
|
|
442
|
+
self,
|
|
443
|
+
organization: str,
|
|
444
|
+
policy: str,
|
|
445
|
+
benchmark: str,
|
|
446
|
+
*,
|
|
447
|
+
name: str,
|
|
448
|
+
shards: int | None,
|
|
449
|
+
shards_per_gpu: int | None,
|
|
450
|
+
policy_version: str | None = None,
|
|
451
|
+
benchmark_version: str | None = None,
|
|
452
|
+
) -> Run:
|
|
453
|
+
body: dict[str, Any] = {
|
|
454
|
+
"organization": organization,
|
|
455
|
+
"policy": policy,
|
|
456
|
+
"benchmark": benchmark,
|
|
457
|
+
"name": name,
|
|
458
|
+
"shards_per_gpu": shards_per_gpu,
|
|
459
|
+
}
|
|
460
|
+
# Left out rather than sent as null: an absent key leaves the default
|
|
461
|
+
# to the server, which takes the newest version and the benchmark
|
|
462
|
+
# version's own shard count.
|
|
463
|
+
if policy_version is not None:
|
|
464
|
+
body["policy_version"] = policy_version
|
|
465
|
+
if benchmark_version is not None:
|
|
466
|
+
body["benchmark_version"] = benchmark_version
|
|
467
|
+
if shards is not None:
|
|
468
|
+
body["shards"] = shards
|
|
469
|
+
response = self._client.post(f"{self._base_url}/v0/runs", json=body)
|
|
470
|
+
# A 403 here is the server refusing the pairing itself: a private policy
|
|
471
|
+
# or benchmark owned by an organization other than the run's. A 409
|
|
472
|
+
# means that the submitter does not serve the self-hosted version. Both
|
|
473
|
+
# are the caller's to fix, not an API fault.
|
|
474
|
+
if response.status_code in (
|
|
475
|
+
HTTPStatus.NOT_FOUND,
|
|
476
|
+
HTTPStatus.FORBIDDEN,
|
|
477
|
+
HTTPStatus.CONFLICT,
|
|
478
|
+
):
|
|
479
|
+
raise RunSubmitError(
|
|
480
|
+
error_detail(response.text) or "The run could not be submitted."
|
|
481
|
+
)
|
|
482
|
+
if response.is_error:
|
|
483
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
484
|
+
return Run.model_validate(response.json())
|
|
485
|
+
|
|
486
|
+
def list_runs(
|
|
487
|
+
self, organization: str, *, limit: int | None = None, cursor: str | None = None
|
|
488
|
+
) -> Page[Run]:
|
|
489
|
+
response = self._client.get(
|
|
490
|
+
f"{self._base_url}/v0/runs",
|
|
491
|
+
params={"organization": organization, **_page_params(limit, cursor)},
|
|
492
|
+
)
|
|
493
|
+
# NOTE: the runs themselves are filtered, not looked up, so the only
|
|
494
|
+
# thing this route can fail to find is the organization.
|
|
495
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
496
|
+
# The fallback message deliberately does not distinguish a slug
|
|
497
|
+
# owned by someone else from one that does not exist.
|
|
498
|
+
raise OrganizationNotFoundError(
|
|
499
|
+
error_detail(response.text) or f"'{organization}' was not found"
|
|
500
|
+
)
|
|
501
|
+
if response.is_error:
|
|
502
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
503
|
+
return Page[Run].model_validate(response.json())
|
|
504
|
+
|
|
505
|
+
def get_run(self, run_id: UUID) -> Run:
|
|
506
|
+
response = self._client.get(f"{self._base_url}/v0/runs/{run_id}")
|
|
507
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
508
|
+
raise RunNotFoundError(error_detail(response.text) or "run not found")
|
|
509
|
+
if response.is_error:
|
|
510
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
511
|
+
return Run.model_validate(response.json())
|
|
512
|
+
|
|
513
|
+
def cancel_run(self, run_id: UUID) -> Run:
|
|
514
|
+
response = self._client.post(
|
|
515
|
+
f"{self._base_url}/v0/runs/{run_id}/status",
|
|
516
|
+
json={"status": "canceled"},
|
|
517
|
+
)
|
|
518
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
519
|
+
raise RunNotFoundError(error_detail(response.text) or "run not found")
|
|
520
|
+
# A 409 means the run is already in a terminal state, possibly since
|
|
521
|
+
# the caller last read it.
|
|
522
|
+
if response.status_code == HTTPStatus.CONFLICT:
|
|
523
|
+
raise RunCancelRejectedError(
|
|
524
|
+
error_detail(response.text) or "the run cannot be canceled"
|
|
525
|
+
)
|
|
526
|
+
if response.is_error:
|
|
527
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
528
|
+
return Run.model_validate(response.json())
|
|
529
|
+
|
|
530
|
+
def delete_run(self, run_id: UUID) -> None:
|
|
531
|
+
response = self._client.delete(f"{self._base_url}/v0/runs/{run_id}")
|
|
532
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
533
|
+
raise RunNotFoundError(error_detail(response.text) or "run not found")
|
|
534
|
+
if response.status_code == HTTPStatus.CONFLICT:
|
|
535
|
+
raise RunDeleteRejectedError(
|
|
536
|
+
error_detail(response.text) or "the run cannot be deleted"
|
|
537
|
+
)
|
|
538
|
+
if response.is_error:
|
|
539
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
540
|
+
|
|
541
|
+
def poll_for_assignment(self, *, timeout_s: float) -> AssignmentDispatch | None:
|
|
542
|
+
"""Wait for the assignment naming this runner, and collect it.
|
|
543
|
+
|
|
544
|
+
The runner does not choose: a placement pass writes the row before this
|
|
545
|
+
is called, and the poll hands over one side or nothing. None means the
|
|
546
|
+
request was held open and nothing arrived, which is the ordinary answer
|
|
547
|
+
for an idle runner -- ask again.
|
|
548
|
+
|
|
549
|
+
`timeout_s` has to exceed the server's own poll window, or every poll
|
|
550
|
+
looks like a transport failure from here.
|
|
551
|
+
"""
|
|
552
|
+
response = self._client.get(
|
|
553
|
+
f"{self._base_url}/v0/runs/assignment",
|
|
554
|
+
timeout=timeout_s,
|
|
555
|
+
)
|
|
556
|
+
if response.is_error:
|
|
557
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
558
|
+
body = response.json()
|
|
559
|
+
return AssignmentDispatch.model_validate(body) if body is not None else None
|
|
560
|
+
|
|
561
|
+
def claim_task(
|
|
562
|
+
self, run_id: UUID, assignment_id: UUID, claim_request_id: UUID
|
|
563
|
+
) -> TaskClaim:
|
|
564
|
+
response = self._task_queue_request(
|
|
565
|
+
run_id,
|
|
566
|
+
"claim",
|
|
567
|
+
{
|
|
568
|
+
"assignment_id": str(assignment_id),
|
|
569
|
+
"claim_request_id": str(claim_request_id),
|
|
570
|
+
},
|
|
571
|
+
)
|
|
572
|
+
return TaskClaim.model_validate(response.json())
|
|
573
|
+
|
|
574
|
+
def mint_task_upload(
|
|
575
|
+
self,
|
|
576
|
+
run_id: UUID,
|
|
577
|
+
assignment_id: UUID,
|
|
578
|
+
attempt_id: UUID,
|
|
579
|
+
episode_idx: int,
|
|
580
|
+
size_bytes: int,
|
|
581
|
+
) -> ReplayUploadSlot:
|
|
582
|
+
response = self._task_queue_request(
|
|
583
|
+
run_id,
|
|
584
|
+
"mint-upload",
|
|
585
|
+
{
|
|
586
|
+
"assignment_id": str(assignment_id),
|
|
587
|
+
"attempt_id": str(attempt_id),
|
|
588
|
+
"episode_idx": episode_idx,
|
|
589
|
+
"size_bytes": size_bytes,
|
|
590
|
+
},
|
|
591
|
+
)
|
|
592
|
+
return ReplayUploadSlot.model_validate(response.json())
|
|
593
|
+
|
|
594
|
+
def complete_task(
|
|
595
|
+
self,
|
|
596
|
+
run_id: UUID,
|
|
597
|
+
assignment_id: UUID,
|
|
598
|
+
attempt_id: UUID,
|
|
599
|
+
results: list[TaskResult],
|
|
600
|
+
) -> None:
|
|
601
|
+
self._task_queue_request(
|
|
602
|
+
run_id,
|
|
603
|
+
"complete",
|
|
604
|
+
{
|
|
605
|
+
"assignment_id": str(assignment_id),
|
|
606
|
+
"attempt_id": str(attempt_id),
|
|
607
|
+
"results": [result.model_dump(mode="json") for result in results],
|
|
608
|
+
},
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
def _task_queue_request(
|
|
612
|
+
self, run_id: UUID, operation: str, body: dict[str, Any]
|
|
613
|
+
) -> httpx.Response:
|
|
614
|
+
response = self._client.post(
|
|
615
|
+
f"{self._base_url}/v0/runs/{run_id}/task-queue/{operation}",
|
|
616
|
+
json=body,
|
|
617
|
+
timeout=EPISODE_REPORT_TIMEOUT_SECONDS,
|
|
618
|
+
)
|
|
619
|
+
if (
|
|
620
|
+
response.status_code == HTTPStatus.CONFLICT
|
|
621
|
+
and _refusal_code(response.text) == "lease_lost"
|
|
622
|
+
):
|
|
623
|
+
raise TaskQueueLeaseLostError(response.status_code, response.text)
|
|
624
|
+
if response.is_error:
|
|
625
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
626
|
+
return response
|
|
627
|
+
|
|
628
|
+
@asynccontextmanager
|
|
629
|
+
async def assignment_heartbeats(
|
|
630
|
+
self,
|
|
631
|
+
) -> AsyncIterator[AssignmentHeartbeatClient]:
|
|
632
|
+
async with httpx.AsyncClient(auth=self._auth, timeout=None) as client:
|
|
633
|
+
yield AssignmentHeartbeatClient(self._base_url, client)
|
|
634
|
+
|
|
635
|
+
def begin_assignment_preparation(self) -> Assignment:
|
|
636
|
+
"""Move this runner's collected side into preparation."""
|
|
637
|
+
response = self._client.post(
|
|
638
|
+
f"{self._base_url}/v0/runs/assignment/preparation/start"
|
|
639
|
+
)
|
|
640
|
+
return Assignment.model_validate(_assignment_body(response))
|
|
641
|
+
|
|
642
|
+
def complete_assignment_preparation(self) -> Assignment:
|
|
643
|
+
"""Move this runner's prepared side into worker startup."""
|
|
644
|
+
response = self._client.post(
|
|
645
|
+
f"{self._base_url}/v0/runs/assignment/preparation/complete"
|
|
646
|
+
)
|
|
647
|
+
return Assignment.model_validate(_assignment_body(response))
|
|
648
|
+
|
|
649
|
+
def publish_run_bridge(
|
|
650
|
+
self, run_id: UUID, address: str, *, can_send_step_times: bool
|
|
651
|
+
) -> RunBridge:
|
|
652
|
+
"""Record where the benchmark side dials to reach the policy side, and
|
|
653
|
+
whether the policy side's bridge can send step times."""
|
|
654
|
+
response = self._client.post(
|
|
655
|
+
f"{self._base_url}/v0/runs/{run_id}/bridge",
|
|
656
|
+
json={"address": address, "can_send_step_times": can_send_step_times},
|
|
657
|
+
)
|
|
658
|
+
return RunBridge.model_validate(_assigned_side_body(response, run_id))
|
|
659
|
+
|
|
660
|
+
def get_run_bridge(self, run_id: UUID) -> RunBridge:
|
|
661
|
+
"""Read the policy side's address. None while it is still loading."""
|
|
662
|
+
response = self._client.get(f"{self._base_url}/v0/runs/{run_id}/bridge")
|
|
663
|
+
return RunBridge.model_validate(_assigned_side_body(response, run_id))
|
|
664
|
+
|
|
665
|
+
def report_run_side(
|
|
666
|
+
self,
|
|
667
|
+
run_id: UUID,
|
|
668
|
+
assignment_id: UUID,
|
|
669
|
+
outcome: SideOutcome,
|
|
670
|
+
*,
|
|
671
|
+
detail: str,
|
|
672
|
+
failed_attempt_id: UUID | None,
|
|
673
|
+
) -> Run:
|
|
674
|
+
"""Report how this runner's attempt at a side of the run finished.
|
|
675
|
+
|
|
676
|
+
`failed_attempt_id` is the task-queue attempt whose worker failed.
|
|
677
|
+
"""
|
|
678
|
+
body: dict[str, Any] = {"outcome": outcome.value, "detail": detail}
|
|
679
|
+
if failed_attempt_id is not None:
|
|
680
|
+
body["failed_attempt_id"] = str(failed_attempt_id)
|
|
681
|
+
response = self._client.post(
|
|
682
|
+
f"{self._base_url}/v0/runs/{run_id}/attempts/{assignment_id}/report",
|
|
683
|
+
json=body,
|
|
684
|
+
)
|
|
685
|
+
return Run.model_validate(_assigned_side_body(response, run_id))
|
|
686
|
+
|
|
687
|
+
def list_run_episodes(self, run_id: UUID) -> list[Episode]:
|
|
688
|
+
response = self._client.get(f"{self._base_url}/v0/runs/{run_id}/episodes")
|
|
689
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
690
|
+
raise RunNotFoundError(error_detail(response.text) or "run not found")
|
|
691
|
+
if response.is_error:
|
|
692
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
693
|
+
return [Episode.model_validate(item) for item in response.json()]
|
|
694
|
+
|
|
695
|
+
def list_run_log_streams(self, run_id: UUID) -> list[RunLogStream]:
|
|
696
|
+
response = self._client.get(f"{self._base_url}/v0/runs/{run_id}/logs/streams")
|
|
697
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
698
|
+
raise RunNotFoundError(error_detail(response.text) or "run not found")
|
|
699
|
+
if response.is_error:
|
|
700
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
701
|
+
return [RunLogStream.model_validate(item) for item in response.json()]
|
|
702
|
+
|
|
703
|
+
def search_run_log(
|
|
704
|
+
self,
|
|
705
|
+
run_id: UUID,
|
|
706
|
+
*,
|
|
707
|
+
pattern: str,
|
|
708
|
+
assignment_id: UUID | None,
|
|
709
|
+
context_lines: int | None,
|
|
710
|
+
since: datetime | None,
|
|
711
|
+
until: datetime | None,
|
|
712
|
+
order: SortOrder,
|
|
713
|
+
cursor: str | None,
|
|
714
|
+
) -> Page[LogMatch]:
|
|
715
|
+
params: dict[str, str | int] = {"pattern": pattern, "order": order.value}
|
|
716
|
+
if assignment_id is not None:
|
|
717
|
+
params["assignment_id"] = str(assignment_id)
|
|
718
|
+
if context_lines is not None:
|
|
719
|
+
params["context_lines"] = context_lines
|
|
720
|
+
if since is not None:
|
|
721
|
+
params["since"] = since.isoformat()
|
|
722
|
+
if until is not None:
|
|
723
|
+
params["until"] = until.isoformat()
|
|
724
|
+
if cursor is not None:
|
|
725
|
+
params["cursor"] = cursor
|
|
726
|
+
response = self._client.get(
|
|
727
|
+
f"{self._base_url}/v0/runs/{run_id}/logs/search", params=params
|
|
728
|
+
)
|
|
729
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
730
|
+
raise RunNotFoundError(error_detail(response.text) or "run not found")
|
|
731
|
+
if response.is_error:
|
|
732
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
733
|
+
return Page[LogMatch].model_validate(response.json())
|
|
734
|
+
|
|
735
|
+
def read_run_log(
|
|
736
|
+
self,
|
|
737
|
+
run_id: UUID,
|
|
738
|
+
*,
|
|
739
|
+
assignment_id: UUID,
|
|
740
|
+
producer: LogProducer,
|
|
741
|
+
worker_index: int,
|
|
742
|
+
source: LogSource,
|
|
743
|
+
around_seq: int | None,
|
|
744
|
+
limit: int | None,
|
|
745
|
+
) -> list[LogLine]:
|
|
746
|
+
params: dict[str, int] = {}
|
|
747
|
+
if around_seq is not None:
|
|
748
|
+
params["around_seq"] = around_seq
|
|
749
|
+
if limit is not None:
|
|
750
|
+
params["limit"] = limit
|
|
751
|
+
response = self._client.get(
|
|
752
|
+
f"{self._base_url}/v0/runs/{run_id}/logs/streams/{assignment_id}"
|
|
753
|
+
f"/{producer.value}/{worker_index}/{source.value}",
|
|
754
|
+
params=params,
|
|
755
|
+
)
|
|
756
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
757
|
+
raise RunNotFoundError(error_detail(response.text) or "run not found")
|
|
758
|
+
if response.is_error:
|
|
759
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
760
|
+
return [LogLine.model_validate(item) for item in response.json()]
|
|
761
|
+
|
|
762
|
+
def record_run_episodes(self, run_id: UUID, episodes: list[EpisodeReport]) -> Run:
|
|
763
|
+
response = self._client.post(
|
|
764
|
+
f"{self._base_url}/v0/runs/{run_id}/episodes",
|
|
765
|
+
json={"episodes": [report.model_dump(mode="json") for report in episodes]},
|
|
766
|
+
timeout=EPISODE_REPORT_TIMEOUT_SECONDS,
|
|
767
|
+
)
|
|
768
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
769
|
+
raise RunNotFoundError(error_detail(response.text) or "run not found")
|
|
770
|
+
if response.status_code == HTTPStatus.CONFLICT:
|
|
771
|
+
raise EpisodesRejectedError(
|
|
772
|
+
error_detail(response.text) or "the run cannot record these episodes"
|
|
773
|
+
)
|
|
774
|
+
if response.is_error:
|
|
775
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
776
|
+
return Run.model_validate(response.json())
|
|
777
|
+
|
|
778
|
+
def publish_live_view(
|
|
779
|
+
self,
|
|
780
|
+
run_id: UUID,
|
|
781
|
+
worker_index: int,
|
|
782
|
+
*,
|
|
783
|
+
episode_idx: int,
|
|
784
|
+
step: int,
|
|
785
|
+
task: str,
|
|
786
|
+
captured_at: datetime,
|
|
787
|
+
jpeg: bytes | None,
|
|
788
|
+
task_id: str | None = None,
|
|
789
|
+
) -> PublishedLiveView:
|
|
790
|
+
response = self._client.post(
|
|
791
|
+
f"{self._base_url}/v0/runs/{run_id}/live-views/{worker_index}",
|
|
792
|
+
params={
|
|
793
|
+
"episode_idx": episode_idx,
|
|
794
|
+
"step": step,
|
|
795
|
+
"task": task,
|
|
796
|
+
"captured_at": captured_at.isoformat(),
|
|
797
|
+
**({} if task_id is None else {"task_id": task_id}),
|
|
798
|
+
},
|
|
799
|
+
content=jpeg or b"",
|
|
800
|
+
headers={"content-type": "image/jpeg"} if jpeg is not None else {},
|
|
801
|
+
)
|
|
802
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
803
|
+
raise RunNotFoundError(error_detail(response.text) or "run not found")
|
|
804
|
+
if response.is_error:
|
|
805
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
806
|
+
return PublishedLiveView.model_validate(response.json())
|
|
807
|
+
|
|
808
|
+
def delete_live_view(
|
|
809
|
+
self, run_id: UUID, worker_index: int, *, assignment_id: UUID
|
|
810
|
+
) -> None:
|
|
811
|
+
"""Delete the benchmark worker's live view for `assignment_id`.
|
|
812
|
+
|
|
813
|
+
The server accepts the request only while `assignment_id` is assigned
|
|
814
|
+
to this runner and no later benchmark attempt has replaced it.
|
|
815
|
+
"""
|
|
816
|
+
response = self._client.delete(
|
|
817
|
+
f"{self._base_url}/v0/runs/{run_id}/live-views/{worker_index}",
|
|
818
|
+
params={"assignment_id": str(assignment_id)},
|
|
819
|
+
)
|
|
820
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
821
|
+
raise RunNotFoundError(
|
|
822
|
+
error_detail(response.text)
|
|
823
|
+
or "latest benchmark assignment does not match the request"
|
|
824
|
+
)
|
|
825
|
+
if response.is_error:
|
|
826
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
827
|
+
|
|
828
|
+
def post_captured_output(self, run_id: UUID, batch: LogBatch) -> None:
|
|
829
|
+
"""Send one batch of the output this runner's side has captured.
|
|
830
|
+
|
|
831
|
+
Nothing comes back. The server resolves which side's output this is from
|
|
832
|
+
the credential, so a runner with one side of a run assigned to it cannot post
|
|
833
|
+
for the other.
|
|
834
|
+
"""
|
|
835
|
+
response = self._client.post(
|
|
836
|
+
f"{self._base_url}/v0/runs/{run_id}/logs",
|
|
837
|
+
json=batch.model_dump(mode="json"),
|
|
838
|
+
)
|
|
839
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
840
|
+
raise RunNotFoundError(error_detail(response.text) or "run not found")
|
|
841
|
+
if response.is_error:
|
|
842
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
843
|
+
|
|
844
|
+
def mint_replay_upload(
|
|
845
|
+
self, run_id: UUID, episode_idx: int, size_bytes: int
|
|
846
|
+
) -> ReplayUploadSlot:
|
|
847
|
+
"""Ask where one episode's replay should be PUT, declaring its size.
|
|
848
|
+
|
|
849
|
+
The size is signed into the URL, so the PUT has to send exactly that many
|
|
850
|
+
bytes: the recording is written before the slot is asked for.
|
|
851
|
+
"""
|
|
852
|
+
response = self._client.post(
|
|
853
|
+
f"{self._base_url}/v0/runs/{run_id}/replays/{episode_idx}/slot",
|
|
854
|
+
json={"size_bytes": size_bytes},
|
|
855
|
+
)
|
|
856
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
857
|
+
raise RunNotFoundError(error_detail(response.text) or "run not found")
|
|
858
|
+
if response.status_code == HTTPStatus.CONFLICT:
|
|
859
|
+
raise ReplayRejectedError(
|
|
860
|
+
error_detail(response.text) or "the run cannot take this replay"
|
|
861
|
+
)
|
|
862
|
+
if response.is_error:
|
|
863
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
864
|
+
return ReplayUploadSlot.model_validate(response.json())
|
|
865
|
+
|
|
866
|
+
def put_replay(self, slot: ReplayUploadSlot, path: Path) -> int:
|
|
867
|
+
"""PUT the recording to the slot's signed URL, returning the bytes sent.
|
|
868
|
+
|
|
869
|
+
Straight to object storage rather than through the API, which is what the
|
|
870
|
+
signed URL is for. A fresh client: the signature carries its own
|
|
871
|
+
authorization, and the session's bearer token has no business leaving
|
|
872
|
+
Manifold's own host.
|
|
873
|
+
"""
|
|
874
|
+
size = path.stat().st_size
|
|
875
|
+
with (
|
|
876
|
+
path.open("rb") as body,
|
|
877
|
+
httpx.Client(timeout=REPLAY_PUT_TIMEOUT) as client,
|
|
878
|
+
):
|
|
879
|
+
response = client.put(
|
|
880
|
+
slot.url, content=body, headers={"Content-Type": RRD_MIME}
|
|
881
|
+
)
|
|
882
|
+
if response.is_error:
|
|
883
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
884
|
+
return size
|
|
885
|
+
|
|
886
|
+
def confirm_replay_upload(self, run_id: UUID, episode_idx: int) -> None:
|
|
887
|
+
"""Tell the server the PUT is done, which is what records the row.
|
|
888
|
+
|
|
889
|
+
Nothing to report: the server heads the object and reads the size off it,
|
|
890
|
+
so a confirm carries no claim the bucket cannot settle.
|
|
891
|
+
"""
|
|
892
|
+
response = self._client.post(
|
|
893
|
+
f"{self._base_url}/v0/runs/{run_id}/replays/{episode_idx}"
|
|
894
|
+
)
|
|
895
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
896
|
+
raise RunNotFoundError(error_detail(response.text) or "run not found")
|
|
897
|
+
if response.status_code == HTTPStatus.CONFLICT:
|
|
898
|
+
raise ReplayRejectedError(
|
|
899
|
+
error_detail(response.text) or "the run cannot take this replay"
|
|
900
|
+
)
|
|
901
|
+
if response.is_error:
|
|
902
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
903
|
+
|
|
904
|
+
def mint_runner(
|
|
905
|
+
self,
|
|
906
|
+
name: str,
|
|
907
|
+
*,
|
|
908
|
+
ram_gb: int | None,
|
|
909
|
+
gpu_memory_gb: int,
|
|
910
|
+
gpu_devices: list[RunnerGpuDevice],
|
|
911
|
+
gpu_model: GpuModel,
|
|
912
|
+
provider: RunnerProvider | None,
|
|
913
|
+
is_preemptible: bool,
|
|
914
|
+
organization: str | None,
|
|
915
|
+
) -> MintedRunner:
|
|
916
|
+
# Without an organization, the server mints the runner in the operator
|
|
917
|
+
# organization.
|
|
918
|
+
body: dict[str, object] = {
|
|
919
|
+
"name": name,
|
|
920
|
+
"ram_gb": ram_gb,
|
|
921
|
+
"gpu_memory_gb": gpu_memory_gb,
|
|
922
|
+
"gpu_devices": [device.model_dump() for device in gpu_devices],
|
|
923
|
+
"gpu_model": gpu_model,
|
|
924
|
+
"is_preemptible": is_preemptible,
|
|
925
|
+
}
|
|
926
|
+
if provider is not None:
|
|
927
|
+
body["provider"] = provider
|
|
928
|
+
if organization is not None:
|
|
929
|
+
body["organization"] = organization
|
|
930
|
+
response = self._client.post(f"{self._base_url}/v0/runners", json=body)
|
|
931
|
+
if response.status_code == HTTPStatus.CONFLICT:
|
|
932
|
+
raise RunnerNameTakenError(
|
|
933
|
+
error_detail(response.text) or f"a runner named '{name}' already exists"
|
|
934
|
+
)
|
|
935
|
+
if organization is not None and response.status_code == HTTPStatus.NOT_FOUND:
|
|
936
|
+
raise OrganizationNotFoundError(
|
|
937
|
+
error_detail(response.text) or f"'{organization}' was not found"
|
|
938
|
+
)
|
|
939
|
+
if response.is_error:
|
|
940
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
941
|
+
return MintedRunner.model_validate(response.json())
|
|
942
|
+
|
|
943
|
+
def register_serving_runner(
|
|
944
|
+
self,
|
|
945
|
+
name: str,
|
|
946
|
+
organization: str,
|
|
947
|
+
*,
|
|
948
|
+
policy: str,
|
|
949
|
+
version: str,
|
|
950
|
+
gpu_devices: list[RunnerGpuDevice],
|
|
951
|
+
source: RunnerSource,
|
|
952
|
+
signature: dict[str, Any] | None,
|
|
953
|
+
) -> MintedRunner:
|
|
954
|
+
"""Register this machine as a runner for one policy version."""
|
|
955
|
+
response = self._client.post(
|
|
956
|
+
f"{self._base_url}/v0/runners/serving",
|
|
957
|
+
json={
|
|
958
|
+
"organization": organization,
|
|
959
|
+
"name": name,
|
|
960
|
+
"policy": policy,
|
|
961
|
+
"version": version,
|
|
962
|
+
"gpu_devices": [device.model_dump() for device in gpu_devices],
|
|
963
|
+
"source": source.model_dump(),
|
|
964
|
+
"signature": signature,
|
|
965
|
+
},
|
|
966
|
+
)
|
|
967
|
+
if response.status_code == HTTPStatus.CONFLICT:
|
|
968
|
+
raise RunnerNameTakenError(
|
|
969
|
+
error_detail(response.text) or f"a runner named '{name}' already exists"
|
|
970
|
+
)
|
|
971
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
972
|
+
raise PolicyNotFoundError(
|
|
973
|
+
error_detail(response.text) or f"policy version '{version}' not found"
|
|
974
|
+
)
|
|
975
|
+
if response.is_error:
|
|
976
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
977
|
+
return MintedRunner.model_validate(response.json())
|
|
978
|
+
|
|
979
|
+
def release_runner(self) -> None:
|
|
980
|
+
"""Revoke this runner's own credential.
|
|
981
|
+
|
|
982
|
+
The server identifies the runner from the token.
|
|
983
|
+
"""
|
|
984
|
+
response = self._client.post(f"{self._base_url}/v0/runners/release")
|
|
985
|
+
if response.is_error:
|
|
986
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
987
|
+
|
|
988
|
+
def start_runner(self) -> None:
|
|
989
|
+
"""Mark this runner started, so that placement may assign a side to it.
|
|
990
|
+
|
|
991
|
+
The server identifies the runner from the token.
|
|
992
|
+
"""
|
|
993
|
+
response = self._client.post(f"{self._base_url}/v0/runners/start")
|
|
994
|
+
if response.is_error:
|
|
995
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
996
|
+
|
|
997
|
+
def stop_runner(self) -> None:
|
|
998
|
+
"""Mark this runner stopping, so that placement skips it.
|
|
999
|
+
|
|
1000
|
+
The server identifies the runner from the token.
|
|
1001
|
+
"""
|
|
1002
|
+
response = self._client.post(f"{self._base_url}/v0/runners/stop")
|
|
1003
|
+
if response.is_error:
|
|
1004
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
1005
|
+
|
|
1006
|
+
def report_driver(self, driver_version: str) -> None:
|
|
1007
|
+
"""Record the driver this box reports.
|
|
1008
|
+
|
|
1009
|
+
The server looks the runner up from the API credential, which is
|
|
1010
|
+
minted per runner.
|
|
1011
|
+
"""
|
|
1012
|
+
response = self._client.post(
|
|
1013
|
+
f"{self._base_url}/v0/runners/driver",
|
|
1014
|
+
json={"driver_version": driver_version},
|
|
1015
|
+
)
|
|
1016
|
+
if response.is_error:
|
|
1017
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
1018
|
+
|
|
1019
|
+
def report_resource_usage(self, report: ReportResourceUsageRequest) -> None:
|
|
1020
|
+
"""Send samples of this box's resource usage for export to Honeycomb.
|
|
1021
|
+
|
|
1022
|
+
The server looks the runner up from the API credential.
|
|
1023
|
+
"""
|
|
1024
|
+
response = self._client.post(
|
|
1025
|
+
f"{self._base_url}/v0/observability/resource-usage",
|
|
1026
|
+
json=report.model_dump(mode="json"),
|
|
1027
|
+
)
|
|
1028
|
+
if response.is_error:
|
|
1029
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
1030
|
+
|
|
1031
|
+
def report_policy_metrics(self, report: ReportPolicyMetricsRequest) -> None:
|
|
1032
|
+
"""Send the policy steps that this box's bridge timed for export to
|
|
1033
|
+
Honeycomb.
|
|
1034
|
+
|
|
1035
|
+
The server looks the runner up from the API credential.
|
|
1036
|
+
"""
|
|
1037
|
+
response = self._client.post(
|
|
1038
|
+
f"{self._base_url}/v0/observability/policy-metrics",
|
|
1039
|
+
json=report.model_dump(mode="json"),
|
|
1040
|
+
)
|
|
1041
|
+
if response.is_error:
|
|
1042
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
1043
|
+
|
|
1044
|
+
def report_benchmark_metrics(self, report: ReportBenchmarkMetricsRequest) -> None:
|
|
1045
|
+
"""Send the steps that this box's benchmark bridge timed for export to
|
|
1046
|
+
Honeycomb.
|
|
1047
|
+
|
|
1048
|
+
The server looks the runner up from the API credential.
|
|
1049
|
+
"""
|
|
1050
|
+
response = self._client.post(
|
|
1051
|
+
f"{self._base_url}/v0/observability/benchmark-metrics",
|
|
1052
|
+
json=report.model_dump(mode="json"),
|
|
1053
|
+
)
|
|
1054
|
+
if response.is_error:
|
|
1055
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
1056
|
+
|
|
1057
|
+
def list_runners(self, *, organization: str | None) -> list[ListedRunner]:
|
|
1058
|
+
response = self._client.get(
|
|
1059
|
+
f"{self._base_url}/v0/runners", params=_organization_params(organization)
|
|
1060
|
+
)
|
|
1061
|
+
if organization is not None and response.status_code == HTTPStatus.NOT_FOUND:
|
|
1062
|
+
raise OrganizationNotFoundError(
|
|
1063
|
+
error_detail(response.text) or f"'{organization}' was not found"
|
|
1064
|
+
)
|
|
1065
|
+
if response.is_error:
|
|
1066
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
1067
|
+
return [ListedRunner.model_validate(item) for item in response.json()]
|
|
1068
|
+
|
|
1069
|
+
def revoke_runner(self, runner_id: UUID, *, organization: str | None) -> Runner:
|
|
1070
|
+
response = self._client.post(
|
|
1071
|
+
f"{self._base_url}/v0/runners/{runner_id}/revoke",
|
|
1072
|
+
params=_organization_params(organization),
|
|
1073
|
+
)
|
|
1074
|
+
# NOTE: the server also returns 404 for an unknown organization, and
|
|
1075
|
+
# its detail says whether the organization or the runner was not found.
|
|
1076
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
1077
|
+
raise RunnerNotFoundError(error_detail(response.text) or "runner not found")
|
|
1078
|
+
if response.is_error:
|
|
1079
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
1080
|
+
return Runner.model_validate(response.json())
|
|
1081
|
+
|
|
1082
|
+
def set_instance_override(
|
|
1083
|
+
self,
|
|
1084
|
+
runner_id: UUID,
|
|
1085
|
+
override_type: InstanceOverrideType,
|
|
1086
|
+
*,
|
|
1087
|
+
until: datetime | None,
|
|
1088
|
+
organization: str | None,
|
|
1089
|
+
) -> Runner:
|
|
1090
|
+
response = self._client.put(
|
|
1091
|
+
f"{self._base_url}/v0/runners/{runner_id}/instance-override",
|
|
1092
|
+
params=_organization_params(organization),
|
|
1093
|
+
json={
|
|
1094
|
+
"override_type": override_type.value,
|
|
1095
|
+
"until": None if until is None else until.isoformat(),
|
|
1096
|
+
},
|
|
1097
|
+
)
|
|
1098
|
+
return _parse_runner(response)
|
|
1099
|
+
|
|
1100
|
+
def clear_instance_override(
|
|
1101
|
+
self, runner_id: UUID, *, organization: str | None
|
|
1102
|
+
) -> Runner:
|
|
1103
|
+
response = self._client.delete(
|
|
1104
|
+
f"{self._base_url}/v0/runners/{runner_id}/instance-override",
|
|
1105
|
+
params=_organization_params(organization),
|
|
1106
|
+
)
|
|
1107
|
+
return _parse_runner(response)
|
|
1108
|
+
|
|
1109
|
+
def pause_placement(
|
|
1110
|
+
self, runner_id: UUID, *, until: datetime, organization: str | None
|
|
1111
|
+
) -> Runner:
|
|
1112
|
+
response = self._client.put(
|
|
1113
|
+
f"{self._base_url}/v0/runners/{runner_id}/placement-pause",
|
|
1114
|
+
params=_organization_params(organization),
|
|
1115
|
+
json={"until": until.isoformat()},
|
|
1116
|
+
)
|
|
1117
|
+
return _parse_runner(response)
|
|
1118
|
+
|
|
1119
|
+
def resume_placement(self, runner_id: UUID, *, organization: str | None) -> Runner:
|
|
1120
|
+
response = self._client.delete(
|
|
1121
|
+
f"{self._base_url}/v0/runners/{runner_id}/placement-pause",
|
|
1122
|
+
params=_organization_params(organization),
|
|
1123
|
+
)
|
|
1124
|
+
return _parse_runner(response)
|
|
1125
|
+
|
|
1126
|
+
def create_access_token(
|
|
1127
|
+
self,
|
|
1128
|
+
name: str,
|
|
1129
|
+
scopes: list[Scope],
|
|
1130
|
+
*,
|
|
1131
|
+
expires_at: datetime | None,
|
|
1132
|
+
) -> CreatedAccessToken:
|
|
1133
|
+
response = self._client.post(
|
|
1134
|
+
f"{self._base_url}/v0/tokens",
|
|
1135
|
+
json={
|
|
1136
|
+
"name": name,
|
|
1137
|
+
"scopes": [scope.value for scope in scopes],
|
|
1138
|
+
"expires_at": (
|
|
1139
|
+
expires_at.isoformat() if expires_at is not None else None
|
|
1140
|
+
),
|
|
1141
|
+
},
|
|
1142
|
+
)
|
|
1143
|
+
if response.is_error:
|
|
1144
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
1145
|
+
return CreatedAccessToken.model_validate(response.json())
|
|
1146
|
+
|
|
1147
|
+
def list_access_tokens(self) -> list[AccessToken]:
|
|
1148
|
+
response = self._client.get(f"{self._base_url}/v0/tokens")
|
|
1149
|
+
if response.is_error:
|
|
1150
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
1151
|
+
return _ACCESS_TOKENS_ADAPTER.validate_python(response.json())
|
|
1152
|
+
|
|
1153
|
+
def revoke_access_token(self, token_id: UUID) -> AccessToken:
|
|
1154
|
+
response = self._client.post(f"{self._base_url}/v0/tokens/{token_id}/revoke")
|
|
1155
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
1156
|
+
raise AccessTokenNotFoundError(
|
|
1157
|
+
error_detail(response.text) or "access token not found"
|
|
1158
|
+
)
|
|
1159
|
+
if response.is_error:
|
|
1160
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
1161
|
+
return AccessToken.model_validate(response.json())
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _assigned_side_body(response: httpx.Response, run_id: UUID) -> object:
|
|
1165
|
+
"""The body of a call on a side assigned to this runner, or why it was refused.
|
|
1166
|
+
|
|
1167
|
+
A 409 is a call the run's state or this runner's standing does not allow,
|
|
1168
|
+
not an API fault. Only the server's `detail` distinguishes them, and the reactions
|
|
1169
|
+
differ: a side that has already reported is finished with, and a side no
|
|
1170
|
+
longer assigned to this runner has its worker torn down.
|
|
1171
|
+
"""
|
|
1172
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
1173
|
+
raise RunNotFoundError(error_detail(response.text) or f"run {run_id} not found")
|
|
1174
|
+
if response.status_code == HTTPStatus.CONFLICT:
|
|
1175
|
+
raise RunSideRefusedError(
|
|
1176
|
+
error_detail(response.text) or f"run {run_id} refused the call"
|
|
1177
|
+
)
|
|
1178
|
+
if response.is_error:
|
|
1179
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
1180
|
+
return response.json()
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def _assignment_body(response: httpx.Response) -> object:
|
|
1184
|
+
"""The assignment body, or why it is no longer assigned to this runner."""
|
|
1185
|
+
if response.status_code == HTTPStatus.CONFLICT:
|
|
1186
|
+
raise AssignmentNotAssignedToRunnerError(
|
|
1187
|
+
_refusal_message(raw_error_detail(response.text))
|
|
1188
|
+
or "an assignment is not assigned to this runner"
|
|
1189
|
+
)
|
|
1190
|
+
if response.is_error:
|
|
1191
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
1192
|
+
return response.json()
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def _heartbeat_refusal(
|
|
1196
|
+
response: httpx.Response, assignment_id: UUID
|
|
1197
|
+
) -> AssignmentNotAssignedToRunnerError:
|
|
1198
|
+
"""The error for a refused heartbeat, with the run's status where one was sent.
|
|
1199
|
+
|
|
1200
|
+
A server that predates `run_status` sends `detail` as a string. A
|
|
1201
|
+
`run_status` outside `RunStatus` is treated as absent, so the side is
|
|
1202
|
+
handled as reclaimed.
|
|
1203
|
+
"""
|
|
1204
|
+
detail = raw_error_detail(response.text)
|
|
1205
|
+
message = (
|
|
1206
|
+
_refusal_message(detail)
|
|
1207
|
+
or f"assignment {assignment_id} is not assigned to this runner"
|
|
1208
|
+
)
|
|
1209
|
+
if not isinstance(detail, dict):
|
|
1210
|
+
return AssignmentNotAssignedToRunnerError(message)
|
|
1211
|
+
try:
|
|
1212
|
+
run_status = RunStatus(detail.get("run_status"))
|
|
1213
|
+
except ValueError:
|
|
1214
|
+
run_status = None
|
|
1215
|
+
return AssignmentNotAssignedToRunnerError(message, run_status=run_status)
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def _refusal_message(detail: object) -> str | None:
|
|
1219
|
+
"""The message of an assignment refusal's `detail`.
|
|
1220
|
+
|
|
1221
|
+
A server that predates the `detail` object sends the message as a string.
|
|
1222
|
+
"""
|
|
1223
|
+
if isinstance(detail, dict):
|
|
1224
|
+
detail = detail.get("message")
|
|
1225
|
+
return detail if isinstance(detail, str) else None
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _refusal_code(body: str) -> str | None:
|
|
1229
|
+
"""The `code` of a task-queue refusal, whose `detail` is an object."""
|
|
1230
|
+
try:
|
|
1231
|
+
payload = json.loads(body)
|
|
1232
|
+
except ValueError:
|
|
1233
|
+
return None
|
|
1234
|
+
detail = payload.get("detail") if isinstance(payload, dict) else None
|
|
1235
|
+
code = detail.get("code") if isinstance(detail, dict) else None
|
|
1236
|
+
return code if isinstance(code, str) else None
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
def _raise_for_register_rejection(response: httpx.Response, slug: str) -> None:
|
|
1240
|
+
"""Raise CatalogRegisterError for a refused catalog write.
|
|
1241
|
+
|
|
1242
|
+
The server refuses one of two ways: 409 when another member of the
|
|
1243
|
+
organization owns the slug, 403 when the visibility asked for is the
|
|
1244
|
+
operator organization's to set. Both are the caller's to fix, so neither
|
|
1245
|
+
is an API failure.
|
|
1246
|
+
"""
|
|
1247
|
+
if response.status_code not in _REGISTER_REJECTIONS:
|
|
1248
|
+
return
|
|
1249
|
+
raise CatalogRegisterError(
|
|
1250
|
+
error_detail(response.text) or f"'{slug}' could not be registered"
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
_REGISTER_REJECTIONS = frozenset({HTTPStatus.FORBIDDEN, HTTPStatus.CONFLICT})
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def _page_params(limit: int | None, cursor: str | None) -> dict[str, str | int]:
|
|
1258
|
+
"""Query params for a paged list call.
|
|
1259
|
+
|
|
1260
|
+
Unset params are omitted so the server applies its own defaults.
|
|
1261
|
+
"""
|
|
1262
|
+
params: dict[str, str | int] = {}
|
|
1263
|
+
if limit is not None:
|
|
1264
|
+
params["limit"] = limit
|
|
1265
|
+
if cursor is not None:
|
|
1266
|
+
params["cursor"] = cursor
|
|
1267
|
+
return params
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def _parse_runner(response: httpx.Response) -> Runner:
|
|
1271
|
+
"""The runner from an override route's response."""
|
|
1272
|
+
if response.is_error:
|
|
1273
|
+
raise ManifoldApiError(response.status_code, response.text)
|
|
1274
|
+
return Runner.model_validate(response.json())
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _organization_params(organization: str | None) -> dict[str, str]:
|
|
1278
|
+
"""Query params for an optional organization, empty when the caller gave none."""
|
|
1279
|
+
if organization is None:
|
|
1280
|
+
return {}
|
|
1281
|
+
return {"organization": organization}
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def is_transient_api_failure(exc: BaseException) -> bool:
|
|
1285
|
+
"""Whether a call failed in transport or with a 5xx response."""
|
|
1286
|
+
return isinstance(exc, httpx.TransportError) or (
|
|
1287
|
+
isinstance(exc, ManifoldApiError) and exc.status_code >= 500
|
|
1288
|
+
)
|