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.
- relaygpu/__init__.py +21 -0
- relaygpu/_async/__init__.py +1 -0
- relaygpu/_async/_http.py +146 -0
- relaygpu/_async/account.py +83 -0
- relaygpu/_async/audio.py +86 -0
- relaygpu/_async/client.py +218 -0
- relaygpu/_async/estimate.py +18 -0
- relaygpu/_async/files.py +293 -0
- relaygpu/_async/image.py +157 -0
- relaygpu/_async/keys.py +103 -0
- relaygpu/_async/models.py +75 -0
- relaygpu/_async/run.py +99 -0
- relaygpu/_async/tasks.py +66 -0
- relaygpu/_async/video.py +114 -0
- relaygpu/_async/webhooks.py +67 -0
- relaygpu/_async/workflows.py +149 -0
- relaygpu/_clock.py +31 -0
- relaygpu/_core.py +208 -0
- relaygpu/_errors.py +119 -0
- relaygpu/_estimate.py +224 -0
- relaygpu/_exceptions.py +166 -0
- relaygpu/_generated/__init__.py +1 -0
- relaygpu/_generated/error_codes.py +400 -0
- relaygpu/_generated/models_map.py +229 -0
- relaygpu/_generated/types.py +2676 -0
- relaygpu/_images.py +70 -0
- relaygpu/_run_common.py +124 -0
- relaygpu/_shapes.py +85 -0
- relaygpu/_sync/__init__.py +2 -0
- relaygpu/_sync/_http.py +147 -0
- relaygpu/_sync/account.py +84 -0
- relaygpu/_sync/audio.py +87 -0
- relaygpu/_sync/client.py +219 -0
- relaygpu/_sync/estimate.py +19 -0
- relaygpu/_sync/files.py +294 -0
- relaygpu/_sync/image.py +158 -0
- relaygpu/_sync/keys.py +104 -0
- relaygpu/_sync/models.py +76 -0
- relaygpu/_sync/run.py +100 -0
- relaygpu/_sync/tasks.py +67 -0
- relaygpu/_sync/video.py +115 -0
- relaygpu/_sync/webhooks.py +68 -0
- relaygpu/_sync/workflows.py +150 -0
- relaygpu/_util.py +73 -0
- relaygpu/_version.py +1 -0
- relaygpu/async_client.py +5 -0
- relaygpu/client.py +5 -0
- relaygpu/errors.py +10 -0
- relaygpu/inputs.py +306 -0
- relaygpu/py.typed +0 -0
- relaygpu/types.py +166 -0
- relaygpu/webhooks.py +212 -0
- relaygpu_client-0.1.0.dist-info/METADATA +567 -0
- relaygpu_client-0.1.0.dist-info/RECORD +56 -0
- relaygpu_client-0.1.0.dist-info/WHEEL +4 -0
- relaygpu_client-0.1.0.dist-info/licenses/LICENSE +21 -0
relaygpu/_async/files.py
ADDED
|
@@ -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)))
|
relaygpu/_async/image.py
ADDED
|
@@ -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
|
+
)
|
relaygpu/_async/keys.py
ADDED
|
@@ -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)
|