visionai-sdk-python 2.2.0.dev2__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,8 +1,10 @@
1
1
  """One client-credentials token for every client and caller in a process.
2
2
 
3
- Platform mints at most 50 tokens per client per month, so the token is cached
4
- until five minutes before it expires, one mint runs at a time, a 401 buys one
5
- replacement, and a 429 stops minting for an hour.
3
+ A mint is a round trip to Platform and, on sites that cap client tokens, a
4
+ scarce one, so the token is cached until shortly before it expires, one mint
5
+ runs at a time, a refresh that fails keeps the current token while Platform
6
+ still accepts it, a 401 replaces a token that is old enough to have been
7
+ revoked, and a 429 stops minting for an hour.
6
8
  """
7
9
 
8
10
  from __future__ import annotations
@@ -20,31 +22,43 @@ import httpx
20
22
  from .._base import _BaseClient
21
23
  from .._request_attribution import merge_request_attribution
22
24
  from ..endpoints import AuthEndpoint
23
- from ..exceptions import RateLimitedError, VisionaiSDKError
25
+ from ..exceptions import (
26
+ RateLimitedError,
27
+ VisionaiSDKError,
28
+ from_transport_error,
29
+ raise_for_status,
30
+ )
24
31
  from .models import TokenResponse
25
32
 
26
33
  logger = logging.getLogger(__name__)
27
34
 
28
- # A token counts as expired this long before Platform says so.
35
+ # A token is refreshed this long before it expires, or at half its lifetime when
36
+ # that is sooner, so a token is never served after Platform stops accepting it.
29
37
  REFRESH_BUFFER_S = 300
30
- # Anything shorter would mint on every call; Platform issues 3-day tokens.
31
- MIN_EXPIRES_IN_S = 2 * REFRESH_BUFFER_S
32
- # After a 429 (the monthly quota is used up) no mint is tried for this long.
33
- QUOTA_COOLDOWN_S = 3600
38
+ # After a 429 no mint is tried for this long.
39
+ RATE_LIMIT_COOLDOWN_S = 3600
40
+ # A 401 on a token minted less than this long ago does not replace it.
41
+ REPLACE_COOLDOWN_S = 30 * 60
34
42
 
35
43
  _now = time.monotonic
36
44
 
37
45
 
38
46
  @dataclass(frozen=True)
39
47
  class AccessToken:
40
- """A bearer token and the ``time.monotonic()`` second it expires at."""
48
+ """A bearer token with the ``time.monotonic()`` seconds it expires at and was issued."""
41
49
 
42
50
  token: str
43
51
  expires_on: float
52
+ token_type: str
53
+ issued_at: float
54
+
55
+ def expires_in(self) -> int:
56
+ """Seconds left before ``expires_on``, never negative."""
57
+ return max(int(self.expires_on - _now()), 0)
44
58
 
45
59
 
46
60
  class _Credentials:
47
- """What the sync and async variants share: the cache, the 401 and 429 rules."""
61
+ """What the sync and async variants share: the cache, the refresh, 401 and 429 rules."""
48
62
 
49
63
  def __init__(self, auth_url: str, client_id: str, client_secret: str) -> None:
50
64
  if not auth_url.strip():
@@ -57,12 +71,9 @@ class _Credentials:
57
71
  self.client_id = client_id.strip()
58
72
  self._client_secret = client_secret
59
73
  self._url = _BaseClient._build_url(self.auth_url, AuthEndpoint.CLIENT_TOKEN)
60
- self._token: AccessToken | None = None
61
- #: The last token endpoint response, for callers that want ``token_type`` too.
62
- self.last_response: TokenResponse | None = None
63
- # True while the cached token is the replacement a 401 bought.
64
- self._replaced = False
74
+ self._current: AccessToken | None = None # the token this credential holds
65
75
  self._blocked_until = 0.0
76
+ self._minting = False # a mint is in flight (set under the lock)
66
77
 
67
78
  def is_for(self, client_id: str, client_secret: str) -> bool:
68
79
  """True when this credential was built for exactly these client credentials."""
@@ -70,63 +81,89 @@ class _Credentials:
70
81
  self._client_secret, client_secret
71
82
  )
72
83
 
73
- def _cached(self) -> AccessToken | None:
74
- if self._token and _now() < self._token.expires_on - REFRESH_BUFFER_S:
75
- return self._token
84
+ def _fresh(self) -> AccessToken | None:
85
+ """The current token while it is not yet due for a refresh."""
86
+ token = self._current
87
+ if token is None:
88
+ return None
89
+ buffer = min(REFRESH_BUFFER_S, (token.expires_on - token.issued_at) / 2)
90
+ return token if _now() < token.expires_on - buffer else None
91
+
92
+ def _valid(self) -> AccessToken | None:
93
+ """The current token while Platform still accepts it, due for a refresh or not."""
94
+ if self._current and _now() < self._current.expires_on:
95
+ return self._current
76
96
  return None
77
97
 
78
- def _mint_request(self) -> dict[str, Any]:
79
- """kwargs for the client-token POST; refuses while a 429 cooldown runs."""
98
+ def _begin_mint(self) -> dict[str, Any]:
99
+ """First half of a mint: the 429 gate, then the kwargs for the client-token POST."""
80
100
  if _now() < self._blocked_until:
81
101
  raise RateLimitedError(
82
- "client-token quota used up; not minting until the cooldown ends"
102
+ "client-token requests are rate limited; not minting until the cooldown ends"
83
103
  )
84
- if self._token is not None:
85
- # Natural refresh: the next 401 may buy one replacement again.
86
- self._replaced = False
87
104
  kwargs: dict[str, Any] = {
88
105
  "json": {"client_id": self.client_id, "client_secret": self._client_secret}
89
106
  }
90
107
  merge_request_attribution(kwargs)
91
108
  return kwargs
92
109
 
93
- def _store(self, response: httpx.Response) -> AccessToken:
110
+ def _finish_mint(self, response: httpx.Response) -> AccessToken:
111
+ """Second half of a mint: the response becomes the current token, or raises."""
94
112
  try:
95
- _BaseClient._handle_response(response)
113
+ raise_for_status(response)
96
114
  except RateLimitedError:
97
- self._blocked_until = _now() + QUOTA_COOLDOWN_S
115
+ self._blocked_until = _now() + RATE_LIMIT_COOLDOWN_S
98
116
  raise
99
117
  try:
100
118
  parsed = TokenResponse(**response.json())
101
119
  except (ValueError, TypeError) as e:
102
120
  raise VisionaiSDKError(f"client-token response is not a token: {e}") from e
103
- self.last_response = parsed
104
- self._token = AccessToken(
105
- parsed.access_token, _now() + max(parsed.expires_in, MIN_EXPIRES_IN_S)
121
+ now = _now()
122
+ self._current = AccessToken(
123
+ parsed.access_token, now + parsed.expires_in, parsed.token_type, now
106
124
  )
107
125
  logger.info(
108
126
  "platform token minted",
109
127
  extra={"client_id": self.client_id, "expires_in": parsed.expires_in},
110
128
  )
111
- return self._token
129
+ return self._current
130
+
131
+ def _after_failed_refresh(self, error: VisionaiSDKError) -> AccessToken:
132
+ """A refresh failed: keep serving the current token while it is valid, else raise."""
133
+ valid = self._valid()
134
+ if valid is None:
135
+ raise error
136
+ logger.warning(
137
+ "token refresh failed; using the current token until it expires",
138
+ extra={
139
+ "client_id": self.client_id,
140
+ "error": str(error),
141
+ "expires_in": valid.expires_in(),
142
+ },
143
+ )
144
+ return valid
112
145
 
113
146
  def invalidate(self, token: str) -> bool:
114
147
  """Report a 401 for ``token``. True: dropped, so the next ``get_token()`` mints.
115
148
 
116
- A 401 for a token already replaced changes nothing. A 401 for the
117
- replacement itself is not expiry but a client or allowlist problem that
118
- another mint would not fix, so that token stays until its own refresh.
149
+ A late report for a token no longer held changes nothing. A token minted
150
+ less than ``REPLACE_COOLDOWN_S`` ago is kept: a 401 that soon is a client or
151
+ allowlist problem another mint would not fix, and the cooldown bounds how
152
+ often a persistent 401 can mint. While a mint is in flight the report is
153
+ already being acted on.
119
154
  """
120
- if self._token is None or token != self._token.token:
155
+ if self._current is None or token != self._current.token:
121
156
  return False
122
- if self._replaced:
123
- logger.error(
124
- "replacement token rejected too; keeping it until it expires",
157
+ if self._minting:
158
+ return True
159
+ if _now() - self._current.issued_at < REPLACE_COOLDOWN_S:
160
+ logger.warning(
161
+ "401 on a token minted %d s ago; keeping it until the cooldown ends",
162
+ int(_now() - self._current.issued_at),
125
163
  extra={"client_id": self.client_id},
126
164
  )
127
165
  return False
128
- self._token = None
129
- self._replaced = True
166
+ self._current = None
130
167
  return True
131
168
 
132
169
 
@@ -149,32 +186,43 @@ class ClientCredentials(_Credentials):
149
186
  ) -> None:
150
187
  super().__init__(auth_url, client_id, client_secret)
151
188
  self._http = http or httpx.Client(verify=verify_ssl, timeout=timeout)
152
- self._owns_http = http is None
189
+ self._http_owner = http is None # a client we built is ours to close
153
190
  self._lock = threading.Lock()
154
191
 
155
- def get_token(self) -> AccessToken:
156
- """The cached token, or a fresh one when it is within five minutes of expiry.
192
+ def get_token(self, *, force_refresh: bool = False) -> AccessToken:
193
+ """The cached token, refreshed shortly before it expires.
194
+
195
+ ``force_refresh=True`` mints even while the cached token is fresh. A refresh
196
+ that fails keeps serving the current token while Platform still accepts it.
157
197
 
158
198
  Raises:
159
199
  AuthenticationError: Platform refused the client credentials (401).
160
- RateLimitedError: The monthly mint quota is used up (429), or a 429 was
161
- seen less than an hour ago.
200
+ RateLimitedError: Platform answered 429, or did so less than an hour ago.
162
201
  NetworkError: Platform could not be reached.
163
202
  VisionaiSDKError: Any other failure.
164
203
  """
165
204
  with self._lock:
166
- cached = self._cached()
167
- if cached is not None:
168
- return cached
169
- kwargs = self._mint_request()
205
+ if not force_refresh:
206
+ fresh = self._fresh()
207
+ if fresh is not None:
208
+ return fresh
209
+ self._minting = True
170
210
  try:
171
- response = self._http.post(self._url, **kwargs)
172
- except httpx.RequestError as e:
173
- raise _BaseClient._map_transport_error(e) from e
174
- return self._store(response)
211
+ kwargs = self._begin_mint()
212
+ try:
213
+ response = self._http.post(self._url, **kwargs)
214
+ except httpx.RequestError as e:
215
+ raise from_transport_error(e) from e
216
+ return self._finish_mint(response)
217
+ except VisionaiSDKError as e:
218
+ if force_refresh:
219
+ raise
220
+ return self._after_failed_refresh(e)
221
+ finally:
222
+ self._minting = False
175
223
 
176
224
  def close(self) -> None:
177
- if self._owns_http:
225
+ if self._http_owner:
178
226
  self._http.close()
179
227
 
180
228
  def __enter__(self) -> ClientCredentials:
@@ -204,27 +252,37 @@ class AsyncClientCredentials(_Credentials):
204
252
  ) -> None:
205
253
  super().__init__(auth_url, client_id, client_secret)
206
254
  self._http = http or httpx.AsyncClient(verify=verify_ssl, timeout=timeout)
207
- self._owns_http = http is None
255
+ self._http_owner = http is None # a client we built is ours to close
208
256
  self._lock = asyncio.Lock()
209
257
 
210
- async def get_token(self) -> AccessToken:
211
- """The cached token, or a fresh one when it is within five minutes of expiry.
258
+ async def get_token(self, *, force_refresh: bool = False) -> AccessToken:
259
+ """The cached token, refreshed shortly before it expires.
212
260
 
213
- Concurrent callers wait for one mint. Raises as ``ClientCredentials.get_token``.
261
+ Concurrent callers wait for one mint. Behaves and raises as
262
+ ``ClientCredentials.get_token``.
214
263
  """
215
264
  async with self._lock:
216
- cached = self._cached()
217
- if cached is not None:
218
- return cached
219
- kwargs = self._mint_request()
265
+ if not force_refresh:
266
+ fresh = self._fresh()
267
+ if fresh is not None:
268
+ return fresh
269
+ self._minting = True
220
270
  try:
221
- response = await self._http.post(self._url, **kwargs)
222
- except httpx.RequestError as e:
223
- raise _BaseClient._map_transport_error(e) from e
224
- return self._store(response)
271
+ kwargs = self._begin_mint()
272
+ try:
273
+ response = await self._http.post(self._url, **kwargs)
274
+ except httpx.RequestError as e:
275
+ raise from_transport_error(e) from e
276
+ return self._finish_mint(response)
277
+ except VisionaiSDKError as e:
278
+ if force_refresh:
279
+ raise
280
+ return self._after_failed_refresh(e)
281
+ finally:
282
+ self._minting = False
225
283
 
226
284
  async def aclose(self) -> None:
227
- if self._owns_http:
285
+ if self._http_owner:
228
286
  await self._http.aclose()
229
287
 
230
288
  async def __aenter__(self) -> AsyncClientCredentials:
@@ -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
 
@@ -41,7 +41,7 @@ class Client(_BaseClient):
41
41
  max_keepalive_connections=self.max_keepalive_connections,
42
42
  ),
43
43
  )
44
- self._owns_http = http_client is None
44
+ self._http_owner = http_client is None # a client we built is ours to close
45
45
  # Register the different function resource
46
46
  self.auth = AuthResource(self)
47
47
  self.vlm = VLMResource(self)
@@ -52,46 +52,41 @@ class Client(_BaseClient):
52
52
  try:
53
53
  response = self._client.request(method, url, **kwargs)
54
54
  except httpx.RequestError as e:
55
- raise self._map_transport_error(e) from e
55
+ raise from_transport_error(e) from e
56
56
  return self._handle_response(response)
57
57
 
58
58
  def request(self, method: str, url: str, **kwargs) -> httpx.Response:
59
59
  """Send any request with this client's bearer token.
60
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.
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.
64
67
 
65
68
  Raises:
69
+ ValueError: ``url`` is not a full URL.
66
70
  AuthenticationError: No token, or the fresh one was refused too.
67
71
  RateLimitedError, NetworkError, ServerError, ClientError: As the
68
72
  resource methods.
69
73
  """
70
- url = self._absolute(url)
71
- self._ensure_token()
72
- token = self._access_token
74
+ url = self._full_url(url)
75
+ token = self._ensure_token()
73
76
  try:
74
77
  return self._request(method, url, **self._with_auth(kwargs, token))
75
78
  except AuthenticationError:
76
- if not self._renew_rejected(token):
79
+ if self._credential is None or not self._resendable(kwargs):
77
80
  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
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))
91
86
 
92
87
  def close(self) -> None:
93
88
  """Close the HTTP client this client created and release its connections."""
94
- if self._owns_http:
89
+ if self._http_owner:
95
90
  self._client.close()
96
91
 
97
92
  def __enter__(self) -> "Client":
@@ -103,7 +98,9 @@ class Client(_BaseClient):
103
98
  self.close()
104
99
 
105
100
  def _refresh_token(self) -> None:
106
- """Refresh token using stored credentials.
101
+ """Refresh the token with the stored login credentials.
102
+
103
+ Client credentials refresh through the credential object instead.
107
104
 
108
105
  Raises:
109
106
  AuthenticationError: If no credentials are stored or refresh fails.
@@ -111,26 +108,19 @@ class Client(_BaseClient):
111
108
  if self._credentials is None or self._credentials_type is None:
112
109
  raise AuthenticationError("No credentials available for token refresh")
113
110
 
114
- if self._credentials_type == "login":
115
- self.auth.login(
116
- email=self._credentials["email"],
117
- password=self._credentials["password"],
118
- )
119
- elif self._credentials_type == "client":
120
- self.auth.get_access_token(
121
- client_id=self._credentials["client_id"],
122
- client_secret=self._credentials["client_secret"],
123
- )
111
+ self.auth.login(
112
+ email=self._credentials["email"],
113
+ password=self._credentials["password"],
114
+ )
124
115
 
125
- def _ensure_token(self) -> None:
126
- """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.
127
118
 
128
119
  Raises:
129
120
  AuthenticationError: If no token is available or token expired without credentials.
130
121
  """
131
122
  if self._credential is not None:
132
- self._access_token = (self._credential.get_token()).token
133
- return
123
+ return (self._credential.get_token()).token
134
124
  if self._access_token is None:
135
125
  raise AuthenticationError(
136
126
  "Not authenticated. Call login() or get_access_token() first."
@@ -142,3 +132,4 @@ class Client(_BaseClient):
142
132
  "Token expired and no credentials available for refresh"
143
133
  )
144
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
+ ]