visionai-sdk-python 2.1.1__tar.gz → 2.2.0.dev2__tar.gz
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-2.1.1 → visionai_sdk_python-2.2.0.dev2}/PKG-INFO +23 -6
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/README.md +22 -5
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/__init__.py +6 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/_base.py +52 -5
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/async_client.py +53 -10
- visionai_sdk_python-2.2.0.dev2/src/visionai_sdk_python/auth/__init__.py +15 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/auth/async_resource.py +28 -12
- visionai_sdk_python-2.2.0.dev2/src/visionai_sdk_python/auth/credentials.py +234 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/auth/models.py +1 -1
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/auth/resource.py +28 -12
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/client.py +51 -10
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/exceptions.py +7 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/vlm/async_resource.py +10 -18
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/vlm/resource.py +10 -18
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python.egg-info/PKG-INFO +23 -6
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python.egg-info/SOURCES.txt +3 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python.egg-info/scm_file_list.json +3 -0
- visionai_sdk_python-2.2.0.dev2/src/visionai_sdk_python.egg-info/scm_version.json +8 -0
- visionai_sdk_python-2.2.0.dev2/tests/test_credentials.py +295 -0
- visionai_sdk_python-2.2.0.dev2/tests/test_request.py +218 -0
- visionai_sdk_python-2.1.1/src/visionai_sdk_python/auth/__init__.py +0 -7
- visionai_sdk_python-2.1.1/src/visionai_sdk_python.egg-info/scm_version.json +0 -8
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/.github/dependabot.yml +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/.github/workflows/ci.yml +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/.github/workflows/publish.yml +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/.gitignore +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/pyproject.toml +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/setup.cfg +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/_jwt_verifier.py +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/_request_attribution.py +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/_source_header.py +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/auth/_mixin.py +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/constants.py +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/endpoints.py +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/instrumentation.py +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/py.typed +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/vlm/__init__.py +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/vlm/_mixin.py +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/vlm/models.py +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/vlm/preprocess.py +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python.egg-info/dependency_links.txt +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python.egg-info/requires.txt +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python.egg-info/top_level.txt +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/tests/__init__.py +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/tests/constants.py +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/tests/test__base.py +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/tests/test_async_client.py +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/tests/test_client.py +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/tests/test_instrumentation.py +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/tests/test_vlm_preprocess.py +0 -0
- {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: visionai-sdk-python
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0.dev2
|
|
4
4
|
Summary: VisionAI SDK for Python
|
|
5
5
|
Author-email: Tony Yang <tonyyang@linkervision.com>
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -134,15 +134,32 @@ token = client.auth.login("user@example.com", "password")
|
|
|
134
134
|
|
|
135
135
|
### OAuth Client Credentials
|
|
136
136
|
|
|
137
|
+
A `ClientCredentials` (or `AsyncClientCredentials`) holds the token for one Platform
|
|
138
|
+
client: it is minted on first use, cached until five minutes before it expires, and
|
|
139
|
+
one instance can serve any number of clients and your own requests.
|
|
140
|
+
|
|
137
141
|
```python
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
client_secret="your-client-secret"
|
|
142
|
+
from visionai_sdk_python import Client, ClientCredentials
|
|
143
|
+
|
|
144
|
+
cred = ClientCredentials(
|
|
145
|
+
auth_url="...", client_id="your-client-id", client_secret="your-client-secret"
|
|
142
146
|
)
|
|
147
|
+
client = Client(auth_url="...", vlm_url="...", credential=cred) # vlm.chat() uses it
|
|
148
|
+
|
|
149
|
+
token = cred.get_token().token # for a WebSocket upgrade or another library
|
|
150
|
+
|
|
151
|
+
# Any other HTTP call with the same token; a 401 gets one fresh token and one retry
|
|
152
|
+
nodes = client.request("GET", "https://vs.example.com/mgmt/nodes").json()
|
|
143
153
|
```
|
|
144
154
|
|
|
145
|
-
|
|
155
|
+
Platform mints at most 50 tokens per client per month, so the credential protects the
|
|
156
|
+
quota: concurrent callers share one mint; a 401 on a cached token
|
|
157
|
+
(`cred.invalidate(token)`) buys exactly one replacement, and a replacement that is
|
|
158
|
+
rejected too is kept until its own refresh; after a 429 no mint is tried for an hour
|
|
159
|
+
(`RateLimitedError`).
|
|
160
|
+
|
|
161
|
+
`client.auth.get_access_token(client_id, client_secret)` still works and now builds a
|
|
162
|
+
`ClientCredentials` underneath. `vlm_url` is optional for clients that only authenticate.
|
|
146
163
|
|
|
147
164
|
## VLM Inference
|
|
148
165
|
|
|
@@ -116,15 +116,32 @@ token = client.auth.login("user@example.com", "password")
|
|
|
116
116
|
|
|
117
117
|
### OAuth Client Credentials
|
|
118
118
|
|
|
119
|
+
A `ClientCredentials` (or `AsyncClientCredentials`) holds the token for one Platform
|
|
120
|
+
client: it is minted on first use, cached until five minutes before it expires, and
|
|
121
|
+
one instance can serve any number of clients and your own requests.
|
|
122
|
+
|
|
119
123
|
```python
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
client_secret="your-client-secret"
|
|
124
|
+
from visionai_sdk_python import Client, ClientCredentials
|
|
125
|
+
|
|
126
|
+
cred = ClientCredentials(
|
|
127
|
+
auth_url="...", client_id="your-client-id", client_secret="your-client-secret"
|
|
124
128
|
)
|
|
129
|
+
client = Client(auth_url="...", vlm_url="...", credential=cred) # vlm.chat() uses it
|
|
130
|
+
|
|
131
|
+
token = cred.get_token().token # for a WebSocket upgrade or another library
|
|
132
|
+
|
|
133
|
+
# Any other HTTP call with the same token; a 401 gets one fresh token and one retry
|
|
134
|
+
nodes = client.request("GET", "https://vs.example.com/mgmt/nodes").json()
|
|
125
135
|
```
|
|
126
136
|
|
|
127
|
-
|
|
137
|
+
Platform mints at most 50 tokens per client per month, so the credential protects the
|
|
138
|
+
quota: concurrent callers share one mint; a 401 on a cached token
|
|
139
|
+
(`cred.invalidate(token)`) buys exactly one replacement, and a replacement that is
|
|
140
|
+
rejected too is kept until its own refresh; after a 429 no mint is tried for an hour
|
|
141
|
+
(`RateLimitedError`).
|
|
142
|
+
|
|
143
|
+
`client.auth.get_access_token(client_id, client_secret)` still works and now builds a
|
|
144
|
+
`ClientCredentials` underneath. `vlm_url` is optional for clients that only authenticate.
|
|
128
145
|
|
|
129
146
|
## VLM Inference
|
|
130
147
|
|
{visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/__init__.py
RENAMED
|
@@ -4,6 +4,7 @@ A client library for interacting with VisionAI authentication and VLM services.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from .async_client import AsyncClient
|
|
7
|
+
from .auth.credentials import AccessToken, AsyncClientCredentials, ClientCredentials
|
|
7
8
|
from .client import Client
|
|
8
9
|
from .exceptions import (
|
|
9
10
|
AuthenticationError,
|
|
@@ -11,16 +12,21 @@ from .exceptions import (
|
|
|
11
12
|
JwksDiscoveryError,
|
|
12
13
|
NetworkError,
|
|
13
14
|
PermissionDeniedError,
|
|
15
|
+
RateLimitedError,
|
|
14
16
|
ServerError,
|
|
15
17
|
VisionaiSDKError,
|
|
16
18
|
)
|
|
17
19
|
|
|
18
20
|
__all__ = [
|
|
21
|
+
"AccessToken",
|
|
19
22
|
"AsyncClient",
|
|
23
|
+
"AsyncClientCredentials",
|
|
20
24
|
"Client",
|
|
25
|
+
"ClientCredentials",
|
|
21
26
|
"VisionaiSDKError",
|
|
22
27
|
"AuthenticationError",
|
|
23
28
|
"PermissionDeniedError",
|
|
29
|
+
"RateLimitedError",
|
|
24
30
|
"ClientError",
|
|
25
31
|
"ServerError",
|
|
26
32
|
"NetworkError",
|
{visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/_base.py
RENAMED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import time
|
|
2
|
-
from typing import Literal
|
|
2
|
+
from typing import TYPE_CHECKING, Literal
|
|
3
3
|
|
|
4
4
|
import httpx
|
|
5
5
|
|
|
@@ -9,10 +9,16 @@ from .exceptions import (
|
|
|
9
9
|
APIError,
|
|
10
10
|
AuthenticationError,
|
|
11
11
|
ClientError,
|
|
12
|
+
NetworkError,
|
|
12
13
|
PermissionDeniedError,
|
|
14
|
+
RateLimitedError,
|
|
13
15
|
ServerError,
|
|
16
|
+
VisionaiSDKError,
|
|
14
17
|
)
|
|
15
18
|
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .auth.credentials import AsyncClientCredentials, ClientCredentials
|
|
21
|
+
|
|
16
22
|
|
|
17
23
|
class _BaseClient:
|
|
18
24
|
"""Base class for VisionAI SDK clients.
|
|
@@ -24,18 +30,20 @@ class _BaseClient:
|
|
|
24
30
|
def __init__(
|
|
25
31
|
self,
|
|
26
32
|
auth_url: str,
|
|
27
|
-
vlm_url: str,
|
|
33
|
+
vlm_url: str | None = None,
|
|
28
34
|
allowed_issuers: list[str] | None = None,
|
|
29
35
|
verify_ssl: bool = True,
|
|
30
36
|
timeout: float = 10.0,
|
|
31
37
|
max_connections: int = 100,
|
|
32
38
|
max_keepalive_connections: int = 20,
|
|
39
|
+
credential: "ClientCredentials | AsyncClientCredentials | None" = None,
|
|
33
40
|
) -> None:
|
|
34
41
|
"""Initialize the client with connection settings.
|
|
35
42
|
|
|
36
43
|
Args:
|
|
37
44
|
auth_url: Base URL for the authentication service.
|
|
38
|
-
vlm_url: Base URL for the VLM inference service.
|
|
45
|
+
vlm_url: Base URL for the VLM inference service. Optional for clients
|
|
46
|
+
that only authenticate; VLM calls then raise ``ValueError``.
|
|
39
47
|
allowed_issuers: Optional list of allowed JWT issuers. If provided, tokens
|
|
40
48
|
whose ``iss`` claim is not in this list will be rejected. If omitted,
|
|
41
49
|
the issuers are derived from ``auth_url``.
|
|
@@ -43,13 +51,18 @@ class _BaseClient:
|
|
|
43
51
|
timeout: Default request timeout in seconds.
|
|
44
52
|
max_connections: Maximum number of concurrent connections in the pool.
|
|
45
53
|
max_keepalive_connections: Maximum number of idle keep-alive connections.
|
|
54
|
+
credential: A ``ClientCredentials`` / ``AsyncClientCredentials`` whose
|
|
55
|
+
token every call uses; shareable between clients. Without it the
|
|
56
|
+
client keeps the token from ``auth.login()`` / ``auth.get_access_token()``
|
|
57
|
+
or ``set_token()``.
|
|
46
58
|
"""
|
|
47
59
|
if not auth_url.strip():
|
|
48
60
|
raise ValueError("auth_url must not be empty")
|
|
49
|
-
if not vlm_url.strip():
|
|
61
|
+
if vlm_url is not None and not vlm_url.strip():
|
|
50
62
|
raise ValueError("vlm_url must not be empty")
|
|
51
63
|
self.auth_url = auth_url.strip()
|
|
52
|
-
self.vlm_url = vlm_url.strip()
|
|
64
|
+
self.vlm_url = vlm_url.strip() if vlm_url is not None else None
|
|
65
|
+
self._credential = credential
|
|
53
66
|
self.verify_ssl = verify_ssl
|
|
54
67
|
self.timeout = timeout
|
|
55
68
|
self.max_connections = max_connections
|
|
@@ -72,11 +85,43 @@ class _BaseClient:
|
|
|
72
85
|
self._credentials: dict | None = None
|
|
73
86
|
self._credentials_type: Literal["login", "client"] | None = None
|
|
74
87
|
|
|
88
|
+
@property
|
|
89
|
+
def credential(self) -> "ClientCredentials | AsyncClientCredentials | None":
|
|
90
|
+
"""The credential this client was built with, for callers that need the
|
|
91
|
+
raw token themselves (a WebSocket upgrade, another HTTP library)."""
|
|
92
|
+
return self._credential
|
|
93
|
+
|
|
75
94
|
@staticmethod
|
|
76
95
|
def _build_url(base_url: str, path: str) -> str:
|
|
77
96
|
"""Join a base URL and a path, normalizing slashes."""
|
|
78
97
|
return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
|
|
79
98
|
|
|
99
|
+
def _absolute(self, url: str) -> str:
|
|
100
|
+
"""``url`` as given when absolute, else a path under ``auth_url``."""
|
|
101
|
+
return url if "://" in url else self._build_url(self.auth_url, url)
|
|
102
|
+
|
|
103
|
+
def _with_auth(self, kwargs: dict, token: str) -> dict:
|
|
104
|
+
"""``kwargs`` for httpx with the bearer header; the client's token wins."""
|
|
105
|
+
headers = {
|
|
106
|
+
**dict(kwargs.get("headers") or {}),
|
|
107
|
+
**self._build_auth_header(token),
|
|
108
|
+
}
|
|
109
|
+
return {**kwargs, "headers": headers}
|
|
110
|
+
|
|
111
|
+
def _vlm_base(self) -> str:
|
|
112
|
+
if self.vlm_url is None:
|
|
113
|
+
raise ValueError("vlm_url is required for VLM calls; pass it to the client")
|
|
114
|
+
return self.vlm_url
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _map_transport_error(error: httpx.RequestError) -> VisionaiSDKError:
|
|
118
|
+
"""The SDK exception for an httpx request that never got a response."""
|
|
119
|
+
if isinstance(error, httpx.TimeoutException):
|
|
120
|
+
return NetworkError("Request timed out")
|
|
121
|
+
if isinstance(error, httpx.NetworkError):
|
|
122
|
+
return NetworkError(f"Network error: {error}")
|
|
123
|
+
return VisionaiSDKError(f"Request failed: {error}")
|
|
124
|
+
|
|
80
125
|
@staticmethod
|
|
81
126
|
def _build_auth_header(access_token: str) -> dict[str, str]:
|
|
82
127
|
"""Build authorization header."""
|
|
@@ -201,6 +246,8 @@ class _BaseClient:
|
|
|
201
246
|
raise AuthenticationError(detail) from e
|
|
202
247
|
if status == 403:
|
|
203
248
|
raise PermissionDeniedError(detail) from e
|
|
249
|
+
if status == 429:
|
|
250
|
+
raise RateLimitedError(detail) from e
|
|
204
251
|
if 400 <= status < 500:
|
|
205
252
|
raise ClientError(status, detail) from e
|
|
206
253
|
if 500 <= status < 600:
|
{visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/async_client.py
RENAMED
|
@@ -3,7 +3,8 @@ import httpx
|
|
|
3
3
|
from ._base import _BaseClient
|
|
4
4
|
from ._request_attribution import merge_request_attribution
|
|
5
5
|
from .auth.async_resource import AsyncAuthResource
|
|
6
|
-
from .
|
|
6
|
+
from .auth.credentials import AsyncClientCredentials
|
|
7
|
+
from .exceptions import AuthenticationError
|
|
7
8
|
from .vlm.async_resource import AsyncVLMResource
|
|
8
9
|
|
|
9
10
|
|
|
@@ -11,13 +12,17 @@ class AsyncClient(_BaseClient):
|
|
|
11
12
|
def __init__(
|
|
12
13
|
self,
|
|
13
14
|
auth_url: str,
|
|
14
|
-
vlm_url: str,
|
|
15
|
+
vlm_url: str | None = None,
|
|
15
16
|
allowed_issuers: list[str] | None = None,
|
|
16
17
|
verify_ssl: bool = True,
|
|
17
18
|
timeout: float = 10.0,
|
|
18
19
|
max_connections: int = 100,
|
|
19
20
|
max_keepalive_connections: int = 20,
|
|
21
|
+
credential: AsyncClientCredentials | None = None,
|
|
22
|
+
http_client: httpx.AsyncClient | None = None,
|
|
20
23
|
) -> None:
|
|
24
|
+
"""See ``_BaseClient``; ``http_client`` is an httpx.AsyncClient to send through
|
|
25
|
+
(tests, shared pools); the client closes only the one it created."""
|
|
21
26
|
super().__init__(
|
|
22
27
|
auth_url=auth_url,
|
|
23
28
|
vlm_url=vlm_url,
|
|
@@ -26,8 +31,9 @@ class AsyncClient(_BaseClient):
|
|
|
26
31
|
timeout=timeout,
|
|
27
32
|
max_connections=max_connections,
|
|
28
33
|
max_keepalive_connections=max_keepalive_connections,
|
|
34
|
+
credential=credential,
|
|
29
35
|
)
|
|
30
|
-
self._client = httpx.AsyncClient(
|
|
36
|
+
self._client = http_client or httpx.AsyncClient(
|
|
31
37
|
verify=self.verify_ssl,
|
|
32
38
|
timeout=self.timeout,
|
|
33
39
|
limits=httpx.Limits(
|
|
@@ -35,6 +41,7 @@ class AsyncClient(_BaseClient):
|
|
|
35
41
|
max_keepalive_connections=self.max_keepalive_connections,
|
|
36
42
|
),
|
|
37
43
|
)
|
|
44
|
+
self._owns_http = http_client is None
|
|
38
45
|
# Register the different function resource
|
|
39
46
|
self.auth = AsyncAuthResource(self)
|
|
40
47
|
self.vlm = AsyncVLMResource(self)
|
|
@@ -44,17 +51,50 @@ class AsyncClient(_BaseClient):
|
|
|
44
51
|
merge_request_attribution(kwargs)
|
|
45
52
|
try:
|
|
46
53
|
response = await self._client.request(method, url, **kwargs)
|
|
47
|
-
except httpx.TimeoutException as e:
|
|
48
|
-
raise NetworkError("Request timed out") from e
|
|
49
|
-
except httpx.NetworkError as e:
|
|
50
|
-
raise NetworkError(f"Network error: {e}") from e
|
|
51
54
|
except httpx.RequestError as e:
|
|
52
|
-
raise
|
|
55
|
+
raise self._map_transport_error(e) from e
|
|
53
56
|
return self._handle_response(response)
|
|
54
57
|
|
|
58
|
+
async def request(self, method: str, url: str, **kwargs) -> httpx.Response:
|
|
59
|
+
"""Send any request with this client's bearer token.
|
|
60
|
+
|
|
61
|
+
``url`` is absolute, or a path under ``auth_url``; ``kwargs`` go to httpx.
|
|
62
|
+
A 401 buys one fresh token (from ``credential`` or the stored login) and
|
|
63
|
+
one retry, so a token revoked server-side costs one extra request.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
AuthenticationError: No token, or the fresh one was refused too.
|
|
67
|
+
RateLimitedError, NetworkError, ServerError, ClientError: As the
|
|
68
|
+
resource methods.
|
|
69
|
+
"""
|
|
70
|
+
url = self._absolute(url)
|
|
71
|
+
await self._ensure_token()
|
|
72
|
+
token = self._access_token
|
|
73
|
+
try:
|
|
74
|
+
return await self._request(method, url, **self._with_auth(kwargs, token))
|
|
75
|
+
except AuthenticationError:
|
|
76
|
+
if not await self._renew_rejected(token):
|
|
77
|
+
raise
|
|
78
|
+
return await self._request(
|
|
79
|
+
method, url, **self._with_auth(kwargs, self._access_token)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
async def _renew_rejected(self, token: str) -> bool:
|
|
83
|
+
"""A 401 for ``token``: get a different one, once; False when none would help."""
|
|
84
|
+
if self._credential is not None:
|
|
85
|
+
if not self._credential.invalidate(token):
|
|
86
|
+
return False
|
|
87
|
+
self._access_token = (await self._credential.get_token()).token
|
|
88
|
+
return True
|
|
89
|
+
if self._credentials is None or self._access_token != token:
|
|
90
|
+
return False
|
|
91
|
+
await self._refresh_token()
|
|
92
|
+
return self._access_token != token
|
|
93
|
+
|
|
55
94
|
async def close(self) -> None:
|
|
56
|
-
"""Close the HTTP client and release connections."""
|
|
57
|
-
|
|
95
|
+
"""Close the HTTP client this client created and release its connections."""
|
|
96
|
+
if self._owns_http:
|
|
97
|
+
await self._client.aclose()
|
|
58
98
|
|
|
59
99
|
async def __aenter__(self) -> "AsyncClient":
|
|
60
100
|
"""Async context manager entry."""
|
|
@@ -90,6 +130,9 @@ class AsyncClient(_BaseClient):
|
|
|
90
130
|
Raises:
|
|
91
131
|
AuthenticationError: If no token is available or token expired without credentials.
|
|
92
132
|
"""
|
|
133
|
+
if self._credential is not None:
|
|
134
|
+
self._access_token = (await self._credential.get_token()).token
|
|
135
|
+
return
|
|
93
136
|
if self._access_token is None:
|
|
94
137
|
raise AuthenticationError(
|
|
95
138
|
"Not authenticated. Call login() or get_access_token() first."
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Authentication resources for VisionAI SDK."""
|
|
2
|
+
|
|
3
|
+
from .async_resource import AsyncAuthResource
|
|
4
|
+
from .credentials import AccessToken, AsyncClientCredentials, ClientCredentials
|
|
5
|
+
from .models import TokenResponse
|
|
6
|
+
from .resource import AuthResource
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AccessToken",
|
|
10
|
+
"AsyncAuthResource",
|
|
11
|
+
"AsyncClientCredentials",
|
|
12
|
+
"AuthResource",
|
|
13
|
+
"ClientCredentials",
|
|
14
|
+
"TokenResponse",
|
|
15
|
+
]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Authentication resource for async operations."""
|
|
2
2
|
|
|
3
|
+
import time
|
|
3
4
|
from typing import TYPE_CHECKING
|
|
4
5
|
|
|
5
6
|
import jwt
|
|
@@ -7,6 +8,7 @@ import jwt
|
|
|
7
8
|
from ..endpoints import AuthEndpoint
|
|
8
9
|
from ..exceptions import JwksDiscoveryError
|
|
9
10
|
from ._mixin import AuthMixin
|
|
11
|
+
from .credentials import AsyncClientCredentials
|
|
10
12
|
from .models import TokenResponse
|
|
11
13
|
|
|
12
14
|
if TYPE_CHECKING:
|
|
@@ -33,6 +35,11 @@ class AsyncAuthResource(AuthMixin):
|
|
|
33
35
|
|
|
34
36
|
The token is stored internally and will be used automatically for subsequent
|
|
35
37
|
API calls. If the token expires, it will be automatically refreshed.
|
|
38
|
+
Calling this again with the same credentials returns the cached token.
|
|
39
|
+
|
|
40
|
+
New code should build a ``ClientCredentials`` / ``AsyncClientCredentials``
|
|
41
|
+
and pass it as ``credential=`` to the client instead; this method stays for
|
|
42
|
+
existing callers.
|
|
36
43
|
|
|
37
44
|
Args:
|
|
38
45
|
client_id: OAuth client ID
|
|
@@ -49,21 +56,30 @@ class AsyncAuthResource(AuthMixin):
|
|
|
49
56
|
# Validate (from Mixin)
|
|
50
57
|
self._validate_client_credentials(client_id, client_secret)
|
|
51
58
|
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
# One credential per client: cached, one mint at a time, one replacement
|
|
60
|
+
# per 401, no mint for an hour after a 429. Calling this again with the
|
|
61
|
+
# same client credentials reuses it, so it does not mint again.
|
|
62
|
+
credential = self._sdk_client._credential
|
|
63
|
+
if not (
|
|
64
|
+
isinstance(credential, AsyncClientCredentials)
|
|
65
|
+
and credential.is_for(client_id, client_secret)
|
|
66
|
+
):
|
|
67
|
+
credential = AsyncClientCredentials(
|
|
68
|
+
self._sdk_client.auth_url,
|
|
69
|
+
client_id,
|
|
70
|
+
client_secret,
|
|
71
|
+
http=self._sdk_client._client,
|
|
72
|
+
)
|
|
73
|
+
self._sdk_client._credential = credential
|
|
74
|
+
token = await credential.get_token()
|
|
75
|
+
self._sdk_client._store_token(
|
|
76
|
+
access_token=token.token,
|
|
77
|
+
expires_in=max(int(token.expires_on - time.monotonic()), 0),
|
|
64
78
|
credentials={"client_id": client_id, "client_secret": client_secret},
|
|
65
79
|
credentials_type="client",
|
|
66
80
|
)
|
|
81
|
+
assert credential.last_response is not None
|
|
82
|
+
return credential.last_response
|
|
67
83
|
|
|
68
84
|
async def login(self, email: str, password: str) -> TokenResponse:
|
|
69
85
|
"""Login with email and password to get JWT token.
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""One client-credentials token for every client and caller in a process.
|
|
2
|
+
|
|
3
|
+
Platform mints at most 50 tokens per client per month, so the token is cached
|
|
4
|
+
until five minutes before it expires, one mint runs at a time, a 401 buys one
|
|
5
|
+
replacement, and a 429 stops minting for an hour.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import hmac
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from .._base import _BaseClient
|
|
21
|
+
from .._request_attribution import merge_request_attribution
|
|
22
|
+
from ..endpoints import AuthEndpoint
|
|
23
|
+
from ..exceptions import RateLimitedError, VisionaiSDKError
|
|
24
|
+
from .models import TokenResponse
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# A token counts as expired this long before Platform says so.
|
|
29
|
+
REFRESH_BUFFER_S = 300
|
|
30
|
+
# Anything shorter would mint on every call; Platform issues 3-day tokens.
|
|
31
|
+
MIN_EXPIRES_IN_S = 2 * REFRESH_BUFFER_S
|
|
32
|
+
# After a 429 (the monthly quota is used up) no mint is tried for this long.
|
|
33
|
+
QUOTA_COOLDOWN_S = 3600
|
|
34
|
+
|
|
35
|
+
_now = time.monotonic
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class AccessToken:
|
|
40
|
+
"""A bearer token and the ``time.monotonic()`` second it expires at."""
|
|
41
|
+
|
|
42
|
+
token: str
|
|
43
|
+
expires_on: float
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _Credentials:
|
|
47
|
+
"""What the sync and async variants share: the cache, the 401 and 429 rules."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, auth_url: str, client_id: str, client_secret: str) -> None:
|
|
50
|
+
if not auth_url.strip():
|
|
51
|
+
raise ValueError("auth_url must not be empty")
|
|
52
|
+
if not client_id.strip():
|
|
53
|
+
raise ValueError("client_id must not be empty")
|
|
54
|
+
if not client_secret.strip():
|
|
55
|
+
raise ValueError("client_secret must not be empty")
|
|
56
|
+
self.auth_url = auth_url.strip()
|
|
57
|
+
self.client_id = client_id.strip()
|
|
58
|
+
self._client_secret = client_secret
|
|
59
|
+
self._url = _BaseClient._build_url(self.auth_url, AuthEndpoint.CLIENT_TOKEN)
|
|
60
|
+
self._token: AccessToken | None = None
|
|
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
|
|
65
|
+
self._blocked_until = 0.0
|
|
66
|
+
|
|
67
|
+
def is_for(self, client_id: str, client_secret: str) -> bool:
|
|
68
|
+
"""True when this credential was built for exactly these client credentials."""
|
|
69
|
+
return self.client_id == client_id.strip() and hmac.compare_digest(
|
|
70
|
+
self._client_secret, client_secret
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def _cached(self) -> AccessToken | None:
|
|
74
|
+
if self._token and _now() < self._token.expires_on - REFRESH_BUFFER_S:
|
|
75
|
+
return self._token
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
def _mint_request(self) -> dict[str, Any]:
|
|
79
|
+
"""kwargs for the client-token POST; refuses while a 429 cooldown runs."""
|
|
80
|
+
if _now() < self._blocked_until:
|
|
81
|
+
raise RateLimitedError(
|
|
82
|
+
"client-token quota used up; not minting until the cooldown ends"
|
|
83
|
+
)
|
|
84
|
+
if self._token is not None:
|
|
85
|
+
# Natural refresh: the next 401 may buy one replacement again.
|
|
86
|
+
self._replaced = False
|
|
87
|
+
kwargs: dict[str, Any] = {
|
|
88
|
+
"json": {"client_id": self.client_id, "client_secret": self._client_secret}
|
|
89
|
+
}
|
|
90
|
+
merge_request_attribution(kwargs)
|
|
91
|
+
return kwargs
|
|
92
|
+
|
|
93
|
+
def _store(self, response: httpx.Response) -> AccessToken:
|
|
94
|
+
try:
|
|
95
|
+
_BaseClient._handle_response(response)
|
|
96
|
+
except RateLimitedError:
|
|
97
|
+
self._blocked_until = _now() + QUOTA_COOLDOWN_S
|
|
98
|
+
raise
|
|
99
|
+
try:
|
|
100
|
+
parsed = TokenResponse(**response.json())
|
|
101
|
+
except (ValueError, TypeError) as e:
|
|
102
|
+
raise VisionaiSDKError(f"client-token response is not a token: {e}") from e
|
|
103
|
+
self.last_response = parsed
|
|
104
|
+
self._token = AccessToken(
|
|
105
|
+
parsed.access_token, _now() + max(parsed.expires_in, MIN_EXPIRES_IN_S)
|
|
106
|
+
)
|
|
107
|
+
logger.info(
|
|
108
|
+
"platform token minted",
|
|
109
|
+
extra={"client_id": self.client_id, "expires_in": parsed.expires_in},
|
|
110
|
+
)
|
|
111
|
+
return self._token
|
|
112
|
+
|
|
113
|
+
def invalidate(self, token: str) -> bool:
|
|
114
|
+
"""Report a 401 for ``token``. True: dropped, so the next ``get_token()`` mints.
|
|
115
|
+
|
|
116
|
+
A 401 for a token already replaced changes nothing. A 401 for the
|
|
117
|
+
replacement itself is not expiry but a client or allowlist problem that
|
|
118
|
+
another mint would not fix, so that token stays until its own refresh.
|
|
119
|
+
"""
|
|
120
|
+
if self._token is None or token != self._token.token:
|
|
121
|
+
return False
|
|
122
|
+
if self._replaced:
|
|
123
|
+
logger.error(
|
|
124
|
+
"replacement token rejected too; keeping it until it expires",
|
|
125
|
+
extra={"client_id": self.client_id},
|
|
126
|
+
)
|
|
127
|
+
return False
|
|
128
|
+
self._token = None
|
|
129
|
+
self._replaced = True
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class ClientCredentials(_Credentials):
|
|
134
|
+
"""Client-credentials token for one Platform client, for sync code.
|
|
135
|
+
|
|
136
|
+
Pass it to ``Client(credential=...)`` and use ``get_token()`` for requests the
|
|
137
|
+
SDK does not make itself. One instance serves any number of clients and callers.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
auth_url: str,
|
|
143
|
+
client_id: str,
|
|
144
|
+
client_secret: str,
|
|
145
|
+
*,
|
|
146
|
+
http: httpx.Client | None = None,
|
|
147
|
+
verify_ssl: bool = True,
|
|
148
|
+
timeout: float = 10.0,
|
|
149
|
+
) -> None:
|
|
150
|
+
super().__init__(auth_url, client_id, client_secret)
|
|
151
|
+
self._http = http or httpx.Client(verify=verify_ssl, timeout=timeout)
|
|
152
|
+
self._owns_http = http is None
|
|
153
|
+
self._lock = threading.Lock()
|
|
154
|
+
|
|
155
|
+
def get_token(self) -> AccessToken:
|
|
156
|
+
"""The cached token, or a fresh one when it is within five minutes of expiry.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
AuthenticationError: Platform refused the client credentials (401).
|
|
160
|
+
RateLimitedError: The monthly mint quota is used up (429), or a 429 was
|
|
161
|
+
seen less than an hour ago.
|
|
162
|
+
NetworkError: Platform could not be reached.
|
|
163
|
+
VisionaiSDKError: Any other failure.
|
|
164
|
+
"""
|
|
165
|
+
with self._lock:
|
|
166
|
+
cached = self._cached()
|
|
167
|
+
if cached is not None:
|
|
168
|
+
return cached
|
|
169
|
+
kwargs = self._mint_request()
|
|
170
|
+
try:
|
|
171
|
+
response = self._http.post(self._url, **kwargs)
|
|
172
|
+
except httpx.RequestError as e:
|
|
173
|
+
raise _BaseClient._map_transport_error(e) from e
|
|
174
|
+
return self._store(response)
|
|
175
|
+
|
|
176
|
+
def close(self) -> None:
|
|
177
|
+
if self._owns_http:
|
|
178
|
+
self._http.close()
|
|
179
|
+
|
|
180
|
+
def __enter__(self) -> ClientCredentials:
|
|
181
|
+
return self
|
|
182
|
+
|
|
183
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
184
|
+
self.close()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class AsyncClientCredentials(_Credentials):
|
|
188
|
+
"""Client-credentials token for one Platform client, for asyncio code.
|
|
189
|
+
|
|
190
|
+
Pass it to ``AsyncClient(credential=...)`` and use ``await get_token()`` for
|
|
191
|
+
requests the SDK does not make itself, such as a WebSocket upgrade. One
|
|
192
|
+
instance serves any number of clients and callers.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def __init__(
|
|
196
|
+
self,
|
|
197
|
+
auth_url: str,
|
|
198
|
+
client_id: str,
|
|
199
|
+
client_secret: str,
|
|
200
|
+
*,
|
|
201
|
+
http: httpx.AsyncClient | None = None,
|
|
202
|
+
verify_ssl: bool = True,
|
|
203
|
+
timeout: float = 10.0,
|
|
204
|
+
) -> None:
|
|
205
|
+
super().__init__(auth_url, client_id, client_secret)
|
|
206
|
+
self._http = http or httpx.AsyncClient(verify=verify_ssl, timeout=timeout)
|
|
207
|
+
self._owns_http = http is None
|
|
208
|
+
self._lock = asyncio.Lock()
|
|
209
|
+
|
|
210
|
+
async def get_token(self) -> AccessToken:
|
|
211
|
+
"""The cached token, or a fresh one when it is within five minutes of expiry.
|
|
212
|
+
|
|
213
|
+
Concurrent callers wait for one mint. Raises as ``ClientCredentials.get_token``.
|
|
214
|
+
"""
|
|
215
|
+
async with self._lock:
|
|
216
|
+
cached = self._cached()
|
|
217
|
+
if cached is not None:
|
|
218
|
+
return cached
|
|
219
|
+
kwargs = self._mint_request()
|
|
220
|
+
try:
|
|
221
|
+
response = await self._http.post(self._url, **kwargs)
|
|
222
|
+
except httpx.RequestError as e:
|
|
223
|
+
raise _BaseClient._map_transport_error(e) from e
|
|
224
|
+
return self._store(response)
|
|
225
|
+
|
|
226
|
+
async def aclose(self) -> None:
|
|
227
|
+
if self._owns_http:
|
|
228
|
+
await self._http.aclose()
|
|
229
|
+
|
|
230
|
+
async def __aenter__(self) -> AsyncClientCredentials:
|
|
231
|
+
return self
|
|
232
|
+
|
|
233
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
234
|
+
await self.aclose()
|
{visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/auth/models.py
RENAMED
|
@@ -7,6 +7,6 @@ class TokenResponse(BaseModel):
|
|
|
7
7
|
Used by both /api/users/jwt and /api/users/client-token endpoints.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
access_token: str = Field(..., description="JWT access token")
|
|
10
|
+
access_token: str = Field(..., min_length=1, description="JWT access token")
|
|
11
11
|
expires_in: int = Field(..., description="Token expiration time in seconds")
|
|
12
12
|
token_type: str = Field(..., description="Token type (e.g., 'Bearer')")
|