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.
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/.github/workflows/ci.yml +2 -7
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/PKG-INFO +5 -2
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/README.md +4 -1
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/_base.py +18 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/async_client.py +44 -3
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/client.py +42 -3
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/vlm/async_resource.py +8 -16
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/vlm/resource.py +8 -16
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python.egg-info/PKG-INFO +5 -2
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python.egg-info/SOURCES.txt +1 -0
- {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
- {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
- visionai_sdk_python-2.2.0.dev2/tests/test_request.py +218 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/.github/dependabot.yml +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/.github/workflows/publish.yml +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/.gitignore +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/pyproject.toml +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/setup.cfg +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/__init__.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/_jwt_verifier.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/_request_attribution.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/_source_header.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/auth/__init__.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/auth/_mixin.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/auth/async_resource.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/auth/credentials.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/auth/models.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/auth/resource.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/constants.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/endpoints.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/exceptions.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/instrumentation.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/py.typed +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/vlm/__init__.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/vlm/_mixin.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/vlm/models.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/vlm/preprocess.py +0 -0
- {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
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python.egg-info/requires.txt +0 -0
- {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
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/__init__.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/constants.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/test__base.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/test_async_client.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/test_client.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/test_credentials.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/test_instrumentation.py +0 -0
- {visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/test_vlm_preprocess.py +0 -0
- {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
|
|
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:
|
|
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.
|
|
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
|
|
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
|
|
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
|
{visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/_base.py
RENAMED
|
@@ -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
|
-
|
|
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."""
|
{visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/client.py
RENAMED
|
@@ -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.
|
|
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
|
-
|
|
32
|
-
|
|
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.
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
#
|
|
86
|
-
await self._sdk_client.
|
|
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
|
-
|
|
32
|
-
|
|
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.
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
#
|
|
84
|
-
self._sdk_client.
|
|
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.
|
|
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
|
|
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
|
|
@@ -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
|
|
File without changes
|
{visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/.github/workflows/publish.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/src/visionai_sdk_python/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/test_async_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/test_instrumentation.py
RENAMED
|
File without changes
|
{visionai_sdk_python-2.2.0.dev1 → visionai_sdk_python-2.2.0.dev2}/tests/test_vlm_preprocess.py
RENAMED
|
File without changes
|
|
File without changes
|