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.
- visionai_sdk_python/_base.py +46 -61
- visionai_sdk_python/async_client.py +50 -20
- visionai_sdk_python/auth/_mixin.py +1 -1
- visionai_sdk_python/auth/async_resource.py +15 -13
- visionai_sdk_python/auth/credentials.py +126 -68
- visionai_sdk_python/auth/resource.py +17 -13
- visionai_sdk_python/client.py +50 -20
- visionai_sdk_python/exceptions.py +51 -1
- visionai_sdk_python/relay/__init__.py +67 -0
- visionai_sdk_python/relay/protocol.py +178 -0
- visionai_sdk_python/vlm/async_resource.py +12 -18
- visionai_sdk_python/vlm/resource.py +12 -18
- {visionai_sdk_python-2.2.0.dev1.dist-info → visionai_sdk_python-2.2.0.dev3.dist-info}/METADATA +25 -10
- visionai_sdk_python-2.2.0.dev3.dist-info/RECORD +30 -0
- visionai_sdk_python-2.2.0.dev1.dist-info/RECORD +0 -28
- {visionai_sdk_python-2.2.0.dev1.dist-info → visionai_sdk_python-2.2.0.dev3.dist-info}/WHEEL +0 -0
- {visionai_sdk_python-2.2.0.dev1.dist-info → visionai_sdk_python-2.2.0.dev3.dist-info}/top_level.txt +0 -0
|
@@ -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(
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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=
|
|
76
|
-
|
|
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.
|
visionai_sdk_python/client.py
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
-
|
|
76
|
-
self.
|
|
77
|
-
|
|
78
|
-
|
|
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) ->
|
|
87
|
-
"""
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,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.
|
|
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(
|
|
58
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
#
|
|
86
|
-
await self._sdk_client.
|
|
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.
|
|
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
|
-
|
|
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,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.
|
|
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(
|
|
58
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
#
|
|
84
|
-
self._sdk_client.
|
|
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.
|
|
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)
|