visionai-sdk-python 2.2.0.dev1__py3-none-any.whl → 2.2.0.dev3__py3-none-any.whl

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.
@@ -1,6 +1,5 @@
1
1
  """Authentication resource for sync operations."""
2
2
 
3
- import time
4
3
  from typing import TYPE_CHECKING
5
4
 
6
5
  import jwt
@@ -28,12 +27,17 @@ class AuthResource(AuthMixin):
28
27
  """
29
28
  self._sdk_client = client
30
29
 
31
- def get_access_token(self, client_id: str, client_secret: str) -> TokenResponse:
30
+ def get_access_token(
31
+ self, client_id: str, client_secret: str, *, force_refresh: bool = False
32
+ ) -> TokenResponse:
32
33
  """Get access token using client credentials flow.
33
34
 
34
- The token is stored internally and will be used automatically for subsequent
35
- API calls. If the token expires, it will be automatically refreshed.
36
- 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.
37
41
 
38
42
  New code should build a ``ClientCredentials`` / ``AsyncClientCredentials``
39
43
  and pass it as ``credential=`` to the client instead; this method stays for
@@ -42,6 +46,7 @@ class AuthResource(AuthMixin):
42
46
  Args:
43
47
  client_id: OAuth client ID
44
48
  client_secret: OAuth client secret
49
+ force_refresh: Mint a new token even if a fresh one is cached.
45
50
 
46
51
  Returns:
47
52
  TokenResponse with access_token, expires_in, and token_type
@@ -68,16 +73,15 @@ class AuthResource(AuthMixin):
68
73
  client_secret,
69
74
  http=self._sdk_client._client,
70
75
  )
71
- self._sdk_client._credential = credential
72
- token = credential.get_token()
73
- self._sdk_client._store_token(
76
+ token = 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(
74
81
  access_token=token.token,
75
- expires_in=max(int(token.expires_on - time.monotonic()), 0),
76
- credentials={"client_id": client_id, "client_secret": client_secret},
77
- credentials_type="client",
82
+ expires_in=token.expires_in(),
83
+ token_type=token.token_type,
78
84
  )
79
- assert credential.last_response is not None
80
- return credential.last_response
81
85
 
82
86
  def login(self, email: str, password: str) -> TokenResponse:
83
87
  """Login with email and password to get JWT token.
@@ -4,7 +4,7 @@ from ._base import _BaseClient
4
4
  from ._request_attribution import merge_request_attribution
5
5
  from .auth.credentials import ClientCredentials
6
6
  from .auth.resource import AuthResource
7
- from .exceptions import AuthenticationError
7
+ from .exceptions import AuthenticationError, from_transport_error
8
8
  from .vlm.resource import VLMResource
9
9
 
10
10
 
@@ -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._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 = AuthResource(self)
43
47
  self.vlm = VLMResource(self)
@@ -48,12 +52,42 @@ class Client(_BaseClient):
48
52
  try:
49
53
  response = 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
+ 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 = self._ensure_token()
76
+ try:
77
+ return 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 = (self._credential.get_token()).token
83
+ if replacement == token:
84
+ raise
85
+ return self._request(method, url, **self._with_auth(kwargs, replacement))
86
+
54
87
  def close(self) -> None:
55
- """Close the HTTP client and release connections."""
56
- self._client.close()
88
+ """Close the HTTP client this client created and release its connections."""
89
+ if self._http_owner:
90
+ self._client.close()
57
91
 
58
92
  def __enter__(self) -> "Client":
59
93
  """Context manager entry."""
@@ -64,7 +98,9 @@ class Client(_BaseClient):
64
98
  self.close()
65
99
 
66
100
  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 Client(_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
- self.auth.login(
77
- email=self._credentials["email"],
78
- password=self._credentials["password"],
79
- )
80
- elif self._credentials_type == "client":
81
- self.auth.get_access_token(
82
- client_id=self._credentials["client_id"],
83
- client_secret=self._credentials["client_secret"],
84
- )
111
+ self.auth.login(
112
+ email=self._credentials["email"],
113
+ password=self._credentials["password"],
114
+ )
85
115
 
86
- def _ensure_token(self) -> None:
87
- """Ensure a valid token is available, refreshing if necessary.
116
+ 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 = (self._credential.get_token()).token
94
- return
123
+ return (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 Client(_BaseClient):
103
132
  "Token expired and no credentials available for refresh"
104
133
  )
105
134
  self._refresh_token()
135
+ return self._access_token
@@ -1,3 +1,6 @@
1
+ import httpx
2
+
3
+
1
4
  class VisionaiSDKError(Exception):
2
5
  """Base exception for all SDK errors."""
3
6
 
@@ -29,7 +32,7 @@ class PermissionDeniedError(ClientError):
29
32
 
30
33
 
31
34
  class RateLimitedError(ClientError):
32
- """429 - Too many requests; on client-token, the monthly mint quota is used up."""
35
+ """429 - Too many requests; on client-token, Platform is rate limiting mints."""
33
36
 
34
37
  def __init__(self, message: str) -> None:
35
38
  super().__init__(429, message)
@@ -45,3 +48,50 @@ class NetworkError(VisionaiSDKError):
45
48
 
46
49
  class JwksDiscoveryError(VisionaiSDKError):
47
50
  """Failed to fetch or parse the OIDC discovery document or JWKS endpoint."""
51
+
52
+
53
+ def from_transport_error(error: httpx.RequestError) -> VisionaiSDKError:
54
+ """The SDK exception for an httpx request that never got a response."""
55
+ if isinstance(error, httpx.TimeoutException):
56
+ return NetworkError("Request timed out")
57
+ if isinstance(error, httpx.NetworkError):
58
+ return NetworkError(f"Network error: {error}")
59
+ return VisionaiSDKError(f"Request failed: {error}")
60
+
61
+
62
+ def raise_for_status(response: httpx.Response) -> httpx.Response:
63
+ """Raise SDK-specific exceptions for non-2xx responses.
64
+
65
+ Raises:
66
+ AuthenticationError: If the server returns 401
67
+ PermissionDeniedError: If the server returns 403
68
+ ClientError: If the server returns any other 4xx
69
+ ServerError: If the server returns 5xx
70
+ APIError: If the server returns any other non-2xx status
71
+ """
72
+ try:
73
+ response.raise_for_status()
74
+ except httpx.HTTPStatusError as e:
75
+ body = None
76
+ if e.response.content:
77
+ try:
78
+ body = e.response.json()
79
+ except Exception:
80
+ pass
81
+ if isinstance(body, dict):
82
+ detail: str = body.get("detail") or body.get("message") or str(e)
83
+ else:
84
+ detail = str(e)
85
+ status = e.response.status_code
86
+ if status == 401:
87
+ raise AuthenticationError(detail) from e
88
+ if status == 403:
89
+ raise PermissionDeniedError(detail) from e
90
+ if status == 429:
91
+ raise RateLimitedError(detail) from e
92
+ if 400 <= status < 500:
93
+ raise ClientError(status, detail) from e
94
+ if 500 <= status < 600:
95
+ raise ServerError(status, detail) from e
96
+ raise APIError(status, detail) from e
97
+ return response
@@ -0,0 +1,67 @@
1
+ """The platform-relay wire protocol, shared by platform-relay and platform-edge-agent.
2
+
3
+ Internal to the platform; the spec is platform-service's
4
+ ``docs/adr/0001-relay-protocol.md``. See :mod:`.protocol`.
5
+ """
6
+
7
+ from .protocol import (
8
+ AGENT_TIMEOUT_S,
9
+ HEARTBEAT_INTERVAL_S,
10
+ MAX_BODY_BYTES,
11
+ MAX_FRAME_BYTES,
12
+ REGISTER_DEADLINE_S,
13
+ RELAY_TIMEOUT_S,
14
+ STALE_AFTER_S,
15
+ SUBPROTOCOL,
16
+ AgentFrame,
17
+ CloseCode,
18
+ Error,
19
+ ErrorCode,
20
+ Frame,
21
+ Heartbeat,
22
+ InvalidFrame,
23
+ ModelEntry,
24
+ Register,
25
+ Registered,
26
+ Rejected,
27
+ RejectReason,
28
+ RelayFrame,
29
+ Request,
30
+ Response,
31
+ WorkspaceEntry,
32
+ dump_frame,
33
+ is_workspace_id,
34
+ parse_agent_frame,
35
+ parse_relay_frame,
36
+ )
37
+
38
+ __all__ = [
39
+ "AGENT_TIMEOUT_S",
40
+ "HEARTBEAT_INTERVAL_S",
41
+ "MAX_BODY_BYTES",
42
+ "MAX_FRAME_BYTES",
43
+ "REGISTER_DEADLINE_S",
44
+ "RELAY_TIMEOUT_S",
45
+ "STALE_AFTER_S",
46
+ "SUBPROTOCOL",
47
+ "AgentFrame",
48
+ "CloseCode",
49
+ "Error",
50
+ "ErrorCode",
51
+ "Frame",
52
+ "Heartbeat",
53
+ "InvalidFrame",
54
+ "ModelEntry",
55
+ "Register",
56
+ "Registered",
57
+ "Rejected",
58
+ "RejectReason",
59
+ "RelayFrame",
60
+ "Request",
61
+ "Response",
62
+ "WorkspaceEntry",
63
+ "dump_frame",
64
+ "is_workspace_id",
65
+ "parse_agent_frame",
66
+ "parse_relay_frame",
67
+ ]
@@ -0,0 +1,178 @@
1
+ """Wire format between platform-relay and platform-edge-agent.
2
+
3
+ The spec is platform-service's ``docs/adr/0001-relay-protocol.md``; this module is
4
+ the one implementation both sides import. Frames are JSON objects told apart by
5
+ ``type``. Receivers ignore unknown fields, and the string-typed ``reason`` /
6
+ ``code`` fields accept values they do not know, so either side can be upgraded
7
+ first.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from enum import IntEnum, StrEnum
15
+ from typing import Annotated, Literal
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
18
+
19
+ SUBPROTOCOL = "platform-relay.v1"
20
+ # The first frame after the upgrade must be `register`, this soon; else the relay closes 1008.
21
+ REGISTER_DEADLINE_S = 10.0
22
+ HEARTBEAT_INTERVAL_S = 30.0
23
+ # No frame for this long: the relay closes 4001 and forgets the connection.
24
+ STALE_AFTER_S = 90.0
25
+ # Both sides refuse to send a larger frame; the relay refuses a larger chat body (413).
26
+ MAX_FRAME_BYTES = 16 * 1024 * 1024
27
+ MAX_BODY_BYTES = 15 * 1024 * 1024
28
+ # Timeouts nest: the agent waits for VS, the relay waits a little longer for the agent.
29
+ AGENT_TIMEOUT_S = 100
30
+ RELAY_TIMEOUT_S = 110.0
31
+
32
+ # Platform workspace ids: lowercase hyphenated UUIDs.
33
+ 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}")
34
+
35
+
36
+ def is_workspace_id(value: str) -> bool:
37
+ return UUID_RE.fullmatch(value) is not None
38
+
39
+
40
+ class CloseCode(IntEnum):
41
+ NORMAL = 1000
42
+ GOING_AWAY = 1001
43
+ POLICY_VIOLATION = 1008
44
+ TOO_BIG = 1009
45
+ REPLACED = 4000
46
+ STALE = 4001
47
+
48
+
49
+ class RejectReason(StrEnum):
50
+ INVALID_ID = "invalid_id"
51
+ CLUSTER_MISMATCH = "cluster_mismatch"
52
+ PLATFORM_UNAVAILABLE = "platform_unavailable"
53
+
54
+
55
+ class ErrorCode(StrEnum):
56
+ INVALID_MESSAGE = "invalid_message"
57
+ UNKNOWN_WORKSPACE = "unknown_workspace"
58
+ PATH_NOT_ALLOWED = "path_not_allowed"
59
+ UPSTREAM_UNREACHABLE = "upstream_unreachable"
60
+ UPSTREAM_TIMEOUT = "upstream_timeout"
61
+
62
+
63
+ class Frame(BaseModel):
64
+ # Receivers ignore unknown fields.
65
+ model_config = ConfigDict(extra="ignore")
66
+
67
+
68
+ class ModelEntry(Frame):
69
+ name: str
70
+ version: str
71
+ # Required by the ADR. A receiver counts None as 0 and logs it: the sender has a bug.
72
+ replicas: int | None = Field(default=None, ge=0)
73
+
74
+
75
+ class WorkspaceEntry(Frame):
76
+ id: str
77
+ models: list[ModelEntry] = Field(default_factory=list)
78
+
79
+
80
+ class Register(Frame):
81
+ type: Literal["register"] = "register"
82
+ agent_id: str = Field(min_length=1)
83
+ cluster_id: str = Field(min_length=1)
84
+ workspaces: list[WorkspaceEntry] = Field(default_factory=list)
85
+
86
+
87
+ class Rejected(Frame):
88
+ id: str
89
+ reason: str # a RejectReason; a plain str so an older agent accepts new reasons
90
+
91
+
92
+ class Registered(Frame):
93
+ type: Literal["registered"] = "registered"
94
+ accepted: list[str] = Field(default_factory=list)
95
+ rejected: list[Rejected] = Field(default_factory=list)
96
+
97
+
98
+ class Heartbeat(Frame):
99
+ type: Literal["heartbeat"] = "heartbeat"
100
+
101
+
102
+ class Request(Frame):
103
+ type: Literal["request"] = "request"
104
+ request_id: str
105
+ workspace_id: str
106
+ method: str
107
+ path: str
108
+ headers: dict[str, str] = Field(default_factory=dict)
109
+ body: dict | list | str | None = None
110
+ timeout_s: int = Field(gt=0)
111
+
112
+
113
+ class Response(Frame):
114
+ type: Literal["response"] = "response"
115
+ request_id: str
116
+ status: int
117
+ headers: dict[str, str] = Field(default_factory=dict)
118
+ body: dict | list | str | None = None
119
+
120
+
121
+ class Error(Frame):
122
+ type: Literal["error"] = "error"
123
+ request_id: str | None = None
124
+ code: str # an ErrorCode; a plain str so an older receiver accepts new codes
125
+ message: str
126
+
127
+
128
+ # What each side receives.
129
+ AgentFrame = Register | Heartbeat | Response | Error # agent -> relay
130
+ RelayFrame = Registered | Request | Error # relay -> agent
131
+ _agent_frame = TypeAdapter(Annotated[AgentFrame, Field(discriminator="type")])
132
+ _relay_frame = TypeAdapter(Annotated[RelayFrame, Field(discriminator="type")])
133
+
134
+
135
+ class InvalidFrame(ValueError):
136
+ """A frame the receiver cannot read; the text is safe to send back in an ``error``."""
137
+
138
+ def __init__(self, message: str, request_id: str | None = None) -> None:
139
+ super().__init__(message)
140
+ self.request_id = request_id
141
+
142
+
143
+ def parse_agent_frame(text: str) -> AgentFrame:
144
+ """A frame the relay received from an agent."""
145
+ return _parse(text, _agent_frame)
146
+
147
+
148
+ def parse_relay_frame(text: str) -> RelayFrame:
149
+ """A frame the agent received from the relay."""
150
+ return _parse(text, _relay_frame)
151
+
152
+
153
+ def _parse(text: str, adapter: TypeAdapter):
154
+ try:
155
+ data = json.loads(text)
156
+ except ValueError:
157
+ raise InvalidFrame("frame is not JSON") from None
158
+ if not isinstance(data, dict):
159
+ raise InvalidFrame("frame is not a JSON object")
160
+ kind = data.get("type")
161
+ if not isinstance(kind, str):
162
+ raise InvalidFrame("frame has no type")
163
+ request_id = data.get("request_id")
164
+ request_id = request_id if isinstance(request_id, str) else None
165
+ try:
166
+ return adapter.validate_python(data)
167
+ except ValidationError as exc:
168
+ if any(err["type"] == "union_tag_invalid" for err in exc.errors()):
169
+ raise InvalidFrame(f"unknown type {kind!r}", request_id) from None
170
+ fields = ", ".join(
171
+ ".".join(str(part) for part in err["loc"][1:]) or "body"
172
+ for err in exc.errors()
173
+ )
174
+ raise InvalidFrame(f"{kind} fails validation: {fields}", request_id) from None
175
+
176
+
177
+ def dump_frame(frame: BaseModel) -> str:
178
+ return frame.model_dump_json(exclude_none=True)
@@ -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,15 @@ 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
- 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),
54
+ self._sdk_client._build_url(
55
+ self._sdk_client._require_vlm_url(), VLMEndpoint.CHAT
56
+ ),
59
57
  json=body,
60
58
  )
61
59
 
@@ -67,8 +65,8 @@ class AsyncVLMResource(VLMMixin):
67
65
  ) -> ResponseNormalModel | ResponseErrorModel:
68
66
  """Poll the result of a previously submitted inference request.
69
67
 
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.
68
+ Sent with the client's token (``credential``, ``login()`` or ``get_access_token()``);
69
+ a 401 gets one fresh token and one retry.
72
70
 
73
71
  Args:
74
72
  result_id: Chat result ID returned from a prior chat() call.
@@ -82,16 +80,12 @@ class AsyncVLMResource(VLMMixin):
82
80
  NetworkError: If the request times out or a network error occurs.
83
81
  VisionaiSDKError: If the request fails for any other reason.
84
82
  """
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(
83
+ # I/O operation (async); the client adds the token and retries once on 401
84
+ response = await self._sdk_client.request(
90
85
  "GET",
91
86
  self._sdk_client._build_url(
92
- self._sdk_client._vlm_base(), f"{VLMEndpoint.CHAT}/{result_id}"
87
+ self._sdk_client._require_vlm_url(), f"{VLMEndpoint.CHAT}/{result_id}"
93
88
  ),
94
- headers=self._sdk_client._build_auth_header(self._sdk_client._access_token),
95
89
  )
96
90
 
97
91
  # 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,15 @@ 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
- 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),
54
+ self._sdk_client._build_url(
55
+ self._sdk_client._require_vlm_url(), VLMEndpoint.CHAT
56
+ ),
59
57
  json=body,
60
58
  )
61
59
 
@@ -65,8 +63,8 @@ class VLMResource(VLMMixin):
65
63
  def get_chat(self, result_id: str) -> ResponseNormalModel | ResponseErrorModel:
66
64
  """Poll the result of a previously submitted inference request.
67
65
 
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.
66
+ Sent with the client's token (``credential``, ``login()`` or ``get_access_token()``);
67
+ a 401 gets one fresh token and one retry.
70
68
 
71
69
  Args:
72
70
  result_id: Chat result ID returned from a prior chat() call.
@@ -80,16 +78,12 @@ class VLMResource(VLMMixin):
80
78
  NetworkError: If the request times out or a network error occurs.
81
79
  VisionaiSDKError: If the request fails for any other reason.
82
80
  """
83
- # Ensure token is valid
84
- self._sdk_client._ensure_token()
85
-
86
- # I/O operation (sync)
87
- response = self._sdk_client._request(
81
+ # I/O operation (sync); the client adds the token and retries once on 401
82
+ response = self._sdk_client.request(
88
83
  "GET",
89
84
  self._sdk_client._build_url(
90
- self._sdk_client._vlm_base(), f"{VLMEndpoint.CHAT}/{result_id}"
85
+ self._sdk_client._require_vlm_url(), f"{VLMEndpoint.CHAT}/{result_id}"
91
86
  ),
92
- headers=self._sdk_client._build_auth_header(self._sdk_client._access_token),
93
87
  )
94
88
 
95
89
  # Parse response (from Mixin)