visionai-sdk-python 2.2.0.dev1__tar.gz → 2.2.0.dev2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/.github/workflows/ci.yml +2 -7
  2. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/PKG-INFO +5 -2
  3. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/README.md +4 -1
  4. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/_base.py +18 -0
  5. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/async_client.py +44 -3
  6. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/client.py +42 -3
  7. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/vlm/async_resource.py +8 -16
  8. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/vlm/resource.py +8 -16
  9. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python.egg-info/PKG-INFO +5 -2
  10. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python.egg-info/SOURCES.txt +1 -0
  11. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python.egg-info/scm_file_list.json +1 -0
  12. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python.egg-info/scm_version.json +2 -2
  13. visionai_sdk_python-2.2.0.dev2/tests/test_request.py +218 -0
  14. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/.github/dependabot.yml +0 -0
  15. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/.github/workflows/publish.yml +0 -0
  16. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/.gitignore +0 -0
  17. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/pyproject.toml +0 -0
  18. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/setup.cfg +0 -0
  19. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/__init__.py +0 -0
  20. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/_jwt_verifier.py +0 -0
  21. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/_request_attribution.py +0 -0
  22. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/_source_header.py +0 -0
  23. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/auth/__init__.py +0 -0
  24. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/auth/_mixin.py +0 -0
  25. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/auth/async_resource.py +0 -0
  26. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/auth/credentials.py +0 -0
  27. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/auth/models.py +0 -0
  28. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/auth/resource.py +0 -0
  29. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/constants.py +0 -0
  30. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/endpoints.py +0 -0
  31. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/exceptions.py +0 -0
  32. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/instrumentation.py +0 -0
  33. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/py.typed +0 -0
  34. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/vlm/__init__.py +0 -0
  35. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/vlm/_mixin.py +0 -0
  36. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/vlm/models.py +0 -0
  37. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/vlm/preprocess.py +0 -0
  38. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python.egg-info/dependency_links.txt +0 -0
  39. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python.egg-info/requires.txt +0 -0
  40. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python.egg-info/top_level.txt +0 -0
  41. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/__init__.py +0 -0
  42. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/constants.py +0 -0
  43. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/test__base.py +0 -0
  44. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/test_async_client.py +0 -0
  45. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/test_client.py +0 -0
  46. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/test_credentials.py +0 -0
  47. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/test_instrumentation.py +0 -0
  48. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/test_vlm_preprocess.py +0 -0
  49. {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/uv.lock +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.dev2
4
4
  Summary: VisionAI SDK for Python
5
5
  Author-email: Tony Yang <tonyyang@linkervision.com>
6
6
  Requires-Python: >=3.11
@@ -146,7 +146,10 @@ 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
155
  Platform mints at most 50 tokens per client per month, so the credential protects the
@@ -128,7 +128,10 @@ 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
137
  Platform mints at most 50 tokens per client per month, so the credential protects the
@@ -85,11 +85,29 @@ class _BaseClient:
85
85
  self._credentials: dict | None = None
86
86
  self._credentials_type: Literal["login", "client"] | None = None
87
87
 
88
+ @property
89
+ def credential(self) -> "ClientCredentials | AsyncClientCredentials | None":
90
+ """The credential this client was built with, for callers that need the
91
+ raw token themselves (a WebSocket upgrade, another HTTP library)."""
92
+ return self._credential
93
+
88
94
  @staticmethod
89
95
  def _build_url(base_url: str, path: str) -> str:
90
96
  """Join a base URL and a path, normalizing slashes."""
91
97
  return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
92
98
 
99
+ def _absolute(self, url: str) -> str:
100
+ """``url`` as given when absolute, else a path under ``auth_url``."""
101
+ return url if "://" in url else self._build_url(self.auth_url, url)
102
+
103
+ def _with_auth(self, kwargs: dict, token: str) -> dict:
104
+ """``kwargs`` for httpx with the bearer header; the client's token wins."""
105
+ headers = {
106
+ **dict(kwargs.get("headers") or {}),
107
+ **self._build_auth_header(token),
108
+ }
109
+ return {**kwargs, "headers": headers}
110
+
93
111
  def _vlm_base(self) -> str:
94
112
  if self.vlm_url is None:
95
113
  raise ValueError("vlm_url is required for VLM calls; pass it to the client")
@@ -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._owns_http = http_client is None
41
45
  # Register the different function resource
42
46
  self.auth = AsyncAuthResource(self)
43
47
  self.vlm = AsyncVLMResource(self)
@@ -51,9 +55,46 @@ class AsyncClient(_BaseClient):
51
55
  raise self._map_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 absolute, or a path under ``auth_url``; ``kwargs`` go to httpx.
62
+ A 401 buys one fresh token (from ``credential`` or the stored login) and
63
+ one retry, so a token revoked server-side costs one extra request.
64
+
65
+ Raises:
66
+ AuthenticationError: No token, or the fresh one was refused too.
67
+ RateLimitedError, NetworkError, ServerError, ClientError: As the
68
+ resource methods.
69
+ """
70
+ url = self._absolute(url)
71
+ await self._ensure_token()
72
+ token = self._access_token
73
+ try:
74
+ return await self._request(method, url, **self._with_auth(kwargs, token))
75
+ except AuthenticationError:
76
+ if not await self._renew_rejected(token):
77
+ raise
78
+ return await self._request(
79
+ method, url, **self._with_auth(kwargs, self._access_token)
80
+ )
81
+
82
+ async def _renew_rejected(self, token: str) -> bool:
83
+ """A 401 for ``token``: get a different one, once; False when none would help."""
84
+ if self._credential is not None:
85
+ if not self._credential.invalidate(token):
86
+ return False
87
+ self._access_token = (await self._credential.get_token()).token
88
+ return True
89
+ if self._credentials is None or self._access_token != token:
90
+ return False
91
+ await self._refresh_token()
92
+ return self._access_token != token
93
+
54
94
  async def close(self) -> None:
55
- """Close the HTTP client and release connections."""
56
- await self._client.aclose()
95
+ """Close the HTTP client this client created and release its connections."""
96
+ if self._owns_http:
97
+ await self._client.aclose()
57
98
 
58
99
  async def __aenter__(self) -> "AsyncClient":
59
100
  """Async context manager entry."""
@@ -19,7 +19,10 @@ class Client(_BaseClient):
19
19
  max_connections: int = 100,
20
20
  max_keepalive_connections: int = 20,
21
21
  credential: ClientCredentials | None = None,
22
+ http_client: httpx.Client | None = None,
22
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."""
23
26
  super().__init__(
24
27
  auth_url=auth_url,
25
28
  vlm_url=vlm_url,
@@ -30,7 +33,7 @@ class Client(_BaseClient):
30
33
  max_keepalive_connections=max_keepalive_connections,
31
34
  credential=credential,
32
35
  )
33
- self._client = httpx.Client(
36
+ self._client = http_client or httpx.Client(
34
37
  verify=self.verify_ssl,
35
38
  timeout=self.timeout,
36
39
  limits=httpx.Limits(
@@ -38,6 +41,7 @@ class Client(_BaseClient):
38
41
  max_keepalive_connections=self.max_keepalive_connections,
39
42
  ),
40
43
  )
44
+ self._owns_http = http_client is None
41
45
  # Register the different function resource
42
46
  self.auth = AuthResource(self)
43
47
  self.vlm = VLMResource(self)
@@ -51,9 +55,44 @@ class Client(_BaseClient):
51
55
  raise self._map_transport_error(e) from e
52
56
  return self._handle_response(response)
53
57
 
58
+ def request(self, method: str, url: str, **kwargs) -> httpx.Response:
59
+ """Send any request with this client's bearer token.
60
+
61
+ ``url`` is absolute, or a path under ``auth_url``; ``kwargs`` go to httpx.
62
+ A 401 buys one fresh token (from ``credential`` or the stored login) and
63
+ one retry, so a token revoked server-side costs one extra request.
64
+
65
+ Raises:
66
+ AuthenticationError: No token, or the fresh one was refused too.
67
+ RateLimitedError, NetworkError, ServerError, ClientError: As the
68
+ resource methods.
69
+ """
70
+ url = self._absolute(url)
71
+ self._ensure_token()
72
+ token = self._access_token
73
+ try:
74
+ return self._request(method, url, **self._with_auth(kwargs, token))
75
+ except AuthenticationError:
76
+ if not self._renew_rejected(token):
77
+ raise
78
+ return self._request(method, url, **self._with_auth(kwargs, self._access_token))
79
+
80
+ def _renew_rejected(self, token: str) -> bool:
81
+ """A 401 for ``token``: get a different one, once; False when none would help."""
82
+ if self._credential is not None:
83
+ if not self._credential.invalidate(token):
84
+ return False
85
+ self._access_token = self._credential.get_token().token
86
+ return True
87
+ if self._credentials is None or self._access_token != token:
88
+ return False
89
+ self._refresh_token()
90
+ return self._access_token != token
91
+
54
92
  def close(self) -> None:
55
- """Close the HTTP client and release connections."""
56
- self._client.close()
93
+ """Close the HTTP client this client created and release its connections."""
94
+ if self._owns_http:
95
+ self._client.close()
57
96
 
58
97
  def __enter__(self) -> "Client":
59
98
  """Context manager entry."""
@@ -28,8 +28,8 @@ class AsyncVLMResource(VLMMixin):
28
28
  ) -> ResponseNormalModel | ResponseErrorModel:
29
29
  """Submit an inference request to the VLM service.
30
30
 
31
- Uses the internally stored access token obtained from login() or get_access_token().
32
- If the token is expiring soon, it will be automatically refreshed.
31
+ Sent with the client's token (``credential``, ``login()`` or ``get_access_token()``);
32
+ a 401 gets one fresh token and one retry.
33
33
 
34
34
  Args:
35
35
  payload: Inference parameters as a NIMRequestModel instance or a dict
@@ -45,17 +45,13 @@ class AsyncVLMResource(VLMMixin):
45
45
  NetworkError: If the request times out or a network error occurs.
46
46
  VisionaiSDKError: If the request fails for any other reason.
47
47
  """
48
- # Ensure token is valid
49
- await self._sdk_client._ensure_token()
50
-
51
48
  # Prepare request (from Mixin)
52
49
  body = self._prepare_chat_request(payload)
53
50
 
54
- # I/O operation (async)
55
- response = await self._sdk_client._request(
51
+ # I/O operation (async); the client adds the token and retries once on 401
52
+ response = await self._sdk_client.request(
56
53
  "POST",
57
54
  self._sdk_client._build_url(self._sdk_client._vlm_base(), VLMEndpoint.CHAT),
58
- headers=self._sdk_client._build_auth_header(self._sdk_client._access_token),
59
55
  json=body,
60
56
  )
61
57
 
@@ -67,8 +63,8 @@ class AsyncVLMResource(VLMMixin):
67
63
  ) -> ResponseNormalModel | ResponseErrorModel:
68
64
  """Poll the result of a previously submitted inference request.
69
65
 
70
- Uses the internally stored access token obtained from login() or get_access_token().
71
- If the token is expiring soon, it will be automatically refreshed.
66
+ Sent with the client's token (``credential``, ``login()`` or ``get_access_token()``);
67
+ a 401 gets one fresh token and one retry.
72
68
 
73
69
  Args:
74
70
  result_id: Chat result ID returned from a prior chat() call.
@@ -82,16 +78,12 @@ class AsyncVLMResource(VLMMixin):
82
78
  NetworkError: If the request times out or a network error occurs.
83
79
  VisionaiSDKError: If the request fails for any other reason.
84
80
  """
85
- # Ensure token is valid
86
- await self._sdk_client._ensure_token()
87
-
88
- # I/O operation (async)
89
- response = await self._sdk_client._request(
81
+ # I/O operation (async); the client adds the token and retries once on 401
82
+ response = await self._sdk_client.request(
90
83
  "GET",
91
84
  self._sdk_client._build_url(
92
85
  self._sdk_client._vlm_base(), f"{VLMEndpoint.CHAT}/{result_id}"
93
86
  ),
94
- headers=self._sdk_client._build_auth_header(self._sdk_client._access_token),
95
87
  )
96
88
 
97
89
  # Parse response (from Mixin)
@@ -28,8 +28,8 @@ class VLMResource(VLMMixin):
28
28
  ) -> ResponseNormalModel | ResponseErrorModel:
29
29
  """Submit an inference request to the VLM service.
30
30
 
31
- Uses the internally stored access token obtained from login() or get_access_token().
32
- If the token is expiring soon, it will be automatically refreshed.
31
+ Sent with the client's token (``credential``, ``login()`` or ``get_access_token()``);
32
+ a 401 gets one fresh token and one retry.
33
33
 
34
34
  Args:
35
35
  payload: Inference parameters as a NIMRequestModel instance or a dict
@@ -45,17 +45,13 @@ class VLMResource(VLMMixin):
45
45
  NetworkError: If the request times out or a network error occurs.
46
46
  VisionaiSDKError: If the request fails for any other reason.
47
47
  """
48
- # Ensure token is valid
49
- self._sdk_client._ensure_token()
50
-
51
48
  # Prepare request (from Mixin)
52
49
  body = self._prepare_chat_request(payload)
53
50
 
54
- # I/O operation (sync)
55
- response = self._sdk_client._request(
51
+ # I/O operation (sync); the client adds the token and retries once on 401
52
+ response = self._sdk_client.request(
56
53
  "POST",
57
54
  self._sdk_client._build_url(self._sdk_client._vlm_base(), VLMEndpoint.CHAT),
58
- headers=self._sdk_client._build_auth_header(self._sdk_client._access_token),
59
55
  json=body,
60
56
  )
61
57
 
@@ -65,8 +61,8 @@ class VLMResource(VLMMixin):
65
61
  def get_chat(self, result_id: str) -> ResponseNormalModel | ResponseErrorModel:
66
62
  """Poll the result of a previously submitted inference request.
67
63
 
68
- Uses the internally stored access token obtained from login() or get_access_token().
69
- If the token is expiring soon, it will be automatically refreshed.
64
+ Sent with the client's token (``credential``, ``login()`` or ``get_access_token()``);
65
+ a 401 gets one fresh token and one retry.
70
66
 
71
67
  Args:
72
68
  result_id: Chat result ID returned from a prior chat() call.
@@ -80,16 +76,12 @@ class VLMResource(VLMMixin):
80
76
  NetworkError: If the request times out or a network error occurs.
81
77
  VisionaiSDKError: If the request fails for any other reason.
82
78
  """
83
- # Ensure token is valid
84
- self._sdk_client._ensure_token()
85
-
86
- # I/O operation (sync)
87
- response = self._sdk_client._request(
79
+ # I/O operation (sync); the client adds the token and retries once on 401
80
+ response = self._sdk_client.request(
88
81
  "GET",
89
82
  self._sdk_client._build_url(
90
83
  self._sdk_client._vlm_base(), f"{VLMEndpoint.CHAT}/{result_id}"
91
84
  ),
92
- headers=self._sdk_client._build_auth_header(self._sdk_client._access_token),
93
85
  )
94
86
 
95
87
  # Parse response (from Mixin)
@@ -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.dev2
4
4
  Summary: VisionAI SDK for Python
5
5
  Author-email: Tony Yang <tonyyang@linkervision.com>
6
6
  Requires-Python: >=3.11
@@ -146,7 +146,10 @@ 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
155
  Platform mints at most 50 tokens per client per month, so the credential protects the
@@ -43,4 +43,5 @@ tests/test_async_client.py
43
43
  tests/test_client.py
44
44
  tests/test_credentials.py
45
45
  tests/test_instrumentation.py
46
+ tests/test_request.py
46
47
  tests/test_vlm_preprocess.py
@@ -37,6 +37,7 @@
37
37
  "tests/test_client.py",
38
38
  "tests/test_credentials.py",
39
39
  "tests/test_instrumentation.py",
40
+ "tests/test_request.py",
40
41
  "tests/test_vlm_preprocess.py",
41
42
  "uv.lock"
42
43
  ]
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "2.2.0.dev1",
2
+ "tag": "2.2.0.dev2",
3
3
  "distance": 0,
4
- "node": "gb0c172d368ada597a6ac62fde2d1f71ba0faed72",
4
+ "node": "gac5a00feaebf548247cbea098aac2ae47e15c87a",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-10-20"
@@ -0,0 +1,218 @@
1
+ """``client.request()``: any URL with the client's token, one fresh token on 401."""
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from tests.constants import (
7
+ AUTH_URL,
8
+ NORMAL_COMPLETED_RESPONSE,
9
+ TOKEN_PAYLOAD,
10
+ VALID_NIM_PAYLOAD,
11
+ VLM_URL,
12
+ )
13
+ from visionai_sdk_python import (
14
+ AsyncClient,
15
+ AsyncClientCredentials,
16
+ Client,
17
+ ClientCredentials,
18
+ )
19
+ from visionai_sdk_python.exceptions import AuthenticationError
20
+ from visionai_sdk_python.vlm.models import ResponseNormalModel
21
+
22
+ NODES = "https://vs.example.com/mgmt/nodes"
23
+
24
+
25
+ class FakeWorld:
26
+ """Platform (mint, login) and token-checking services behind one handler."""
27
+
28
+ def __init__(self) -> None:
29
+ self.minted = 0
30
+ self.logins = 0
31
+ self.rejected: set[str] = set()
32
+ self.calls: list[httpx.Request] = []
33
+
34
+ def handler(self, request: httpx.Request) -> httpx.Response:
35
+ path = request.url.path
36
+ if path == "/api/users/client-token":
37
+ self.minted += 1
38
+ token = f"tok-{self.minted}"
39
+ return httpx.Response(200, json={**TOKEN_PAYLOAD, "access_token": token})
40
+ if path == "/api/users/jwt":
41
+ self.logins += 1
42
+ token = f"login-{self.logins}"
43
+ return httpx.Response(200, json={**TOKEN_PAYLOAD, "access_token": token})
44
+ self.calls.append(request)
45
+ token = request.headers.get("authorization", "").removeprefix("Bearer ")
46
+ if not token or token in self.rejected:
47
+ return httpx.Response(401, json={"detail": "invalid token"})
48
+ if path == "/api/chat":
49
+ return httpx.Response(200, json=NORMAL_COMPLETED_RESPONSE)
50
+ return httpx.Response(200, json={"path": path})
51
+
52
+ def tokens_sent(self) -> list[str]:
53
+ return [
54
+ r.headers.get("authorization", "").removeprefix("Bearer ")
55
+ for r in self.calls
56
+ ]
57
+
58
+
59
+ @pytest.fixture
60
+ def world() -> FakeWorld:
61
+ return FakeWorld()
62
+
63
+
64
+ @pytest.fixture
65
+ def transport(world) -> httpx.MockTransport:
66
+ return httpx.MockTransport(world.handler)
67
+
68
+
69
+ @pytest.fixture
70
+ def client(transport) -> AsyncClient:
71
+ cred = AsyncClientCredentials(
72
+ AUTH_URL, "cid", "secret", http=httpx.AsyncClient(transport=transport)
73
+ )
74
+ return AsyncClient(
75
+ AUTH_URL,
76
+ VLM_URL,
77
+ credential=cred,
78
+ http_client=httpx.AsyncClient(transport=transport),
79
+ )
80
+
81
+
82
+ @pytest.fixture
83
+ def sync_client(transport) -> Client:
84
+ cred = ClientCredentials(
85
+ AUTH_URL, "cid", "secret", http=httpx.Client(transport=transport)
86
+ )
87
+ return Client(
88
+ AUTH_URL,
89
+ VLM_URL,
90
+ credential=cred,
91
+ http_client=httpx.Client(transport=transport),
92
+ )
93
+
94
+
95
+ async def test_an_injected_http_client_is_used_and_not_closed(transport):
96
+ http = httpx.AsyncClient(transport=transport)
97
+ async with AsyncClient(AUTH_URL, http_client=http) as client:
98
+ assert client._client is http
99
+ assert not http.is_closed
100
+
101
+ owned = AsyncClient(AUTH_URL)
102
+ await owned.close()
103
+ assert owned._client.is_closed
104
+
105
+
106
+ def test_the_credential_is_reachable_for_callers_that_need_the_raw_token(client):
107
+ assert isinstance(client.credential, AsyncClientCredentials)
108
+ assert AsyncClient(AUTH_URL).credential is None
109
+
110
+
111
+ async def test_request_sends_the_token_to_any_url(world, client):
112
+ response = await client.request("GET", NODES)
113
+
114
+ assert response.status_code == 200
115
+ assert world.tokens_sent() == ["tok-1"]
116
+ assert str(world.calls[0].url) == NODES
117
+
118
+
119
+ async def test_request_joins_a_path_onto_auth_url(world, client):
120
+ await client.request("GET", "/api/anything")
121
+
122
+ assert str(world.calls[0].url) == f"{AUTH_URL}/api/anything"
123
+
124
+
125
+ async def test_request_keeps_caller_headers_but_uses_the_clients_token(world, client):
126
+ await client.request(
127
+ "GET",
128
+ NODES,
129
+ headers={"X-Request-Source": "agent", "Authorization": "Bearer nope"},
130
+ )
131
+
132
+ (call,) = world.calls
133
+ assert call.headers["x-request-source"] == "agent"
134
+ assert call.headers["authorization"] == "Bearer tok-1"
135
+
136
+
137
+ async def test_request_retries_once_with_a_fresh_token_after_a_401(world, client):
138
+ world.rejected = {"tok-1"}
139
+
140
+ response = await client.request("GET", NODES)
141
+
142
+ assert response.status_code == 200
143
+ assert world.tokens_sent() == ["tok-1", "tok-2"]
144
+ assert world.minted == 2
145
+
146
+
147
+ async def test_request_does_not_mint_a_third_token_when_the_replacement_is_rejected(
148
+ world, client
149
+ ):
150
+ world.rejected = {"tok-1", "tok-2"}
151
+
152
+ for _ in range(3):
153
+ with pytest.raises(AuthenticationError):
154
+ await client.request("GET", NODES)
155
+
156
+ assert world.minted == 2
157
+ assert world.tokens_sent() == ["tok-1", "tok-2", "tok-2", "tok-2"]
158
+
159
+
160
+ async def test_request_after_login_logs_in_again_once_on_401(world, transport):
161
+ client = AsyncClient(
162
+ AUTH_URL, VLM_URL, http_client=httpx.AsyncClient(transport=transport)
163
+ )
164
+ await client.auth.login("user@example.com", "password")
165
+ world.rejected = {"login-1"}
166
+
167
+ response = await client.request("GET", NODES)
168
+
169
+ assert response.status_code == 200
170
+ assert world.logins == 2
171
+ assert world.tokens_sent() == ["login-1", "login-2"]
172
+
173
+
174
+ async def test_request_with_an_injected_token_and_no_credentials_raises_on_401(
175
+ world, transport
176
+ ):
177
+ # Dataverse's path: a token it minted itself, nothing to refresh with.
178
+ client = AsyncClient(
179
+ AUTH_URL, VLM_URL, http_client=httpx.AsyncClient(transport=transport)
180
+ )
181
+ client._store_token("fixed", expires_in=3600)
182
+ world.rejected = {"fixed"}
183
+
184
+ with pytest.raises(AuthenticationError):
185
+ await client.request("GET", NODES)
186
+
187
+ assert world.tokens_sent() == ["fixed"]
188
+ assert world.minted == 0
189
+
190
+
191
+ async def test_chat_gets_one_fresh_token_on_401(world, client):
192
+ world.rejected = {"tok-1"}
193
+
194
+ result = await client.vlm.chat(VALID_NIM_PAYLOAD)
195
+
196
+ assert isinstance(result, ResponseNormalModel)
197
+ assert result.status == "completed"
198
+ assert world.tokens_sent() == ["tok-1", "tok-2"]
199
+
200
+
201
+ def test_sync_request_sends_the_token_and_retries_once(world, sync_client):
202
+ world.rejected = {"tok-1"}
203
+
204
+ response = sync_client.request("GET", NODES)
205
+
206
+ assert response.status_code == 200
207
+ assert world.tokens_sent() == ["tok-1", "tok-2"]
208
+
209
+
210
+ def test_sync_request_keeps_a_rejected_replacement(world, sync_client):
211
+ world.rejected = {"tok-1", "tok-2"}
212
+
213
+ with pytest.raises(AuthenticationError):
214
+ sync_client.request("GET", NODES)
215
+ with pytest.raises(AuthenticationError):
216
+ sync_client.vlm.chat(VALID_NIM_PAYLOAD)
217
+
218
+ assert world.minted == 2