relaygpu-client 0.1.0__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.
Files changed (56) hide show
  1. relaygpu/__init__.py +21 -0
  2. relaygpu/_async/__init__.py +1 -0
  3. relaygpu/_async/_http.py +146 -0
  4. relaygpu/_async/account.py +83 -0
  5. relaygpu/_async/audio.py +86 -0
  6. relaygpu/_async/client.py +218 -0
  7. relaygpu/_async/estimate.py +18 -0
  8. relaygpu/_async/files.py +293 -0
  9. relaygpu/_async/image.py +157 -0
  10. relaygpu/_async/keys.py +103 -0
  11. relaygpu/_async/models.py +75 -0
  12. relaygpu/_async/run.py +99 -0
  13. relaygpu/_async/tasks.py +66 -0
  14. relaygpu/_async/video.py +114 -0
  15. relaygpu/_async/webhooks.py +67 -0
  16. relaygpu/_async/workflows.py +149 -0
  17. relaygpu/_clock.py +31 -0
  18. relaygpu/_core.py +208 -0
  19. relaygpu/_errors.py +119 -0
  20. relaygpu/_estimate.py +224 -0
  21. relaygpu/_exceptions.py +166 -0
  22. relaygpu/_generated/__init__.py +1 -0
  23. relaygpu/_generated/error_codes.py +400 -0
  24. relaygpu/_generated/models_map.py +229 -0
  25. relaygpu/_generated/types.py +2676 -0
  26. relaygpu/_images.py +70 -0
  27. relaygpu/_run_common.py +124 -0
  28. relaygpu/_shapes.py +85 -0
  29. relaygpu/_sync/__init__.py +2 -0
  30. relaygpu/_sync/_http.py +147 -0
  31. relaygpu/_sync/account.py +84 -0
  32. relaygpu/_sync/audio.py +87 -0
  33. relaygpu/_sync/client.py +219 -0
  34. relaygpu/_sync/estimate.py +19 -0
  35. relaygpu/_sync/files.py +294 -0
  36. relaygpu/_sync/image.py +158 -0
  37. relaygpu/_sync/keys.py +104 -0
  38. relaygpu/_sync/models.py +76 -0
  39. relaygpu/_sync/run.py +100 -0
  40. relaygpu/_sync/tasks.py +67 -0
  41. relaygpu/_sync/video.py +115 -0
  42. relaygpu/_sync/webhooks.py +68 -0
  43. relaygpu/_sync/workflows.py +150 -0
  44. relaygpu/_util.py +73 -0
  45. relaygpu/_version.py +1 -0
  46. relaygpu/async_client.py +5 -0
  47. relaygpu/client.py +5 -0
  48. relaygpu/errors.py +10 -0
  49. relaygpu/inputs.py +306 -0
  50. relaygpu/py.typed +0 -0
  51. relaygpu/types.py +166 -0
  52. relaygpu/webhooks.py +212 -0
  53. relaygpu_client-0.1.0.dist-info/METADATA +567 -0
  54. relaygpu_client-0.1.0.dist-info/RECORD +56 -0
  55. relaygpu_client-0.1.0.dist-info/WHEEL +4 -0
  56. relaygpu_client-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,293 @@
1
+ """``POST|GET|DELETE /v2/files`` and the implicit upload (port of src/files.ts). Hand-written async;
2
+ ``_sync/files.py`` is generated from it by scripts/unasync.py. The pure rules live in ``relaygpu/inputs.py``."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import os
8
+ from collections.abc import AsyncIterator, Mapping
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any, Literal, cast
11
+
12
+ from .._core import ReplayableBody
13
+ from .._util import path_id
14
+ from ..inputs import (
15
+ CHUNK_SIZE,
16
+ HEAD,
17
+ INLINE_IMAGE_MAX_BYTES,
18
+ UPLOAD_TYPE_ERROR,
19
+ FileData,
20
+ as_chunk,
21
+ async_read,
22
+ collect_files,
23
+ contains_file,
24
+ file_name_of,
25
+ is_async_iterator,
26
+ is_bytes_like,
27
+ is_file_data,
28
+ is_stream,
29
+ size_of,
30
+ sniff_media_type,
31
+ substitute,
32
+ unknown_type_error,
33
+ )
34
+ from ..types import FileListResponse, FileObject, Retention, UploadOptions
35
+
36
+ if TYPE_CHECKING:
37
+ from .._core import RawBody
38
+ from .client import AsyncRelay
39
+
40
+ __all__ = ["INLINE_IMAGE_MAX_BYTES", "AsyncFiles"]
41
+
42
+
43
+ def _path(file_id: str) -> str:
44
+ return f"/v2/files/{path_id(file_id)}"
45
+
46
+
47
+ def _refuse_foreign_stream(data: object) -> None:
48
+ """The async client reads every stream. Its generated sync twin reads ``isinstance(data, Iterator)`` here, so an
49
+ async iterator handed to ``Relay`` is refused before anything is sent."""
50
+ if is_async_iterator(data) and not isinstance(data, AsyncIterator):
51
+ raise TypeError("Relay: the sync client cannot read an async iterator; pass an iterator of bytes, or use the async client")
52
+
53
+
54
+ async def _chunks(data: Any) -> AsyncIterator[bytes]:
55
+ """Every byte of a path, a file object or an iterator, in chunks; never the whole file in memory."""
56
+ if isinstance(data, os.PathLike):
57
+ with open(data, "rb") as f:
58
+ while chunk := await async_read(f, CHUNK_SIZE):
59
+ yield chunk
60
+ elif callable(getattr(data, "read", None)):
61
+ while chunk := await async_read(data, CHUNK_SIZE):
62
+ yield chunk
63
+ elif isinstance(data, AsyncIterator):
64
+ async for c in data:
65
+ yield as_chunk(c)
66
+ else:
67
+ for c in data:
68
+ yield as_chunk(c)
69
+
70
+
71
+ class _PathBody(ReplayableBody):
72
+ """A path's bytes as a request body that re-opens the file on each iteration, so the transport may resend it."""
73
+
74
+ def __init__(self, path: os.PathLike[str]) -> None:
75
+ self._path = path
76
+
77
+ def __aiter__(self) -> AsyncIterator[bytes]:
78
+ return _chunks(self._path)
79
+
80
+
81
+ async def _peek(src: AsyncIterator[bytes], n: int) -> tuple[bytes, AsyncIterator[bytes]]:
82
+ """Reads at least ``n`` bytes (or to the end) and returns them plus an iterator that replays everything."""
83
+ chunks: list[bytes] = []
84
+ have = 0
85
+ while have < n:
86
+ try:
87
+ c = await src.__anext__()
88
+ except StopAsyncIteration:
89
+ break
90
+ chunks.append(c)
91
+ have += len(c)
92
+
93
+ async def replay() -> AsyncIterator[bytes]:
94
+ for c in chunks:
95
+ yield c
96
+ async for c in src:
97
+ yield c
98
+
99
+ return b"".join(chunks)[:n], replay()
100
+
101
+
102
+ def _media_type_of(data: Any) -> str | None:
103
+ """The type sniffed from the first bytes of bytes or a path (never a stream: it can be read once)."""
104
+ if is_bytes_like(data):
105
+ return sniff_media_type(bytes(data[:HEAD]))
106
+ if isinstance(data, os.PathLike):
107
+ with open(data, "rb") as f:
108
+ return sniff_media_type(f.read(HEAD))
109
+ return None
110
+
111
+
112
+ async def _resolve(data: Any, explicit: str | None) -> tuple[RawBody, str | None]:
113
+ """The request body and media type (``explicit`` → sniffed magic bytes) without consuming the data: a path is
114
+ re-opened to stream, a file object or an iterator is peeked and handed back whole."""
115
+ if is_bytes_like(data):
116
+ return bytes(data), explicit or _media_type_of(data)
117
+ if isinstance(data, os.PathLike):
118
+ return _PathBody(data), explicit or _media_type_of(data)
119
+ stream = _chunks(data)
120
+ if explicit:
121
+ return stream, explicit
122
+ head, replay = await _peek(stream, HEAD)
123
+ return replay, sniff_media_type(head)
124
+
125
+
126
+ async def _inline(data: Any, data_uri: bool, key: str) -> str:
127
+ """The file as raw base64, or as a data URI typed from its first bytes. Reads the data once."""
128
+ if data_uri and is_stream(data):
129
+ raise unknown_type_error(f'field "{key}"') # a stream is never sniffed: it can be read once
130
+ buf = bytes(data) if is_bytes_like(data) else b"".join([c async for c in _chunks(data)])
131
+ b64 = base64.b64encode(buf).decode("ascii")
132
+ if not data_uri:
133
+ return b64
134
+ media_type = sniff_media_type(buf[:HEAD])
135
+ if not media_type:
136
+ raise unknown_type_error(f'field "{key}"')
137
+ return f"data:{media_type};base64,{b64}"
138
+
139
+
140
+ class AsyncFiles:
141
+ """``POST|GET|DELETE /v2/files``: host an image, video or audio file and get a link any ``*_url`` field takes."""
142
+
143
+ def __init__(self, relay: AsyncRelay) -> None:
144
+ self._relay = relay
145
+
146
+ async def upload(
147
+ self,
148
+ data: FileData | str,
149
+ *,
150
+ retention: Retention | None = None,
151
+ filename: str | None = None,
152
+ content_type: str | None = None,
153
+ idempotency_key: str | None = None,
154
+ ) -> FileObject:
155
+ """Uploads the raw bytes and returns the hosted file (``url`` is the link).
156
+
157
+ ``data``: bytes, a path (``pathlib.Path`` or ``str``), a binary file object, or an iterator of bytes (the
158
+ async client also takes an async iterator). Paths and file objects are streamed in chunks; a path, a file
159
+ object or an iterator is sent once, never retried. ``content_type`` defaults to the type sniffed from the first bytes;
160
+ ``filename`` to the path's / file object's name; ``retention`` to ``relay1h`` (server-side).
161
+ """
162
+ if isinstance(data, str):
163
+ data = Path(data)
164
+ if not is_file_data(data):
165
+ raise TypeError(UPLOAD_TYPE_ERROR)
166
+ _refuse_foreign_stream(data)
167
+ body, media_type = await _resolve(data, content_type)
168
+ if not media_type:
169
+ raise unknown_type_error("the upload")
170
+ # A path's size is known, so it rides as Content-Length (as a Blob's does) instead of a chunked body.
171
+ size = size_of(data) if isinstance(data, os.PathLike) else None
172
+ res = await self._relay._http.request(
173
+ "POST",
174
+ "/v2/files",
175
+ content=body,
176
+ content_type=media_type,
177
+ headers={"Content-Length": str(size)} if size is not None else None,
178
+ query={"retention": retention, "filename": filename if filename is not None else file_name_of(data)},
179
+ idempotency_key=idempotency_key,
180
+ )
181
+ return cast(FileObject, res.data)
182
+
183
+ async def copy(
184
+ self,
185
+ url: str,
186
+ *,
187
+ retention: Retention | None = None,
188
+ filename: str | None = None,
189
+ idempotency_key: str | None = None,
190
+ ) -> FileObject:
191
+ """Has Relay copy a public http(s) object (the JSON form; ``retention`` / ``filename`` ride in the body)."""
192
+ body: dict[str, str] = {"url": url}
193
+ if retention is not None:
194
+ body["retention"] = retention
195
+ if filename is not None:
196
+ body["filename"] = filename
197
+ res = await self._relay._http.request("POST", "/v2/files", json=body, idempotency_key=idempotency_key)
198
+ return cast(FileObject, res.data)
199
+
200
+ async def get(self, file_id: str) -> FileObject:
201
+ """One file. Unknown, foreign or long-expired ids raise ``FileNotFoundError`` (``relaygpu.errors``)."""
202
+ res = await self._relay._http.request("GET", _path(file_id))
203
+ return cast(FileObject, res.data)
204
+
205
+ async def list(
206
+ self,
207
+ *,
208
+ source: Literal["upload", "result"] | None = None,
209
+ status: Literal["ready", "expired"] | None = None,
210
+ limit: int | None = None,
211
+ cursor: str | None = None,
212
+ ) -> FileListResponse:
213
+ """One page, newest first: uploads and hosted results. Follow ``next_cursor`` (``None`` on the last page).
214
+ ``limit`` is 1–200, default 50."""
215
+ query = {"source": source, "status": status, "limit": limit, "cursor": cursor}
216
+ res = await self._relay._http.request("GET", "/v2/files", query=query)
217
+ return cast(FileListResponse, res.data)
218
+
219
+ async def list_all(
220
+ self,
221
+ *,
222
+ source: Literal["upload", "result"] | None = None,
223
+ status: Literal["ready", "expired"] | None = None,
224
+ limit: int | None = None,
225
+ cursor: str | None = None,
226
+ ) -> AsyncIterator[FileObject]:
227
+ """Every file across pages, following ``next_cursor``."""
228
+ while True:
229
+ page = await self.list(source=source, status=status, limit=limit, cursor=cursor)
230
+ files = page["files"]
231
+ for f in files:
232
+ yield f
233
+ nxt = page.get("next_cursor")
234
+ if not nxt or not files or nxt == cursor:
235
+ return
236
+ cursor = nxt
237
+
238
+ async def delete(self, file_id: str) -> None:
239
+ """Takes the link down now (idempotent; no refund)."""
240
+ await self._relay._http.request("DELETE", _path(file_id))
241
+
242
+ async def prepare_inputs(
243
+ self,
244
+ input: Mapping[str, Any],
245
+ *,
246
+ upload: UploadOptions | None = None,
247
+ inline_images: bool = False,
248
+ request_schema: Mapping[str, Any] | None = None,
249
+ ) -> dict[str, Any]:
250
+ """The implicit upload: a copy of ``input`` with every file in a ``*_url`` / ``*_urls`` field (nested too)
251
+ uploaded and replaced by its link, or — per the route's request schema — inlined as base64:
252
+
253
+ - a ``*_url`` / ``*_urls`` field, or a field whose schema says it takes a URL → uploaded, replaced by its link;
254
+ - with ``inline_images``, an image ≤ 4 MB (bytes or a path) in a field whose schema says it takes base64 →
255
+ inlined (a data URI for URL-shaped fields and fields that name data URIs, raw base64 otherwise);
256
+ - a field that takes base64 only (no URL) → always inlined, whatever the size;
257
+ - anything else → ``TypeError``, before the first upload.
258
+
259
+ A ``str`` is never file data here (it is the URL or base64 the caller wrote). One upload per distinct file
260
+ object (identity) per call. A body without files makes no calls.
261
+ """
262
+ if not contains_file(input):
263
+ return dict(input)
264
+ found = collect_files(input, request_schema) # refuses a misplaced file before the first upload
265
+ for data, _f, _r in found:
266
+ _refuse_foreign_stream(data)
267
+ chosen = (upload or {}).get("retention")
268
+ retention = chosen if chosen is not None else "relay1h"
269
+ done: dict[tuple[int, str], str] = {} # one upload / encoding per distinct object and form
270
+ keep: list[object] = [] # holds each object so its id() is not reused during the call
271
+ out: list[str] = []
272
+ for data, f, rule in found:
273
+ if not rule.base64:
274
+ inline = False
275
+ elif not rule.url:
276
+ inline = True # base64-only: inlining is the only way in, whatever the size (a stream is read into memory)
277
+ else:
278
+ size = size_of(data) # None for a stream: never inlined
279
+ inline = (
280
+ inline_images
281
+ and size is not None
282
+ and size <= INLINE_IMAGE_MAX_BYTES
283
+ and (_media_type_of(data) or "").startswith("image/")
284
+ )
285
+ key = (id(data), ("uri" if rule.data_uri else "raw") if inline else "upload")
286
+ if key not in done:
287
+ keep.append(data)
288
+ if inline:
289
+ done[key] = await _inline(data, rule.data_uri, f.key)
290
+ else:
291
+ done[key] = (await self.upload(data, retention=retention, filename=file_name_of(data)))["url"]
292
+ out.append(done[key])
293
+ return cast("dict[str, Any]", substitute(input, iter(out)))
@@ -0,0 +1,157 @@
1
+ """Image generation and editing (port of src/image.ts). The parsing lives in ``relaygpu/_images.py``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any, ClassVar, cast
9
+
10
+ from .. import _clock
11
+ from .._images import decode_b64, image_values, parse_image_value
12
+ from ..types import KnownImageModel, Mode, TaskProgress, UploadOptions
13
+ from ._http import AsyncHttpClient
14
+ from .run import run
15
+
16
+ if TYPE_CHECKING:
17
+ from .client import AsyncRelay
18
+
19
+ __all__ = ["AsyncImageResult", "AsyncImages", "AsyncRelayImage", "normalise_images", "to_relay_image"]
20
+
21
+
22
+ @dataclass(frozen=True, repr=False)
23
+ class AsyncRelayImage:
24
+ """One output image, URL- or base64-backed. URLs expire (1 h by default; ``store_output`` buys 1/7/30 d)."""
25
+
26
+ url: str | None = None
27
+ b64: str | None = None
28
+ """Raw base64 (no ``data:`` prefix)."""
29
+ mime_type: str | None = None
30
+
31
+ _transport: ClassVar[AsyncHttpClient | None] = None
32
+ """The client's transport (set on the images ``images.generate`` / ``edit`` return; never a dataclass field)."""
33
+
34
+ def __getstate__(self) -> dict[str, Any]:
35
+ """Pickle / copy without the transport (such a copy downloads over a fresh client)."""
36
+ return {k: v for k, v in self.__dict__.items() if k != "_transport"}
37
+
38
+ def __repr__(self) -> str:
39
+ if self.url is not None:
40
+ return f"{type(self).__name__}(url={self.url!r})"
41
+ return f"{type(self).__name__}(b64=<{len(self.b64 or '')} chars>, mime_type={self.mime_type!r})"
42
+
43
+ async def to_bytes(self) -> bytes:
44
+ """Downloads (URL; no Relay credential is sent) or decodes (base64) the bytes. An image ``images.generate`` /
45
+ ``edit`` returned downloads over that client's HTTP transport; one built by ``to_relay_image`` over a fresh one."""
46
+ if self.url is None:
47
+ return decode_b64(self.b64 or "")
48
+ if self._transport is not None:
49
+ return await self._transport.download(self.url)
50
+ transport = AsyncHttpClient()
51
+ try:
52
+ return await transport.download(self.url)
53
+ finally:
54
+ await transport.aclose()
55
+
56
+ async def save(self, path: str | Path) -> None:
57
+ """Writes the bytes to a file."""
58
+ await _clock.async_write_bytes(path, await self.to_bytes())
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class AsyncImageResult:
63
+ """``images``: every output image, normalised. ``raw``: the response body (or the async task's ``result``) exactly
64
+ as the API sent it."""
65
+
66
+ images: list[AsyncRelayImage] = field(default_factory=list)
67
+ raw: dict[str, Any] = field(default_factory=dict)
68
+
69
+
70
+ def to_relay_image(value: str, mime_hint: str | None = None) -> AsyncRelayImage:
71
+ """Builds a ``RelayImage`` from a URL, a data URI or a bare base64 string."""
72
+ url, b64, mime = parse_image_value(value, mime_hint)
73
+ return AsyncRelayImage(url=url, b64=b64, mime_type=mime)
74
+
75
+
76
+ def normalise_images(body: Mapping[str, Any], input: Mapping[str, Any] | None = None) -> list[AsyncRelayImage]:
77
+ """Normalises every image response shape the live routes use: ``urls[]``, ``images[]`` (base64 or links),
78
+ OpenAI-style ``data[]``, single ``url`` / ``image``."""
79
+ values, hint = image_values(body, input)
80
+ return [to_relay_image(v, hint) for v in values]
81
+
82
+
83
+ class AsyncImages:
84
+ """Image generation and editing. Both methods take any image model; the route comes from ``relay.models.get``."""
85
+
86
+ def __init__(self, relay: AsyncRelay) -> None:
87
+ self._relay = relay
88
+
89
+ async def generate(
90
+ self,
91
+ model: KnownImageModel | str,
92
+ input: Mapping[str, Any],
93
+ *,
94
+ on_progress: Callable[[TaskProgress], None] | None = None,
95
+ timeout: float | None = None,
96
+ mode: Mode | None = None,
97
+ store_output: str | None = None,
98
+ webhook_url: str | None = None,
99
+ idempotency_key: str | None = None,
100
+ async_: bool | None = None,
101
+ upload: UploadOptions | None = None,
102
+ inline_images: bool = False,
103
+ ) -> AsyncImageResult:
104
+ """Generates images. Returns ``ImageResult(images, raw)`` with every image normalised to a ``RelayImage`` (URL or
105
+ base64). If the route answers async (``async_=True``), waits for the task like ``run()`` does; ``timeout`` is
106
+ that wait's budget (default 20 min) and the per-attempt HTTP timeout of a sync call."""
107
+ raw = await run(
108
+ self._relay,
109
+ model,
110
+ input,
111
+ wait=True,
112
+ on_progress=on_progress,
113
+ timeout=timeout,
114
+ mode=mode,
115
+ store_output=store_output,
116
+ webhook_url=webhook_url,
117
+ idempotency_key=idempotency_key,
118
+ async_=async_,
119
+ upload=upload,
120
+ inline_images=inline_images,
121
+ )
122
+ body = cast("dict[str, Any]", raw) # wait=True: never the 202 envelope
123
+ images = normalise_images(body, input)
124
+ for img in images:
125
+ object.__setattr__(img, "_transport", self._relay._http) # frozen; a ClassVar, so no field changes
126
+ return AsyncImageResult(images=images, raw=body)
127
+
128
+ async def edit(
129
+ self,
130
+ model: KnownImageModel | str,
131
+ input: Mapping[str, Any],
132
+ *,
133
+ on_progress: Callable[[TaskProgress], None] | None = None,
134
+ timeout: float | None = None,
135
+ mode: Mode | None = None,
136
+ store_output: str | None = None,
137
+ webhook_url: str | None = None,
138
+ idempotency_key: str | None = None,
139
+ async_: bool | None = None,
140
+ upload: UploadOptions | None = None,
141
+ inline_images: bool = False,
142
+ ) -> AsyncImageResult:
143
+ """Image-to-image editing (``image`` / ``images`` / ``*_url`` inputs; file values are uploaded). Same contract
144
+ as ``generate``."""
145
+ return await self.generate(
146
+ model,
147
+ input,
148
+ on_progress=on_progress,
149
+ timeout=timeout,
150
+ mode=mode,
151
+ store_output=store_output,
152
+ webhook_url=webhook_url,
153
+ idempotency_key=idempotency_key,
154
+ async_=async_,
155
+ upload=upload,
156
+ inline_images=inline_images,
157
+ )
@@ -0,0 +1,103 @@
1
+ """The ``keys`` namespace (port of src/keys.ts)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+ from typing import TYPE_CHECKING, Any, cast
7
+
8
+ from .._core import _UNSET
9
+ from .._generated.error_codes import KeyNotFoundError
10
+ from .._generated.types import (
11
+ CreateKeyRequest,
12
+ CreateKeyResponse,
13
+ KeyBudgetTopupResponse,
14
+ KeyListResponse,
15
+ KeyResponse,
16
+ PromoteKeyResponse,
17
+ UpdateKeyRequest,
18
+ )
19
+ from .._shapes import KeyListAllParams, KeyListParams
20
+ from .._util import path_id
21
+
22
+ if TYPE_CHECKING:
23
+ from typing_extensions import Unpack
24
+
25
+ from .client import AsyncRelay
26
+
27
+ KeyMutationResult = dict[str, Any]
28
+ """The spec leaves these bodies untyped."""
29
+
30
+
31
+ def _key_path(key_id: str) -> str:
32
+ return f"/v2/customer/keys/{path_id(key_id)}"
33
+
34
+
35
+ class AsyncKeys:
36
+ """API key management. Auth: a dashboard JWT, or a partner superkey (custom tiers); anything else gets the server's
37
+ 403. Address keys by ``key_id``, never by secret (a secret in a path answers 400)."""
38
+
39
+ def __init__(self, relay: AsyncRelay) -> None:
40
+ self._relay = relay
41
+
42
+ async def _call(self, method: str, path: str, body: Any = _UNSET) -> Any:
43
+ return (await self._relay._http.request(method, path, json=body)).data
44
+
45
+ async def list(self, **params: Unpack[KeyListParams]) -> KeyListResponse:
46
+ """``GET /v2/customer/keys``: newest first, secrets masked. Page with ``starting_after`` = ``next_cursor``."""
47
+ res = await self._relay._http.request("GET", "/v2/customer/keys", query=params)
48
+ return cast(KeyListResponse, res.data)
49
+
50
+ async def list_all(self, **params: Unpack[KeyListAllParams]) -> AsyncIterator[KeyResponse]:
51
+ """Every key matching the filters, following ``next_cursor`` across pages."""
52
+ cursor: str | None = None
53
+ while True:
54
+ query: KeyListParams = {**params, "starting_after": cursor}
55
+ page = await self.list(**query)
56
+ for k in page["keys"]:
57
+ yield k
58
+ nxt = page.get("next_cursor")
59
+ if not page["has_more"] or not nxt:
60
+ return
61
+ cursor = nxt
62
+
63
+ async def create(self, **fields: Unpack[CreateKeyRequest]) -> CreateKeyResponse:
64
+ """``POST /v2/customer/keys``. The response's ``key`` is the full secret and is returned **only here**, once; store it
65
+ now (every later response masks it). The first key on a custom tier becomes the superkey (``is_superkey: True``).
66
+ ``key_budget`` is custom-tier only (403 otherwise). Never retried (a retry could mint twice)."""
67
+ return cast(CreateKeyResponse, await self._call("POST", "/v2/customer/keys", dict(fields)))
68
+
69
+ async def get(self, key_id: str) -> KeyResponse:
70
+ """One key by ``key_id``. The API has no get-one route, so this pages ``GET /v2/customer/keys`` and matches
71
+ ``key_id``; absent → ``KeyNotFoundError`` (code ``KEY_NOT_FOUND``, raised client-side, ``status`` None)."""
72
+ async for k in self.list_all(limit=1000):
73
+ if k.get("key_id") == key_id:
74
+ return k
75
+ raise KeyNotFoundError(f"Key not found: {key_id}", code="KEY_NOT_FOUND")
76
+
77
+ async def update(self, key_id: str, **patch: Unpack[UpdateKeyRequest]) -> KeyMutationResult:
78
+ """``PATCH /v2/customer/keys/{key_id}``: send only what changes; ``restrictions`` is replaced, not merged."""
79
+ return cast(KeyMutationResult, await self._call("PATCH", _key_path(key_id), dict(patch)))
80
+
81
+ async def rename(self, key_id: str, name: str) -> KeyMutationResult:
82
+ """Renames a key (names are unique per customer; 400 on duplicate)."""
83
+ return await self.update(key_id, name=name)
84
+
85
+ async def revoke(self, key_id: str) -> KeyMutationResult:
86
+ """``POST …/revoke``. Reversible with :meth:`unrevoke`."""
87
+ return cast(KeyMutationResult, await self._call("POST", f"{_key_path(key_id)}/revoke"))
88
+
89
+ async def unrevoke(self, key_id: str) -> KeyMutationResult:
90
+ """``POST …/unrevoke``."""
91
+ return cast(KeyMutationResult, await self._call("POST", f"{_key_path(key_id)}/unrevoke"))
92
+
93
+ async def delete(self, key_id: str) -> KeyMutationResult:
94
+ """``DELETE /v2/customer/keys/{key_id}``: permanent; the key must be revoked first."""
95
+ return cast(KeyMutationResult, await self._call("DELETE", _key_path(key_id)))
96
+
97
+ async def topup(self, key_id: str, amount: float) -> KeyBudgetTopupResponse:
98
+ """``POST …/topup``: atomically adds ``amount`` USD to the key's ``key_budget`` (custom tiers; uncapped → amount)."""
99
+ return cast(KeyBudgetTopupResponse, await self._call("POST", f"{_key_path(key_id)}/topup", {"amount": amount}))
100
+
101
+ async def promote(self, key_id: str) -> PromoteKeyResponse:
102
+ """``POST …/promote``: makes the key the superkey. JWT only server-side (a superkey gets 403)."""
103
+ return cast(PromoteKeyResponse, await self._call("POST", f"{_key_path(key_id)}/promote"))
@@ -0,0 +1,75 @@
1
+ """The catalog, keyless (port of src/models.ts). ``models.get`` is the SDK's one resolver of route, ``model_in_body``,
2
+ ``async_default`` and schemas."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import TYPE_CHECKING, cast
7
+
8
+ from .. import _clock
9
+ from .. import _run_common as common
10
+ from .._util import model_path
11
+ from ..types import HealthResponse, ModelCatalog, ModelDetail, ModelRow, PricingResponse, TiersResponse
12
+
13
+ if TYPE_CHECKING:
14
+ from .client import AsyncRelay
15
+
16
+
17
+ class AsyncModels:
18
+ """``GET /v2/models`` and ``GET /v2/models/{name}`` (keyless)."""
19
+
20
+ def __init__(self, relay: AsyncRelay) -> None:
21
+ self._relay = relay
22
+ self._cache: dict[str, tuple[float, ModelDetail]] = {}
23
+
24
+ async def list(self, *, tag: str | None = None) -> list[ModelRow]:
25
+ """``GET /v2/models`` flattened: every model listed under ``auto``, once, optionally filtered by ``tag``
26
+ (e.g. ``text-to-video``)."""
27
+ res = await self._relay._http.request("GET", "/v2/models", no_auth=True)
28
+ data = cast(ModelCatalog, res.data)
29
+ seen: dict[str, ModelRow] = {}
30
+ for rows in (data.get("auto") or {}).values():
31
+ for row in rows or []:
32
+ seen.setdefault(row["name"], row)
33
+ rows_all = list(seen.values())
34
+ return [r for r in rows_all if r.get("tag") == tag] if tag else rows_all
35
+
36
+ async def get(self, name: str) -> ModelDetail:
37
+ """``GET /v2/models/{name}``: route, request/response schema, example, pricing, status. Cached per client for
38
+ 5 minutes. An unknown name raises ``ModelNotFoundError`` (never cached); a retired model resolves with
39
+ ``status: "retired"``."""
40
+ now = _clock.monotonic()
41
+ hit = self._cache.get(name)
42
+ if hit is not None and hit[0] > now:
43
+ return hit[1]
44
+ res = await self._relay._http.request("GET", model_path(name), no_auth=True)
45
+ detail = cast(ModelDetail, res.data)
46
+ self._cache[name] = (now + common.MODEL_CACHE_SECONDS, detail)
47
+ return detail
48
+
49
+
50
+ class AsyncPricing:
51
+ """``GET /v2/pricing``: list prices per mode and model, plus the ``media_storage`` per-file fees."""
52
+
53
+ def __init__(self, relay: AsyncRelay) -> None:
54
+ self._relay = relay
55
+
56
+ async def get(self) -> PricingResponse:
57
+ res = await self._relay._http.request("GET", "/v2/pricing", no_auth=True)
58
+ return cast(PricingResponse, res.data)
59
+
60
+
61
+ class AsyncTiers:
62
+ """``GET /v2/tiers``."""
63
+
64
+ def __init__(self, relay: AsyncRelay) -> None:
65
+ self._relay = relay
66
+
67
+ async def list(self) -> TiersResponse:
68
+ res = await self._relay._http.request("GET", "/v2/tiers", no_auth=True)
69
+ return cast(TiersResponse, res.data)
70
+
71
+
72
+ async def health(relay: AsyncRelay) -> HealthResponse:
73
+ """``GET /v2/health``: ``{status, version, commit}``."""
74
+ res = await relay._http.request("GET", "/v2/health", no_auth=True)
75
+ return cast(HealthResponse, res.data)