visionai-sdk-python 2.2.0.dev1__tar.gz → 2.2.0.dev3__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.dev1 → visionai_sdk_python-2.2.0.dev3}/.github/workflows/ci.yml +2 -7
  2. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/PKG-INFO +25 -10
  3. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/README.md +24 -9
  4. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/_base.py +46 -61
  5. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/async_client.py +50 -20
  6. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/auth/_mixin.py +1 -1
  7. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/auth/async_resource.py +15 -13
  8. visionai_sdk_python-2.2.0.dev3/src/visionai_sdk_python/auth/credentials.py +292 -0
  9. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/auth/resource.py +17 -13
  10. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/client.py +50 -20
  11. visionai_sdk_python-2.2.0.dev3/src/visionai_sdk_python/exceptions.py +97 -0
  12. visionai_sdk_python-2.2.0.dev3/src/visionai_sdk_python/relay/__init__.py +67 -0
  13. visionai_sdk_python-2.2.0.dev3/src/visionai_sdk_python/relay/protocol.py +178 -0
  14. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/vlm/async_resource.py +12 -18
  15. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/vlm/resource.py +12 -18
  16. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python.egg-info/PKG-INFO +25 -10
  17. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python.egg-info/SOURCES.txt +14 -1
  18. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python.egg-info/scm_file_list.json +13 -0
  19. visionai_sdk_python-2.2.0.dev3/src/visionai_sdk_python.egg-info/scm_version.json +8 -0
  20. visionai_sdk_python-2.2.0.dev3/tests/relay/__init__.py +0 -0
  21. visionai_sdk_python-2.2.0.dev3/tests/relay/fixtures/adr/error_connection.json +3 -0
  22. visionai_sdk_python-2.2.0.dev3/tests/relay/fixtures/adr/error_request.json +4 -0
  23. visionai_sdk_python-2.2.0.dev3/tests/relay/fixtures/adr/heartbeat.json +1 -0
  24. visionai_sdk_python-2.2.0.dev3/tests/relay/fixtures/adr/register.json +8 -0
  25. visionai_sdk_python-2.2.0.dev3/tests/relay/fixtures/adr/registered.json +4 -0
  26. visionai_sdk_python-2.2.0.dev3/tests/relay/fixtures/adr/request.json +17 -0
  27. visionai_sdk_python-2.2.0.dev3/tests/relay/fixtures/adr/response.json +9 -0
  28. visionai_sdk_python-2.2.0.dev3/tests/relay/fixtures/adr/response_text.json +5 -0
  29. visionai_sdk_python-2.2.0.dev3/tests/relay/test_protocol.py +176 -0
  30. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/tests/test_async_client.py +4 -5
  31. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/tests/test_client.py +4 -5
  32. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/tests/test_credentials.py +136 -29
  33. visionai_sdk_python-2.2.0.dev3/tests/test_request.py +307 -0
  34. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/uv.lock +489 -349
  35. visionai_sdk_python-2.2.0.dev1/src/visionai_sdk_python/auth/credentials.py +0 -234
  36. visionai_sdk_python-2.2.0.dev1/src/visionai_sdk_python/exceptions.py +0 -47
  37. visionai_sdk_python-2.2.0.dev1/src/visionai_sdk_python.egg-info/scm_version.json +0 -8
  38. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/.github/dependabot.yml +0 -0
  39. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/.github/workflows/publish.yml +0 -0
  40. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/.gitignore +0 -0
  41. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/pyproject.toml +0 -0
  42. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/setup.cfg +0 -0
  43. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/__init__.py +0 -0
  44. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/_jwt_verifier.py +0 -0
  45. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/_request_attribution.py +0 -0
  46. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/_source_header.py +0 -0
  47. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/auth/__init__.py +0 -0
  48. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/auth/models.py +0 -0
  49. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/constants.py +0 -0
  50. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/endpoints.py +0 -0
  51. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/instrumentation.py +0 -0
  52. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/py.typed +0 -0
  53. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/vlm/__init__.py +0 -0
  54. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/vlm/_mixin.py +0 -0
  55. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/vlm/models.py +0 -0
  56. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python/vlm/preprocess.py +0 -0
  57. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python.egg-info/dependency_links.txt +0 -0
  58. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python.egg-info/requires.txt +0 -0
  59. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/src/visionai_sdk_python.egg-info/top_level.txt +0 -0
  60. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/tests/__init__.py +0 -0
  61. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/tests/constants.py +0 -0
  62. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/tests/test__base.py +0 -0
  63. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/tests/test_instrumentation.py +0 -0
  64. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev3}/tests/test_vlm_preprocess.py +0 -0
@@ -14,13 +14,8 @@ permissions:
14
14
 
15
15
  jobs:
16
16
  check:
17
- name: Lint, test, and build (Python ${{ matrix.python-version }})
17
+ name: Lint, test, and build
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"]
24
19
  steps:
25
20
  - uses: actions/checkout@v4
26
21
 
@@ -32,7 +27,7 @@ jobs:
32
27
  - name: Set up Python
33
28
  uses: actions/setup-python@v5
34
29
  with:
35
- python-version: ${{ matrix.python-version }}
30
+ python-version-file: pyproject.toml
36
31
 
37
32
  - name: Install locked dependencies
38
33
  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.2.0.dev1
3
+ Version: 2.2.0.dev3
4
4
  Summary: VisionAI SDK for Python
5
5
  Author-email: Tony Yang <tonyyang@linkervision.com>
6
6
  Requires-Python: >=3.11
@@ -135,8 +135,8 @@ token = client.auth.login("user@example.com", "password")
135
135
  ### OAuth Client Credentials
136
136
 
137
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.
138
+ client: it is minted on first use, cached until shortly before it expires, and one
139
+ instance can serve any number of clients and your own requests.
140
140
 
141
141
  ```python
142
142
  from visionai_sdk_python import Client, ClientCredentials
@@ -146,17 +146,32 @@ cred = ClientCredentials(
146
146
  )
147
147
  client = Client(auth_url="...", vlm_url="...", credential=cred) # vlm.chat() uses it
148
148
 
149
- token = cred.get_token().token # for requests the SDK does not make itself
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()
150
153
  ```
151
154
 
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`).
155
+ A mint is a Platform round trip and, on sites that cap client tokens, a scarce one:
156
+ concurrent callers share one mint, a refresh that fails keeps the current token while
157
+ Platform still accepts it, a 401 on a token minted more than 30 minutes ago buys a
158
+ replacement (`cred.invalidate(token)`; a younger one is kept, so a persistent 401
159
+ mints at most twice an hour), and after a 429 no mint is tried for an hour
160
+ (`RateLimitedError`). `get_token(force_refresh=True)` mints on demand.
157
161
 
158
162
  `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.
163
+ `ClientCredentials` underneath; it returns the cached token unless `force_refresh=True`.
164
+ A later `login()` or `set_token()` replaces the credential: the last explicit
165
+ authentication wins. `vlm_url` is optional for clients that only authenticate.
166
+
167
+ ## Relay protocol (internal)
168
+
169
+ `visionai_sdk_python.relay` holds the wire format between platform-relay and
170
+ platform-edge-agent: the frame models, the constants and `parse_agent_frame()` /
171
+ `parse_relay_frame()` / `dump_frame()`. The spec is platform-service's
172
+ `docs/adr/0001-relay-protocol.md`; both sides import this module instead of keeping a
173
+ copy, and the ADR's JSON examples are this package's tests. It is internal to the
174
+ platform and not a public API.
160
175
 
161
176
  ## VLM Inference
162
177
 
@@ -117,8 +117,8 @@ token = client.auth.login("user@example.com", "password")
117
117
  ### OAuth Client Credentials
118
118
 
119
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.
120
+ client: it is minted on first use, cached until shortly before it expires, and one
121
+ instance can serve any number of clients and your own requests.
122
122
 
123
123
  ```python
124
124
  from visionai_sdk_python import Client, ClientCredentials
@@ -128,17 +128,32 @@ cred = ClientCredentials(
128
128
  )
129
129
  client = Client(auth_url="...", vlm_url="...", credential=cred) # vlm.chat() uses it
130
130
 
131
- token = cred.get_token().token # for requests the SDK does not make itself
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()
132
135
  ```
133
136
 
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`).
137
+ A mint is a Platform round trip and, on sites that cap client tokens, a scarce one:
138
+ concurrent callers share one mint, a refresh that fails keeps the current token while
139
+ Platform still accepts it, a 401 on a token minted more than 30 minutes ago buys a
140
+ replacement (`cred.invalidate(token)`; a younger one is kept, so a persistent 401
141
+ mints at most twice an hour), and after a 429 no mint is tried for an hour
142
+ (`RateLimitedError`). `get_token(force_refresh=True)` mints on demand.
139
143
 
140
144
  `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.
145
+ `ClientCredentials` underneath; it returns the cached token unless `force_refresh=True`.
146
+ A later `login()` or `set_token()` replaces the credential: the last explicit
147
+ authentication wins. `vlm_url` is optional for clients that only authenticate.
148
+
149
+ ## Relay protocol (internal)
150
+
151
+ `visionai_sdk_python.relay` holds the wire format between platform-relay and
152
+ platform-edge-agent: the frame models, the constants and `parse_agent_frame()` /
153
+ `parse_relay_frame()` / `dump_frame()`. The spec is platform-service's
154
+ `docs/adr/0001-relay-protocol.md`; both sides import this module instead of keeping a
155
+ copy, and the ADR's JSON examples are this package's tests. It is internal to the
156
+ platform and not a public API.
142
157
 
143
158
  ## VLM Inference
144
159
 
@@ -5,16 +5,7 @@ import httpx
5
5
 
6
6
  from ._jwt_verifier import JwtVerifier
7
7
  from .constants import resolve_allowed_issuers
8
- from .exceptions import (
9
- APIError,
10
- AuthenticationError,
11
- ClientError,
12
- NetworkError,
13
- PermissionDeniedError,
14
- RateLimitedError,
15
- ServerError,
16
- VisionaiSDKError,
17
- )
8
+ from .exceptions import raise_for_status
18
9
 
19
10
  if TYPE_CHECKING:
20
11
  from .auth.credentials import AsyncClientCredentials, ClientCredentials
@@ -53,8 +44,9 @@ class _BaseClient:
53
44
  max_keepalive_connections: Maximum number of idle keep-alive connections.
54
45
  credential: A ``ClientCredentials`` / ``AsyncClientCredentials`` whose
55
46
  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()``.
47
+ client keeps the token from ``auth.login()`` or ``set_token()``;
48
+ ``auth.get_access_token()`` installs a credential. The last explicit
49
+ authentication wins.
58
50
  """
59
51
  if not auth_url.strip():
60
52
  raise ValueError("auth_url must not be empty")
@@ -83,27 +75,49 @@ class _BaseClient:
83
75
  self._access_token: str | None = None
84
76
  self._token_expires_at: float | None = None # monotonic time
85
77
  self._credentials: dict | None = None
86
- self._credentials_type: Literal["login", "client"] | None = None
78
+ self._credentials_type: Literal["login"] | None = None
79
+
80
+ @property
81
+ 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)."""
84
+ return self._credential
87
85
 
88
86
  @staticmethod
89
87
  def _build_url(base_url: str, path: str) -> str:
90
88
  """Join a base URL and a path, normalizing slashes."""
91
89
  return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
92
90
 
93
- def _vlm_base(self) -> str:
91
+ @staticmethod
92
+ def _full_url(url: str) -> str:
93
+ """``request()`` takes a full URL: the client has two bases (``auth_url``,
94
+ ``vlm_url``), so a bare path would not say which one is meant."""
95
+ if "://" not in url:
96
+ raise ValueError(f"request() needs a full URL, got {url!r}")
97
+ return url
98
+
99
+ def _with_auth(self, kwargs: dict, token: str) -> dict:
100
+ """``kwargs`` for httpx with the bearer header; the client's token wins."""
101
+ headers = httpx.Headers(kwargs.get("headers"))
102
+ headers["Authorization"] = f"Bearer {token}" # case-insensitive: one header
103
+ return {**kwargs, "headers": headers}
104
+
105
+ @staticmethod
106
+ def _resendable(kwargs: dict) -> bool:
107
+ """Whether the body can be sent a second time (streams and files cannot)."""
108
+ content = kwargs.get("content")
109
+ if content is not None and not isinstance(content, (bytes, str)):
110
+ return False
111
+ if kwargs.get("files") is not None:
112
+ return False
113
+ data = kwargs.get("data")
114
+ return data is None or isinstance(data, dict)
115
+
116
+ def _require_vlm_url(self) -> str:
94
117
  if self.vlm_url is None:
95
118
  raise ValueError("vlm_url is required for VLM calls; pass it to the client")
96
119
  return self.vlm_url
97
120
 
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
-
107
121
  @staticmethod
108
122
  def _build_auth_header(access_token: str) -> dict[str, str]:
109
123
  """Build authorization header."""
@@ -116,16 +130,20 @@ class _BaseClient:
116
130
  access_token: str,
117
131
  expires_in: int,
118
132
  credentials: dict | None = None,
119
- credentials_type: Literal["login", "client"] | None = None,
133
+ credentials_type: Literal["login"] | None = None,
120
134
  ) -> None:
121
- """Store token and credentials for auto-refresh.
135
+ """Use this token from now on; a credential set earlier is dropped.
136
+
137
+ The last explicit authentication wins, in both directions.
122
138
 
123
139
  Args:
124
140
  access_token: JWT access token.
125
141
  expires_in: Token expiration time in seconds.
126
142
  credentials: Optional credentials for auto-refresh.
127
- credentials_type: Type of credentials ("login" or "client").
143
+ credentials_type: "login" for email / password credentials; client
144
+ credentials live in a ``ClientCredentials`` on the client instead.
128
145
  """
146
+ self._credential = None
129
147
  self._access_token = access_token
130
148
  self._token_expires_at = time.monotonic() + expires_in
131
149
  self._credentials = credentials
@@ -201,38 +219,5 @@ class _BaseClient:
201
219
 
202
220
  @staticmethod
203
221
  def _handle_response(response: httpx.Response) -> httpx.Response:
204
- """Raise SDK-specific exceptions for non-2xx responses.
205
-
206
- Raises:
207
- AuthenticationError: If the server returns 401
208
- PermissionDeniedError: If the server returns 403
209
- ClientError: If the server returns any other 4xx
210
- ServerError: If the server returns 5xx
211
- APIError: If the server returns any other non-2xx status
212
- """
213
- try:
214
- response.raise_for_status()
215
- except httpx.HTTPStatusError as e:
216
- body = None
217
- if e.response.content:
218
- try:
219
- body = e.response.json()
220
- except Exception:
221
- pass
222
- if isinstance(body, dict):
223
- detail: str = body.get("detail") or body.get("message") or str(e)
224
- else:
225
- detail = str(e)
226
- status = e.response.status_code
227
- if status == 401:
228
- raise AuthenticationError(detail) from e
229
- if status == 403:
230
- raise PermissionDeniedError(detail) from e
231
- if status == 429:
232
- raise RateLimitedError(detail) from e
233
- if 400 <= status < 500:
234
- raise ClientError(status, detail) from e
235
- if 500 <= status < 600:
236
- raise ServerError(status, detail) from e
237
- raise APIError(status, detail) from e
238
- return response
222
+ """Raise the SDK exception for a non-2xx response (``exceptions.raise_for_status``)."""
223
+ return raise_for_status(response)
@@ -4,7 +4,7 @@ from ._base import _BaseClient
4
4
  from ._request_attribution import merge_request_attribution
5
5
  from .auth.async_resource import AsyncAuthResource
6
6
  from .auth.credentials import AsyncClientCredentials
7
- from .exceptions import AuthenticationError
7
+ from .exceptions import AuthenticationError, from_transport_error
8
8
  from .vlm.async_resource import AsyncVLMResource
9
9
 
10
10
 
@@ -19,7 +19,10 @@ class AsyncClient(_BaseClient):
19
19
  max_connections: int = 100,
20
20
  max_keepalive_connections: int = 20,
21
21
  credential: AsyncClientCredentials | None = None,
22
+ http_client: httpx.AsyncClient | None = None,
22
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."""
23
26
  super().__init__(
24
27
  auth_url=auth_url,
25
28
  vlm_url=vlm_url,
@@ -30,7 +33,7 @@ class AsyncClient(_BaseClient):
30
33
  max_keepalive_connections=max_keepalive_connections,
31
34
  credential=credential,
32
35
  )
33
- self._client = httpx.AsyncClient(
36
+ self._client = http_client or httpx.AsyncClient(
34
37
  verify=self.verify_ssl,
35
38
  timeout=self.timeout,
36
39
  limits=httpx.Limits(
@@ -38,6 +41,7 @@ class AsyncClient(_BaseClient):
38
41
  max_keepalive_connections=self.max_keepalive_connections,
39
42
  ),
40
43
  )
44
+ self._http_owner = http_client is None # a client we built is ours to close
41
45
  # Register the different function resource
42
46
  self.auth = AsyncAuthResource(self)
43
47
  self.vlm = AsyncVLMResource(self)
@@ -48,12 +52,42 @@ class AsyncClient(_BaseClient):
48
52
  try:
49
53
  response = await self._client.request(method, url, **kwargs)
50
54
  except httpx.RequestError as e:
51
- raise self._map_transport_error(e) from e
55
+ raise from_transport_error(e) from e
52
56
  return self._handle_response(response)
53
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 a full URL (the client has two bases); ``kwargs`` go to httpx.
62
+ With a ``credential``, a 401 buys one retry with the credential's current
63
+ token, which it replaces when the rejected one is old enough to have been
64
+ revoked (``REPLACE_COOLDOWN_S``); a 401 on the retry raises. Without a
65
+ credential, or with a body that cannot be sent twice (a stream or a
66
+ file), a 401 raises.
67
+
68
+ Raises:
69
+ ValueError: ``url`` is not a full URL.
70
+ AuthenticationError: No token, or the fresh one was refused too.
71
+ RateLimitedError, NetworkError, ServerError, ClientError: As the
72
+ resource methods.
73
+ """
74
+ url = self._full_url(url)
75
+ token = await self._ensure_token()
76
+ try:
77
+ return await self._request(method, url, **self._with_auth(kwargs, token))
78
+ except AuthenticationError:
79
+ if self._credential is None or not self._resendable(kwargs):
80
+ raise
81
+ self._credential.invalidate(token)
82
+ replacement = (await self._credential.get_token()).token
83
+ if replacement == token:
84
+ raise
85
+ return await self._request(method, url, **self._with_auth(kwargs, replacement))
86
+
54
87
  async def close(self) -> None:
55
- """Close the HTTP client and release connections."""
56
- await self._client.aclose()
88
+ """Close the HTTP client this client created and release its connections."""
89
+ if self._http_owner:
90
+ await self._client.aclose()
57
91
 
58
92
  async def __aenter__(self) -> "AsyncClient":
59
93
  """Async context manager entry."""
@@ -64,7 +98,9 @@ class AsyncClient(_BaseClient):
64
98
  await self.close()
65
99
 
66
100
  async def _refresh_token(self) -> None:
67
- """Refresh token using stored credentials.
101
+ """Refresh the token with the stored login credentials.
102
+
103
+ Client credentials refresh through the credential object instead.
68
104
 
69
105
  Raises:
70
106
  AuthenticationError: If no credentials are stored or refresh fails.
@@ -72,26 +108,19 @@ class AsyncClient(_BaseClient):
72
108
  if self._credentials is None or self._credentials_type is None:
73
109
  raise AuthenticationError("No credentials available for token refresh")
74
110
 
75
- if self._credentials_type == "login":
76
- await self.auth.login(
77
- email=self._credentials["email"],
78
- password=self._credentials["password"],
79
- )
80
- elif self._credentials_type == "client":
81
- await self.auth.get_access_token(
82
- client_id=self._credentials["client_id"],
83
- client_secret=self._credentials["client_secret"],
84
- )
111
+ await self.auth.login(
112
+ email=self._credentials["email"],
113
+ password=self._credentials["password"],
114
+ )
85
115
 
86
- async def _ensure_token(self) -> None:
87
- """Ensure a valid token is available, refreshing if necessary.
116
+ async def _ensure_token(self) -> str:
117
+ """The token to send: the credential's, else the stored one, refreshed if expiring.
88
118
 
89
119
  Raises:
90
120
  AuthenticationError: If no token is available or token expired without credentials.
91
121
  """
92
122
  if self._credential is not None:
93
- self._access_token = (await self._credential.get_token()).token
94
- return
123
+ return (await self._credential.get_token()).token
95
124
  if self._access_token is None:
96
125
  raise AuthenticationError(
97
126
  "Not authenticated. Call login() or get_access_token() first."
@@ -103,3 +132,4 @@ class AsyncClient(_BaseClient):
103
132
  "Token expired and no credentials available for refresh"
104
133
  )
105
134
  await self._refresh_token()
135
+ return self._access_token
@@ -97,7 +97,7 @@ class AuthMixin:
97
97
  Args:
98
98
  response_data: Response JSON data
99
99
  credentials: Credentials dict for auto-refresh
100
- credentials_type: Type of credentials ("login" or "client")
100
+ credentials_type: Type of credentials ("login")
101
101
 
102
102
  Returns:
103
103
  Parsed TokenResponse
@@ -1,6 +1,5 @@
1
1
  """Authentication resource for async operations."""
2
2
 
3
- import time
4
3
  from typing import TYPE_CHECKING
5
4
 
6
5
  import jwt
@@ -29,13 +28,16 @@ class AsyncAuthResource(AuthMixin):
29
28
  self._sdk_client = client
30
29
 
31
30
  async def get_access_token(
32
- self, client_id: str, client_secret: str
31
+ self, client_id: str, client_secret: str, *, force_refresh: bool = False
33
32
  ) -> TokenResponse:
34
33
  """Get access token using client credentials flow.
35
34
 
36
- The token is stored internally and will be used automatically for subsequent
37
- API calls. If the token expires, it will be automatically refreshed.
38
- Calling this again with the same credentials returns the cached token.
35
+ The client keeps the resulting credential and uses it for subsequent API
36
+ calls, minting a new token when the current one expires. Calling this
37
+ again with the same client credentials returns the cached token;
38
+ ``force_refresh=True`` mints a new one. A later ``login()`` or
39
+ ``set_token()`` replaces the credential: the last explicit
40
+ authentication wins.
39
41
 
40
42
  New code should build a ``ClientCredentials`` / ``AsyncClientCredentials``
41
43
  and pass it as ``credential=`` to the client instead; this method stays for
@@ -44,6 +46,7 @@ class AsyncAuthResource(AuthMixin):
44
46
  Args:
45
47
  client_id: OAuth client ID
46
48
  client_secret: OAuth client secret
49
+ force_refresh: Mint a new token even if a fresh one is cached.
47
50
 
48
51
  Returns:
49
52
  TokenResponse with access_token, expires_in, and token_type
@@ -70,16 +73,15 @@ class AsyncAuthResource(AuthMixin):
70
73
  client_secret,
71
74
  http=self._sdk_client._client,
72
75
  )
73
- self._sdk_client._credential = credential
74
- token = await credential.get_token()
75
- self._sdk_client._store_token(
76
+ token = await credential.get_token(force_refresh=force_refresh)
77
+ # Installed only once it works, and over a login() / set_token() token:
78
+ # the last explicit authentication wins.
79
+ self._sdk_client._credential = credential
80
+ return TokenResponse(
76
81
  access_token=token.token,
77
- expires_in=max(int(token.expires_on - time.monotonic()), 0),
78
- credentials={"client_id": client_id, "client_secret": client_secret},
79
- credentials_type="client",
82
+ expires_in=token.expires_in(),
83
+ token_type=token.token_type,
80
84
  )
81
- assert credential.last_response is not None
82
- return credential.last_response
83
85
 
84
86
  async def login(self, email: str, password: str) -> TokenResponse:
85
87
  """Login with email and password to get JWT token.