nestor-cloud 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.
- nestor/__about__.py +1 -0
- nestor/__init__.py +140 -0
- nestor/__main__.py +5 -0
- nestor/_client.py +397 -0
- nestor/_http.py +210 -0
- nestor/applications.py +173 -0
- nestor/cli/__init__.py +103 -0
- nestor/cli/__main__.py +3 -0
- nestor/cli/_common.py +300 -0
- nestor/cli/applications.py +179 -0
- nestor/cli/auth.py +126 -0
- nestor/cli/data.py +114 -0
- nestor/cli/inference.py +542 -0
- nestor/cli/management.py +99 -0
- nestor/cli/operations.py +88 -0
- nestor/cli/quotas.py +74 -0
- nestor/cli/runs.py +66 -0
- nestor/cli/secrets.py +58 -0
- nestor/cli/training.py +391 -0
- nestor/client.py +201 -0
- nestor/config.py +179 -0
- nestor/data.py +409 -0
- nestor/errors.py +343 -0
- nestor/inference.py +578 -0
- nestor/management.py +101 -0
- nestor/operations.py +88 -0
- nestor/pagination.py +78 -0
- nestor/quotas.py +102 -0
- nestor/runs.py +270 -0
- nestor/secrets.py +75 -0
- nestor/source.py +170 -0
- nestor/training.py +436 -0
- nestor/types.py +716 -0
- nestor_cloud-0.1.0.dist-info/METADATA +97 -0
- nestor_cloud-0.1.0.dist-info/RECORD +38 -0
- nestor_cloud-0.1.0.dist-info/WHEEL +5 -0
- nestor_cloud-0.1.0.dist-info/entry_points.txt +2 -0
- nestor_cloud-0.1.0.dist-info/top_level.txt +1 -0
nestor/__about__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
nestor/__init__.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Nestor Cloud Python SDK.
|
|
2
|
+
|
|
3
|
+
from nestor import Nestor
|
|
4
|
+
|
|
5
|
+
client = Nestor() # NESTOR_TOKEN / NESTOR_ORGANIZATION / NESTOR_PROJECT or `nestor auth login`
|
|
6
|
+
endpoint = client.inference.endpoints.create(model="Qwen/Qwen2.5-0.5B-Instruct", wait=True)
|
|
7
|
+
print(client.inference.endpoints.chat(endpoint, "Hello").text)
|
|
8
|
+
run = client.run("build_model.py", wait=True)
|
|
9
|
+
training = client.training.run(".", command=["python", "train.py"], wait=True)
|
|
10
|
+
app = client.applications.create("my-product")
|
|
11
|
+
|
|
12
|
+
``nestor.Client`` and ``nestor.TrainingRun`` are the Training v0.1 client,
|
|
13
|
+
kept as a compatibility façade over the same transport; ``TrainingRunDocument``
|
|
14
|
+
is the plain document type used by ``client.training``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .__about__ import __version__
|
|
18
|
+
from ._client import Nestor
|
|
19
|
+
from .client import Client, TrainingRun # Training v0.1 compatibility client
|
|
20
|
+
from .data import DataItem
|
|
21
|
+
from .errors import (
|
|
22
|
+
APIError,
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
ConfigurationError,
|
|
25
|
+
ConflictError,
|
|
26
|
+
ConnectionError,
|
|
27
|
+
IntegrityError,
|
|
28
|
+
InternalError,
|
|
29
|
+
InvalidRequestError,
|
|
30
|
+
NestorError,
|
|
31
|
+
NotFoundError,
|
|
32
|
+
OperationFailedError,
|
|
33
|
+
PermissionDeniedError,
|
|
34
|
+
PreconditionFailedError,
|
|
35
|
+
ProtocolError,
|
|
36
|
+
QuotaExceededError,
|
|
37
|
+
ResourceExhaustedError,
|
|
38
|
+
ServiceUnavailableError,
|
|
39
|
+
StreamError,
|
|
40
|
+
TimeoutError,
|
|
41
|
+
WorkloadStateError,
|
|
42
|
+
)
|
|
43
|
+
from .pagination import Page
|
|
44
|
+
from .runs import Run
|
|
45
|
+
from .types import (
|
|
46
|
+
Application,
|
|
47
|
+
ApplicationActivityEvent,
|
|
48
|
+
ApplicationOverview,
|
|
49
|
+
ApplicationTrainingRun,
|
|
50
|
+
ApplicationWorkloadSummary,
|
|
51
|
+
AuditEvent,
|
|
52
|
+
ChatChunk,
|
|
53
|
+
ChatCompletion,
|
|
54
|
+
ChatStream,
|
|
55
|
+
DataFile,
|
|
56
|
+
Endpoint,
|
|
57
|
+
EndpointEvent,
|
|
58
|
+
EndpointMetrics,
|
|
59
|
+
EndpointRevision,
|
|
60
|
+
EndpointUsage,
|
|
61
|
+
InvocationKey,
|
|
62
|
+
InvocationLog,
|
|
63
|
+
Model,
|
|
64
|
+
Operation,
|
|
65
|
+
Organization,
|
|
66
|
+
Project,
|
|
67
|
+
Quota,
|
|
68
|
+
ReplicaStatus,
|
|
69
|
+
RunEvent,
|
|
70
|
+
RunFile,
|
|
71
|
+
RunLog,
|
|
72
|
+
Secret,
|
|
73
|
+
TrainingEvent,
|
|
74
|
+
TrainingFile,
|
|
75
|
+
TrainingLog,
|
|
76
|
+
TrainingRun as TrainingRunDocument,
|
|
77
|
+
TrainingSourceSnapshot,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
"APIError",
|
|
82
|
+
"Application",
|
|
83
|
+
"ApplicationActivityEvent",
|
|
84
|
+
"ApplicationOverview",
|
|
85
|
+
"ApplicationTrainingRun",
|
|
86
|
+
"ApplicationWorkloadSummary",
|
|
87
|
+
"AuditEvent",
|
|
88
|
+
"AuthenticationError",
|
|
89
|
+
"ChatChunk",
|
|
90
|
+
"ChatCompletion",
|
|
91
|
+
"ChatStream",
|
|
92
|
+
"Client",
|
|
93
|
+
"ConfigurationError",
|
|
94
|
+
"ConflictError",
|
|
95
|
+
"ConnectionError",
|
|
96
|
+
"DataFile",
|
|
97
|
+
"DataItem",
|
|
98
|
+
"Endpoint",
|
|
99
|
+
"EndpointEvent",
|
|
100
|
+
"EndpointMetrics",
|
|
101
|
+
"EndpointRevision",
|
|
102
|
+
"EndpointUsage",
|
|
103
|
+
"IntegrityError",
|
|
104
|
+
"InternalError",
|
|
105
|
+
"InvalidRequestError",
|
|
106
|
+
"InvocationKey",
|
|
107
|
+
"InvocationLog",
|
|
108
|
+
"Model",
|
|
109
|
+
"Nestor",
|
|
110
|
+
"NestorError",
|
|
111
|
+
"NotFoundError",
|
|
112
|
+
"Operation",
|
|
113
|
+
"OperationFailedError",
|
|
114
|
+
"Organization",
|
|
115
|
+
"Page",
|
|
116
|
+
"PermissionDeniedError",
|
|
117
|
+
"PreconditionFailedError",
|
|
118
|
+
"Project",
|
|
119
|
+
"ProtocolError",
|
|
120
|
+
"Quota",
|
|
121
|
+
"QuotaExceededError",
|
|
122
|
+
"ReplicaStatus",
|
|
123
|
+
"ResourceExhaustedError",
|
|
124
|
+
"Run",
|
|
125
|
+
"RunEvent",
|
|
126
|
+
"RunFile",
|
|
127
|
+
"RunLog",
|
|
128
|
+
"Secret",
|
|
129
|
+
"ServiceUnavailableError",
|
|
130
|
+
"StreamError",
|
|
131
|
+
"TimeoutError",
|
|
132
|
+
"TrainingEvent",
|
|
133
|
+
"TrainingFile",
|
|
134
|
+
"TrainingLog",
|
|
135
|
+
"TrainingRun",
|
|
136
|
+
"TrainingRunDocument",
|
|
137
|
+
"TrainingSourceSnapshot",
|
|
138
|
+
"WorkloadStateError",
|
|
139
|
+
"__version__",
|
|
140
|
+
]
|
nestor/__main__.py
ADDED
nestor/_client.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""The ``Nestor`` client: one object, one credential, one project scope.
|
|
2
|
+
|
|
3
|
+
from nestor import Nestor
|
|
4
|
+
client = Nestor() # reads NESTOR_* or `nestor auth login`
|
|
5
|
+
op = client.inference.endpoints.create(model="Qwen/Qwen2.5-0.5B-Instruct")
|
|
6
|
+
endpoint = client.inference.endpoints.wait(op)
|
|
7
|
+
|
|
8
|
+
The client is synchronous. Every method performs exactly the HTTP calls it
|
|
9
|
+
documents; there is no background thread, cache or connection pool to reason
|
|
10
|
+
about. An asynchronous client is not provided in this version.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from typing import TYPE_CHECKING, Any, BinaryIO, Callable, Iterator, Mapping, TypeVar
|
|
21
|
+
from urllib.parse import quote, urlsplit
|
|
22
|
+
|
|
23
|
+
from . import __about__
|
|
24
|
+
from ._http import Response, Transport, UrllibTransport
|
|
25
|
+
from .config import ENV_ALLOW_INSECURE_HTTP, Settings
|
|
26
|
+
from .errors import (
|
|
27
|
+
ConfigurationError,
|
|
28
|
+
ConnectionError as NestorConnectionError,
|
|
29
|
+
ProtocolError,
|
|
30
|
+
StreamError,
|
|
31
|
+
error_from_response,
|
|
32
|
+
)
|
|
33
|
+
from .pagination import Page, extract_items
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from .runs import Run
|
|
37
|
+
|
|
38
|
+
T = TypeVar("T")
|
|
39
|
+
|
|
40
|
+
RETRYABLE_STATUSES = (502, 503, 504)
|
|
41
|
+
MUTATING_METHODS = ("POST", "PATCH", "PUT", "DELETE")
|
|
42
|
+
LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
43
|
+
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_id(value: Any, what: str = "id") -> str:
|
|
47
|
+
"""Validate an opaque resource ID before it is placed in a URL path.
|
|
48
|
+
|
|
49
|
+
IDs are opaque strings; the SDK only refuses values that could change the
|
|
50
|
+
route (separators, empty strings, non-strings).
|
|
51
|
+
"""
|
|
52
|
+
if not isinstance(value, str) or not _ID.match(value):
|
|
53
|
+
raise ValueError(f"{what} must be an opaque Nestor ID, got {value!r}")
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_api_url(url: str, *, allow_insecure_http: bool = False) -> str:
|
|
58
|
+
"""Accept ``https://host[:port]`` or ``http://`` to a loopback host only."""
|
|
59
|
+
parts = urlsplit(url)
|
|
60
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
61
|
+
raise ConfigurationError(f"api_url must be an http(s) origin, got {url!r}")
|
|
62
|
+
if parts.username or parts.password or parts.query or parts.fragment:
|
|
63
|
+
raise ConfigurationError("api_url must not carry credentials, a query string or a fragment")
|
|
64
|
+
host = parts.hostname.lower()
|
|
65
|
+
loopback = host in LOOPBACK_HOSTS or host.endswith(".localhost")
|
|
66
|
+
if parts.scheme == "http" and not loopback and not allow_insecure_http:
|
|
67
|
+
raise ConfigurationError(
|
|
68
|
+
f"refusing to send a bearer token over plain HTTP to {host}",
|
|
69
|
+
hint=f"use https://, or set {ENV_ALLOW_INSECURE_HTTP}=1 / allow_insecure_http=True for a trusted private network",
|
|
70
|
+
)
|
|
71
|
+
return url.rstrip("/")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Nestor:
|
|
75
|
+
"""Entry point for the Nestor API.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
api_url: API origin, for example ``https://api.nestor.example``.
|
|
79
|
+
Plain ``http://`` is accepted only for loopback hosts.
|
|
80
|
+
token: Bearer token (a development token, a production access
|
|
81
|
+
token, or an invocation key for chat-only clients).
|
|
82
|
+
api_key: Project machine API key for unattended backends and CI
|
|
83
|
+
(console Access → Service accounts). Mutually exclusive with ``token``; also
|
|
84
|
+
read from ``NESTOR_API_KEY``, which takes precedence over
|
|
85
|
+
``NESTOR_TOKEN`` and never falls back to it.
|
|
86
|
+
organization: Organization ID used for every project-scoped call.
|
|
87
|
+
project: Project ID used for every project-scoped call.
|
|
88
|
+
timeout: Socket timeout in seconds per request (connect and each read).
|
|
89
|
+
max_retries: Extra attempts for connection failures and 502/503/504.
|
|
90
|
+
Reads are always retried. Mutations are retried only when they
|
|
91
|
+
carry an ``Idempotency-Key`` (the same key on every attempt, so a
|
|
92
|
+
retry can never create a second resource); a request without a
|
|
93
|
+
key, such as a chat completion, is sent exactly once.
|
|
94
|
+
transport: Replacement HTTP transport (tests).
|
|
95
|
+
use_profile: Read the ``nestor auth login`` profile as a fallback.
|
|
96
|
+
allow_insecure_http: Permit ``http://`` to non-loopback hosts.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
*,
|
|
102
|
+
api_url: str | None = None,
|
|
103
|
+
token: str | None = None,
|
|
104
|
+
organization: str | None = None,
|
|
105
|
+
project: str | None = None,
|
|
106
|
+
timeout: float = 30.0,
|
|
107
|
+
max_retries: int = 2,
|
|
108
|
+
transport: Transport | None = None,
|
|
109
|
+
use_profile: bool = True,
|
|
110
|
+
allow_insecure_http: bool | None = None,
|
|
111
|
+
api_key: str | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
if token is not None and api_key is not None:
|
|
114
|
+
raise ConfigurationError("choose either token or api_key, not both")
|
|
115
|
+
self.settings = Settings.resolve(
|
|
116
|
+
api_url=api_url, token=token, organization=organization, project=project, use_profile=use_profile, api_key=api_key
|
|
117
|
+
)
|
|
118
|
+
if allow_insecure_http is None:
|
|
119
|
+
allow_insecure_http = os.environ.get(ENV_ALLOW_INSECURE_HTTP, "") in ("1", "true", "yes")
|
|
120
|
+
self.settings.api_url = validate_api_url(self.settings.api_url, allow_insecure_http=allow_insecure_http)
|
|
121
|
+
self.timeout = timeout
|
|
122
|
+
self.max_retries = max(0, max_retries)
|
|
123
|
+
self.transport: Transport = transport or UrllibTransport(f"nestor-python/{__about__.__version__}")
|
|
124
|
+
self.sleep: Callable[[float], None] = time.sleep # patched in tests
|
|
125
|
+
self._bind_namespaces()
|
|
126
|
+
|
|
127
|
+
def _bind_namespaces(self) -> None:
|
|
128
|
+
from .applications import Applications
|
|
129
|
+
from .data import Data
|
|
130
|
+
from .inference import Inference
|
|
131
|
+
from .management import Organizations, Projects
|
|
132
|
+
from .operations import Operations
|
|
133
|
+
from .quotas import Quotas
|
|
134
|
+
from .runs import Runs
|
|
135
|
+
from .secrets import Secrets
|
|
136
|
+
from .training import Training
|
|
137
|
+
|
|
138
|
+
self.organizations = Organizations(self)
|
|
139
|
+
self.projects = Projects(self)
|
|
140
|
+
self.operations = Operations(self)
|
|
141
|
+
self.inference = Inference(self)
|
|
142
|
+
self.training = Training(self)
|
|
143
|
+
self.runs = Runs(self)
|
|
144
|
+
self.applications = Applications(self)
|
|
145
|
+
self.secrets = Secrets(self)
|
|
146
|
+
self.quotas = Quotas(self)
|
|
147
|
+
self.data = Data(self)
|
|
148
|
+
|
|
149
|
+
def __repr__(self) -> str:
|
|
150
|
+
return f"Nestor(api_url={self.settings.api_url!r}, organization={self.settings.organization!r}, project={self.settings.project!r})"
|
|
151
|
+
|
|
152
|
+
def run(self, source: Any = ".", **arguments: Any) -> "Run":
|
|
153
|
+
"""Run a finite program and return its Run: ``client.runs.run`` (see ``nestor.runs``)."""
|
|
154
|
+
return self.runs.run(source, **arguments)
|
|
155
|
+
|
|
156
|
+
# -- scope ----------------------------------------------------------------
|
|
157
|
+
@property
|
|
158
|
+
def api_url(self) -> str:
|
|
159
|
+
return self.settings.api_url
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def organization(self) -> str:
|
|
163
|
+
return self.settings.organization
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def project(self) -> str:
|
|
167
|
+
return self.settings.project
|
|
168
|
+
|
|
169
|
+
def with_scope(self, organization: str | None = None, project: str | None = None) -> "Nestor":
|
|
170
|
+
"""A copy of this client bound to another organization and/or project."""
|
|
171
|
+
clone = Nestor.__new__(Nestor)
|
|
172
|
+
clone.__dict__.update(self.__dict__)
|
|
173
|
+
clone.settings = Settings(
|
|
174
|
+
api_url=self.settings.api_url,
|
|
175
|
+
token=self.settings.token,
|
|
176
|
+
organization=check_id(organization, "organization") if organization else self.settings.organization,
|
|
177
|
+
project=check_id(project, "project") if project else self.settings.project,
|
|
178
|
+
sources=dict(self.settings.sources),
|
|
179
|
+
)
|
|
180
|
+
clone._bind_namespaces()
|
|
181
|
+
return clone
|
|
182
|
+
|
|
183
|
+
def organization_path(self) -> str:
|
|
184
|
+
if not self.settings.organization:
|
|
185
|
+
raise ConfigurationError(
|
|
186
|
+
"no organization selected",
|
|
187
|
+
hint="pass organization=… or set NESTOR_ORGANIZATION (or run `nestor projects use`)",
|
|
188
|
+
)
|
|
189
|
+
return f"/v1/organizations/{check_id(self.settings.organization, 'organization')}"
|
|
190
|
+
|
|
191
|
+
def project_path(self) -> str:
|
|
192
|
+
organization = self.organization_path()
|
|
193
|
+
if not self.settings.project:
|
|
194
|
+
raise ConfigurationError(
|
|
195
|
+
"no project selected",
|
|
196
|
+
hint="pass project=… or set NESTOR_PROJECT (or run `nestor projects use`)",
|
|
197
|
+
)
|
|
198
|
+
return f"{organization}/projects/{check_id(self.settings.project, 'project')}"
|
|
199
|
+
|
|
200
|
+
# -- transport ------------------------------------------------------------
|
|
201
|
+
@staticmethod
|
|
202
|
+
def new_idempotency_key() -> str:
|
|
203
|
+
return uuid.uuid4().hex
|
|
204
|
+
|
|
205
|
+
def request(
|
|
206
|
+
self,
|
|
207
|
+
method: str,
|
|
208
|
+
path: str,
|
|
209
|
+
*,
|
|
210
|
+
json_body: Any = None,
|
|
211
|
+
body: "bytes | BinaryIO | None" = None,
|
|
212
|
+
content_type: str | None = None,
|
|
213
|
+
content_length: int | None = None,
|
|
214
|
+
params: Mapping[str, Any] | None = None,
|
|
215
|
+
idempotency_key: str | None = None,
|
|
216
|
+
auto_idempotency: bool = True,
|
|
217
|
+
etag: str | None = None,
|
|
218
|
+
token: str | None = None,
|
|
219
|
+
stream: bool = False,
|
|
220
|
+
expect: tuple[int, ...] = (200, 201, 202),
|
|
221
|
+
timeout: float | None = None,
|
|
222
|
+
) -> Response:
|
|
223
|
+
"""Send one request and return the response or raise a typed error.
|
|
224
|
+
|
|
225
|
+
Mutations always carry an ``Idempotency-Key``; one is generated when
|
|
226
|
+
the caller does not supply it, unless ``auto_idempotency`` is False
|
|
227
|
+
(chat completions, where the header is optional). A generated key is
|
|
228
|
+
fixed for the life of this call, so every retry replays the same
|
|
229
|
+
logical request. A non-streamed success must be JSON; anything else
|
|
230
|
+
raises ``ProtocolError`` rather than being returned as a result.
|
|
231
|
+
"""
|
|
232
|
+
bearer = token or self.settings.token
|
|
233
|
+
if not bearer:
|
|
234
|
+
raise ConfigurationError(
|
|
235
|
+
"no credential configured",
|
|
236
|
+
hint="run `nestor auth login`, set NESTOR_TOKEN or NESTOR_API_KEY, or pass token=…/api_key=…",
|
|
237
|
+
)
|
|
238
|
+
headers = {"Authorization": f"Bearer {bearer}", "Accept": "application/json"}
|
|
239
|
+
if idempotency_key:
|
|
240
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
241
|
+
elif auto_idempotency and method in MUTATING_METHODS:
|
|
242
|
+
headers["Idempotency-Key"] = self.new_idempotency_key()
|
|
243
|
+
if json_body is not None:
|
|
244
|
+
body = json.dumps(json_body, separators=(",", ":")).encode()
|
|
245
|
+
headers["Content-Type"] = "application/json"
|
|
246
|
+
elif body is not None:
|
|
247
|
+
headers["Content-Type"] = content_type or "application/octet-stream"
|
|
248
|
+
if not isinstance(body, (bytes, bytearray)):
|
|
249
|
+
# A streamed file body: its length is declared so nothing is
|
|
250
|
+
# buffered, and it is rewound before any retry.
|
|
251
|
+
if content_length is None:
|
|
252
|
+
raise ValueError("content_length is required for a streamed body")
|
|
253
|
+
headers["Content-Length"] = str(content_length)
|
|
254
|
+
elif method in ("POST", "PATCH", "PUT"):
|
|
255
|
+
body = b"{}"
|
|
256
|
+
headers["Content-Type"] = "application/json"
|
|
257
|
+
if etag is not None:
|
|
258
|
+
headers["If-Match"] = _quote_etag(etag)
|
|
259
|
+
url = self.settings.api_url + path
|
|
260
|
+
if params:
|
|
261
|
+
query = "&".join(f"{k}={_encode(v)}" for k, v in params.items() if v not in (None, ""))
|
|
262
|
+
if query:
|
|
263
|
+
url += ("&" if "?" in url else "?") + query
|
|
264
|
+
# A request may be replayed only when the server can recognise it as
|
|
265
|
+
# the same request: reads, and mutations that carry a key.
|
|
266
|
+
replayable = method in ("GET", "HEAD") or "Idempotency-Key" in headers
|
|
267
|
+
attempt = 0
|
|
268
|
+
while True:
|
|
269
|
+
if body is not None and not isinstance(body, (bytes, bytearray)) and attempt > 0:
|
|
270
|
+
body.seek(0)
|
|
271
|
+
try:
|
|
272
|
+
response = self.transport.send(method, url, headers, body, timeout or self.timeout, stream=stream)
|
|
273
|
+
except NestorConnectionError:
|
|
274
|
+
if not replayable or attempt >= self.max_retries:
|
|
275
|
+
raise
|
|
276
|
+
attempt += 1
|
|
277
|
+
self.sleep(min(0.5 * 2**attempt, 4.0))
|
|
278
|
+
continue
|
|
279
|
+
if response.status in RETRYABLE_STATUSES and replayable and attempt < self.max_retries:
|
|
280
|
+
response.close()
|
|
281
|
+
attempt += 1
|
|
282
|
+
self.sleep(min(0.5 * 2**attempt, 4.0))
|
|
283
|
+
continue
|
|
284
|
+
break
|
|
285
|
+
if response.status not in expect:
|
|
286
|
+
try:
|
|
287
|
+
payload = response.json()
|
|
288
|
+
except ProtocolError:
|
|
289
|
+
payload = None
|
|
290
|
+
raise error_from_response(
|
|
291
|
+
status=response.status, body=payload, method=method, path=path, request_id=response.request_id
|
|
292
|
+
)
|
|
293
|
+
return response
|
|
294
|
+
|
|
295
|
+
def get_json(self, path: str, **kwargs: Any) -> Any:
|
|
296
|
+
payload = self.request("GET", path, expect=(200,), **kwargs).json()
|
|
297
|
+
if not isinstance(payload, dict):
|
|
298
|
+
raise ProtocolError(f"expected a JSON object from GET {path}")
|
|
299
|
+
return payload
|
|
300
|
+
|
|
301
|
+
def paginate(
|
|
302
|
+
self,
|
|
303
|
+
path: str,
|
|
304
|
+
convert: Callable[[Mapping[str, Any]], T],
|
|
305
|
+
*,
|
|
306
|
+
key: str | None = None,
|
|
307
|
+
page_size: int | None = None,
|
|
308
|
+
page_token: str | None = None,
|
|
309
|
+
params: Mapping[str, Any] | None = None,
|
|
310
|
+
) -> Page[T]:
|
|
311
|
+
"""Fetch one page. The returned ``Page`` fetches the rest lazily.
|
|
312
|
+
|
|
313
|
+
Tokens are opaque and passed back verbatim. A server that returns the
|
|
314
|
+
token it was just given would loop forever, so that is refused.
|
|
315
|
+
"""
|
|
316
|
+
query: dict[str, Any] = dict(params or {})
|
|
317
|
+
if page_size is not None:
|
|
318
|
+
if not 1 <= page_size <= 100:
|
|
319
|
+
raise ValueError("page_size must be between 1 and 100")
|
|
320
|
+
query["page_size"] = page_size
|
|
321
|
+
if page_token:
|
|
322
|
+
query["page_token"] = page_token
|
|
323
|
+
body = self.get_json(path, params=query)
|
|
324
|
+
items = [convert(item) for item in extract_items(body, key)]
|
|
325
|
+
next_token = str(body.get("next_page_token") or "")
|
|
326
|
+
if next_token and next_token == page_token:
|
|
327
|
+
raise ProtocolError(f"GET {path} returned the page token it was given; refusing to loop")
|
|
328
|
+
|
|
329
|
+
def fetch_next(tok: str) -> Page[T]:
|
|
330
|
+
return self.paginate(path, convert, key=key, page_size=page_size, page_token=tok, params=params)
|
|
331
|
+
|
|
332
|
+
return Page(items, next_token, fetch_next)
|
|
333
|
+
|
|
334
|
+
def iter_sse(self, response: Response) -> "SSEEvents":
|
|
335
|
+
"""Parsed ``data:`` JSON objects from a server-sent event stream."""
|
|
336
|
+
return SSEEvents(response)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class SSEEvents:
|
|
340
|
+
"""Iterator over the JSON payloads of an event stream.
|
|
341
|
+
|
|
342
|
+
``done`` becomes True when the server sent ``data: [DONE]``. A stream that
|
|
343
|
+
ends without it is incomplete, and an ``event: error`` event raises
|
|
344
|
+
``StreamError`` carrying the API's error code.
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
def __init__(self, response: Response) -> None:
|
|
348
|
+
self.response = response
|
|
349
|
+
self.request_id = response.request_id
|
|
350
|
+
self.done = False
|
|
351
|
+
self.delivered = 0
|
|
352
|
+
|
|
353
|
+
def __iter__(self) -> Iterator[dict[str, Any]]:
|
|
354
|
+
event_name = ""
|
|
355
|
+
for line in self.response.iter_lines():
|
|
356
|
+
if not line:
|
|
357
|
+
event_name = ""
|
|
358
|
+
continue
|
|
359
|
+
if line.startswith("event:"):
|
|
360
|
+
event_name = line[6:].strip()
|
|
361
|
+
continue
|
|
362
|
+
if not line.startswith("data:"):
|
|
363
|
+
continue
|
|
364
|
+
data = line[5:].strip()
|
|
365
|
+
if data == "[DONE]":
|
|
366
|
+
self.done = True
|
|
367
|
+
self.response.close()
|
|
368
|
+
return
|
|
369
|
+
try:
|
|
370
|
+
payload = json.loads(data)
|
|
371
|
+
except ValueError:
|
|
372
|
+
raise StreamError("stream carried a malformed event", partial=self.delivered > 0, request_id=self.request_id) from None
|
|
373
|
+
if event_name == "error" or (isinstance(payload, dict) and "error" in payload and "choices" not in payload):
|
|
374
|
+
error = payload.get("error") if isinstance(payload, dict) else None
|
|
375
|
+
code = str((error or {}).get("code") or "UNAVAILABLE")
|
|
376
|
+
message = str((error or {}).get("message") or "stream failed")
|
|
377
|
+
failure = StreamError(f"stream failed: {code}: {message}", partial=self.delivered > 0, request_id=self.request_id)
|
|
378
|
+
failure.code = code
|
|
379
|
+
raise failure
|
|
380
|
+
self.delivered += 1
|
|
381
|
+
yield payload
|
|
382
|
+
if not self.done:
|
|
383
|
+
raise StreamError("stream ended before the server marked it complete", partial=self.delivered > 0, request_id=self.request_id)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _quote_etag(etag: str) -> str:
|
|
387
|
+
etag = etag.strip()
|
|
388
|
+
if etag.startswith('"') and etag.endswith('"'):
|
|
389
|
+
return etag
|
|
390
|
+
return f'"{etag}"'
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _encode(value: Any) -> str:
|
|
394
|
+
return quote(str(value), safe="")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
__all__ = ["Nestor", "SSEEvents", "check_id", "validate_api_url"]
|