zerogen 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.
- zerogen/__init__.py +27 -0
- zerogen/_client.py +444 -0
- zerogen/_errors.py +96 -0
- zerogen/_models.py +9 -0
- zerogen/_options.py +31 -0
- zerogen/_pagination.py +94 -0
- zerogen/_version.py +1 -0
- zerogen/py.typed +0 -0
- zerogen/resources/__init__.py +5 -0
- zerogen/resources/generations.py +226 -0
- zerogen/resources/models.py +127 -0
- zerogen/resources/uploads.py +306 -0
- zerogen/types/__init__.py +77 -0
- zerogen/types/_requests.py +151 -0
- zerogen/types/_responses.py +353 -0
- zerogen-0.1.0.dist-info/METADATA +207 -0
- zerogen-0.1.0.dist-info/RECORD +19 -0
- zerogen-0.1.0.dist-info/WHEEL +4 -0
- zerogen-0.1.0.dist-info/licenses/LICENSE +21 -0
zerogen/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""The official Python library for the ZeroGen API."""
|
|
2
|
+
|
|
3
|
+
from . import types
|
|
4
|
+
from ._client import AsyncZeroGen, ZeroGen
|
|
5
|
+
from ._errors import ZeroGenConnectionError, ZeroGenError
|
|
6
|
+
from ._options import PostRequestOptions, RequestOptions
|
|
7
|
+
from ._pagination import AsyncPage, AsyncPaginator, Page
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
from .resources.generations import has_ended
|
|
10
|
+
from .resources.uploads import ReadyUpload, UploadSource
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AsyncPage",
|
|
14
|
+
"AsyncPaginator",
|
|
15
|
+
"AsyncZeroGen",
|
|
16
|
+
"Page",
|
|
17
|
+
"PostRequestOptions",
|
|
18
|
+
"ReadyUpload",
|
|
19
|
+
"RequestOptions",
|
|
20
|
+
"UploadSource",
|
|
21
|
+
"ZeroGen",
|
|
22
|
+
"ZeroGenConnectionError",
|
|
23
|
+
"ZeroGenError",
|
|
24
|
+
"__version__",
|
|
25
|
+
"has_ended",
|
|
26
|
+
"types",
|
|
27
|
+
]
|
zerogen/_client.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import AsyncIterable, Callable, Iterable, Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Literal, Protocol, TypeVar, cast
|
|
12
|
+
|
|
13
|
+
import anyio
|
|
14
|
+
import httpx
|
|
15
|
+
from typing_extensions import Self, Unpack
|
|
16
|
+
|
|
17
|
+
from ._errors import ZeroGenConnectionError, ZeroGenError
|
|
18
|
+
from ._options import PostRequestOptions, RequestOptions
|
|
19
|
+
from ._pagination import AsyncPage, AsyncPaginator, Page
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
from .resources.generations import AsyncGenerations, Generations
|
|
22
|
+
from .resources.models import AsyncModels, Models
|
|
23
|
+
from .resources.uploads import AsyncUploads, Uploads
|
|
24
|
+
from .types import Balance, Me
|
|
25
|
+
|
|
26
|
+
DEFAULT_BASE_URL = "https://api.zerogen.com/v1"
|
|
27
|
+
RETRY_AFTER_MAX_SECONDS = 60.0
|
|
28
|
+
|
|
29
|
+
Result = TypeVar("Result")
|
|
30
|
+
Item = TypeVar("Item")
|
|
31
|
+
Query = Mapping[str, "str | int | bool | None"]
|
|
32
|
+
Method = Literal["GET", "POST"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ListBody(Protocol[Item]):
|
|
36
|
+
@property
|
|
37
|
+
def data(self) -> list[Item]: ...
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def next_cursor(self) -> str | None: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class _Prepared:
|
|
45
|
+
url: str
|
|
46
|
+
params: dict[str, str]
|
|
47
|
+
headers: httpx.Headers
|
|
48
|
+
content: bytes | None
|
|
49
|
+
timeout: float
|
|
50
|
+
max_retries: int
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _BaseClient:
|
|
54
|
+
base_url: str
|
|
55
|
+
timeout: float
|
|
56
|
+
"""Seconds httpx waits on each step of one attempt. A request that waits on a generation
|
|
57
|
+
gets its wait added."""
|
|
58
|
+
max_retries: int
|
|
59
|
+
"""How many times to retry a connection error or a retryable response."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
*,
|
|
64
|
+
api_key: str | None,
|
|
65
|
+
base_url: str | None,
|
|
66
|
+
timeout: float,
|
|
67
|
+
max_retries: int,
|
|
68
|
+
default_headers: Mapping[str, str] | None,
|
|
69
|
+
) -> None:
|
|
70
|
+
api_key = api_key if api_key is not None else _env("ZEROGEN_API_KEY")
|
|
71
|
+
if not api_key:
|
|
72
|
+
raise ValueError("Pass api_key or set the ZEROGEN_API_KEY environment variable.")
|
|
73
|
+
self._api_key = api_key
|
|
74
|
+
base_url = base_url if base_url is not None else _env("ZEROGEN_BASE_URL")
|
|
75
|
+
self.base_url = (base_url if base_url is not None else DEFAULT_BASE_URL).rstrip("/")
|
|
76
|
+
self.timeout = timeout
|
|
77
|
+
self.max_retries = max_retries
|
|
78
|
+
self._default_headers = dict(default_headers or {})
|
|
79
|
+
|
|
80
|
+
def _prepare(
|
|
81
|
+
self,
|
|
82
|
+
method: Method,
|
|
83
|
+
path: str,
|
|
84
|
+
query: Query | None,
|
|
85
|
+
body: object | None,
|
|
86
|
+
wait: int | None,
|
|
87
|
+
options: RequestOptions,
|
|
88
|
+
) -> _Prepared:
|
|
89
|
+
headers = httpx.Headers(
|
|
90
|
+
{"Accept": "application/json", "User-Agent": f"zerogen-python/{__version__}"}
|
|
91
|
+
)
|
|
92
|
+
headers.update(self._default_headers)
|
|
93
|
+
headers.update(options.get("headers") or {})
|
|
94
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
95
|
+
content = None
|
|
96
|
+
if body is not None:
|
|
97
|
+
headers["Content-Type"] = "application/json"
|
|
98
|
+
content = json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode()
|
|
99
|
+
if method == "POST":
|
|
100
|
+
key = cast("PostRequestOptions", options).get("idempotency_key")
|
|
101
|
+
headers["Idempotency-Key"] = key if key is not None else str(uuid.uuid4())
|
|
102
|
+
if wait is not None:
|
|
103
|
+
headers["Prefer"] = f"wait={wait}"
|
|
104
|
+
return _Prepared(
|
|
105
|
+
url=self.base_url + path,
|
|
106
|
+
params={
|
|
107
|
+
name: _query_value(value)
|
|
108
|
+
for name, value in (query or {}).items()
|
|
109
|
+
if value is not None
|
|
110
|
+
},
|
|
111
|
+
headers=headers,
|
|
112
|
+
content=content,
|
|
113
|
+
timeout=options.get("timeout", self.timeout) + (wait or 0),
|
|
114
|
+
max_retries=options.get("max_retries", self.max_retries),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ZeroGen(_BaseClient):
|
|
119
|
+
"""A client for the ZeroGen API."""
|
|
120
|
+
|
|
121
|
+
generations: Generations
|
|
122
|
+
models: Models
|
|
123
|
+
uploads: Uploads
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
*,
|
|
128
|
+
api_key: str | None = None,
|
|
129
|
+
base_url: str | None = None,
|
|
130
|
+
timeout: float = 120.0,
|
|
131
|
+
max_retries: int = 2,
|
|
132
|
+
default_headers: Mapping[str, str] | None = None,
|
|
133
|
+
http_client: httpx.Client | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Creates a client.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
api_key: Defaults to the `ZEROGEN_API_KEY` environment variable.
|
|
139
|
+
base_url: Defaults to the `ZEROGEN_BASE_URL` environment variable, then
|
|
140
|
+
`https://api.zerogen.com/v1`.
|
|
141
|
+
timeout: Seconds httpx waits on each step of one attempt of a request. A request
|
|
142
|
+
that waits on a generation gets its wait added.
|
|
143
|
+
max_retries: How many times to retry a connection error or a retryable response.
|
|
144
|
+
default_headers: Headers to send with every request.
|
|
145
|
+
http_client: An `httpx.Client` to send requests with. The client doesn't close it.
|
|
146
|
+
"""
|
|
147
|
+
super().__init__(
|
|
148
|
+
api_key=api_key,
|
|
149
|
+
base_url=base_url,
|
|
150
|
+
timeout=timeout,
|
|
151
|
+
max_retries=max_retries,
|
|
152
|
+
default_headers=default_headers,
|
|
153
|
+
)
|
|
154
|
+
self._http = http_client if http_client is not None else httpx.Client()
|
|
155
|
+
self._owns_http = http_client is None
|
|
156
|
+
self.generations = Generations(self)
|
|
157
|
+
self.models = Models(self)
|
|
158
|
+
self.uploads = Uploads(self)
|
|
159
|
+
|
|
160
|
+
def me(self, **options: Unpack[RequestOptions]) -> Me:
|
|
161
|
+
"""The API key, the organization it belongs to, and what it can do."""
|
|
162
|
+
return self._request(Me.model_validate, "GET", "/me", options=options)
|
|
163
|
+
|
|
164
|
+
def balance(
|
|
165
|
+
self, *, project_id: str | None = None, **options: Unpack[RequestOptions]
|
|
166
|
+
) -> Balance:
|
|
167
|
+
"""The organization's balance and spending limits, and a project's with `project_id`."""
|
|
168
|
+
return self._request(
|
|
169
|
+
Balance.model_validate,
|
|
170
|
+
"GET",
|
|
171
|
+
"/balance",
|
|
172
|
+
query={"projectId": project_id},
|
|
173
|
+
options=options,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def close(self) -> None:
|
|
177
|
+
"""Closes the HTTP client, unless it was passed in as `http_client`."""
|
|
178
|
+
if self._owns_http:
|
|
179
|
+
self._http.close()
|
|
180
|
+
|
|
181
|
+
def __enter__(self) -> Self:
|
|
182
|
+
return self
|
|
183
|
+
|
|
184
|
+
def __exit__(self, *_: object) -> None:
|
|
185
|
+
self.close()
|
|
186
|
+
|
|
187
|
+
def _request(
|
|
188
|
+
self,
|
|
189
|
+
cast_to: Callable[[Any], Result],
|
|
190
|
+
method: Method,
|
|
191
|
+
path: str,
|
|
192
|
+
*,
|
|
193
|
+
query: Query | None = None,
|
|
194
|
+
body: object | None = None,
|
|
195
|
+
wait: int | None = None,
|
|
196
|
+
options: RequestOptions,
|
|
197
|
+
) -> Result:
|
|
198
|
+
prepared = self._prepare(method, path, query, body, wait, options)
|
|
199
|
+
response = self._send(
|
|
200
|
+
method,
|
|
201
|
+
prepared.url,
|
|
202
|
+
params=prepared.params,
|
|
203
|
+
headers=prepared.headers,
|
|
204
|
+
content=lambda: prepared.content,
|
|
205
|
+
timeout=prepared.timeout,
|
|
206
|
+
max_retries=prepared.max_retries,
|
|
207
|
+
)
|
|
208
|
+
return cast_to(response.json())
|
|
209
|
+
|
|
210
|
+
def _list(
|
|
211
|
+
self,
|
|
212
|
+
cast_to: Callable[[Any], ListBody[Item]],
|
|
213
|
+
path: str,
|
|
214
|
+
query: Query,
|
|
215
|
+
options: RequestOptions,
|
|
216
|
+
) -> Page[Item]:
|
|
217
|
+
def load(cursor: str | None) -> Page[Item]:
|
|
218
|
+
body = self._request(
|
|
219
|
+
cast_to,
|
|
220
|
+
"GET",
|
|
221
|
+
path,
|
|
222
|
+
query=query if cursor is None else {**query, "cursor": cursor},
|
|
223
|
+
options=options,
|
|
224
|
+
)
|
|
225
|
+
return Page(body.data, body.next_cursor, load)
|
|
226
|
+
|
|
227
|
+
return load(None)
|
|
228
|
+
|
|
229
|
+
def _send(
|
|
230
|
+
self,
|
|
231
|
+
method: str,
|
|
232
|
+
url: str,
|
|
233
|
+
*,
|
|
234
|
+
params: dict[str, str] | None = None,
|
|
235
|
+
headers: httpx.Headers | Mapping[str, str],
|
|
236
|
+
content: Callable[[], bytes | Iterable[bytes] | None],
|
|
237
|
+
timeout: float | None,
|
|
238
|
+
max_retries: int,
|
|
239
|
+
) -> httpx.Response:
|
|
240
|
+
"""Sends a request, retrying connection errors and retryable responses, and returns the
|
|
241
|
+
first successful response. A `timeout` of None sends without one."""
|
|
242
|
+
attempt = 0
|
|
243
|
+
while True:
|
|
244
|
+
try:
|
|
245
|
+
response = self._http.request(
|
|
246
|
+
method, url, params=params, headers=headers, content=content(), timeout=timeout
|
|
247
|
+
)
|
|
248
|
+
except httpx.TransportError as error:
|
|
249
|
+
time.sleep(_connection_retry_delay(error, attempt, max_retries, timeout))
|
|
250
|
+
else:
|
|
251
|
+
if response.is_success:
|
|
252
|
+
return response
|
|
253
|
+
time.sleep(_error_retry_delay(response, attempt, max_retries))
|
|
254
|
+
attempt += 1
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class AsyncZeroGen(_BaseClient):
|
|
258
|
+
"""An asynchronous client for the ZeroGen API."""
|
|
259
|
+
|
|
260
|
+
generations: AsyncGenerations
|
|
261
|
+
models: AsyncModels
|
|
262
|
+
uploads: AsyncUploads
|
|
263
|
+
|
|
264
|
+
def __init__(
|
|
265
|
+
self,
|
|
266
|
+
*,
|
|
267
|
+
api_key: str | None = None,
|
|
268
|
+
base_url: str | None = None,
|
|
269
|
+
timeout: float = 120.0,
|
|
270
|
+
max_retries: int = 2,
|
|
271
|
+
default_headers: Mapping[str, str] | None = None,
|
|
272
|
+
http_client: httpx.AsyncClient | None = None,
|
|
273
|
+
) -> None:
|
|
274
|
+
"""Creates a client.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
api_key: Defaults to the `ZEROGEN_API_KEY` environment variable.
|
|
278
|
+
base_url: Defaults to the `ZEROGEN_BASE_URL` environment variable, then
|
|
279
|
+
`https://api.zerogen.com/v1`.
|
|
280
|
+
timeout: Seconds httpx waits on each step of one attempt of a request. A request
|
|
281
|
+
that waits on a generation gets its wait added.
|
|
282
|
+
max_retries: How many times to retry a connection error or a retryable response.
|
|
283
|
+
default_headers: Headers to send with every request.
|
|
284
|
+
http_client: An `httpx.AsyncClient` to send requests with. The client doesn't
|
|
285
|
+
close it.
|
|
286
|
+
"""
|
|
287
|
+
super().__init__(
|
|
288
|
+
api_key=api_key,
|
|
289
|
+
base_url=base_url,
|
|
290
|
+
timeout=timeout,
|
|
291
|
+
max_retries=max_retries,
|
|
292
|
+
default_headers=default_headers,
|
|
293
|
+
)
|
|
294
|
+
self._http = http_client if http_client is not None else httpx.AsyncClient()
|
|
295
|
+
self._owns_http = http_client is None
|
|
296
|
+
self.generations = AsyncGenerations(self)
|
|
297
|
+
self.models = AsyncModels(self)
|
|
298
|
+
self.uploads = AsyncUploads(self)
|
|
299
|
+
|
|
300
|
+
async def me(self, **options: Unpack[RequestOptions]) -> Me:
|
|
301
|
+
"""The API key, the organization it belongs to, and what it can do."""
|
|
302
|
+
return await self._request(Me.model_validate, "GET", "/me", options=options)
|
|
303
|
+
|
|
304
|
+
async def balance(
|
|
305
|
+
self, *, project_id: str | None = None, **options: Unpack[RequestOptions]
|
|
306
|
+
) -> Balance:
|
|
307
|
+
"""The organization's balance and spending limits, and a project's with `project_id`."""
|
|
308
|
+
return await self._request(
|
|
309
|
+
Balance.model_validate,
|
|
310
|
+
"GET",
|
|
311
|
+
"/balance",
|
|
312
|
+
query={"projectId": project_id},
|
|
313
|
+
options=options,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
async def aclose(self) -> None:
|
|
317
|
+
"""Closes the HTTP client, unless it was passed in as `http_client`."""
|
|
318
|
+
if self._owns_http:
|
|
319
|
+
await self._http.aclose()
|
|
320
|
+
|
|
321
|
+
async def __aenter__(self) -> Self:
|
|
322
|
+
return self
|
|
323
|
+
|
|
324
|
+
async def __aexit__(self, *_: object) -> None:
|
|
325
|
+
await self.aclose()
|
|
326
|
+
|
|
327
|
+
async def _request(
|
|
328
|
+
self,
|
|
329
|
+
cast_to: Callable[[Any], Result],
|
|
330
|
+
method: Method,
|
|
331
|
+
path: str,
|
|
332
|
+
*,
|
|
333
|
+
query: Query | None = None,
|
|
334
|
+
body: object | None = None,
|
|
335
|
+
wait: int | None = None,
|
|
336
|
+
options: RequestOptions,
|
|
337
|
+
) -> Result:
|
|
338
|
+
prepared = self._prepare(method, path, query, body, wait, options)
|
|
339
|
+
response = await self._send(
|
|
340
|
+
method,
|
|
341
|
+
prepared.url,
|
|
342
|
+
params=prepared.params,
|
|
343
|
+
headers=prepared.headers,
|
|
344
|
+
content=lambda: prepared.content,
|
|
345
|
+
timeout=prepared.timeout,
|
|
346
|
+
max_retries=prepared.max_retries,
|
|
347
|
+
)
|
|
348
|
+
return cast_to(response.json())
|
|
349
|
+
|
|
350
|
+
def _list(
|
|
351
|
+
self,
|
|
352
|
+
cast_to: Callable[[Any], ListBody[Item]],
|
|
353
|
+
path: str,
|
|
354
|
+
query: Query,
|
|
355
|
+
options: RequestOptions,
|
|
356
|
+
) -> AsyncPaginator[Item]:
|
|
357
|
+
async def load(cursor: str | None = None) -> AsyncPage[Item]:
|
|
358
|
+
body = await self._request(
|
|
359
|
+
cast_to,
|
|
360
|
+
"GET",
|
|
361
|
+
path,
|
|
362
|
+
query=query if cursor is None else {**query, "cursor": cursor},
|
|
363
|
+
options=options,
|
|
364
|
+
)
|
|
365
|
+
return AsyncPage(body.data, body.next_cursor, load)
|
|
366
|
+
|
|
367
|
+
return AsyncPaginator(load)
|
|
368
|
+
|
|
369
|
+
async def _send(
|
|
370
|
+
self,
|
|
371
|
+
method: str,
|
|
372
|
+
url: str,
|
|
373
|
+
*,
|
|
374
|
+
params: dict[str, str] | None = None,
|
|
375
|
+
headers: httpx.Headers | Mapping[str, str],
|
|
376
|
+
content: Callable[[], bytes | AsyncIterable[bytes] | None],
|
|
377
|
+
timeout: float | None,
|
|
378
|
+
max_retries: int,
|
|
379
|
+
) -> httpx.Response:
|
|
380
|
+
"""Sends a request, retrying connection errors and retryable responses, and returns the
|
|
381
|
+
first successful response. A `timeout` of None sends without one."""
|
|
382
|
+
attempt = 0
|
|
383
|
+
while True:
|
|
384
|
+
try:
|
|
385
|
+
response = await self._http.request(
|
|
386
|
+
method, url, params=params, headers=headers, content=content(), timeout=timeout
|
|
387
|
+
)
|
|
388
|
+
except httpx.TransportError as error:
|
|
389
|
+
await anyio.sleep(_connection_retry_delay(error, attempt, max_retries, timeout))
|
|
390
|
+
else:
|
|
391
|
+
if response.is_success:
|
|
392
|
+
return response
|
|
393
|
+
await anyio.sleep(_error_retry_delay(response, attempt, max_retries))
|
|
394
|
+
attempt += 1
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _env(name: str) -> str | None:
|
|
398
|
+
return os.environ.get(name) or None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _query_value(value: str | int | bool) -> str:
|
|
402
|
+
if isinstance(value, bool):
|
|
403
|
+
return "true" if value else "false"
|
|
404
|
+
return str(value)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _backoff(attempt: int) -> float:
|
|
408
|
+
return min(8.0, 0.5 * 2**attempt) * (0.75 + random.random() * 0.5)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _retry_delay(headers: httpx.Headers, attempt: int) -> float | None:
|
|
412
|
+
"""How long to wait before retrying: `Retry-After` when the response sets it, or no retry
|
|
413
|
+
past a minute."""
|
|
414
|
+
try:
|
|
415
|
+
seconds = float(headers.get("retry-after", "nan"))
|
|
416
|
+
except ValueError:
|
|
417
|
+
seconds = math.nan
|
|
418
|
+
if not math.isfinite(seconds) or seconds < 0:
|
|
419
|
+
return _backoff(attempt)
|
|
420
|
+
return seconds if seconds <= RETRY_AFTER_MAX_SECONDS else None
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _connection_retry_delay(
|
|
424
|
+
error: httpx.TransportError, attempt: int, max_retries: int, timeout: float | None
|
|
425
|
+
) -> float:
|
|
426
|
+
if attempt >= max_retries:
|
|
427
|
+
if isinstance(error, httpx.TimeoutException) and timeout is not None:
|
|
428
|
+
raise ZeroGenConnectionError(
|
|
429
|
+
f"The request timed out after {timeout:g} seconds."
|
|
430
|
+
) from error
|
|
431
|
+
raise ZeroGenConnectionError("The request failed to connect.") from error
|
|
432
|
+
return _backoff(attempt)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _error_retry_delay(response: httpx.Response, attempt: int, max_retries: int) -> float:
|
|
436
|
+
error = ZeroGenError.from_response(response)
|
|
437
|
+
delay = (
|
|
438
|
+
_retry_delay(response.headers, attempt)
|
|
439
|
+
if error.retryable and attempt < max_retries
|
|
440
|
+
else None
|
|
441
|
+
)
|
|
442
|
+
if delay is None:
|
|
443
|
+
raise error
|
|
444
|
+
return delay
|
zerogen/_errors.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ZeroGenError(Exception):
|
|
10
|
+
"""An error response from the ZeroGen API.
|
|
11
|
+
|
|
12
|
+
Branch on `code`, which is stable; `message` is for people and can change.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
status: int
|
|
16
|
+
"""The HTTP status."""
|
|
17
|
+
code: str
|
|
18
|
+
message: str
|
|
19
|
+
param: str | None
|
|
20
|
+
"""The request field, header or query parameter at fault."""
|
|
21
|
+
retryable: bool
|
|
22
|
+
"""Whether sending the same request again can succeed."""
|
|
23
|
+
details: dict[str, Any] | None
|
|
24
|
+
request_id: str | None
|
|
25
|
+
"""The response's `Request-Id`; quote it when contacting support."""
|
|
26
|
+
headers: httpx.Headers
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
message: str,
|
|
31
|
+
*,
|
|
32
|
+
status: int,
|
|
33
|
+
code: str,
|
|
34
|
+
param: str | None = None,
|
|
35
|
+
retryable: bool = False,
|
|
36
|
+
details: dict[str, Any] | None = None,
|
|
37
|
+
headers: httpx.Headers | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.status = status
|
|
41
|
+
self.code = code
|
|
42
|
+
self.message = message
|
|
43
|
+
self.param = param
|
|
44
|
+
self.retryable = retryable
|
|
45
|
+
self.details = details
|
|
46
|
+
self.headers = headers if headers is not None else httpx.Headers()
|
|
47
|
+
self.request_id = self.headers.get("request-id")
|
|
48
|
+
|
|
49
|
+
def __str__(self) -> str:
|
|
50
|
+
return f"{self.code}: {self.message}"
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_response(cls, response: httpx.Response) -> ZeroGenError:
|
|
54
|
+
"""Reads an error response, including one that isn't ZeroGen's JSON envelope."""
|
|
55
|
+
text = response.text
|
|
56
|
+
error = _envelope(text)
|
|
57
|
+
if error is not None:
|
|
58
|
+
param = error.get("param")
|
|
59
|
+
details = error.get("details")
|
|
60
|
+
return cls(
|
|
61
|
+
error["message"],
|
|
62
|
+
status=response.status_code,
|
|
63
|
+
code=error["code"],
|
|
64
|
+
param=param if isinstance(param, str) else None,
|
|
65
|
+
retryable=error.get("retryable") is True,
|
|
66
|
+
details=cast("dict[str, Any]", details) if isinstance(details, dict) else None,
|
|
67
|
+
headers=response.headers,
|
|
68
|
+
)
|
|
69
|
+
return cls(
|
|
70
|
+
f"HTTP {response.status_code}: {text[:200]}"
|
|
71
|
+
if text
|
|
72
|
+
else f"HTTP {response.status_code}",
|
|
73
|
+
status=response.status_code,
|
|
74
|
+
code="unexpected_response",
|
|
75
|
+
retryable=response.status_code == 429 or response.status_code >= 500,
|
|
76
|
+
headers=response.headers,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ZeroGenConnectionError(Exception):
|
|
81
|
+
"""The request got no response: the connection failed or the request timed out."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _envelope(text: str) -> dict[str, Any] | None:
|
|
85
|
+
"""The error in ZeroGen's envelope, when the body is one."""
|
|
86
|
+
try:
|
|
87
|
+
body: object = json.loads(text)
|
|
88
|
+
except ValueError:
|
|
89
|
+
return None
|
|
90
|
+
error = cast("dict[str, object]", body).get("error") if isinstance(body, dict) else None
|
|
91
|
+
if not isinstance(error, dict):
|
|
92
|
+
return None
|
|
93
|
+
error = cast("dict[str, Any]", error)
|
|
94
|
+
if not isinstance(error.get("code"), str) or not isinstance(error.get("message"), str):
|
|
95
|
+
return None
|
|
96
|
+
return error
|
zerogen/_models.py
ADDED
zerogen/_options.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
from typing_extensions import TypedDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RequestOptions(TypedDict, total=False):
|
|
10
|
+
"""Options that every method takes as keyword arguments."""
|
|
11
|
+
|
|
12
|
+
timeout: float
|
|
13
|
+
"""Overrides the client's `timeout` for this request."""
|
|
14
|
+
max_retries: int
|
|
15
|
+
"""Overrides the client's `max_retries` for this request."""
|
|
16
|
+
headers: Mapping[str, str]
|
|
17
|
+
"""Headers to send with this request."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PostRequestOptions(RequestOptions, total=False):
|
|
21
|
+
"""Options that every method sending a `POST` takes as keyword arguments."""
|
|
22
|
+
|
|
23
|
+
idempotency_key: str
|
|
24
|
+
"""Each call sends a new random key by default and reuses it on that call's retries; pass
|
|
25
|
+
your own to make a retry across calls safe."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def without_idempotency_key(options: PostRequestOptions) -> RequestOptions:
|
|
29
|
+
rest = dict(options)
|
|
30
|
+
rest.pop("idempotency_key", None)
|
|
31
|
+
return cast(RequestOptions, rest)
|
zerogen/_pagination.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Generator, Iterator
|
|
4
|
+
from typing import Any, Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
Item = TypeVar("Item")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Page(Generic[Item]):
|
|
10
|
+
"""One page of a list. Iterate it to read every item on this page and the pages after it."""
|
|
11
|
+
|
|
12
|
+
data: list[Item]
|
|
13
|
+
next_cursor: str | None
|
|
14
|
+
"""The cursor for the next page, or None on the last page."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self, data: list[Item], next_cursor: str | None, load: Callable[[str], Page[Item]]
|
|
18
|
+
) -> None:
|
|
19
|
+
self.data = data
|
|
20
|
+
self.next_cursor = next_cursor
|
|
21
|
+
self._load = load
|
|
22
|
+
|
|
23
|
+
def has_next_page(self) -> bool:
|
|
24
|
+
return self.next_cursor is not None
|
|
25
|
+
|
|
26
|
+
def get_next_page(self) -> Page[Item]:
|
|
27
|
+
if self.next_cursor is None:
|
|
28
|
+
raise RuntimeError("This is the last page.")
|
|
29
|
+
return self._load(self.next_cursor)
|
|
30
|
+
|
|
31
|
+
def __iter__(self) -> Iterator[Item]:
|
|
32
|
+
page = self
|
|
33
|
+
while True:
|
|
34
|
+
yield from page.data
|
|
35
|
+
if not page.has_next_page():
|
|
36
|
+
return
|
|
37
|
+
page = page.get_next_page()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AsyncPage(Generic[Item]):
|
|
41
|
+
"""One page of a list. Iterate it with `async for` to read every item on this page and the
|
|
42
|
+
pages after it."""
|
|
43
|
+
|
|
44
|
+
data: list[Item]
|
|
45
|
+
next_cursor: str | None
|
|
46
|
+
"""The cursor for the next page, or None on the last page."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
data: list[Item],
|
|
51
|
+
next_cursor: str | None,
|
|
52
|
+
load: Callable[[str], Awaitable[AsyncPage[Item]]],
|
|
53
|
+
) -> None:
|
|
54
|
+
self.data = data
|
|
55
|
+
self.next_cursor = next_cursor
|
|
56
|
+
self._load = load
|
|
57
|
+
|
|
58
|
+
def has_next_page(self) -> bool:
|
|
59
|
+
return self.next_cursor is not None
|
|
60
|
+
|
|
61
|
+
async def get_next_page(self) -> AsyncPage[Item]:
|
|
62
|
+
if self.next_cursor is None:
|
|
63
|
+
raise RuntimeError("This is the last page.")
|
|
64
|
+
return await self._load(self.next_cursor)
|
|
65
|
+
|
|
66
|
+
async def __aiter__(self) -> AsyncIterator[Item]:
|
|
67
|
+
page = self
|
|
68
|
+
while True:
|
|
69
|
+
for item in page.data:
|
|
70
|
+
yield item
|
|
71
|
+
if not page.has_next_page():
|
|
72
|
+
return
|
|
73
|
+
page = await page.get_next_page()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AsyncPaginator(Awaitable[AsyncPage[Item]], AsyncIterable[Item]):
|
|
77
|
+
"""A list request. Await it for the first page, or iterate it with `async for` for every
|
|
78
|
+
item on every page."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, load: Callable[[], Awaitable[AsyncPage[Item]]]) -> None:
|
|
81
|
+
self._load = load
|
|
82
|
+
self._first: AsyncPage[Item] | None = None
|
|
83
|
+
|
|
84
|
+
async def _first_page(self) -> AsyncPage[Item]:
|
|
85
|
+
if self._first is None:
|
|
86
|
+
self._first = await self._load()
|
|
87
|
+
return self._first
|
|
88
|
+
|
|
89
|
+
def __await__(self) -> Generator[Any, Any, AsyncPage[Item]]:
|
|
90
|
+
return self._first_page().__await__()
|
|
91
|
+
|
|
92
|
+
async def __aiter__(self) -> AsyncIterator[Item]:
|
|
93
|
+
async for item in await self:
|
|
94
|
+
yield item
|