visionai-sdk-python 2.2.0.dev2__py3-none-any.whl → 2.2.0.dev4__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 +35 -68
- visionai_sdk_python/async_client.py +30 -41
- visionai_sdk_python/auth/_mixin.py +1 -1
- visionai_sdk_python/auth/async_resource.py +15 -13
- visionai_sdk_python/auth/credentials.py +126 -68
- visionai_sdk_python/auth/resource.py +17 -13
- visionai_sdk_python/client.py +30 -39
- visionai_sdk_python/exceptions.py +51 -1
- visionai_sdk_python/relay/__init__.py +67 -0
- visionai_sdk_python/relay/examples/__init__.py +28 -0
- visionai_sdk_python/relay/examples/error_connection.json +3 -0
- visionai_sdk_python/relay/examples/error_request.json +4 -0
- visionai_sdk_python/relay/examples/heartbeat.json +1 -0
- visionai_sdk_python/relay/examples/register.json +8 -0
- visionai_sdk_python/relay/examples/registered.json +4 -0
- visionai_sdk_python/relay/examples/request.json +17 -0
- visionai_sdk_python/relay/examples/response.json +9 -0
- visionai_sdk_python/relay/examples/response_text.json +5 -0
- visionai_sdk_python/relay/protocol.py +178 -0
- visionai_sdk_python/vlm/async_resource.py +4 -2
- visionai_sdk_python/vlm/resource.py +4 -2
- {visionai_sdk_python-2.2.0.dev2.dist-info → visionai_sdk_python-2.2.0.dev4.dist-info}/METADATA +22 -9
- visionai_sdk_python-2.2.0.dev4.dist-info/RECORD +39 -0
- visionai_sdk_python-2.2.0.dev2.dist-info/RECORD +0 -28
- {visionai_sdk_python-2.2.0.dev2.dist-info → visionai_sdk_python-2.2.0.dev4.dist-info}/WHEEL +0 -0
- {visionai_sdk_python-2.2.0.dev2.dist-info → visionai_sdk_python-2.2.0.dev4.dist-info}/top_level.txt +0 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""One client-credentials token for every client and caller in a process.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
A mint is a round trip to Platform and, on sites that cap client tokens, a
|
|
4
|
+
scarce one, so the token is cached until shortly before it expires, one mint
|
|
5
|
+
runs at a time, a refresh that fails keeps the current token while Platform
|
|
6
|
+
still accepts it, a 401 replaces a token that is old enough to have been
|
|
7
|
+
revoked, and a 429 stops minting for an hour.
|
|
6
8
|
"""
|
|
7
9
|
|
|
8
10
|
from __future__ import annotations
|
|
@@ -20,31 +22,43 @@ import httpx
|
|
|
20
22
|
from .._base import _BaseClient
|
|
21
23
|
from .._request_attribution import merge_request_attribution
|
|
22
24
|
from ..endpoints import AuthEndpoint
|
|
23
|
-
from ..exceptions import
|
|
25
|
+
from ..exceptions import (
|
|
26
|
+
RateLimitedError,
|
|
27
|
+
VisionaiSDKError,
|
|
28
|
+
from_transport_error,
|
|
29
|
+
raise_for_status,
|
|
30
|
+
)
|
|
24
31
|
from .models import TokenResponse
|
|
25
32
|
|
|
26
33
|
logger = logging.getLogger(__name__)
|
|
27
34
|
|
|
28
|
-
# A token
|
|
35
|
+
# A token is refreshed this long before it expires, or at half its lifetime when
|
|
36
|
+
# that is sooner, so a token is never served after Platform stops accepting it.
|
|
29
37
|
REFRESH_BUFFER_S = 300
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
#
|
|
33
|
-
|
|
38
|
+
# After a 429 no mint is tried for this long.
|
|
39
|
+
RATE_LIMIT_COOLDOWN_S = 3600
|
|
40
|
+
# A 401 on a token minted less than this long ago does not replace it.
|
|
41
|
+
REPLACE_COOLDOWN_S = 30 * 60
|
|
34
42
|
|
|
35
43
|
_now = time.monotonic
|
|
36
44
|
|
|
37
45
|
|
|
38
46
|
@dataclass(frozen=True)
|
|
39
47
|
class AccessToken:
|
|
40
|
-
"""A bearer token
|
|
48
|
+
"""A bearer token with the ``time.monotonic()`` seconds it expires at and was issued."""
|
|
41
49
|
|
|
42
50
|
token: str
|
|
43
51
|
expires_on: float
|
|
52
|
+
token_type: str
|
|
53
|
+
issued_at: float
|
|
54
|
+
|
|
55
|
+
def expires_in(self) -> int:
|
|
56
|
+
"""Seconds left before ``expires_on``, never negative."""
|
|
57
|
+
return max(int(self.expires_on - _now()), 0)
|
|
44
58
|
|
|
45
59
|
|
|
46
60
|
class _Credentials:
|
|
47
|
-
"""What the sync and async variants share: the cache, the 401 and 429 rules."""
|
|
61
|
+
"""What the sync and async variants share: the cache, the refresh, 401 and 429 rules."""
|
|
48
62
|
|
|
49
63
|
def __init__(self, auth_url: str, client_id: str, client_secret: str) -> None:
|
|
50
64
|
if not auth_url.strip():
|
|
@@ -57,12 +71,9 @@ class _Credentials:
|
|
|
57
71
|
self.client_id = client_id.strip()
|
|
58
72
|
self._client_secret = client_secret
|
|
59
73
|
self._url = _BaseClient._build_url(self.auth_url, AuthEndpoint.CLIENT_TOKEN)
|
|
60
|
-
self.
|
|
61
|
-
#: The last token endpoint response, for callers that want ``token_type`` too.
|
|
62
|
-
self.last_response: TokenResponse | None = None
|
|
63
|
-
# True while the cached token is the replacement a 401 bought.
|
|
64
|
-
self._replaced = False
|
|
74
|
+
self._current: AccessToken | None = None # the token this credential holds
|
|
65
75
|
self._blocked_until = 0.0
|
|
76
|
+
self._minting = False # a mint is in flight (set under the lock)
|
|
66
77
|
|
|
67
78
|
def is_for(self, client_id: str, client_secret: str) -> bool:
|
|
68
79
|
"""True when this credential was built for exactly these client credentials."""
|
|
@@ -70,63 +81,89 @@ class _Credentials:
|
|
|
70
81
|
self._client_secret, client_secret
|
|
71
82
|
)
|
|
72
83
|
|
|
73
|
-
def
|
|
74
|
-
|
|
75
|
-
|
|
84
|
+
def _fresh(self) -> AccessToken | None:
|
|
85
|
+
"""The current token while it is not yet due for a refresh."""
|
|
86
|
+
token = self._current
|
|
87
|
+
if token is None:
|
|
88
|
+
return None
|
|
89
|
+
buffer = min(REFRESH_BUFFER_S, (token.expires_on - token.issued_at) / 2)
|
|
90
|
+
return token if _now() < token.expires_on - buffer else None
|
|
91
|
+
|
|
92
|
+
def _valid(self) -> AccessToken | None:
|
|
93
|
+
"""The current token while Platform still accepts it, due for a refresh or not."""
|
|
94
|
+
if self._current and _now() < self._current.expires_on:
|
|
95
|
+
return self._current
|
|
76
96
|
return None
|
|
77
97
|
|
|
78
|
-
def
|
|
79
|
-
"""kwargs for the client-token POST
|
|
98
|
+
def _begin_mint(self) -> dict[str, Any]:
|
|
99
|
+
"""First half of a mint: the 429 gate, then the kwargs for the client-token POST."""
|
|
80
100
|
if _now() < self._blocked_until:
|
|
81
101
|
raise RateLimitedError(
|
|
82
|
-
"client-token
|
|
102
|
+
"client-token requests are rate limited; not minting until the cooldown ends"
|
|
83
103
|
)
|
|
84
|
-
if self._token is not None:
|
|
85
|
-
# Natural refresh: the next 401 may buy one replacement again.
|
|
86
|
-
self._replaced = False
|
|
87
104
|
kwargs: dict[str, Any] = {
|
|
88
105
|
"json": {"client_id": self.client_id, "client_secret": self._client_secret}
|
|
89
106
|
}
|
|
90
107
|
merge_request_attribution(kwargs)
|
|
91
108
|
return kwargs
|
|
92
109
|
|
|
93
|
-
def
|
|
110
|
+
def _finish_mint(self, response: httpx.Response) -> AccessToken:
|
|
111
|
+
"""Second half of a mint: the response becomes the current token, or raises."""
|
|
94
112
|
try:
|
|
95
|
-
|
|
113
|
+
raise_for_status(response)
|
|
96
114
|
except RateLimitedError:
|
|
97
|
-
self._blocked_until = _now() +
|
|
115
|
+
self._blocked_until = _now() + RATE_LIMIT_COOLDOWN_S
|
|
98
116
|
raise
|
|
99
117
|
try:
|
|
100
118
|
parsed = TokenResponse(**response.json())
|
|
101
119
|
except (ValueError, TypeError) as e:
|
|
102
120
|
raise VisionaiSDKError(f"client-token response is not a token: {e}") from e
|
|
103
|
-
|
|
104
|
-
self.
|
|
105
|
-
parsed.access_token,
|
|
121
|
+
now = _now()
|
|
122
|
+
self._current = AccessToken(
|
|
123
|
+
parsed.access_token, now + parsed.expires_in, parsed.token_type, now
|
|
106
124
|
)
|
|
107
125
|
logger.info(
|
|
108
126
|
"platform token minted",
|
|
109
127
|
extra={"client_id": self.client_id, "expires_in": parsed.expires_in},
|
|
110
128
|
)
|
|
111
|
-
return self.
|
|
129
|
+
return self._current
|
|
130
|
+
|
|
131
|
+
def _after_failed_refresh(self, error: VisionaiSDKError) -> AccessToken:
|
|
132
|
+
"""A refresh failed: keep serving the current token while it is valid, else raise."""
|
|
133
|
+
valid = self._valid()
|
|
134
|
+
if valid is None:
|
|
135
|
+
raise error
|
|
136
|
+
logger.warning(
|
|
137
|
+
"token refresh failed; using the current token until it expires",
|
|
138
|
+
extra={
|
|
139
|
+
"client_id": self.client_id,
|
|
140
|
+
"error": str(error),
|
|
141
|
+
"expires_in": valid.expires_in(),
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
return valid
|
|
112
145
|
|
|
113
146
|
def invalidate(self, token: str) -> bool:
|
|
114
147
|
"""Report a 401 for ``token``. True: dropped, so the next ``get_token()`` mints.
|
|
115
148
|
|
|
116
|
-
A
|
|
117
|
-
|
|
118
|
-
another mint would not fix,
|
|
149
|
+
A late report for a token no longer held changes nothing. A token minted
|
|
150
|
+
less than ``REPLACE_COOLDOWN_S`` ago is kept: a 401 that soon is a client or
|
|
151
|
+
allowlist problem another mint would not fix, and the cooldown bounds how
|
|
152
|
+
often a persistent 401 can mint. While a mint is in flight the report is
|
|
153
|
+
already being acted on.
|
|
119
154
|
"""
|
|
120
|
-
if self.
|
|
155
|
+
if self._current is None or token != self._current.token:
|
|
121
156
|
return False
|
|
122
|
-
if self.
|
|
123
|
-
|
|
124
|
-
|
|
157
|
+
if self._minting:
|
|
158
|
+
return True
|
|
159
|
+
if _now() - self._current.issued_at < REPLACE_COOLDOWN_S:
|
|
160
|
+
logger.warning(
|
|
161
|
+
"401 on a token minted %d s ago; keeping it until the cooldown ends",
|
|
162
|
+
int(_now() - self._current.issued_at),
|
|
125
163
|
extra={"client_id": self.client_id},
|
|
126
164
|
)
|
|
127
165
|
return False
|
|
128
|
-
self.
|
|
129
|
-
self._replaced = True
|
|
166
|
+
self._current = None
|
|
130
167
|
return True
|
|
131
168
|
|
|
132
169
|
|
|
@@ -149,32 +186,43 @@ class ClientCredentials(_Credentials):
|
|
|
149
186
|
) -> None:
|
|
150
187
|
super().__init__(auth_url, client_id, client_secret)
|
|
151
188
|
self._http = http or httpx.Client(verify=verify_ssl, timeout=timeout)
|
|
152
|
-
self.
|
|
189
|
+
self._http_owner = http is None # a client we built is ours to close
|
|
153
190
|
self._lock = threading.Lock()
|
|
154
191
|
|
|
155
|
-
def get_token(self) -> AccessToken:
|
|
156
|
-
"""The cached token,
|
|
192
|
+
def get_token(self, *, force_refresh: bool = False) -> AccessToken:
|
|
193
|
+
"""The cached token, refreshed shortly before it expires.
|
|
194
|
+
|
|
195
|
+
``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.
|
|
157
197
|
|
|
158
198
|
Raises:
|
|
159
199
|
AuthenticationError: Platform refused the client credentials (401).
|
|
160
|
-
RateLimitedError:
|
|
161
|
-
seen less than an hour ago.
|
|
200
|
+
RateLimitedError: Platform answered 429, or did so less than an hour ago.
|
|
162
201
|
NetworkError: Platform could not be reached.
|
|
163
202
|
VisionaiSDKError: Any other failure.
|
|
164
203
|
"""
|
|
165
204
|
with self._lock:
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
205
|
+
if not force_refresh:
|
|
206
|
+
fresh = self._fresh()
|
|
207
|
+
if fresh is not None:
|
|
208
|
+
return fresh
|
|
209
|
+
self._minting = True
|
|
170
210
|
try:
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
211
|
+
kwargs = self._begin_mint()
|
|
212
|
+
try:
|
|
213
|
+
response = self._http.post(self._url, **kwargs)
|
|
214
|
+
except httpx.RequestError as e:
|
|
215
|
+
raise from_transport_error(e) from e
|
|
216
|
+
return self._finish_mint(response)
|
|
217
|
+
except VisionaiSDKError as e:
|
|
218
|
+
if force_refresh:
|
|
219
|
+
raise
|
|
220
|
+
return self._after_failed_refresh(e)
|
|
221
|
+
finally:
|
|
222
|
+
self._minting = False
|
|
175
223
|
|
|
176
224
|
def close(self) -> None:
|
|
177
|
-
if self.
|
|
225
|
+
if self._http_owner:
|
|
178
226
|
self._http.close()
|
|
179
227
|
|
|
180
228
|
def __enter__(self) -> ClientCredentials:
|
|
@@ -204,27 +252,37 @@ class AsyncClientCredentials(_Credentials):
|
|
|
204
252
|
) -> None:
|
|
205
253
|
super().__init__(auth_url, client_id, client_secret)
|
|
206
254
|
self._http = http or httpx.AsyncClient(verify=verify_ssl, timeout=timeout)
|
|
207
|
-
self.
|
|
255
|
+
self._http_owner = http is None # a client we built is ours to close
|
|
208
256
|
self._lock = asyncio.Lock()
|
|
209
257
|
|
|
210
|
-
async def get_token(self) -> AccessToken:
|
|
211
|
-
"""The cached token,
|
|
258
|
+
async def get_token(self, *, force_refresh: bool = False) -> AccessToken:
|
|
259
|
+
"""The cached token, refreshed shortly before it expires.
|
|
212
260
|
|
|
213
|
-
Concurrent callers wait for one mint.
|
|
261
|
+
Concurrent callers wait for one mint. Behaves and raises as
|
|
262
|
+
``ClientCredentials.get_token``.
|
|
214
263
|
"""
|
|
215
264
|
async with self._lock:
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
265
|
+
if not force_refresh:
|
|
266
|
+
fresh = self._fresh()
|
|
267
|
+
if fresh is not None:
|
|
268
|
+
return fresh
|
|
269
|
+
self._minting = True
|
|
220
270
|
try:
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
271
|
+
kwargs = self._begin_mint()
|
|
272
|
+
try:
|
|
273
|
+
response = await self._http.post(self._url, **kwargs)
|
|
274
|
+
except httpx.RequestError as e:
|
|
275
|
+
raise from_transport_error(e) from e
|
|
276
|
+
return self._finish_mint(response)
|
|
277
|
+
except VisionaiSDKError as e:
|
|
278
|
+
if force_refresh:
|
|
279
|
+
raise
|
|
280
|
+
return self._after_failed_refresh(e)
|
|
281
|
+
finally:
|
|
282
|
+
self._minting = False
|
|
225
283
|
|
|
226
284
|
async def aclose(self) -> None:
|
|
227
|
-
if self.
|
|
285
|
+
if self._http_owner:
|
|
228
286
|
await self._http.aclose()
|
|
229
287
|
|
|
230
288
|
async def __aenter__(self) -> AsyncClientCredentials:
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Authentication resource for sync operations."""
|
|
2
2
|
|
|
3
|
-
import time
|
|
4
3
|
from typing import TYPE_CHECKING
|
|
5
4
|
|
|
6
5
|
import jwt
|
|
@@ -28,12 +27,17 @@ class AuthResource(AuthMixin):
|
|
|
28
27
|
"""
|
|
29
28
|
self._sdk_client = client
|
|
30
29
|
|
|
31
|
-
def get_access_token(
|
|
30
|
+
def get_access_token(
|
|
31
|
+
self, client_id: str, client_secret: str, *, force_refresh: bool = False
|
|
32
|
+
) -> TokenResponse:
|
|
32
33
|
"""Get access token using client credentials flow.
|
|
33
34
|
|
|
34
|
-
The
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
The client keeps the resulting credential and uses it for subsequent API
|
|
36
|
+
calls, minting a new token when the current one expires. Calling this
|
|
37
|
+
again with the same client credentials returns the cached token;
|
|
38
|
+
``force_refresh=True`` mints a new one. A later ``login()`` or
|
|
39
|
+
``set_token()`` replaces the credential: the last explicit
|
|
40
|
+
authentication wins.
|
|
37
41
|
|
|
38
42
|
New code should build a ``ClientCredentials`` / ``AsyncClientCredentials``
|
|
39
43
|
and pass it as ``credential=`` to the client instead; this method stays for
|
|
@@ -42,6 +46,7 @@ class AuthResource(AuthMixin):
|
|
|
42
46
|
Args:
|
|
43
47
|
client_id: OAuth client ID
|
|
44
48
|
client_secret: OAuth client secret
|
|
49
|
+
force_refresh: Mint a new token even if a fresh one is cached.
|
|
45
50
|
|
|
46
51
|
Returns:
|
|
47
52
|
TokenResponse with access_token, expires_in, and token_type
|
|
@@ -68,16 +73,15 @@ class AuthResource(AuthMixin):
|
|
|
68
73
|
client_secret,
|
|
69
74
|
http=self._sdk_client._client,
|
|
70
75
|
)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
76
|
+
token = credential.get_token(force_refresh=force_refresh)
|
|
77
|
+
# Installed only once it works, and over a login() / set_token() token:
|
|
78
|
+
# the last explicit authentication wins.
|
|
79
|
+
self._sdk_client._credential = credential
|
|
80
|
+
return TokenResponse(
|
|
74
81
|
access_token=token.token,
|
|
75
|
-
expires_in=
|
|
76
|
-
|
|
77
|
-
credentials_type="client",
|
|
82
|
+
expires_in=token.expires_in(),
|
|
83
|
+
token_type=token.token_type,
|
|
78
84
|
)
|
|
79
|
-
assert credential.last_response is not None
|
|
80
|
-
return credential.last_response
|
|
81
85
|
|
|
82
86
|
def login(self, email: str, password: str) -> TokenResponse:
|
|
83
87
|
"""Login with email and password to get JWT token.
|
visionai_sdk_python/client.py
CHANGED
|
@@ -4,7 +4,7 @@ from ._base import _BaseClient
|
|
|
4
4
|
from ._request_attribution import merge_request_attribution
|
|
5
5
|
from .auth.credentials import ClientCredentials
|
|
6
6
|
from .auth.resource import AuthResource
|
|
7
|
-
from .exceptions import AuthenticationError
|
|
7
|
+
from .exceptions import AuthenticationError, from_transport_error
|
|
8
8
|
from .vlm.resource import VLMResource
|
|
9
9
|
|
|
10
10
|
|
|
@@ -41,7 +41,7 @@ class Client(_BaseClient):
|
|
|
41
41
|
max_keepalive_connections=self.max_keepalive_connections,
|
|
42
42
|
),
|
|
43
43
|
)
|
|
44
|
-
self.
|
|
44
|
+
self._http_owner = http_client is None # a client we built is ours to close
|
|
45
45
|
# Register the different function resource
|
|
46
46
|
self.auth = AuthResource(self)
|
|
47
47
|
self.vlm = VLMResource(self)
|
|
@@ -52,46 +52,41 @@ class Client(_BaseClient):
|
|
|
52
52
|
try:
|
|
53
53
|
response = self._client.request(method, url, **kwargs)
|
|
54
54
|
except httpx.RequestError as e:
|
|
55
|
-
raise
|
|
55
|
+
raise from_transport_error(e) from e
|
|
56
56
|
return self._handle_response(response)
|
|
57
57
|
|
|
58
58
|
def request(self, method: str, url: str, **kwargs) -> httpx.Response:
|
|
59
59
|
"""Send any request with this client's bearer token.
|
|
60
60
|
|
|
61
|
-
``url`` is
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
``url`` is a full URL (the client has two bases); ``kwargs`` go to httpx.
|
|
62
|
+
With a ``credential``, a 401 buys one retry with the credential's current
|
|
63
|
+
token, which it replaces when the rejected one is old enough to have been
|
|
64
|
+
revoked (``REPLACE_COOLDOWN_S``); a 401 on the retry raises. Without a
|
|
65
|
+
credential, or with a body that cannot be sent twice (a stream or a
|
|
66
|
+
file), a 401 raises.
|
|
64
67
|
|
|
65
68
|
Raises:
|
|
69
|
+
ValueError: ``url`` is not a full URL.
|
|
66
70
|
AuthenticationError: No token, or the fresh one was refused too.
|
|
67
71
|
RateLimitedError, NetworkError, ServerError, ClientError: As the
|
|
68
72
|
resource methods.
|
|
69
73
|
"""
|
|
70
|
-
url = self.
|
|
71
|
-
self._ensure_token()
|
|
72
|
-
token = self._access_token
|
|
74
|
+
url = self._full_url(url)
|
|
75
|
+
token = self._ensure_token()
|
|
73
76
|
try:
|
|
74
77
|
return self._request(method, url, **self._with_auth(kwargs, token))
|
|
75
78
|
except AuthenticationError:
|
|
76
|
-
if not self.
|
|
79
|
+
if self._credential is None or not self._resendable(kwargs):
|
|
77
80
|
raise
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if not self._credential.invalidate(token):
|
|
84
|
-
return False
|
|
85
|
-
self._access_token = self._credential.get_token().token
|
|
86
|
-
return True
|
|
87
|
-
if self._credentials is None or self._access_token != token:
|
|
88
|
-
return False
|
|
89
|
-
self._refresh_token()
|
|
90
|
-
return self._access_token != token
|
|
81
|
+
self._credential.invalidate(token)
|
|
82
|
+
replacement = (self._credential.get_token()).token
|
|
83
|
+
if replacement == token:
|
|
84
|
+
raise
|
|
85
|
+
return self._request(method, url, **self._with_auth(kwargs, replacement))
|
|
91
86
|
|
|
92
87
|
def close(self) -> None:
|
|
93
88
|
"""Close the HTTP client this client created and release its connections."""
|
|
94
|
-
if self.
|
|
89
|
+
if self._http_owner:
|
|
95
90
|
self._client.close()
|
|
96
91
|
|
|
97
92
|
def __enter__(self) -> "Client":
|
|
@@ -103,7 +98,9 @@ class Client(_BaseClient):
|
|
|
103
98
|
self.close()
|
|
104
99
|
|
|
105
100
|
def _refresh_token(self) -> None:
|
|
106
|
-
"""Refresh token
|
|
101
|
+
"""Refresh the token with the stored login credentials.
|
|
102
|
+
|
|
103
|
+
Client credentials refresh through the credential object instead.
|
|
107
104
|
|
|
108
105
|
Raises:
|
|
109
106
|
AuthenticationError: If no credentials are stored or refresh fails.
|
|
@@ -111,26 +108,19 @@ class Client(_BaseClient):
|
|
|
111
108
|
if self._credentials is None or self._credentials_type is None:
|
|
112
109
|
raise AuthenticationError("No credentials available for token refresh")
|
|
113
110
|
|
|
114
|
-
|
|
115
|
-
self.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
)
|
|
119
|
-
elif self._credentials_type == "client":
|
|
120
|
-
self.auth.get_access_token(
|
|
121
|
-
client_id=self._credentials["client_id"],
|
|
122
|
-
client_secret=self._credentials["client_secret"],
|
|
123
|
-
)
|
|
111
|
+
self.auth.login(
|
|
112
|
+
email=self._credentials["email"],
|
|
113
|
+
password=self._credentials["password"],
|
|
114
|
+
)
|
|
124
115
|
|
|
125
|
-
def _ensure_token(self) ->
|
|
126
|
-
"""
|
|
116
|
+
def _ensure_token(self) -> str:
|
|
117
|
+
"""The token to send: the credential's, else the stored one, refreshed if expiring.
|
|
127
118
|
|
|
128
119
|
Raises:
|
|
129
120
|
AuthenticationError: If no token is available or token expired without credentials.
|
|
130
121
|
"""
|
|
131
122
|
if self._credential is not None:
|
|
132
|
-
|
|
133
|
-
return
|
|
123
|
+
return (self._credential.get_token()).token
|
|
134
124
|
if self._access_token is None:
|
|
135
125
|
raise AuthenticationError(
|
|
136
126
|
"Not authenticated. Call login() or get_access_token() first."
|
|
@@ -142,3 +132,4 @@ class Client(_BaseClient):
|
|
|
142
132
|
"Token expired and no credentials available for refresh"
|
|
143
133
|
)
|
|
144
134
|
self._refresh_token()
|
|
135
|
+
return self._access_token
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
|
|
1
4
|
class VisionaiSDKError(Exception):
|
|
2
5
|
"""Base exception for all SDK errors."""
|
|
3
6
|
|
|
@@ -29,7 +32,7 @@ class PermissionDeniedError(ClientError):
|
|
|
29
32
|
|
|
30
33
|
|
|
31
34
|
class RateLimitedError(ClientError):
|
|
32
|
-
"""429 - Too many requests; on client-token,
|
|
35
|
+
"""429 - Too many requests; on client-token, Platform is rate limiting mints."""
|
|
33
36
|
|
|
34
37
|
def __init__(self, message: str) -> None:
|
|
35
38
|
super().__init__(429, message)
|
|
@@ -45,3 +48,50 @@ class NetworkError(VisionaiSDKError):
|
|
|
45
48
|
|
|
46
49
|
class JwksDiscoveryError(VisionaiSDKError):
|
|
47
50
|
"""Failed to fetch or parse the OIDC discovery document or JWKS endpoint."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def from_transport_error(error: httpx.RequestError) -> VisionaiSDKError:
|
|
54
|
+
"""The SDK exception for an httpx request that never got a response."""
|
|
55
|
+
if isinstance(error, httpx.TimeoutException):
|
|
56
|
+
return NetworkError("Request timed out")
|
|
57
|
+
if isinstance(error, httpx.NetworkError):
|
|
58
|
+
return NetworkError(f"Network error: {error}")
|
|
59
|
+
return VisionaiSDKError(f"Request failed: {error}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def raise_for_status(response: httpx.Response) -> httpx.Response:
|
|
63
|
+
"""Raise SDK-specific exceptions for non-2xx responses.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
AuthenticationError: If the server returns 401
|
|
67
|
+
PermissionDeniedError: If the server returns 403
|
|
68
|
+
ClientError: If the server returns any other 4xx
|
|
69
|
+
ServerError: If the server returns 5xx
|
|
70
|
+
APIError: If the server returns any other non-2xx status
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
response.raise_for_status()
|
|
74
|
+
except httpx.HTTPStatusError as e:
|
|
75
|
+
body = None
|
|
76
|
+
if e.response.content:
|
|
77
|
+
try:
|
|
78
|
+
body = e.response.json()
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
if isinstance(body, dict):
|
|
82
|
+
detail: str = body.get("detail") or body.get("message") or str(e)
|
|
83
|
+
else:
|
|
84
|
+
detail = str(e)
|
|
85
|
+
status = e.response.status_code
|
|
86
|
+
if status == 401:
|
|
87
|
+
raise AuthenticationError(detail) from e
|
|
88
|
+
if status == 403:
|
|
89
|
+
raise PermissionDeniedError(detail) from e
|
|
90
|
+
if status == 429:
|
|
91
|
+
raise RateLimitedError(detail) from e
|
|
92
|
+
if 400 <= status < 500:
|
|
93
|
+
raise ClientError(status, detail) from e
|
|
94
|
+
if 500 <= status < 600:
|
|
95
|
+
raise ServerError(status, detail) from e
|
|
96
|
+
raise APIError(status, detail) from e
|
|
97
|
+
return response
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The platform-relay wire protocol, shared by platform-relay and platform-edge-agent.
|
|
2
|
+
|
|
3
|
+
Internal to the platform; the spec is platform-service's
|
|
4
|
+
``docs/adr/0001-relay-protocol.md``. See :mod:`.protocol`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .protocol import (
|
|
8
|
+
AGENT_TIMEOUT_S,
|
|
9
|
+
HEARTBEAT_INTERVAL_S,
|
|
10
|
+
MAX_BODY_BYTES,
|
|
11
|
+
MAX_FRAME_BYTES,
|
|
12
|
+
REGISTER_DEADLINE_S,
|
|
13
|
+
RELAY_TIMEOUT_S,
|
|
14
|
+
STALE_AFTER_S,
|
|
15
|
+
SUBPROTOCOL,
|
|
16
|
+
AgentFrame,
|
|
17
|
+
CloseCode,
|
|
18
|
+
Error,
|
|
19
|
+
ErrorCode,
|
|
20
|
+
Frame,
|
|
21
|
+
Heartbeat,
|
|
22
|
+
InvalidFrame,
|
|
23
|
+
ModelEntry,
|
|
24
|
+
Register,
|
|
25
|
+
Registered,
|
|
26
|
+
Rejected,
|
|
27
|
+
RejectReason,
|
|
28
|
+
RelayFrame,
|
|
29
|
+
Request,
|
|
30
|
+
Response,
|
|
31
|
+
WorkspaceEntry,
|
|
32
|
+
dump_frame,
|
|
33
|
+
is_workspace_id,
|
|
34
|
+
parse_agent_frame,
|
|
35
|
+
parse_relay_frame,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"AGENT_TIMEOUT_S",
|
|
40
|
+
"HEARTBEAT_INTERVAL_S",
|
|
41
|
+
"MAX_BODY_BYTES",
|
|
42
|
+
"MAX_FRAME_BYTES",
|
|
43
|
+
"REGISTER_DEADLINE_S",
|
|
44
|
+
"RELAY_TIMEOUT_S",
|
|
45
|
+
"STALE_AFTER_S",
|
|
46
|
+
"SUBPROTOCOL",
|
|
47
|
+
"AgentFrame",
|
|
48
|
+
"CloseCode",
|
|
49
|
+
"Error",
|
|
50
|
+
"ErrorCode",
|
|
51
|
+
"Frame",
|
|
52
|
+
"Heartbeat",
|
|
53
|
+
"InvalidFrame",
|
|
54
|
+
"ModelEntry",
|
|
55
|
+
"Register",
|
|
56
|
+
"Registered",
|
|
57
|
+
"Rejected",
|
|
58
|
+
"RejectReason",
|
|
59
|
+
"RelayFrame",
|
|
60
|
+
"Request",
|
|
61
|
+
"Response",
|
|
62
|
+
"WorkspaceEntry",
|
|
63
|
+
"dump_frame",
|
|
64
|
+
"is_workspace_id",
|
|
65
|
+
"parse_agent_frame",
|
|
66
|
+
"parse_relay_frame",
|
|
67
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The ADR's JSON examples, shipped so every implementation tests against the same frames.
|
|
2
|
+
|
|
3
|
+
``example("register")`` is the parsed object, ``example_text("register")`` the wire text.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
|
|
9
|
+
NAMES = (
|
|
10
|
+
"register",
|
|
11
|
+
"registered",
|
|
12
|
+
"heartbeat",
|
|
13
|
+
"request",
|
|
14
|
+
"response",
|
|
15
|
+
"response_text",
|
|
16
|
+
"error_request",
|
|
17
|
+
"error_connection",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def example_text(name: str) -> str:
|
|
22
|
+
"""One ADR example as the text that goes over the wire."""
|
|
23
|
+
return (files(__package__) / f"{name}.json").read_text(encoding="utf-8")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def example(name: str) -> dict:
|
|
27
|
+
"""One ADR example as a dict, for building or comparing frames in tests."""
|
|
28
|
+
return json.loads(example_text(name))
|