visionai-sdk-python 2.2.0.dev4__py3-none-any.whl → 2.2.0.dev6__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.
- visionai_sdk_python/_base.py +29 -2
- visionai_sdk_python/async_client.py +40 -19
- visionai_sdk_python/auth/credentials.py +73 -18
- visionai_sdk_python/client.py +40 -21
- visionai_sdk_python/relay/__init__.py +4 -0
- visionai_sdk_python/relay/protocol.py +43 -10
- {visionai_sdk_python-2.2.0.dev4.dist-info → visionai_sdk_python-2.2.0.dev6.dist-info}/METADATA +12 -3
- {visionai_sdk_python-2.2.0.dev4.dist-info → visionai_sdk_python-2.2.0.dev6.dist-info}/RECORD +10 -10
- {visionai_sdk_python-2.2.0.dev4.dist-info → visionai_sdk_python-2.2.0.dev6.dist-info}/WHEEL +1 -1
- {visionai_sdk_python-2.2.0.dev4.dist-info → visionai_sdk_python-2.2.0.dev6.dist-info}/top_level.txt +0 -0
visionai_sdk_python/_base.py
CHANGED
|
@@ -11,6 +11,29 @@ if TYPE_CHECKING:
|
|
|
11
11
|
from .auth.credentials import AsyncClientCredentials, ClientCredentials
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
_TRANSPORT_DEFAULTS = {
|
|
15
|
+
"verify_ssl": True,
|
|
16
|
+
"timeout": 10.0,
|
|
17
|
+
"max_connections": 100,
|
|
18
|
+
"max_keepalive_connections": 20,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _transport_settings(injected: object | None, **given: object) -> dict:
|
|
23
|
+
"""The settings for the httpx client the SDK builds, or an error with an injected one.
|
|
24
|
+
|
|
25
|
+
An injected client brings its own TLS, timeout and limits, so none of these
|
|
26
|
+
may be given with it: silently ignoring ``verify_ssl=False`` would surprise.
|
|
27
|
+
"""
|
|
28
|
+
passed = {name: value for name, value in given.items() if value is not None}
|
|
29
|
+
if injected is not None and passed:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
"the injected http client has its own verify, timeout and limits; "
|
|
32
|
+
f"do not pass {', '.join(passed)} with it"
|
|
33
|
+
)
|
|
34
|
+
return {**_TRANSPORT_DEFAULTS, **passed}
|
|
35
|
+
|
|
36
|
+
|
|
14
37
|
class _BaseClient:
|
|
15
38
|
"""Base class for VisionAI SDK clients.
|
|
16
39
|
|
|
@@ -79,8 +102,12 @@ class _BaseClient:
|
|
|
79
102
|
|
|
80
103
|
@property
|
|
81
104
|
def credential(self) -> "ClientCredentials | AsyncClientCredentials | None":
|
|
82
|
-
"""The credential
|
|
83
|
-
|
|
105
|
+
"""The credential currently in use, or None.
|
|
106
|
+
|
|
107
|
+
Set by ``credential=`` or by ``auth.get_access_token()``; dropped by
|
|
108
|
+
``login()`` / ``set_token()``, whose token then takes over. For callers
|
|
109
|
+
that need the raw token themselves (a WebSocket upgrade, another HTTP
|
|
110
|
+
library): ``credential.get_token()`` / ``credential.invalidate(token)``."""
|
|
84
111
|
return self._credential
|
|
85
112
|
|
|
86
113
|
@staticmethod
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import httpx
|
|
2
2
|
|
|
3
|
-
from ._base import _BaseClient
|
|
3
|
+
from ._base import _BaseClient, _transport_settings
|
|
4
4
|
from ._request_attribution import merge_request_attribution
|
|
5
5
|
from .auth.async_resource import AsyncAuthResource
|
|
6
6
|
from .auth.credentials import AsyncClientCredentials
|
|
@@ -14,32 +14,49 @@ class AsyncClient(_BaseClient):
|
|
|
14
14
|
auth_url: str,
|
|
15
15
|
vlm_url: str | None = None,
|
|
16
16
|
allowed_issuers: list[str] | None = None,
|
|
17
|
-
verify_ssl: bool =
|
|
18
|
-
timeout: float =
|
|
19
|
-
max_connections: int =
|
|
20
|
-
max_keepalive_connections: int =
|
|
17
|
+
verify_ssl: bool | None = None,
|
|
18
|
+
timeout: float | None = None,
|
|
19
|
+
max_connections: int | None = None,
|
|
20
|
+
max_keepalive_connections: int | None = None,
|
|
21
21
|
credential: AsyncClientCredentials | None = None,
|
|
22
22
|
http_client: httpx.AsyncClient | None = None,
|
|
23
23
|
) -> None:
|
|
24
|
-
"""See ``_BaseClient
|
|
25
|
-
(tests, shared pools)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
"""See ``_BaseClient``. ``http_client`` is an httpx.AsyncClient to send through
|
|
25
|
+
(tests, shared pools): its own TLS, timeout and limits apply, so
|
|
26
|
+
``verify_ssl`` / ``timeout`` / ``max_connections`` /
|
|
27
|
+
``max_keepalive_connections`` cannot be given with it (``ValueError``);
|
|
28
|
+
the client closes only the one it created."""
|
|
29
|
+
if credential is not None and not isinstance(
|
|
30
|
+
credential, AsyncClientCredentials
|
|
31
|
+
):
|
|
32
|
+
raise TypeError(
|
|
33
|
+
"AsyncClient takes an AsyncClientCredentials; Client takes a ClientCredentials"
|
|
34
|
+
)
|
|
35
|
+
settings = _transport_settings(
|
|
36
|
+
http_client,
|
|
30
37
|
verify_ssl=verify_ssl,
|
|
31
38
|
timeout=timeout,
|
|
32
39
|
max_connections=max_connections,
|
|
33
40
|
max_keepalive_connections=max_keepalive_connections,
|
|
41
|
+
)
|
|
42
|
+
super().__init__(
|
|
43
|
+
auth_url=auth_url,
|
|
44
|
+
vlm_url=vlm_url,
|
|
45
|
+
allowed_issuers=allowed_issuers,
|
|
34
46
|
credential=credential,
|
|
47
|
+
**settings,
|
|
35
48
|
)
|
|
36
|
-
self._client =
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
self._client = (
|
|
50
|
+
http_client
|
|
51
|
+
if http_client is not None
|
|
52
|
+
else httpx.AsyncClient(
|
|
53
|
+
verify=self.verify_ssl,
|
|
54
|
+
timeout=self.timeout,
|
|
55
|
+
limits=httpx.Limits(
|
|
56
|
+
max_connections=self.max_connections,
|
|
57
|
+
max_keepalive_connections=self.max_keepalive_connections,
|
|
58
|
+
),
|
|
59
|
+
)
|
|
43
60
|
)
|
|
44
61
|
self._http_owner = http_client is None # a client we built is ours to close
|
|
45
62
|
# Register the different function resource
|
|
@@ -58,7 +75,11 @@ class AsyncClient(_BaseClient):
|
|
|
58
75
|
async def request(self, method: str, url: str, **kwargs) -> httpx.Response:
|
|
59
76
|
"""Send any request with this client's bearer token.
|
|
60
77
|
|
|
61
|
-
``url`` is a full URL (the client has two bases); ``kwargs`` go to httpx.
|
|
78
|
+
``url`` is a full URL (the client has two bases); ``kwargs`` go to httpx. The
|
|
79
|
+
bearer token goes to whatever host ``url`` names, so ``url`` must come from
|
|
80
|
+
configuration or code, never from a caller's input; redirects are not
|
|
81
|
+
followed unless asked, and httpx drops the header on a cross-origin redirect.
|
|
82
|
+
|
|
62
83
|
With a ``credential``, a 401 buys one retry with the credential's current
|
|
63
84
|
token, which it replaces when the rejected one is old enough to have been
|
|
64
85
|
revoked (``REPLACE_COOLDOWN_S``); a 401 on the retry raises. Without a
|
|
@@ -19,10 +19,12 @@ from typing import Any
|
|
|
19
19
|
|
|
20
20
|
import httpx
|
|
21
21
|
|
|
22
|
-
from .._base import _BaseClient
|
|
22
|
+
from .._base import _BaseClient, _transport_settings
|
|
23
23
|
from .._request_attribution import merge_request_attribution
|
|
24
24
|
from ..endpoints import AuthEndpoint
|
|
25
25
|
from ..exceptions import (
|
|
26
|
+
AuthenticationError,
|
|
27
|
+
PermissionDeniedError,
|
|
26
28
|
RateLimitedError,
|
|
27
29
|
VisionaiSDKError,
|
|
28
30
|
from_transport_error,
|
|
@@ -37,6 +39,8 @@ logger = logging.getLogger(__name__)
|
|
|
37
39
|
REFRESH_BUFFER_S = 300
|
|
38
40
|
# After a 429 no mint is tried for this long.
|
|
39
41
|
RATE_LIMIT_COOLDOWN_S = 3600
|
|
42
|
+
# After a failed refresh the current token is served and no mint is tried for this long.
|
|
43
|
+
REFRESH_RETRY_S = 30
|
|
40
44
|
# A 401 on a token minted less than this long ago does not replace it.
|
|
41
45
|
REPLACE_COOLDOWN_S = 30 * 60
|
|
42
46
|
|
|
@@ -73,12 +77,13 @@ class _Credentials:
|
|
|
73
77
|
self._url = _BaseClient._build_url(self.auth_url, AuthEndpoint.CLIENT_TOKEN)
|
|
74
78
|
self._current: AccessToken | None = None # the token this credential holds
|
|
75
79
|
self._blocked_until = 0.0
|
|
80
|
+
self._retry_after = 0.0 # set by a failed refresh
|
|
76
81
|
self._minting = False # a mint is in flight (set under the lock)
|
|
77
82
|
|
|
78
83
|
def is_for(self, client_id: str, client_secret: str) -> bool:
|
|
79
84
|
"""True when this credential was built for exactly these client credentials."""
|
|
80
85
|
return self.client_id == client_id.strip() and hmac.compare_digest(
|
|
81
|
-
self._client_secret, client_secret
|
|
86
|
+
self._client_secret.encode(), client_secret.encode()
|
|
82
87
|
)
|
|
83
88
|
|
|
84
89
|
def _fresh(self) -> AccessToken | None:
|
|
@@ -129,12 +134,22 @@ class _Credentials:
|
|
|
129
134
|
return self._current
|
|
130
135
|
|
|
131
136
|
def _after_failed_refresh(self, error: VisionaiSDKError) -> AccessToken:
|
|
132
|
-
"""A refresh failed: keep serving the current token while it is valid, else raise.
|
|
137
|
+
"""A refresh failed: keep serving the current token while it is valid, else raise.
|
|
138
|
+
|
|
139
|
+
No mint is tried again for ``REFRESH_RETRY_S``. A refusal (401 / 403) is kept
|
|
140
|
+
too, because the issued token stays valid until it expires; it is logged as an
|
|
141
|
+
error since only the client credentials can be wrong.
|
|
142
|
+
"""
|
|
133
143
|
valid = self._valid()
|
|
134
144
|
if valid is None:
|
|
135
145
|
raise error
|
|
136
|
-
|
|
137
|
-
|
|
146
|
+
self._retry_after = _now() + REFRESH_RETRY_S
|
|
147
|
+
refused = isinstance(error, (AuthenticationError, PermissionDeniedError))
|
|
148
|
+
logger.log(
|
|
149
|
+
logging.ERROR if refused else logging.WARNING,
|
|
150
|
+
"token refresh refused; check the client credentials"
|
|
151
|
+
if refused
|
|
152
|
+
else "token refresh failed; using the current token until it expires",
|
|
138
153
|
extra={
|
|
139
154
|
"client_id": self.client_id,
|
|
140
155
|
"error": str(error),
|
|
@@ -143,8 +158,8 @@ class _Credentials:
|
|
|
143
158
|
)
|
|
144
159
|
return valid
|
|
145
160
|
|
|
146
|
-
def
|
|
147
|
-
"""
|
|
161
|
+
def _invalidate(self, token: str) -> bool:
|
|
162
|
+
"""Behind both ``invalidate()``; the sync class calls it under its lock.
|
|
148
163
|
|
|
149
164
|
A late report for a token no longer held changes nothing. A token minted
|
|
150
165
|
less than ``REPLACE_COOLDOWN_S`` ago is kept: a 401 that soon is a client or
|
|
@@ -152,14 +167,16 @@ class _Credentials:
|
|
|
152
167
|
often a persistent 401 can mint. While a mint is in flight the report is
|
|
153
168
|
already being acted on.
|
|
154
169
|
"""
|
|
155
|
-
|
|
170
|
+
current = self._current
|
|
171
|
+
if current is None or token != current.token:
|
|
156
172
|
return False
|
|
157
173
|
if self._minting:
|
|
158
174
|
return True
|
|
159
|
-
|
|
175
|
+
age = _now() - current.issued_at
|
|
176
|
+
if age < REPLACE_COOLDOWN_S:
|
|
160
177
|
logger.warning(
|
|
161
178
|
"401 on a token minted %d s ago; keeping it until the cooldown ends",
|
|
162
|
-
int(
|
|
179
|
+
int(age),
|
|
163
180
|
extra={"client_id": self.client_id},
|
|
164
181
|
)
|
|
165
182
|
return False
|
|
@@ -181,19 +198,35 @@ class ClientCredentials(_Credentials):
|
|
|
181
198
|
client_secret: str,
|
|
182
199
|
*,
|
|
183
200
|
http: httpx.Client | None = None,
|
|
184
|
-
verify_ssl: bool =
|
|
185
|
-
timeout: float =
|
|
201
|
+
verify_ssl: bool | None = None,
|
|
202
|
+
timeout: float | None = None,
|
|
186
203
|
) -> None:
|
|
187
204
|
super().__init__(auth_url, client_id, client_secret)
|
|
188
|
-
|
|
205
|
+
own = _transport_settings(http, verify_ssl=verify_ssl, timeout=timeout)
|
|
206
|
+
self._http = (
|
|
207
|
+
http
|
|
208
|
+
if http is not None
|
|
209
|
+
else httpx.Client(verify=own["verify_ssl"], timeout=own["timeout"])
|
|
210
|
+
)
|
|
189
211
|
self._http_owner = http is None # a client we built is ours to close
|
|
190
212
|
self._lock = threading.Lock()
|
|
191
213
|
|
|
214
|
+
def invalidate(self, token: str) -> bool:
|
|
215
|
+
"""Report a 401 for ``token``. True: dropped, so the next ``get_token()`` mints.
|
|
216
|
+
|
|
217
|
+
Under the credential's lock: a report that arrives while another thread
|
|
218
|
+
mints waits for it, then finds the fresh token and leaves it alone.
|
|
219
|
+
"""
|
|
220
|
+
with self._lock:
|
|
221
|
+
return self._invalidate(token)
|
|
222
|
+
|
|
192
223
|
def get_token(self, *, force_refresh: bool = False) -> AccessToken:
|
|
193
224
|
"""The cached token, refreshed shortly before it expires.
|
|
194
225
|
|
|
195
226
|
``force_refresh=True`` mints even while the cached token is fresh. A refresh
|
|
196
|
-
that fails keeps serving the current token while Platform still accepts it
|
|
227
|
+
that fails keeps serving the current token while Platform still accepts it
|
|
228
|
+
and is not retried for ``REFRESH_RETRY_S``; a refused one (401 / 403) is kept
|
|
229
|
+
too, logged as an error.
|
|
197
230
|
|
|
198
231
|
Raises:
|
|
199
232
|
AuthenticationError: Platform refused the client credentials (401).
|
|
@@ -206,6 +239,10 @@ class ClientCredentials(_Credentials):
|
|
|
206
239
|
fresh = self._fresh()
|
|
207
240
|
if fresh is not None:
|
|
208
241
|
return fresh
|
|
242
|
+
if _now() < self._retry_after:
|
|
243
|
+
valid = self._valid()
|
|
244
|
+
if valid is not None:
|
|
245
|
+
return valid # a refresh just failed; not again yet
|
|
209
246
|
self._minting = True
|
|
210
247
|
try:
|
|
211
248
|
kwargs = self._begin_mint()
|
|
@@ -237,7 +274,8 @@ class AsyncClientCredentials(_Credentials):
|
|
|
237
274
|
|
|
238
275
|
Pass it to ``AsyncClient(credential=...)`` and use ``await get_token()`` for
|
|
239
276
|
requests the SDK does not make itself, such as a WebSocket upgrade. One
|
|
240
|
-
instance serves any number of clients and callers
|
|
277
|
+
instance serves any number of clients and callers on one event loop; its
|
|
278
|
+
lock and ``httpx.AsyncClient`` bind to that loop.
|
|
241
279
|
"""
|
|
242
280
|
|
|
243
281
|
def __init__(
|
|
@@ -247,14 +285,27 @@ class AsyncClientCredentials(_Credentials):
|
|
|
247
285
|
client_secret: str,
|
|
248
286
|
*,
|
|
249
287
|
http: httpx.AsyncClient | None = None,
|
|
250
|
-
verify_ssl: bool =
|
|
251
|
-
timeout: float =
|
|
288
|
+
verify_ssl: bool | None = None,
|
|
289
|
+
timeout: float | None = None,
|
|
252
290
|
) -> None:
|
|
253
291
|
super().__init__(auth_url, client_id, client_secret)
|
|
254
|
-
|
|
292
|
+
own = _transport_settings(http, verify_ssl=verify_ssl, timeout=timeout)
|
|
293
|
+
self._http = (
|
|
294
|
+
http
|
|
295
|
+
if http is not None
|
|
296
|
+
else httpx.AsyncClient(verify=own["verify_ssl"], timeout=own["timeout"])
|
|
297
|
+
)
|
|
255
298
|
self._http_owner = http is None # a client we built is ours to close
|
|
256
299
|
self._lock = asyncio.Lock()
|
|
257
300
|
|
|
301
|
+
def invalidate(self, token: str) -> bool:
|
|
302
|
+
"""Report a 401 for ``token``. True: dropped, so the next ``get_token()`` mints.
|
|
303
|
+
|
|
304
|
+
No lock: there is no ``await`` here, so the check and the drop are one step
|
|
305
|
+
on the event loop; a mint in flight shows through ``_minting``.
|
|
306
|
+
"""
|
|
307
|
+
return self._invalidate(token)
|
|
308
|
+
|
|
258
309
|
async def get_token(self, *, force_refresh: bool = False) -> AccessToken:
|
|
259
310
|
"""The cached token, refreshed shortly before it expires.
|
|
260
311
|
|
|
@@ -266,6 +317,10 @@ class AsyncClientCredentials(_Credentials):
|
|
|
266
317
|
fresh = self._fresh()
|
|
267
318
|
if fresh is not None:
|
|
268
319
|
return fresh
|
|
320
|
+
if _now() < self._retry_after:
|
|
321
|
+
valid = self._valid()
|
|
322
|
+
if valid is not None:
|
|
323
|
+
return valid # a refresh just failed; not again yet
|
|
269
324
|
self._minting = True
|
|
270
325
|
try:
|
|
271
326
|
kwargs = self._begin_mint()
|
visionai_sdk_python/client.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import httpx
|
|
2
2
|
|
|
3
|
-
from ._base import _BaseClient
|
|
3
|
+
from ._base import _BaseClient, _transport_settings
|
|
4
4
|
from ._request_attribution import merge_request_attribution
|
|
5
5
|
from .auth.credentials import ClientCredentials
|
|
6
6
|
from .auth.resource import AuthResource
|
|
@@ -14,32 +14,47 @@ class Client(_BaseClient):
|
|
|
14
14
|
auth_url: str,
|
|
15
15
|
vlm_url: str | None = None,
|
|
16
16
|
allowed_issuers: list[str] | None = None,
|
|
17
|
-
verify_ssl: bool =
|
|
18
|
-
timeout: float =
|
|
19
|
-
max_connections: int =
|
|
20
|
-
max_keepalive_connections: int =
|
|
17
|
+
verify_ssl: bool | None = None,
|
|
18
|
+
timeout: float | None = None,
|
|
19
|
+
max_connections: int | None = None,
|
|
20
|
+
max_keepalive_connections: int | None = None,
|
|
21
21
|
credential: ClientCredentials | None = None,
|
|
22
22
|
http_client: httpx.Client | None = None,
|
|
23
23
|
) -> None:
|
|
24
|
-
"""See ``_BaseClient
|
|
25
|
-
(tests, shared pools)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
"""See ``_BaseClient``. ``http_client`` is an httpx.Client to send through
|
|
25
|
+
(tests, shared pools): its own TLS, timeout and limits apply, so
|
|
26
|
+
``verify_ssl`` / ``timeout`` / ``max_connections`` /
|
|
27
|
+
``max_keepalive_connections`` cannot be given with it (``ValueError``);
|
|
28
|
+
the client closes only the one it created."""
|
|
29
|
+
if credential is not None and not isinstance(credential, ClientCredentials):
|
|
30
|
+
raise TypeError(
|
|
31
|
+
"Client takes a ClientCredentials; AsyncClient takes an AsyncClientCredentials"
|
|
32
|
+
)
|
|
33
|
+
settings = _transport_settings(
|
|
34
|
+
http_client,
|
|
30
35
|
verify_ssl=verify_ssl,
|
|
31
36
|
timeout=timeout,
|
|
32
37
|
max_connections=max_connections,
|
|
33
38
|
max_keepalive_connections=max_keepalive_connections,
|
|
39
|
+
)
|
|
40
|
+
super().__init__(
|
|
41
|
+
auth_url=auth_url,
|
|
42
|
+
vlm_url=vlm_url,
|
|
43
|
+
allowed_issuers=allowed_issuers,
|
|
34
44
|
credential=credential,
|
|
45
|
+
**settings,
|
|
35
46
|
)
|
|
36
|
-
self._client =
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
self._client = (
|
|
48
|
+
http_client
|
|
49
|
+
if http_client is not None
|
|
50
|
+
else httpx.Client(
|
|
51
|
+
verify=self.verify_ssl,
|
|
52
|
+
timeout=self.timeout,
|
|
53
|
+
limits=httpx.Limits(
|
|
54
|
+
max_connections=self.max_connections,
|
|
55
|
+
max_keepalive_connections=self.max_keepalive_connections,
|
|
56
|
+
),
|
|
57
|
+
)
|
|
43
58
|
)
|
|
44
59
|
self._http_owner = http_client is None # a client we built is ours to close
|
|
45
60
|
# Register the different function resource
|
|
@@ -58,7 +73,11 @@ class Client(_BaseClient):
|
|
|
58
73
|
def request(self, method: str, url: str, **kwargs) -> httpx.Response:
|
|
59
74
|
"""Send any request with this client's bearer token.
|
|
60
75
|
|
|
61
|
-
``url`` is a full URL (the client has two bases); ``kwargs`` go to httpx.
|
|
76
|
+
``url`` is a full URL (the client has two bases); ``kwargs`` go to httpx. The
|
|
77
|
+
bearer token goes to whatever host ``url`` names, so ``url`` must come from
|
|
78
|
+
configuration or code, never from a caller's input; redirects are not
|
|
79
|
+
followed unless asked, and httpx drops the header on a cross-origin redirect.
|
|
80
|
+
|
|
62
81
|
With a ``credential``, a 401 buys one retry with the credential's current
|
|
63
82
|
token, which it replaces when the rejected one is old enough to have been
|
|
64
83
|
revoked (``REPLACE_COOLDOWN_S``); a 401 on the retry raises. Without a
|
|
@@ -79,7 +98,7 @@ class Client(_BaseClient):
|
|
|
79
98
|
if self._credential is None or not self._resendable(kwargs):
|
|
80
99
|
raise
|
|
81
100
|
self._credential.invalidate(token)
|
|
82
|
-
replacement =
|
|
101
|
+
replacement = self._credential.get_token().token
|
|
83
102
|
if replacement == token:
|
|
84
103
|
raise
|
|
85
104
|
return self._request(method, url, **self._with_auth(kwargs, replacement))
|
|
@@ -120,7 +139,7 @@ class Client(_BaseClient):
|
|
|
120
139
|
AuthenticationError: If no token is available or token expired without credentials.
|
|
121
140
|
"""
|
|
122
141
|
if self._credential is not None:
|
|
123
|
-
return
|
|
142
|
+
return self._credential.get_token().token
|
|
124
143
|
if self._access_token is None:
|
|
125
144
|
raise AuthenticationError(
|
|
126
145
|
"Not authenticated. Call login() or get_access_token() first."
|
|
@@ -9,6 +9,7 @@ from .protocol import (
|
|
|
9
9
|
HEARTBEAT_INTERVAL_S,
|
|
10
10
|
MAX_BODY_BYTES,
|
|
11
11
|
MAX_FRAME_BYTES,
|
|
12
|
+
MAX_MESSAGE_CHARS,
|
|
12
13
|
REGISTER_DEADLINE_S,
|
|
13
14
|
RELAY_TIMEOUT_S,
|
|
14
15
|
STALE_AFTER_S,
|
|
@@ -18,6 +19,7 @@ from .protocol import (
|
|
|
18
19
|
Error,
|
|
19
20
|
ErrorCode,
|
|
20
21
|
Frame,
|
|
22
|
+
FrameTooLarge,
|
|
21
23
|
Heartbeat,
|
|
22
24
|
InvalidFrame,
|
|
23
25
|
ModelEntry,
|
|
@@ -40,6 +42,7 @@ __all__ = [
|
|
|
40
42
|
"HEARTBEAT_INTERVAL_S",
|
|
41
43
|
"MAX_BODY_BYTES",
|
|
42
44
|
"MAX_FRAME_BYTES",
|
|
45
|
+
"MAX_MESSAGE_CHARS",
|
|
43
46
|
"REGISTER_DEADLINE_S",
|
|
44
47
|
"RELAY_TIMEOUT_S",
|
|
45
48
|
"STALE_AFTER_S",
|
|
@@ -49,6 +52,7 @@ __all__ = [
|
|
|
49
52
|
"Error",
|
|
50
53
|
"ErrorCode",
|
|
51
54
|
"Frame",
|
|
55
|
+
"FrameTooLarge",
|
|
52
56
|
"Heartbeat",
|
|
53
57
|
"InvalidFrame",
|
|
54
58
|
"ModelEntry",
|
|
@@ -28,6 +28,8 @@ MAX_BODY_BYTES = 15 * 1024 * 1024
|
|
|
28
28
|
# Timeouts nest: the agent waits for VS, the relay waits a little longer for the agent.
|
|
29
29
|
AGENT_TIMEOUT_S = 100
|
|
30
30
|
RELAY_TIMEOUT_S = 110.0
|
|
31
|
+
# An `error` echoes parts of the peer's frame; cut them so the error fits a frame.
|
|
32
|
+
MAX_MESSAGE_CHARS = 200
|
|
31
33
|
|
|
32
34
|
# Platform workspace ids: lowercase hyphenated UUIDs.
|
|
33
35
|
UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
|
|
@@ -58,6 +60,7 @@ class ErrorCode(StrEnum):
|
|
|
58
60
|
PATH_NOT_ALLOWED = "path_not_allowed"
|
|
59
61
|
UPSTREAM_UNREACHABLE = "upstream_unreachable"
|
|
60
62
|
UPSTREAM_TIMEOUT = "upstream_timeout"
|
|
63
|
+
UPSTREAM_TOO_LARGE = "upstream_too_large"
|
|
61
64
|
|
|
62
65
|
|
|
63
66
|
class Frame(BaseModel):
|
|
@@ -133,26 +136,51 @@ _relay_frame = TypeAdapter(Annotated[RelayFrame, Field(discriminator="type")])
|
|
|
133
136
|
|
|
134
137
|
|
|
135
138
|
class InvalidFrame(ValueError):
|
|
136
|
-
"""A frame the receiver cannot read; the text is safe to send back in an ``error``.
|
|
139
|
+
"""A frame the receiver cannot read; the text is safe to send back in an ``error``.
|
|
140
|
+
|
|
141
|
+
Parts of the text come from the peer's frame, so it is cut at ``MAX_MESSAGE_CHARS``
|
|
142
|
+
and a ``request_id`` longer than that is dropped.
|
|
143
|
+
"""
|
|
137
144
|
|
|
138
145
|
def __init__(self, message: str, request_id: str | None = None) -> None:
|
|
146
|
+
if len(message) > MAX_MESSAGE_CHARS:
|
|
147
|
+
message = message[: MAX_MESSAGE_CHARS - 1] + "…"
|
|
148
|
+
if request_id is not None and len(request_id) > MAX_MESSAGE_CHARS:
|
|
149
|
+
request_id = None
|
|
150
|
+
message += " (request_id too long, dropped)"
|
|
139
151
|
super().__init__(message)
|
|
140
152
|
self.request_id = request_id
|
|
141
153
|
|
|
142
154
|
|
|
143
|
-
|
|
144
|
-
"""
|
|
145
|
-
|
|
155
|
+
class FrameTooLarge(ValueError):
|
|
156
|
+
"""The encoded frame exceeds ``MAX_FRAME_BYTES``; the receiver would close with 1009."""
|
|
157
|
+
|
|
158
|
+
def __init__(self, size: int) -> None:
|
|
159
|
+
super().__init__(f"frame is {size} bytes; the limit is {MAX_FRAME_BYTES}")
|
|
160
|
+
self.size = size
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def parse_agent_frame(message: str | bytes | None) -> AgentFrame:
|
|
164
|
+
"""A frame the relay received from an agent.
|
|
165
|
+
|
|
166
|
+
``message`` is what the WebSocket library handed over: ``str`` for a text frame,
|
|
167
|
+
``bytes`` (or Starlette's ``None``) for a binary one, which the protocol does
|
|
168
|
+
not use and is an ``InvalidFrame`` like any other unreadable frame.
|
|
169
|
+
"""
|
|
170
|
+
return _parse(message, _agent_frame)
|
|
146
171
|
|
|
147
172
|
|
|
148
|
-
def parse_relay_frame(
|
|
149
|
-
"""A frame the agent received from the relay
|
|
150
|
-
return _parse(
|
|
173
|
+
def parse_relay_frame(message: str | bytes | None) -> RelayFrame:
|
|
174
|
+
"""A frame the agent received from the relay; see ``parse_agent_frame``."""
|
|
175
|
+
return _parse(message, _relay_frame)
|
|
151
176
|
|
|
152
177
|
|
|
153
|
-
def _parse(
|
|
178
|
+
def _parse(message: str | bytes | None, adapter: TypeAdapter):
|
|
179
|
+
if not isinstance(message, str):
|
|
180
|
+
# json.loads() would happily read JSON out of a binary frame; say no once here.
|
|
181
|
+
raise InvalidFrame("binary frames are not used")
|
|
154
182
|
try:
|
|
155
|
-
data = json.loads(
|
|
183
|
+
data = json.loads(message)
|
|
156
184
|
except ValueError:
|
|
157
185
|
raise InvalidFrame("frame is not JSON") from None
|
|
158
186
|
if not isinstance(data, dict):
|
|
@@ -175,4 +203,9 @@ def _parse(text: str, adapter: TypeAdapter):
|
|
|
175
203
|
|
|
176
204
|
|
|
177
205
|
def dump_frame(frame: BaseModel) -> str:
|
|
178
|
-
|
|
206
|
+
"""The frame as JSON text, or ``FrameTooLarge`` when a receiver would refuse it."""
|
|
207
|
+
text = frame.model_dump_json(exclude_none=True)
|
|
208
|
+
size = len(text.encode())
|
|
209
|
+
if size > MAX_FRAME_BYTES:
|
|
210
|
+
raise FrameTooLarge(size)
|
|
211
|
+
return text
|
{visionai_sdk_python-2.2.0.dev4.dist-info → visionai_sdk_python-2.2.0.dev6.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: visionai-sdk-python
|
|
3
|
-
Version: 2.2.0.
|
|
3
|
+
Version: 2.2.0.dev6
|
|
4
4
|
Summary: VisionAI SDK for Python
|
|
5
5
|
Author-email: Tony Yang <tonyyang@linkervision.com>
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -148,10 +148,18 @@ client = Client(auth_url="...", vlm_url="...", credential=cred) # vlm.chat() u
|
|
|
148
148
|
|
|
149
149
|
token = cred.get_token().token # for a WebSocket upgrade or another library
|
|
150
150
|
|
|
151
|
-
# Any other HTTP call with the same token; a 401 gets one
|
|
151
|
+
# Any other HTTP call with the same token, sent to the host you name; a 401 gets one
|
|
152
|
+
# fresh token and one retry
|
|
152
153
|
nodes = client.request("GET", "https://vs.example.com/mgmt/nodes").json()
|
|
153
154
|
```
|
|
154
155
|
|
|
156
|
+
`request()` raises on a non-2xx like the resource methods (`AuthenticationError`,
|
|
157
|
+
`ClientError`, `ServerError`, ...), unlike plain httpx, and sends the token to whatever
|
|
158
|
+
host `url` names, so `url` comes from configuration or code, never from a caller. A
|
|
159
|
+
client built with `http_client=` (or a credential with `http=`) uses that client's TLS,
|
|
160
|
+
timeout and limits; passing `verify_ssl` / `timeout` / `max_connections` /
|
|
161
|
+
`max_keepalive_connections` with it raises `ValueError`.
|
|
162
|
+
|
|
155
163
|
A mint is a Platform round trip and, on sites that cap client tokens, a scarce one:
|
|
156
164
|
concurrent callers share one mint, a refresh that fails keeps the current token while
|
|
157
165
|
Platform still accepts it, a 401 on a token minted more than 30 minutes ago buys a
|
|
@@ -168,7 +176,8 @@ authentication wins. `vlm_url` is optional for clients that only authenticate.
|
|
|
168
176
|
|
|
169
177
|
`visionai_sdk_python.relay` holds the wire format between platform-relay and
|
|
170
178
|
platform-edge-agent: the frame models, the constants and `parse_agent_frame()` /
|
|
171
|
-
`parse_relay_frame()`
|
|
179
|
+
`parse_relay_frame()` (text frames only: `bytes` or `None` is an `InvalidFrame`) /
|
|
180
|
+
`dump_frame()` (which refuses a frame over `MAX_FRAME_BYTES`). The spec is platform-service's
|
|
172
181
|
`docs/adr/0001-relay-protocol.md`; both sides import this module instead of keeping a
|
|
173
182
|
copy. The ADR's JSON examples ship with it (`visionai_sdk_python.relay.examples`:
|
|
174
183
|
`example(name)` / `example_text(name)`), so every implementation tests against the same
|
{visionai_sdk_python-2.2.0.dev4.dist-info → visionai_sdk_python-2.2.0.dev6.dist-info}/RECORD
RENAMED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
visionai_sdk_python/__init__.py,sha256=EITFK0aQPAQN3BSnwTfrPAt1iu50pmuhXl0-jBSbiD4,774
|
|
2
|
-
visionai_sdk_python/_base.py,sha256=
|
|
2
|
+
visionai_sdk_python/_base.py,sha256=0AbDyYRFyaoNQav3fINLjlvwBPkAaHhCi3s5I18bemA,10419
|
|
3
3
|
visionai_sdk_python/_jwt_verifier.py,sha256=h4CAK1iiDAJ-oKnXrLbCJw5rWcsVe94QNY12ne1Wzto,8442
|
|
4
4
|
visionai_sdk_python/_request_attribution.py,sha256=zQk_bSLsu1RTp1Fe3MGUUX8YRzq3iqfBTmhhjM7BuRU,1907
|
|
5
5
|
visionai_sdk_python/_source_header.py,sha256=DWiR-TGclET4UrqmAEO7bYPtf9XuLSE4FGQjZIgVWiY,5133
|
|
6
|
-
visionai_sdk_python/async_client.py,sha256=
|
|
7
|
-
visionai_sdk_python/client.py,sha256=
|
|
6
|
+
visionai_sdk_python/async_client.py,sha256=03iWCg_MIxentqT8dZJw4YSnZUp4-ZRli_I8vYyZ45I,6493
|
|
7
|
+
visionai_sdk_python/client.py,sha256=oKRVrHVxHkN5IrfkLJBRpXPEv158_zs7Q2T6ujTr-gE,6272
|
|
8
8
|
visionai_sdk_python/constants.py,sha256=KWhoxZdlwqXwhUJDqERStq2mrms-MA8aRDqBwbIvNO0,1471
|
|
9
9
|
visionai_sdk_python/endpoints.py,sha256=CC-AoxXzg39sMAhqx1CTS3gRWKx_H7VOqBANRLVotoU,183
|
|
10
10
|
visionai_sdk_python/exceptions.py,sha256=BUpcoTgILc3z68lIQGyTYs7PCvu_BTSi228YMTx7ZWs,3020
|
|
@@ -13,11 +13,11 @@ visionai_sdk_python/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,
|
|
|
13
13
|
visionai_sdk_python/auth/__init__.py,sha256=sWtanqUos5Q8P80IvjoyvOzXv6gYNkflop9qwDwsZk8,400
|
|
14
14
|
visionai_sdk_python/auth/_mixin.py,sha256=EDDhEB9jHc_7wEoLx_MprhToTVLekL5CyJuLTK8GhNM,5022
|
|
15
15
|
visionai_sdk_python/auth/async_resource.py,sha256=V6O51i7KyYSBkiQeKAbmw-EIKMVZIozO1gDexTX6tXw,5535
|
|
16
|
-
visionai_sdk_python/auth/credentials.py,sha256=
|
|
16
|
+
visionai_sdk_python/auth/credentials.py,sha256=hxigy3hB09lWqmhhcw_QFqYVD-8fHIyvupZYowCIycw,13360
|
|
17
17
|
visionai_sdk_python/auth/models.py,sha256=u6MDFYaWMyf08XCrGZzwPHv43d6AElH9XO2QSjRsb3Q,449
|
|
18
18
|
visionai_sdk_python/auth/resource.py,sha256=3PgcxH8N6unKTs2jSQhMeJT_lg6bTce0hz0bzuID12U,5446
|
|
19
|
-
visionai_sdk_python/relay/__init__.py,sha256=
|
|
20
|
-
visionai_sdk_python/relay/protocol.py,sha256=
|
|
19
|
+
visionai_sdk_python/relay/__init__.py,sha256=yqYdedHUBCo-E9wmVY9Xfa5r9zbOLeii-p6HYTdgzvY,1384
|
|
20
|
+
visionai_sdk_python/relay/protocol.py,sha256=8KvMActRq2iwk8XF94v0Ze3VFAAl6-fAb64BJUDKL7I,7128
|
|
21
21
|
visionai_sdk_python/relay/examples/__init__.py,sha256=GvjlP-EtrhDVgqReOYqcf1mObND61vO6Q8CT_YFfgDM,732
|
|
22
22
|
visionai_sdk_python/relay/examples/error_connection.json,sha256=stjkXo27Vkhoy0rNtPUti2OEXzioxJ8Aqgyq2hAdquA,81
|
|
23
23
|
visionai_sdk_python/relay/examples/error_request.json,sha256=iUB11sSf1vuBAdzvoiHAoUoifZoG4-wGBK6gb0ConjA,228
|
|
@@ -33,7 +33,7 @@ visionai_sdk_python/vlm/async_resource.py,sha256=meMUfBDa0g1hk7vFZq2fDuIQ1-QUpU0
|
|
|
33
33
|
visionai_sdk_python/vlm/models.py,sha256=yDFjo4LfAxqXYBnl6ZW7YUJQoKKRkQWtqBJi2ruI6Jw,984
|
|
34
34
|
visionai_sdk_python/vlm/preprocess.py,sha256=ZpJJa13h8_YKwh_P7Pn8WIXV0x-r2xJi-ETgCFsGw-Y,7892
|
|
35
35
|
visionai_sdk_python/vlm/resource.py,sha256=4eMQ4ACcgd7nUfdRyADWmPd8yZ--uqQC4zwzBft68lg,3386
|
|
36
|
-
visionai_sdk_python-2.2.0.
|
|
37
|
-
visionai_sdk_python-2.2.0.
|
|
38
|
-
visionai_sdk_python-2.2.0.
|
|
39
|
-
visionai_sdk_python-2.2.0.
|
|
36
|
+
visionai_sdk_python-2.2.0.dev6.dist-info/METADATA,sha256=gPr3oj2wON5pearcb_fq_-FGNQ6fL7oOup3f0tLX9jM,31131
|
|
37
|
+
visionai_sdk_python-2.2.0.dev6.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
38
|
+
visionai_sdk_python-2.2.0.dev6.dist-info/top_level.txt,sha256=79a5MCSLLv6Mbfun-8RS-G6O927hahBwDqJjeuWLdvM,20
|
|
39
|
+
visionai_sdk_python-2.2.0.dev6.dist-info/RECORD,,
|
{visionai_sdk_python-2.2.0.dev4.dist-info → visionai_sdk_python-2.2.0.dev6.dist-info}/top_level.txt
RENAMED
|
File without changes
|