visionai-sdk-python 2.1.1__tar.gz → 2.2.0.dev1__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.
Files changed (50) hide show
  1. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/.github/workflows/ci.yml +7 -2
  2. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/PKG-INFO +20 -6
  3. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/README.md +19 -5
  4. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/__init__.py +6 -0
  5. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/_base.py +34 -5
  6. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/async_client.py +9 -7
  7. visionai_sdk_python-2.2.0.dev1/src/visionai_sdk_python/auth/__init__.py +15 -0
  8. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/auth/async_resource.py +28 -12
  9. visionai_sdk_python-2.2.0.dev1/src/visionai_sdk_python/auth/credentials.py +234 -0
  10. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/auth/models.py +1 -1
  11. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/auth/resource.py +28 -12
  12. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/client.py +9 -7
  13. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/exceptions.py +7 -0
  14. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/vlm/async_resource.py +2 -2
  15. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/vlm/resource.py +2 -2
  16. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python.egg-info/PKG-INFO +20 -6
  17. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python.egg-info/SOURCES.txt +2 -0
  18. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python.egg-info/scm_file_list.json +2 -0
  19. visionai_sdk_python-2.2.0.dev1/src/visionai_sdk_python.egg-info/scm_version.json +8 -0
  20. visionai_sdk_python-2.2.0.dev1/tests/test_credentials.py +295 -0
  21. visionai_sdk_python-2.1.1/src/visionai_sdk_python/auth/__init__.py +0 -7
  22. visionai_sdk_python-2.1.1/src/visionai_sdk_python.egg-info/scm_version.json +0 -8
  23. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/.github/dependabot.yml +0 -0
  24. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/.github/workflows/publish.yml +0 -0
  25. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/.gitignore +0 -0
  26. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/pyproject.toml +0 -0
  27. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/setup.cfg +0 -0
  28. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/_jwt_verifier.py +0 -0
  29. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/_request_attribution.py +0 -0
  30. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/_source_header.py +0 -0
  31. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/auth/_mixin.py +0 -0
  32. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/constants.py +0 -0
  33. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/endpoints.py +0 -0
  34. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/instrumentation.py +0 -0
  35. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/py.typed +0 -0
  36. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/vlm/__init__.py +0 -0
  37. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/vlm/_mixin.py +0 -0
  38. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/vlm/models.py +0 -0
  39. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python/vlm/preprocess.py +0 -0
  40. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python.egg-info/dependency_links.txt +0 -0
  41. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python.egg-info/requires.txt +0 -0
  42. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/src/visionai_sdk_python.egg-info/top_level.txt +0 -0
  43. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/tests/__init__.py +0 -0
  44. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/tests/constants.py +0 -0
  45. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/tests/test__base.py +0 -0
  46. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/tests/test_async_client.py +0 -0
  47. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/tests/test_client.py +0 -0
  48. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/tests/test_instrumentation.py +0 -0
  49. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/tests/test_vlm_preprocess.py +0 -0
  50. {visionai_sdk_python-2.1.1 → visionai_sdk_python-2.2.0.dev1}/uv.lock +0 -0
@@ -14,8 +14,13 @@ permissions:
14
14
 
15
15
  jobs:
16
16
  check:
17
- name: Lint, test, and build
17
+ name: Lint, test, and build (Python ${{ matrix.python-version }})
18
18
  runs-on: ubuntu-latest
19
+ strategy:
20
+ fail-fast: false
21
+ matrix:
22
+ # The oldest supported interpreter and the newest one a consumer runs on.
23
+ python-version: ["3.11", "3.14"]
19
24
  steps:
20
25
  - uses: actions/checkout@v4
21
26
 
@@ -27,7 +32,7 @@ jobs:
27
32
  - name: Set up Python
28
33
  uses: actions/setup-python@v5
29
34
  with:
30
- python-version-file: pyproject.toml
35
+ python-version: ${{ matrix.python-version }}
31
36
 
32
37
  - name: Install locked dependencies
33
38
  run: uv sync --locked --all-extras
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: visionai-sdk-python
3
- Version: 2.1.1
3
+ Version: 2.2.0.dev1
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,29 @@ 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
- client = Client(auth_url="...", vlm_url="...")
139
- token = client.auth.get_access_token(
140
- client_id="your-client-id",
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 requests the SDK does not make itself
143
150
  ```
144
151
 
145
- Tokens are stored internally and automatically refreshed before expiration.
152
+ Platform mints at most 50 tokens per client per month, so the credential protects the
153
+ quota: concurrent callers share one mint; a 401 on a cached token
154
+ (`cred.invalidate(token)`) buys exactly one replacement, and a replacement that is
155
+ rejected too is kept until its own refresh; after a 429 no mint is tried for an hour
156
+ (`RateLimitedError`).
157
+
158
+ `client.auth.get_access_token(client_id, client_secret)` still works and now builds a
159
+ `ClientCredentials` underneath. `vlm_url` is optional for clients that only authenticate.
146
160
 
147
161
  ## VLM Inference
148
162
 
@@ -116,15 +116,29 @@ 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
- client = Client(auth_url="...", vlm_url="...")
121
- token = client.auth.get_access_token(
122
- client_id="your-client-id",
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 requests the SDK does not make itself
125
132
  ```
126
133
 
127
- Tokens are stored internally and automatically refreshed before expiration.
134
+ Platform mints at most 50 tokens per client per month, so the credential protects the
135
+ quota: concurrent callers share one mint; a 401 on a cached token
136
+ (`cred.invalidate(token)`) buys exactly one replacement, and a replacement that is
137
+ rejected too is kept until its own refresh; after a 429 no mint is tried for an hour
138
+ (`RateLimitedError`).
139
+
140
+ `client.auth.get_access_token(client_id, client_secret)` still works and now builds a
141
+ `ClientCredentials` underneath. `vlm_url` is optional for clients that only authenticate.
128
142
 
129
143
  ## VLM Inference
130
144
 
@@ -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",
@@ -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
@@ -77,6 +90,20 @@ class _BaseClient:
77
90
  """Join a base URL and a path, normalizing slashes."""
78
91
  return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
79
92
 
93
+ def _vlm_base(self) -> str:
94
+ if self.vlm_url is None:
95
+ raise ValueError("vlm_url is required for VLM calls; pass it to the client")
96
+ return self.vlm_url
97
+
98
+ @staticmethod
99
+ def _map_transport_error(error: httpx.RequestError) -> VisionaiSDKError:
100
+ """The SDK exception for an httpx request that never got a response."""
101
+ if isinstance(error, httpx.TimeoutException):
102
+ return NetworkError("Request timed out")
103
+ if isinstance(error, httpx.NetworkError):
104
+ return NetworkError(f"Network error: {error}")
105
+ return VisionaiSDKError(f"Request failed: {error}")
106
+
80
107
  @staticmethod
81
108
  def _build_auth_header(access_token: str) -> dict[str, str]:
82
109
  """Build authorization header."""
@@ -201,6 +228,8 @@ class _BaseClient:
201
228
  raise AuthenticationError(detail) from e
202
229
  if status == 403:
203
230
  raise PermissionDeniedError(detail) from e
231
+ if status == 429:
232
+ raise RateLimitedError(detail) from e
204
233
  if 400 <= status < 500:
205
234
  raise ClientError(status, detail) from e
206
235
  if 500 <= status < 600:
@@ -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 .exceptions import AuthenticationError, NetworkError, VisionaiSDKError
6
+ from .auth.credentials import AsyncClientCredentials
7
+ from .exceptions import AuthenticationError
7
8
  from .vlm.async_resource import AsyncVLMResource
8
9
 
9
10
 
@@ -11,12 +12,13 @@ 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,
20
22
  ) -> None:
21
23
  super().__init__(
22
24
  auth_url=auth_url,
@@ -26,6 +28,7 @@ class AsyncClient(_BaseClient):
26
28
  timeout=timeout,
27
29
  max_connections=max_connections,
28
30
  max_keepalive_connections=max_keepalive_connections,
31
+ credential=credential,
29
32
  )
30
33
  self._client = httpx.AsyncClient(
31
34
  verify=self.verify_ssl,
@@ -44,12 +47,8 @@ class AsyncClient(_BaseClient):
44
47
  merge_request_attribution(kwargs)
45
48
  try:
46
49
  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
50
  except httpx.RequestError as e:
52
- raise VisionaiSDKError(f"Request failed: {e}") from e
51
+ raise self._map_transport_error(e) from e
53
52
  return self._handle_response(response)
54
53
 
55
54
  async def close(self) -> None:
@@ -90,6 +89,9 @@ class AsyncClient(_BaseClient):
90
89
  Raises:
91
90
  AuthenticationError: If no token is available or token expired without credentials.
92
91
  """
92
+ if self._credential is not None:
93
+ self._access_token = (await self._credential.get_token()).token
94
+ return
93
95
  if self._access_token is None:
94
96
  raise AuthenticationError(
95
97
  "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
- # I/O operation (async)
53
- response = await self._sdk_client._request(
54
- "POST",
55
- self._sdk_client._build_url(
56
- self._sdk_client.auth_url, AuthEndpoint.CLIENT_TOKEN
57
- ),
58
- json=self._prepare_client_token_request(client_id, client_secret),
59
- )
60
-
61
- # Parse and store (from Mixin)
62
- return self._parse_and_store_token(
63
- response.json(),
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()
@@ -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')")