visionai-sdk-python 2.2.0.dev4__tar.gz → 2.2.0.dev6__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 (64) hide show
  1. {visionai_sdk_python-2.2.0.dev4/src/visionai_sdk_python.egg-info → visionai_sdk_python-2.2.0.dev6}/PKG-INFO +12 -3
  2. visionai_sdk_python-2.2.0.dev4/PKG-INFO → visionai_sdk_python-2.2.0.dev6/README.md +11 -20
  3. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/_base.py +29 -2
  4. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/async_client.py +40 -19
  5. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/auth/credentials.py +73 -18
  6. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/client.py +40 -21
  7. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/relay/__init__.py +4 -0
  8. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/relay/protocol.py +43 -10
  9. visionai_sdk_python-2.2.0.dev4/README.md → visionai_sdk_python-2.2.0.dev6/src/visionai_sdk_python.egg-info/PKG-INFO +29 -2
  10. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python.egg-info/SOURCES.txt +1 -0
  11. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python.egg-info/scm_file_list.json +1 -0
  12. visionai_sdk_python-2.2.0.dev6/src/visionai_sdk_python.egg-info/scm_version.json +8 -0
  13. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/tests/relay/test_protocol.py +37 -0
  14. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/tests/test_credentials.py +82 -0
  15. visionai_sdk_python-2.2.0.dev6/tests/test_transport_options.py +57 -0
  16. visionai_sdk_python-2.2.0.dev4/src/visionai_sdk_python.egg-info/scm_version.json +0 -8
  17. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/.github/dependabot.yml +0 -0
  18. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/.github/workflows/ci.yml +0 -0
  19. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/.github/workflows/publish.yml +0 -0
  20. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/.gitignore +0 -0
  21. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/pyproject.toml +0 -0
  22. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/setup.cfg +0 -0
  23. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/__init__.py +0 -0
  24. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/_jwt_verifier.py +0 -0
  25. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/_request_attribution.py +0 -0
  26. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/_source_header.py +0 -0
  27. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/auth/__init__.py +0 -0
  28. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/auth/_mixin.py +0 -0
  29. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/auth/async_resource.py +0 -0
  30. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/auth/models.py +0 -0
  31. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/auth/resource.py +0 -0
  32. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/constants.py +0 -0
  33. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/endpoints.py +0 -0
  34. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/exceptions.py +0 -0
  35. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/instrumentation.py +0 -0
  36. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/py.typed +0 -0
  37. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/relay/examples/__init__.py +0 -0
  38. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/relay/examples/error_connection.json +0 -0
  39. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/relay/examples/error_request.json +0 -0
  40. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/relay/examples/heartbeat.json +0 -0
  41. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/relay/examples/register.json +0 -0
  42. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/relay/examples/registered.json +0 -0
  43. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/relay/examples/request.json +0 -0
  44. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/relay/examples/response.json +0 -0
  45. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/relay/examples/response_text.json +0 -0
  46. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/vlm/__init__.py +0 -0
  47. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/vlm/_mixin.py +0 -0
  48. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/vlm/async_resource.py +0 -0
  49. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/vlm/models.py +0 -0
  50. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/vlm/preprocess.py +0 -0
  51. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python/vlm/resource.py +0 -0
  52. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python.egg-info/dependency_links.txt +0 -0
  53. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python.egg-info/requires.txt +0 -0
  54. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/src/visionai_sdk_python.egg-info/top_level.txt +0 -0
  55. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/tests/__init__.py +0 -0
  56. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/tests/constants.py +0 -0
  57. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/tests/relay/__init__.py +0 -0
  58. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/tests/test__base.py +0 -0
  59. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/tests/test_async_client.py +0 -0
  60. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/tests/test_client.py +0 -0
  61. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/tests/test_instrumentation.py +0 -0
  62. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/tests/test_request.py +0 -0
  63. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/tests/test_vlm_preprocess.py +0 -0
  64. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev6}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: visionai-sdk-python
3
- Version: 2.2.0.dev4
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 fresh token and one retry
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()` / `dump_frame()`. The spec is platform-service's
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
@@ -1,21 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: visionai-sdk-python
3
- Version: 2.2.0.dev4
4
- Summary: VisionAI SDK for Python
5
- Author-email: Tony Yang <tonyyang@linkervision.com>
6
- Requires-Python: >=3.11
7
- Description-Content-Type: text/markdown
8
- Requires-Dist: httpx<1,>=0.28.1
9
- Requires-Dist: pydantic>=2.12.5
10
- Requires-Dist: cryptography>=46.0.5
11
- Requires-Dist: PyJWT[cryptography]>=2.8.0
12
- Provides-Extra: requests
13
- Requires-Dist: requests<3,>=2.31; extra == "requests"
14
- Provides-Extra: aiohttp
15
- Requires-Dist: aiohttp<4,>=3.9; extra == "aiohttp"
16
- Provides-Extra: instrumentation
17
- Requires-Dist: wrapt<3,>=1.16; extra == "instrumentation"
18
-
19
1
  # VisionAI SDK for Python
20
2
 
21
3
  Python client library for VisionAI authentication and Vision Language Model (VLM) inference services.
@@ -148,10 +130,18 @@ client = Client(auth_url="...", vlm_url="...", credential=cred) # vlm.chat() u
148
130
 
149
131
  token = cred.get_token().token # for a WebSocket upgrade or another library
150
132
 
151
- # Any other HTTP call with the same token; a 401 gets one fresh token and one retry
133
+ # Any other HTTP call with the same token, sent to the host you name; a 401 gets one
134
+ # fresh token and one retry
152
135
  nodes = client.request("GET", "https://vs.example.com/mgmt/nodes").json()
153
136
  ```
154
137
 
138
+ `request()` raises on a non-2xx like the resource methods (`AuthenticationError`,
139
+ `ClientError`, `ServerError`, ...), unlike plain httpx, and sends the token to whatever
140
+ host `url` names, so `url` comes from configuration or code, never from a caller. A
141
+ client built with `http_client=` (or a credential with `http=`) uses that client's TLS,
142
+ timeout and limits; passing `verify_ssl` / `timeout` / `max_connections` /
143
+ `max_keepalive_connections` with it raises `ValueError`.
144
+
155
145
  A mint is a Platform round trip and, on sites that cap client tokens, a scarce one:
156
146
  concurrent callers share one mint, a refresh that fails keeps the current token while
157
147
  Platform still accepts it, a 401 on a token minted more than 30 minutes ago buys a
@@ -168,7 +158,8 @@ authentication wins. `vlm_url` is optional for clients that only authenticate.
168
158
 
169
159
  `visionai_sdk_python.relay` holds the wire format between platform-relay and
170
160
  platform-edge-agent: the frame models, the constants and `parse_agent_frame()` /
171
- `parse_relay_frame()` / `dump_frame()`. The spec is platform-service's
161
+ `parse_relay_frame()` (text frames only: `bytes` or `None` is an `InvalidFrame`) /
162
+ `dump_frame()` (which refuses a frame over `MAX_FRAME_BYTES`). The spec is platform-service's
172
163
  `docs/adr/0001-relay-protocol.md`; both sides import this module instead of keeping a
173
164
  copy. The ADR's JSON examples ship with it (`visionai_sdk_python.relay.examples`:
174
165
  `example(name)` / `example_text(name)`), so every implementation tests against the same
@@ -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 this client was built with, for callers that need the
83
- raw token themselves (a WebSocket upgrade, another HTTP library)."""
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 = True,
18
- timeout: float = 10.0,
19
- max_connections: int = 100,
20
- max_keepalive_connections: int = 20,
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``; ``http_client`` is an httpx.AsyncClient to send through
25
- (tests, shared pools); the client closes only the one it created."""
26
- super().__init__(
27
- auth_url=auth_url,
28
- vlm_url=vlm_url,
29
- allowed_issuers=allowed_issuers,
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 = http_client or httpx.AsyncClient(
37
- verify=self.verify_ssl,
38
- timeout=self.timeout,
39
- limits=httpx.Limits(
40
- max_connections=self.max_connections,
41
- max_keepalive_connections=self.max_keepalive_connections,
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
- logger.warning(
137
- "token refresh failed; using the current token until it expires",
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 invalidate(self, token: str) -> bool:
147
- """Report a 401 for ``token``. True: dropped, so the next ``get_token()`` mints.
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
- if self._current is None or token != self._current.token:
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
- if _now() - self._current.issued_at < REPLACE_COOLDOWN_S:
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(_now() - self._current.issued_at),
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 = True,
185
- timeout: float = 10.0,
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
- self._http = http or httpx.Client(verify=verify_ssl, timeout=timeout)
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 = True,
251
- timeout: float = 10.0,
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
- self._http = http or httpx.AsyncClient(verify=verify_ssl, timeout=timeout)
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()
@@ -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 = True,
18
- timeout: float = 10.0,
19
- max_connections: int = 100,
20
- max_keepalive_connections: int = 20,
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``; ``http_client`` is an httpx.Client to send through
25
- (tests, shared pools); the client closes only the one it created."""
26
- super().__init__(
27
- auth_url=auth_url,
28
- vlm_url=vlm_url,
29
- allowed_issuers=allowed_issuers,
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 = http_client or httpx.Client(
37
- verify=self.verify_ssl,
38
- timeout=self.timeout,
39
- limits=httpx.Limits(
40
- max_connections=self.max_connections,
41
- max_keepalive_connections=self.max_keepalive_connections,
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 = (self._credential.get_token()).token
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 (self._credential.get_token()).token
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
- def parse_agent_frame(text: str) -> AgentFrame:
144
- """A frame the relay received from an agent."""
145
- return _parse(text, _agent_frame)
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(text: str) -> RelayFrame:
149
- """A frame the agent received from the relay."""
150
- return _parse(text, _relay_frame)
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(text: str, adapter: TypeAdapter):
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(text)
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
- return frame.model_dump_json(exclude_none=True)
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
@@ -1,3 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: visionai-sdk-python
3
+ Version: 2.2.0.dev6
4
+ Summary: VisionAI SDK for Python
5
+ Author-email: Tony Yang <tonyyang@linkervision.com>
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: httpx<1,>=0.28.1
9
+ Requires-Dist: pydantic>=2.12.5
10
+ Requires-Dist: cryptography>=46.0.5
11
+ Requires-Dist: PyJWT[cryptography]>=2.8.0
12
+ Provides-Extra: requests
13
+ Requires-Dist: requests<3,>=2.31; extra == "requests"
14
+ Provides-Extra: aiohttp
15
+ Requires-Dist: aiohttp<4,>=3.9; extra == "aiohttp"
16
+ Provides-Extra: instrumentation
17
+ Requires-Dist: wrapt<3,>=1.16; extra == "instrumentation"
18
+
1
19
  # VisionAI SDK for Python
2
20
 
3
21
  Python client library for VisionAI authentication and Vision Language Model (VLM) inference services.
@@ -130,10 +148,18 @@ client = Client(auth_url="...", vlm_url="...", credential=cred) # vlm.chat() u
130
148
 
131
149
  token = cred.get_token().token # for a WebSocket upgrade or another library
132
150
 
133
- # Any other HTTP call with the same token; a 401 gets one fresh token and one retry
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
134
153
  nodes = client.request("GET", "https://vs.example.com/mgmt/nodes").json()
135
154
  ```
136
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
+
137
163
  A mint is a Platform round trip and, on sites that cap client tokens, a scarce one:
138
164
  concurrent callers share one mint, a refresh that fails keeps the current token while
139
165
  Platform still accepts it, a 401 on a token minted more than 30 minutes ago buys a
@@ -150,7 +176,8 @@ authentication wins. `vlm_url` is optional for clients that only authenticate.
150
176
 
151
177
  `visionai_sdk_python.relay` holds the wire format between platform-relay and
152
178
  platform-edge-agent: the frame models, the constants and `parse_agent_frame()` /
153
- `parse_relay_frame()` / `dump_frame()`. The spec is platform-service's
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
154
181
  `docs/adr/0001-relay-protocol.md`; both sides import this module instead of keeping a
155
182
  copy. The ADR's JSON examples ship with it (`visionai_sdk_python.relay.examples`:
156
183
  `example(name)` / `example_text(name)`), so every implementation tests against the same
@@ -55,6 +55,7 @@ tests/test_client.py
55
55
  tests/test_credentials.py
56
56
  tests/test_instrumentation.py
57
57
  tests/test_request.py
58
+ tests/test_transport_options.py
58
59
  tests/test_vlm_preprocess.py
59
60
  tests/relay/__init__.py
60
61
  tests/relay/test_protocol.py
@@ -51,6 +51,7 @@
51
51
  "tests/test_credentials.py",
52
52
  "tests/test_instrumentation.py",
53
53
  "tests/test_request.py",
54
+ "tests/test_transport_options.py",
54
55
  "tests/test_vlm_preprocess.py",
55
56
  "uv.lock"
56
57
  ]
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "2.2.0.dev6",
3
+ "distance": 0,
4
+ "node": "g87a0a05ee801b6dfe5be6f6fe205b2917d9363ad",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-27"
8
+ }
@@ -5,7 +5,10 @@ import json
5
5
  import pytest
6
6
 
7
7
  from visionai_sdk_python.relay import (
8
+ MAX_FRAME_BYTES,
9
+ MAX_MESSAGE_CHARS,
8
10
  Error,
11
+ FrameTooLarge,
9
12
  Heartbeat,
10
13
  InvalidFrame,
11
14
  Register,
@@ -101,6 +104,13 @@ def test_unknown_reasons_and_codes_are_accepted_as_strings():
101
104
  @pytest.mark.parametrize(
102
105
  ("parse", "text", "message", "request_id"),
103
106
  [
107
+ (
108
+ parse_agent_frame,
109
+ b'{"type": "heartbeat"}',
110
+ "binary frames are not used",
111
+ None,
112
+ ),
113
+ (parse_relay_frame, None, "binary frames are not used", None),
104
114
  (parse_agent_frame, "not json", "frame is not JSON", None),
105
115
  (parse_agent_frame, "[1, 2]", "frame is not a JSON object", None),
106
116
  (parse_agent_frame, '{"agent_id": "a"}', "frame has no type", None),
@@ -173,3 +183,30 @@ def test_workspace_id_format(value, ok):
173
183
  def test_every_shipped_example_is_valid_json_with_a_type():
174
184
  for name in NAMES:
175
185
  assert isinstance(json.loads(example(name))["type"], str), name
186
+
187
+
188
+ def test_an_error_message_is_cut_so_it_fits_a_frame():
189
+ with pytest.raises(InvalidFrame) as exc:
190
+ parse_agent_frame(json.dumps({"type": "x" * 1_000_000}))
191
+
192
+ assert len(str(exc.value)) == MAX_MESSAGE_CHARS
193
+ assert str(exc.value).endswith("…")
194
+
195
+
196
+ def test_an_oversized_request_id_is_dropped_from_the_error():
197
+ frame = {"type": "request", "request_id": "r" * 1_000_000}
198
+ with pytest.raises(InvalidFrame) as exc:
199
+ parse_relay_frame(json.dumps(frame))
200
+
201
+ assert exc.value.request_id is None
202
+ assert str(exc.value).endswith("(request_id too long, dropped)")
203
+
204
+
205
+ def test_dump_frame_refuses_a_frame_the_receiver_would_close_on():
206
+ frame = Response(request_id="r1", status=200, body="x" * MAX_FRAME_BYTES)
207
+
208
+ with pytest.raises(FrameTooLarge) as exc:
209
+ dump_frame(frame)
210
+
211
+ assert exc.value.size > MAX_FRAME_BYTES
212
+ assert dump_frame(Response(request_id="r1", status=200, body="x"))
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import threading
2
3
 
3
4
  import httpx
4
5
  import pytest
@@ -22,6 +23,7 @@ from visionai_sdk_python.auth import credentials as credentials_module
22
23
  from visionai_sdk_python.auth.credentials import (
23
24
  RATE_LIMIT_COOLDOWN_S,
24
25
  REFRESH_BUFFER_S,
26
+ REFRESH_RETRY_S,
25
27
  REPLACE_COOLDOWN_S,
26
28
  )
27
29
  from visionai_sdk_python.exceptions import (
@@ -266,6 +268,12 @@ async def test_get_access_token_keeps_its_result_and_refreshes_through_a_credent
266
268
  assert await client._ensure_token() == "tok-2"
267
269
 
268
270
 
271
+ def test_is_for_compares_non_ascii_secrets_too():
272
+ cred = ClientCredentials(AUTH_URL, "cid", "pässwörd")
273
+ assert cred.is_for("cid", "pässwörd")
274
+ assert not cred.is_for("cid", "pässword")
275
+
276
+
269
277
  async def test_repeated_get_access_token_reuses_the_credential(platform, clock):
270
278
  client = AsyncClient(AUTH_URL, VLM_URL)
271
279
  client._client = httpx.AsyncClient(transport=httpx.MockTransport(platform.handler))
@@ -370,6 +378,42 @@ async def test_get_access_token_force_refresh_mints_again(platform, clock):
370
378
  assert await client._ensure_token() == "tok-2"
371
379
 
372
380
 
381
+ async def test_a_failed_refresh_is_not_retried_for_a_while(platform, cred, clock):
382
+ assert (await cred.get_token()).token == "tok-1"
383
+ clock[0] += EXPIRES_IN - 100 # inside the refresh window, token still valid
384
+ platform.status = 503
385
+
386
+ assert (await cred.get_token()).token == "tok-1"
387
+ assert (await cred.get_token()).token == "tok-1" # inside the backoff: no attempt
388
+ assert len(platform.requests) == 2
389
+
390
+ clock[0] += REFRESH_RETRY_S + 1
391
+ platform.status = None
392
+ assert (await cred.get_token()).token == "tok-2"
393
+ assert len(platform.requests) == 3
394
+
395
+
396
+ async def test_a_refused_refresh_is_an_error_but_the_token_is_kept(
397
+ platform, cred, clock, caplog
398
+ ):
399
+ assert (await cred.get_token()).token == "tok-1"
400
+ clock[0] += EXPIRES_IN - 100
401
+ platform.status = 401
402
+
403
+ with caplog.at_level("ERROR"):
404
+ assert (await cred.get_token()).token == "tok-1"
405
+
406
+ assert "token refresh refused" in caplog.text
407
+ assert caplog.records[-1].levelname == "ERROR"
408
+
409
+
410
+ def test_a_credential_of_the_other_flavour_is_rejected_at_construction():
411
+ with pytest.raises(TypeError):
412
+ Client(AUTH_URL, credential=AsyncClientCredentials(AUTH_URL, "id", "secret"))
413
+ with pytest.raises(TypeError):
414
+ AsyncClient(AUTH_URL, credential=ClientCredentials(AUTH_URL, "id", "secret"))
415
+
416
+
373
417
  async def test_a_401_reported_during_a_refresh_leaves_the_refresh_alone(
374
418
  platform, clock
375
419
  ):
@@ -400,3 +444,41 @@ async def test_a_401_reported_during_a_refresh_leaves_the_refresh_alone(
400
444
  clock[0] += REPLACE_COOLDOWN_S + 1
401
445
  assert cred.invalidate("tok-2") is True
402
446
  assert platform.minted == 2
447
+
448
+
449
+ def test_sync_invalidate_waits_for_a_mint_in_another_thread():
450
+ """Without the lock the report would drop the token the other thread just minted."""
451
+ entered, release = threading.Event(), threading.Event()
452
+ tokens = iter(["old", "new"])
453
+
454
+ def platform(request: httpx.Request) -> httpx.Response:
455
+ token = next(tokens)
456
+ if token == "new":
457
+ entered.set()
458
+ release.wait(5)
459
+ return httpx.Response(
460
+ 200,
461
+ json={"access_token": token, "expires_in": 3600, "token_type": "Bearer"},
462
+ )
463
+
464
+ cred = ClientCredentials(
465
+ "http://platform.test",
466
+ "cid",
467
+ "secret",
468
+ http=httpx.Client(transport=httpx.MockTransport(platform)),
469
+ )
470
+ old = cred.get_token().token
471
+ minter = threading.Thread(target=cred.get_token, kwargs={"force_refresh": True})
472
+ minter.start()
473
+ assert entered.wait(5)
474
+ result: list[bool] = []
475
+ reporter = threading.Thread(target=lambda: result.append(cred.invalidate(old)))
476
+ reporter.start()
477
+ reporter.join(0.2)
478
+
479
+ assert reporter.is_alive() # blocked behind the mint, not racing it
480
+ release.set()
481
+ minter.join(5)
482
+ reporter.join(5)
483
+ assert result == [False]
484
+ assert cred.get_token().token == "new"
@@ -0,0 +1,57 @@
1
+ """An injected http client brings its own transport settings; the SDK refuses to take more."""
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from visionai_sdk_python import (
7
+ AsyncClient,
8
+ AsyncClientCredentials,
9
+ Client,
10
+ ClientCredentials,
11
+ )
12
+
13
+
14
+ def _sync_http() -> httpx.Client:
15
+ return httpx.Client(transport=httpx.MockTransport(lambda r: httpx.Response(200)))
16
+
17
+
18
+ def _async_http() -> httpx.AsyncClient:
19
+ return httpx.AsyncClient(
20
+ transport=httpx.MockTransport(lambda r: httpx.Response(200))
21
+ )
22
+
23
+
24
+ @pytest.mark.parametrize(
25
+ "build",
26
+ [
27
+ lambda: Client("http://a", http_client=_sync_http(), timeout=1.0),
28
+ lambda: Client("http://a", http_client=_sync_http(), verify_ssl=False),
29
+ lambda: Client("http://a", http_client=_sync_http(), max_connections=5),
30
+ lambda: AsyncClient(
31
+ "http://a", http_client=_async_http(), max_keepalive_connections=5
32
+ ),
33
+ lambda: ClientCredentials(
34
+ "http://a", "id", "s", http=_sync_http(), timeout=1.0
35
+ ),
36
+ lambda: AsyncClientCredentials(
37
+ "http://a", "id", "s", http=_async_http(), verify_ssl=False
38
+ ),
39
+ ],
40
+ )
41
+ def test_transport_options_are_refused_with_an_injected_client(build):
42
+ with pytest.raises(ValueError, match="has its own verify, timeout and limits"):
43
+ build()
44
+
45
+
46
+ def test_defaults_apply_when_the_sdk_builds_the_client():
47
+ client = Client("http://a")
48
+
49
+ assert (client.verify_ssl, client.timeout) == (True, 10.0)
50
+ assert (client.max_connections, client.max_keepalive_connections) == (100, 20)
51
+ assert Client("http://a", timeout=3.0, verify_ssl=False).timeout == 3.0
52
+
53
+
54
+ def test_an_injected_client_is_used_as_is():
55
+ http = _sync_http()
56
+
57
+ assert Client("http://a", http_client=http)._client is http
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "2.2.0.dev4",
3
- "distance": 0,
4
- "node": "gc851da12b0ae901b04a22a963d81b8fba6fe5f26",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-10-21"
8
- }