cortex-suite-sdk 1.0.2__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.
- cortex_sdk/__init__.py +4 -0
- cortex_sdk/_generated_constants.py +26 -0
- cortex_sdk/_generated_errors.py +27 -0
- cortex_sdk/auth.py +117 -0
- cortex_sdk/client.py +331 -0
- cortex_sdk/constants.py +50 -0
- cortex_sdk/errors.py +49 -0
- cortex_sdk/liveness.py +111 -0
- cortex_sdk/py.typed +1 -0
- cortex_sdk/session.py +184 -0
- cortex_sdk/transport.py +129 -0
- cortex_sdk/types.py +55 -0
- cortex_sdk/upload.py +50 -0
- cortex_suite_sdk-1.0.2.dist-info/METADATA +77 -0
- cortex_suite_sdk-1.0.2.dist-info/RECORD +16 -0
- cortex_suite_sdk-1.0.2.dist-info/WHEEL +4 -0
cortex_sdk/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# Generated from shared/constants.json. Do not edit manually.
|
|
4
|
+
|
|
5
|
+
DEFAULT_AUTH_URL: str = 'https://auth.cortexsuite.app'
|
|
6
|
+
AUTH_TOKEN_PATH: str = '/auth/token'
|
|
7
|
+
AUTH_REFRESH_PATH: str = '/auth/refresh'
|
|
8
|
+
WS_SUBPROTOCOL: str = 'cortex-sdk.v1'
|
|
9
|
+
WS_SUBPROTOCOL_JWT_PREFIX: str = 'cortex-sdk.jwt.'
|
|
10
|
+
SCHEMA_VERSION: str = '1.0'
|
|
11
|
+
|
|
12
|
+
DEFAULT_CONNECT_TIMEOUT_MS: int = 10000
|
|
13
|
+
DEFAULT_SEND_TIMEOUT_MS: int = 10000
|
|
14
|
+
DEFAULT_RESYNC_TIMEOUT_MS: int = 15000
|
|
15
|
+
DEFAULT_PING_INTERVAL_MS: int = 15000
|
|
16
|
+
DEFAULT_PONG_TIMEOUT_MS: int = 5000
|
|
17
|
+
DEFAULT_STALE_THRESHOLD_MS: int = 45000
|
|
18
|
+
TOKEN_REFRESH_BUFFER_MS: int = 60000
|
|
19
|
+
|
|
20
|
+
RECONNECT_BACKOFF_MS: tuple[int, ...] = (1000, 2000, 5000, 10000, 20000, 30000,)
|
|
21
|
+
|
|
22
|
+
# Deprecated compatibility aliases. Prefer DEFAULT_AUTH_URL/AUTH_TOKEN_PATH,
|
|
23
|
+
# DEFAULT_AUTH_URL/AUTH_REFRESH_PATH, and WS_SUBPROTOCOL directly.
|
|
24
|
+
CORTEX_AUTH_URL: str = DEFAULT_AUTH_URL + AUTH_TOKEN_PATH
|
|
25
|
+
CORTEX_REFRESH_URL: str = DEFAULT_AUTH_URL + AUTH_REFRESH_PATH
|
|
26
|
+
WS_SUBPROTOCOL_BASE: str = WS_SUBPROTOCOL
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
# Generated from sdk/shared/errors.json. Do not edit manually.
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class GeneratedErrorEntry:
|
|
9
|
+
code: str
|
|
10
|
+
retryable: bool
|
|
11
|
+
fatal: bool
|
|
12
|
+
|
|
13
|
+
GENERATED_ERROR_CATALOG: tuple[GeneratedErrorEntry, ...] = (
|
|
14
|
+
GeneratedErrorEntry('auth_invalid', retryable=False, fatal=True),
|
|
15
|
+
GeneratedErrorEntry('auth_expired', retryable=True, fatal=False),
|
|
16
|
+
GeneratedErrorEntry('auth_refresh_failed', retryable=False, fatal=True),
|
|
17
|
+
GeneratedErrorEntry('transport_connect_timeout', retryable=True, fatal=False),
|
|
18
|
+
GeneratedErrorEntry('transport_send_timeout', retryable=True, fatal=False),
|
|
19
|
+
GeneratedErrorEntry('transport_protocol_violation', retryable=False, fatal=True),
|
|
20
|
+
GeneratedErrorEntry('session_not_found', retryable=False, fatal=True),
|
|
21
|
+
GeneratedErrorEntry('session_terminal', retryable=False, fatal=True),
|
|
22
|
+
GeneratedErrorEntry('resync_timeout', retryable=True, fatal=False),
|
|
23
|
+
GeneratedErrorEntry('replay_unavailable', retryable=True, fatal=False),
|
|
24
|
+
GeneratedErrorEntry('upload_failed', retryable=True, fatal=False),
|
|
25
|
+
GeneratedErrorEntry('upload_too_large', retryable=False, fatal=False),
|
|
26
|
+
GeneratedErrorEntry('upload_type_rejected', retryable=False, fatal=False),
|
|
27
|
+
)
|
cortex_sdk/auth.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .constants import (
|
|
10
|
+
DEFAULT_AUTH_URL,
|
|
11
|
+
AUTH_TOKEN_PATH,
|
|
12
|
+
AUTH_REFRESH_PATH,
|
|
13
|
+
TOKEN_REFRESH_BUFFER,
|
|
14
|
+
)
|
|
15
|
+
from .errors import make_error
|
|
16
|
+
from .types import AuthTokenResponse
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse_jwt_exp(token: str) -> float | None:
|
|
20
|
+
"""Extract exp (as Unix timestamp in seconds) from a JWT without verifying signature."""
|
|
21
|
+
try:
|
|
22
|
+
parts = token.split(".")
|
|
23
|
+
if len(parts) != 3:
|
|
24
|
+
return None
|
|
25
|
+
# base64url → base64 standard (add padding)
|
|
26
|
+
payload_b64 = parts[1]
|
|
27
|
+
# Add padding
|
|
28
|
+
padding = 4 - len(payload_b64) % 4
|
|
29
|
+
if padding != 4:
|
|
30
|
+
payload_b64 += "=" * padding
|
|
31
|
+
payload_json = base64.urlsafe_b64decode(payload_b64)
|
|
32
|
+
payload = json.loads(payload_json)
|
|
33
|
+
exp = payload.get("exp")
|
|
34
|
+
return float(exp) if isinstance(exp, (int, float)) else None
|
|
35
|
+
except Exception:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_token_expiring_soon(access_token: str) -> bool:
|
|
40
|
+
"""Return True if the token expires within TOKEN_REFRESH_BUFFER seconds."""
|
|
41
|
+
exp = _parse_jwt_exp(access_token)
|
|
42
|
+
if exp is None:
|
|
43
|
+
return False
|
|
44
|
+
return time.time() > exp - TOKEN_REFRESH_BUFFER
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def exchange_api_key(
|
|
48
|
+
api_key: str,
|
|
49
|
+
*,
|
|
50
|
+
auth_base_url: str = DEFAULT_AUTH_URL,
|
|
51
|
+
) -> AuthTokenResponse:
|
|
52
|
+
"""Exchange an API key for tokens and WS URL."""
|
|
53
|
+
async with httpx.AsyncClient() as client:
|
|
54
|
+
resp = await client.post(
|
|
55
|
+
_build_auth_endpoint(auth_base_url, AUTH_TOKEN_PATH),
|
|
56
|
+
headers={
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
"Authorization": f"ApiKey {api_key}",
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if not resp.is_success:
|
|
63
|
+
try:
|
|
64
|
+
body = resp.json()
|
|
65
|
+
code = body.get("error", "auth_invalid")
|
|
66
|
+
message = body.get("message", "API key rejected")
|
|
67
|
+
except Exception:
|
|
68
|
+
code, message = "auth_invalid", "API key rejected"
|
|
69
|
+
raise make_error(str(code), str(message))
|
|
70
|
+
|
|
71
|
+
return resp.json() # type: ignore[no-any-return]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def refresh_access_token(
|
|
75
|
+
refresh_token: str,
|
|
76
|
+
*,
|
|
77
|
+
auth_base_url: str = DEFAULT_AUTH_URL,
|
|
78
|
+
) -> str:
|
|
79
|
+
"""Refresh the access token using the refresh token."""
|
|
80
|
+
async with httpx.AsyncClient() as client:
|
|
81
|
+
resp = await client.post(
|
|
82
|
+
_build_auth_endpoint(auth_base_url, AUTH_REFRESH_PATH),
|
|
83
|
+
headers={
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
"Authorization": f"Bearer {refresh_token}",
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if not resp.is_success:
|
|
90
|
+
raise make_error("auth_refresh_failed", "Refresh token expired or invalid")
|
|
91
|
+
|
|
92
|
+
body = resp.json()
|
|
93
|
+
return str(body["access_token"])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def normalize_auth_base_url(auth_url: str) -> str:
|
|
97
|
+
import warnings
|
|
98
|
+
normalized = auth_url.rstrip("/")
|
|
99
|
+
# Guard: consumer may have passed a full endpoint instead of the base URL.
|
|
100
|
+
# Strip known auth paths and warn so developers catch the misconfiguration early.
|
|
101
|
+
for known_path in [AUTH_TOKEN_PATH, AUTH_REFRESH_PATH]:
|
|
102
|
+
if normalized.endswith(known_path):
|
|
103
|
+
base = normalized[: -len(known_path)]
|
|
104
|
+
warnings.warn(
|
|
105
|
+
f"[CortexSDK] auth_url must be a base URL (origin only), not a full endpoint. "
|
|
106
|
+
f'Received "{auth_url}" — "{known_path}" has been stripped automatically. '
|
|
107
|
+
f'Pass the base URL only, e.g., "{base}".',
|
|
108
|
+
UserWarning,
|
|
109
|
+
stacklevel=3,
|
|
110
|
+
)
|
|
111
|
+
normalized = base
|
|
112
|
+
break
|
|
113
|
+
return normalized or DEFAULT_AUTH_URL
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _build_auth_endpoint(auth_base_url: str, path: str) -> str:
|
|
117
|
+
return f"{normalize_auth_base_url(auth_base_url)}{path}"
|
cortex_sdk/client.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import secrets
|
|
5
|
+
from typing import BinaryIO
|
|
6
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
7
|
+
|
|
8
|
+
from .auth import (
|
|
9
|
+
exchange_api_key,
|
|
10
|
+
refresh_access_token,
|
|
11
|
+
is_token_expiring_soon,
|
|
12
|
+
normalize_auth_base_url,
|
|
13
|
+
)
|
|
14
|
+
from .constants import (
|
|
15
|
+
DEFAULT_AUTH_URL,
|
|
16
|
+
DEFAULT_CONNECT_TIMEOUT,
|
|
17
|
+
DEFAULT_SEND_TIMEOUT,
|
|
18
|
+
DEFAULT_RESYNC_TIMEOUT,
|
|
19
|
+
DEFAULT_PING_INTERVAL,
|
|
20
|
+
DEFAULT_PONG_TIMEOUT,
|
|
21
|
+
DEFAULT_STALE_THRESHOLD,
|
|
22
|
+
TOKEN_REFRESH_BUFFER,
|
|
23
|
+
RECONNECT_BACKOFF,
|
|
24
|
+
)
|
|
25
|
+
from .errors import CortexError, make_error
|
|
26
|
+
from .liveness import LivenessMonitor
|
|
27
|
+
from .session import SessionManager
|
|
28
|
+
from .transport import Transport
|
|
29
|
+
from .types import ChannelState, CortexMessage, MessageCallback, SessionState
|
|
30
|
+
from .upload import upload_file
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CortexClient:
|
|
34
|
+
"""Cortex SDK client — one instance per session.
|
|
35
|
+
|
|
36
|
+
Public API:
|
|
37
|
+
connect() / disconnect()
|
|
38
|
+
send_message(content, attachments)
|
|
39
|
+
upload_attachment(file)
|
|
40
|
+
stop()
|
|
41
|
+
session_state / channel_state / session_id (read-only properties)
|
|
42
|
+
|
|
43
|
+
Time options are in **seconds** (not milliseconds).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
api_key: str,
|
|
49
|
+
on_message: MessageCallback,
|
|
50
|
+
auth_url: str | None = None,
|
|
51
|
+
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
|
52
|
+
send_timeout: float = DEFAULT_SEND_TIMEOUT,
|
|
53
|
+
resync_timeout: float = DEFAULT_RESYNC_TIMEOUT,
|
|
54
|
+
ping_interval: float = DEFAULT_PING_INTERVAL,
|
|
55
|
+
pong_timeout: float = DEFAULT_PONG_TIMEOUT,
|
|
56
|
+
stale_threshold: float = DEFAULT_STALE_THRESHOLD,
|
|
57
|
+
# Private test override — not part of the public API
|
|
58
|
+
_upload_url: str | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._api_key = api_key
|
|
61
|
+
self._on_message_cb = on_message
|
|
62
|
+
self._auth_url = normalize_auth_base_url(auth_url or DEFAULT_AUTH_URL)
|
|
63
|
+
self._connect_timeout = connect_timeout
|
|
64
|
+
self._send_timeout = send_timeout
|
|
65
|
+
self._resync_timeout = resync_timeout
|
|
66
|
+
self._ping_interval = ping_interval
|
|
67
|
+
self._pong_timeout = pong_timeout
|
|
68
|
+
self._stale_threshold = stale_threshold
|
|
69
|
+
|
|
70
|
+
self._upload_url = _upload_url # None → runtime-side upload URL heuristic
|
|
71
|
+
|
|
72
|
+
# Internal state
|
|
73
|
+
self._channel_state: ChannelState = "CLOSED"
|
|
74
|
+
self._access_token: str | None = None
|
|
75
|
+
self._refresh_token: str | None = None
|
|
76
|
+
self._ws_url: str | None = None
|
|
77
|
+
self._channel_id: str = f"ch_{secrets.token_hex(4)}"
|
|
78
|
+
self._reconnect_attempt: int = 0
|
|
79
|
+
self._disconnect_requested: bool = False
|
|
80
|
+
|
|
81
|
+
# Components
|
|
82
|
+
self._transport = Transport(connect_timeout, send_timeout)
|
|
83
|
+
self._session = SessionManager(
|
|
84
|
+
on_message=self._dispatch_message,
|
|
85
|
+
on_fatal_error=self._handle_fatal_error,
|
|
86
|
+
)
|
|
87
|
+
self._liveness: LivenessMonitor | None = None
|
|
88
|
+
self._reconnect_task: asyncio.Task[None] | None = None
|
|
89
|
+
self._token_refresh_task: asyncio.Task[None] | None = None
|
|
90
|
+
|
|
91
|
+
# Wire transport callbacks
|
|
92
|
+
self._transport.on_message = self._session.handle_incoming
|
|
93
|
+
self._transport.on_close = self._handle_close
|
|
94
|
+
self._transport.on_error = None # handled via on_close
|
|
95
|
+
|
|
96
|
+
# ── public properties ─────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def session_state(self) -> SessionState:
|
|
100
|
+
return self._session.session_state
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def channel_state(self) -> ChannelState:
|
|
104
|
+
return self._channel_state
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def session_id(self) -> str | None:
|
|
108
|
+
return self._session.session_id
|
|
109
|
+
|
|
110
|
+
# ── public methods ────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
async def connect(self) -> None:
|
|
113
|
+
"""Full bootstrap: auth exchange → WS open → system::init → liveness start."""
|
|
114
|
+
self._disconnect_requested = False
|
|
115
|
+
self._reconnect_attempt = 0
|
|
116
|
+
|
|
117
|
+
auth = await exchange_api_key(self._api_key, auth_base_url=self._auth_url)
|
|
118
|
+
self._access_token = auth["access_token"]
|
|
119
|
+
self._refresh_token = auth["refresh_token"]
|
|
120
|
+
self._ws_url = auth["ws_url"]
|
|
121
|
+
if self._upload_url is None:
|
|
122
|
+
self._upload_url = _derive_upload_url_from_ws_url(self._ws_url)
|
|
123
|
+
|
|
124
|
+
await self._open_channel()
|
|
125
|
+
|
|
126
|
+
self._session.set_transport(self._transport, self._send_timeout)
|
|
127
|
+
await self._session.send_init(auth["runtime_bootstrap"])
|
|
128
|
+
|
|
129
|
+
self._start_liveness()
|
|
130
|
+
self._schedule_token_refresh()
|
|
131
|
+
|
|
132
|
+
async def disconnect(self) -> None:
|
|
133
|
+
"""Close the session and WebSocket connection cleanly."""
|
|
134
|
+
self._disconnect_requested = True
|
|
135
|
+
self._stop_liveness()
|
|
136
|
+
self._stop_token_refresh()
|
|
137
|
+
|
|
138
|
+
if self._reconnect_task and not self._reconnect_task.done():
|
|
139
|
+
self._reconnect_task.cancel()
|
|
140
|
+
try:
|
|
141
|
+
await self._reconnect_task
|
|
142
|
+
except (asyncio.CancelledError, Exception):
|
|
143
|
+
pass
|
|
144
|
+
self._reconnect_task = None
|
|
145
|
+
|
|
146
|
+
self._channel_state = "CLOSED"
|
|
147
|
+
await self._transport.aclose()
|
|
148
|
+
|
|
149
|
+
async def send_message(
|
|
150
|
+
self, content: str, attachments: list[str] | None = None
|
|
151
|
+
) -> None:
|
|
152
|
+
await self._session.send_chat_message(content, attachments)
|
|
153
|
+
|
|
154
|
+
async def upload_attachment(self, file: str | bytes | BinaryIO) -> str:
|
|
155
|
+
if not self._access_token:
|
|
156
|
+
raise make_error("auth_invalid", "Not connected")
|
|
157
|
+
url = self._upload_url or "/upload"
|
|
158
|
+
return await upload_file(file, self._access_token, upload_url=url)
|
|
159
|
+
|
|
160
|
+
async def stop(self) -> None:
|
|
161
|
+
await self._session.send_stop()
|
|
162
|
+
|
|
163
|
+
# ── internal helpers ──────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
async def _open_channel(self) -> None:
|
|
166
|
+
if not self._ws_url or not self._access_token:
|
|
167
|
+
raise RuntimeError("Auth not completed before _open_channel")
|
|
168
|
+
self._channel_state = "CONNECTING"
|
|
169
|
+
await self._transport.open(self._ws_url, self._access_token)
|
|
170
|
+
self._channel_state = "OPEN"
|
|
171
|
+
self._reconnect_attempt = 0
|
|
172
|
+
|
|
173
|
+
def _start_liveness(self) -> None:
|
|
174
|
+
self._stop_liveness()
|
|
175
|
+
self._liveness = LivenessMonitor(
|
|
176
|
+
transport=self._transport,
|
|
177
|
+
ping_interval=self._ping_interval,
|
|
178
|
+
pong_timeout=self._pong_timeout,
|
|
179
|
+
stale_threshold=self._stale_threshold,
|
|
180
|
+
on_stale=self._handle_stale,
|
|
181
|
+
get_session_id=lambda: self._session.session_id,
|
|
182
|
+
get_channel_id=lambda: self._channel_id,
|
|
183
|
+
)
|
|
184
|
+
self._liveness.start()
|
|
185
|
+
|
|
186
|
+
def _stop_liveness(self) -> None:
|
|
187
|
+
if self._liveness:
|
|
188
|
+
self._liveness.stop()
|
|
189
|
+
self._liveness = None
|
|
190
|
+
|
|
191
|
+
def _schedule_token_refresh(self) -> None:
|
|
192
|
+
self._stop_token_refresh()
|
|
193
|
+
|
|
194
|
+
async def _refresh_loop() -> None:
|
|
195
|
+
try:
|
|
196
|
+
while not self._disconnect_requested:
|
|
197
|
+
await asyncio.sleep(TOKEN_REFRESH_BUFFER / 2)
|
|
198
|
+
if not self._access_token or not self._refresh_token:
|
|
199
|
+
continue
|
|
200
|
+
if is_token_expiring_soon(self._access_token):
|
|
201
|
+
try:
|
|
202
|
+
self._access_token = await refresh_access_token(
|
|
203
|
+
self._refresh_token,
|
|
204
|
+
auth_base_url=self._auth_url,
|
|
205
|
+
)
|
|
206
|
+
except Exception:
|
|
207
|
+
pass # surfaced at next reconnect
|
|
208
|
+
except asyncio.CancelledError:
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
self._token_refresh_task = asyncio.create_task(_refresh_loop())
|
|
212
|
+
|
|
213
|
+
def _stop_token_refresh(self) -> None:
|
|
214
|
+
if self._token_refresh_task and not self._token_refresh_task.done():
|
|
215
|
+
self._token_refresh_task.cancel()
|
|
216
|
+
self._token_refresh_task = None
|
|
217
|
+
|
|
218
|
+
# ── callbacks from transport / session ────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
def _dispatch_message(self, msg: CortexMessage) -> None:
|
|
221
|
+
# system::pong is internal — route to liveness, not to user callback
|
|
222
|
+
if msg.get("type") == "system::pong":
|
|
223
|
+
hb_id = msg["payload"].get("heartbeat_id")
|
|
224
|
+
if isinstance(hb_id, str) and self._liveness:
|
|
225
|
+
self._liveness.record_pong(hb_id)
|
|
226
|
+
return
|
|
227
|
+
self._on_message_cb(msg)
|
|
228
|
+
|
|
229
|
+
def _handle_fatal_error(self, err: CortexError) -> None:
|
|
230
|
+
self._channel_state = "AUTH_FAILED"
|
|
231
|
+
self._stop_liveness()
|
|
232
|
+
self._stop_token_refresh()
|
|
233
|
+
self._transport.close()
|
|
234
|
+
# Surface error as a system::error message
|
|
235
|
+
error_msg: CortexMessage = {
|
|
236
|
+
"type": "system::error",
|
|
237
|
+
"schema": "1.0",
|
|
238
|
+
"session_id": self._session.session_id or "",
|
|
239
|
+
"payload": {"code": err.code, "message": str(err)},
|
|
240
|
+
"ts": _now_iso(),
|
|
241
|
+
}
|
|
242
|
+
self._on_message_cb(error_msg)
|
|
243
|
+
|
|
244
|
+
def _handle_stale(self) -> None:
|
|
245
|
+
if self._channel_state in ("STALE", "RECONNECTING"):
|
|
246
|
+
return
|
|
247
|
+
self._channel_state = "STALE"
|
|
248
|
+
self._stop_liveness()
|
|
249
|
+
self._transport.close(1001, "stale")
|
|
250
|
+
# on_close will trigger reconnect
|
|
251
|
+
|
|
252
|
+
def _handle_close(self, code: int, reason: str) -> None:
|
|
253
|
+
if self._disconnect_requested:
|
|
254
|
+
return
|
|
255
|
+
if self._channel_state == "AUTH_FAILED":
|
|
256
|
+
return
|
|
257
|
+
if code == 4001:
|
|
258
|
+
self._channel_state = "AUTH_FAILED"
|
|
259
|
+
return
|
|
260
|
+
self._channel_state = "RECONNECTING"
|
|
261
|
+
self._reconnect_task = asyncio.ensure_future(self._reconnect_loop())
|
|
262
|
+
|
|
263
|
+
async def _reconnect_loop(self) -> None:
|
|
264
|
+
try:
|
|
265
|
+
while (
|
|
266
|
+
not self._disconnect_requested
|
|
267
|
+
and self._channel_state != "AUTH_FAILED"
|
|
268
|
+
):
|
|
269
|
+
idx = min(self._reconnect_attempt, len(RECONNECT_BACKOFF) - 1)
|
|
270
|
+
backoff = RECONNECT_BACKOFF[idx]
|
|
271
|
+
self._reconnect_attempt += 1
|
|
272
|
+
|
|
273
|
+
await asyncio.sleep(backoff)
|
|
274
|
+
if self._disconnect_requested:
|
|
275
|
+
break
|
|
276
|
+
|
|
277
|
+
# Proactively refresh token if needed
|
|
278
|
+
try:
|
|
279
|
+
await self._maybe_refresh_token()
|
|
280
|
+
except CortexError:
|
|
281
|
+
self._channel_state = "AUTH_FAILED"
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
await self._open_channel()
|
|
286
|
+
except Exception:
|
|
287
|
+
continue # retry after next backoff
|
|
288
|
+
|
|
289
|
+
# Re-attach session to the new transport
|
|
290
|
+
self._session.set_transport(self._transport, self._send_timeout)
|
|
291
|
+
|
|
292
|
+
# Resync with timeout
|
|
293
|
+
try:
|
|
294
|
+
await asyncio.wait_for(
|
|
295
|
+
self._session.send_resync(),
|
|
296
|
+
timeout=self._resync_timeout,
|
|
297
|
+
)
|
|
298
|
+
except Exception:
|
|
299
|
+
self._transport.close()
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
# Reconnect successful — restart liveness + token refresh
|
|
303
|
+
self._start_liveness()
|
|
304
|
+
self._schedule_token_refresh()
|
|
305
|
+
return
|
|
306
|
+
except asyncio.CancelledError:
|
|
307
|
+
pass
|
|
308
|
+
|
|
309
|
+
async def _maybe_refresh_token(self) -> None:
|
|
310
|
+
if not self._refresh_token:
|
|
311
|
+
raise make_error("auth_refresh_failed", "No refresh token")
|
|
312
|
+
if not self._access_token or is_token_expiring_soon(self._access_token):
|
|
313
|
+
self._access_token = await refresh_access_token(
|
|
314
|
+
self._refresh_token,
|
|
315
|
+
auth_base_url=self._auth_url,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _now_iso() -> str:
|
|
320
|
+
from datetime import datetime, timezone
|
|
321
|
+
return datetime.now(timezone.utc).isoformat()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _derive_upload_url_from_ws_url(ws_url: str | None) -> str:
|
|
325
|
+
if not ws_url:
|
|
326
|
+
return "/upload"
|
|
327
|
+
|
|
328
|
+
parsed = urlsplit(ws_url)
|
|
329
|
+
scheme = parsed.scheme.lower()
|
|
330
|
+
http_scheme = "https" if scheme == "wss" else "http" if scheme == "ws" else scheme
|
|
331
|
+
return urlunsplit((http_scheme, parsed.netloc, "/upload", "", ""))
|
cortex_sdk/constants.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from ._generated_constants import (
|
|
2
|
+
DEFAULT_AUTH_URL,
|
|
3
|
+
AUTH_TOKEN_PATH,
|
|
4
|
+
AUTH_REFRESH_PATH,
|
|
5
|
+
WS_SUBPROTOCOL,
|
|
6
|
+
WS_SUBPROTOCOL_JWT_PREFIX,
|
|
7
|
+
CORTEX_AUTH_URL,
|
|
8
|
+
CORTEX_REFRESH_URL,
|
|
9
|
+
WS_SUBPROTOCOL_BASE,
|
|
10
|
+
SCHEMA_VERSION,
|
|
11
|
+
DEFAULT_CONNECT_TIMEOUT_MS,
|
|
12
|
+
DEFAULT_SEND_TIMEOUT_MS,
|
|
13
|
+
DEFAULT_RESYNC_TIMEOUT_MS,
|
|
14
|
+
DEFAULT_PING_INTERVAL_MS,
|
|
15
|
+
DEFAULT_PONG_TIMEOUT_MS,
|
|
16
|
+
DEFAULT_STALE_THRESHOLD_MS,
|
|
17
|
+
TOKEN_REFRESH_BUFFER_MS,
|
|
18
|
+
RECONNECT_BACKOFF_MS,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DEFAULT_AUTH_URL",
|
|
23
|
+
"AUTH_TOKEN_PATH",
|
|
24
|
+
"AUTH_REFRESH_PATH",
|
|
25
|
+
"WS_SUBPROTOCOL",
|
|
26
|
+
"WS_SUBPROTOCOL_JWT_PREFIX",
|
|
27
|
+
"CORTEX_AUTH_URL",
|
|
28
|
+
"CORTEX_REFRESH_URL",
|
|
29
|
+
"WS_SUBPROTOCOL_BASE",
|
|
30
|
+
"SCHEMA_VERSION",
|
|
31
|
+
"DEFAULT_CONNECT_TIMEOUT",
|
|
32
|
+
"DEFAULT_SEND_TIMEOUT",
|
|
33
|
+
"DEFAULT_RESYNC_TIMEOUT",
|
|
34
|
+
"DEFAULT_PING_INTERVAL",
|
|
35
|
+
"DEFAULT_PONG_TIMEOUT",
|
|
36
|
+
"DEFAULT_STALE_THRESHOLD",
|
|
37
|
+
"TOKEN_REFRESH_BUFFER",
|
|
38
|
+
"RECONNECT_BACKOFF",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
DEFAULT_CONNECT_TIMEOUT: float = DEFAULT_CONNECT_TIMEOUT_MS / 1000
|
|
43
|
+
DEFAULT_SEND_TIMEOUT: float = DEFAULT_SEND_TIMEOUT_MS / 1000
|
|
44
|
+
DEFAULT_RESYNC_TIMEOUT: float = DEFAULT_RESYNC_TIMEOUT_MS / 1000
|
|
45
|
+
DEFAULT_PING_INTERVAL: float = DEFAULT_PING_INTERVAL_MS / 1000
|
|
46
|
+
DEFAULT_PONG_TIMEOUT: float = DEFAULT_PONG_TIMEOUT_MS / 1000
|
|
47
|
+
DEFAULT_STALE_THRESHOLD: float = DEFAULT_STALE_THRESHOLD_MS / 1000
|
|
48
|
+
TOKEN_REFRESH_BUFFER: float = TOKEN_REFRESH_BUFFER_MS / 1000
|
|
49
|
+
|
|
50
|
+
RECONNECT_BACKOFF: list[float] = [value / 1000 for value in RECONNECT_BACKOFF_MS]
|
cortex_sdk/errors.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from ._generated_errors import GENERATED_ERROR_CATALOG
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class _ErrorEntry:
|
|
10
|
+
code: str
|
|
11
|
+
retryable: bool
|
|
12
|
+
fatal: bool
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_CATALOG: list[_ErrorEntry] = [
|
|
16
|
+
_ErrorEntry(entry.code, retryable=entry.retryable, fatal=entry.fatal)
|
|
17
|
+
for entry in GENERATED_ERROR_CATALOG
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
_CATALOG_MAP: dict[str, _ErrorEntry] = {e.code: e for e in _CATALOG}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CortexError(Exception):
|
|
24
|
+
"""SDK error with canonical code, retryable, and fatal flags."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, code: str, message: str, retryable: bool, fatal: bool) -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.code = code
|
|
29
|
+
self.retryable = retryable
|
|
30
|
+
self.fatal = fatal
|
|
31
|
+
|
|
32
|
+
def __repr__(self) -> str:
|
|
33
|
+
return (
|
|
34
|
+
f"CortexError(code={self.code!r}, message={str(self)!r}, "
|
|
35
|
+
f"retryable={self.retryable}, fatal={self.fatal})"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def make_error(code: str, message: str) -> CortexError:
|
|
40
|
+
"""Build a CortexError, filling retryable/fatal from the catalog."""
|
|
41
|
+
entry = _CATALOG_MAP.get(code)
|
|
42
|
+
if entry is not None:
|
|
43
|
+
return CortexError(entry.code, message, entry.retryable, entry.fatal)
|
|
44
|
+
# Unknown code — conservative defaults
|
|
45
|
+
return CortexError(code, message, retryable=False, fatal=False)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def lookup_error(code: str) -> _ErrorEntry | None:
|
|
49
|
+
return _CATALOG_MAP.get(code)
|
cortex_sdk/liveness.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import secrets
|
|
5
|
+
import time
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from .constants import SCHEMA_VERSION
|
|
9
|
+
from .transport import Transport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _LivenessCallbacks:
|
|
13
|
+
on_stale: Callable[[], None]
|
|
14
|
+
get_session_id: Callable[[], str | None]
|
|
15
|
+
get_channel_id: Callable[[], str]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LivenessMonitor:
|
|
19
|
+
"""Sends system::ping on a fixed interval and monitors pong responses."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
transport: Transport,
|
|
24
|
+
ping_interval: float,
|
|
25
|
+
pong_timeout: float,
|
|
26
|
+
stale_threshold: float,
|
|
27
|
+
on_stale: Callable[[], None],
|
|
28
|
+
get_session_id: Callable[[], str | None],
|
|
29
|
+
get_channel_id: Callable[[], str],
|
|
30
|
+
) -> None:
|
|
31
|
+
self._transport = transport
|
|
32
|
+
self._ping_interval = ping_interval
|
|
33
|
+
self._pong_timeout = pong_timeout
|
|
34
|
+
self._stale_threshold = stale_threshold
|
|
35
|
+
self._on_stale = on_stale
|
|
36
|
+
self._get_session_id = get_session_id
|
|
37
|
+
self._get_channel_id = get_channel_id
|
|
38
|
+
|
|
39
|
+
self._running = False
|
|
40
|
+
self._last_pong_time = time.monotonic()
|
|
41
|
+
self._pending_heartbeat_id: str | None = None
|
|
42
|
+
self._task: asyncio.Task[None] | None = None
|
|
43
|
+
|
|
44
|
+
def start(self) -> None:
|
|
45
|
+
self._running = True
|
|
46
|
+
self._last_pong_time = time.monotonic()
|
|
47
|
+
self._task = asyncio.create_task(self._ping_loop())
|
|
48
|
+
|
|
49
|
+
def stop(self) -> None:
|
|
50
|
+
self._running = False
|
|
51
|
+
if self._task and not self._task.done():
|
|
52
|
+
self._task.cancel()
|
|
53
|
+
self._task = None
|
|
54
|
+
self._pending_heartbeat_id = None
|
|
55
|
+
|
|
56
|
+
def record_pong(self, heartbeat_id: str) -> None:
|
|
57
|
+
"""Called when a system::pong is received."""
|
|
58
|
+
if heartbeat_id == self._pending_heartbeat_id:
|
|
59
|
+
self._pending_heartbeat_id = None
|
|
60
|
+
self._last_pong_time = time.monotonic()
|
|
61
|
+
|
|
62
|
+
async def _ping_loop(self) -> None:
|
|
63
|
+
try:
|
|
64
|
+
while self._running:
|
|
65
|
+
await asyncio.sleep(self._ping_interval)
|
|
66
|
+
if not self._running:
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
# Check stale threshold before sending ping
|
|
70
|
+
if time.monotonic() - self._last_pong_time > self._stale_threshold:
|
|
71
|
+
self._on_stale()
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
heartbeat_id = f"hb_{secrets.token_hex(4)}"
|
|
75
|
+
self._pending_heartbeat_id = heartbeat_id
|
|
76
|
+
|
|
77
|
+
envelope: dict[str, object] = {
|
|
78
|
+
"type": "system::ping",
|
|
79
|
+
"schema": SCHEMA_VERSION,
|
|
80
|
+
"payload": {
|
|
81
|
+
"heartbeat_id": heartbeat_id,
|
|
82
|
+
"channel_id": self._get_channel_id(),
|
|
83
|
+
},
|
|
84
|
+
"ts": _now_iso(),
|
|
85
|
+
}
|
|
86
|
+
session_id = self._get_session_id()
|
|
87
|
+
if session_id:
|
|
88
|
+
envelope["session_id"] = session_id
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
await self._transport.send(envelope)
|
|
92
|
+
except Exception:
|
|
93
|
+
pass # send errors are handled via transport on_close
|
|
94
|
+
|
|
95
|
+
# Schedule pong-timeout check as a separate task
|
|
96
|
+
asyncio.create_task(self._check_pong_timeout(heartbeat_id))
|
|
97
|
+
except asyncio.CancelledError:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
async def _check_pong_timeout(self, heartbeat_id: str) -> None:
|
|
101
|
+
await asyncio.sleep(self._pong_timeout)
|
|
102
|
+
if not self._running:
|
|
103
|
+
return
|
|
104
|
+
if self._pending_heartbeat_id == heartbeat_id:
|
|
105
|
+
if time.monotonic() - self._last_pong_time > self._stale_threshold:
|
|
106
|
+
self._on_stale()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _now_iso() -> str:
|
|
110
|
+
from datetime import datetime, timezone
|
|
111
|
+
return datetime.now(timezone.utc).isoformat()
|
cortex_sdk/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
cortex_sdk/session.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from .constants import SCHEMA_VERSION
|
|
7
|
+
from .errors import make_error, lookup_error, CortexError
|
|
8
|
+
from .transport import Transport
|
|
9
|
+
from .types import CortexMessage, RuntimeBootstrap, SessionState
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_TERMINAL_STATES: set[SessionState] = {
|
|
13
|
+
"COMPLETED",
|
|
14
|
+
"FAILED",
|
|
15
|
+
"STOPPED",
|
|
16
|
+
"TIMEOUT",
|
|
17
|
+
"CANCELLED",
|
|
18
|
+
}
|
|
19
|
+
_LIFECYCLE_STATE_MAP: dict[str, SessionState] = {
|
|
20
|
+
"active": "ACTIVE",
|
|
21
|
+
"waiting": "WAITING",
|
|
22
|
+
"completed": "COMPLETED",
|
|
23
|
+
"failed": "FAILED",
|
|
24
|
+
"stopped": "STOPPED",
|
|
25
|
+
"timeout": "TIMEOUT",
|
|
26
|
+
"cancelled": "CANCELLED",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SessionManager:
|
|
31
|
+
"""Manages session state and envelope construction/parsing."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
on_message: Callable[[CortexMessage], None],
|
|
36
|
+
on_fatal_error: Callable[[CortexError], None],
|
|
37
|
+
) -> None:
|
|
38
|
+
self._on_message = on_message
|
|
39
|
+
self._on_fatal_error = on_fatal_error
|
|
40
|
+
|
|
41
|
+
self._session_id: str | None = None
|
|
42
|
+
self._session_state: SessionState = "CREATED"
|
|
43
|
+
self._last_seq: int = 0
|
|
44
|
+
self._transport: Transport | None = None
|
|
45
|
+
self._send_timeout: float = 10.0
|
|
46
|
+
|
|
47
|
+
def _set_session_state(self, next_state: SessionState) -> None:
|
|
48
|
+
if self._session_state in _TERMINAL_STATES and self._session_state != next_state:
|
|
49
|
+
return
|
|
50
|
+
self._session_state = next_state
|
|
51
|
+
|
|
52
|
+
# ── accessors ─────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def session_id(self) -> str | None:
|
|
56
|
+
return self._session_id
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def session_state(self) -> SessionState:
|
|
60
|
+
return self._session_state
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def last_seq(self) -> int:
|
|
64
|
+
return self._last_seq
|
|
65
|
+
|
|
66
|
+
# ── configuration ─────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def set_transport(self, transport: Transport, send_timeout: float) -> None:
|
|
69
|
+
self._transport = transport
|
|
70
|
+
self._send_timeout = send_timeout
|
|
71
|
+
|
|
72
|
+
# ── outbound helpers ──────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def _build_envelope(
|
|
75
|
+
self, type_: str, payload: dict[str, object]
|
|
76
|
+
) -> dict[str, object]:
|
|
77
|
+
env: dict[str, object] = {
|
|
78
|
+
"type": type_,
|
|
79
|
+
"schema": SCHEMA_VERSION,
|
|
80
|
+
"payload": payload,
|
|
81
|
+
"ts": _now_iso(),
|
|
82
|
+
}
|
|
83
|
+
if self._session_id:
|
|
84
|
+
env["session_id"] = self._session_id
|
|
85
|
+
return env
|
|
86
|
+
|
|
87
|
+
async def _send(self, envelope: dict[str, object]) -> None:
|
|
88
|
+
if self._transport is None:
|
|
89
|
+
raise make_error("transport_send_timeout", "No transport configured")
|
|
90
|
+
await self._transport.send(envelope)
|
|
91
|
+
|
|
92
|
+
async def send_init(self, bootstrap: RuntimeBootstrap) -> None:
|
|
93
|
+
"""Send system::init — intentionally omits session_id."""
|
|
94
|
+
self._session_state = "INITIALIZING"
|
|
95
|
+
envelope: dict[str, object] = {
|
|
96
|
+
"type": "system::init",
|
|
97
|
+
"schema": SCHEMA_VERSION,
|
|
98
|
+
"payload": dict(bootstrap),
|
|
99
|
+
"ts": _now_iso(),
|
|
100
|
+
}
|
|
101
|
+
await self._send(envelope)
|
|
102
|
+
|
|
103
|
+
async def send_resync(self) -> None:
|
|
104
|
+
await self._send(
|
|
105
|
+
self._build_envelope("system::resync", {"last_seq": self._last_seq})
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
async def send_stop(self) -> None:
|
|
109
|
+
await self._send(self._build_envelope("sandbox::stop", {}))
|
|
110
|
+
|
|
111
|
+
async def send_chat_message(
|
|
112
|
+
self, content: str, attachments: list[str] | None
|
|
113
|
+
) -> None:
|
|
114
|
+
payload: dict[str, object] = {"content": content, "role": "user"}
|
|
115
|
+
if attachments:
|
|
116
|
+
payload["attachments"] = attachments
|
|
117
|
+
await self._send(self._build_envelope("chat::message", payload))
|
|
118
|
+
|
|
119
|
+
# ── inbound handling ──────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
def handle_incoming(self, raw: str) -> None:
|
|
122
|
+
"""Parse a raw JSON frame and dispatch callbacks."""
|
|
123
|
+
try:
|
|
124
|
+
parsed: dict[str, object] = json.loads(raw)
|
|
125
|
+
msg: CortexMessage = parsed # type: ignore[assignment]
|
|
126
|
+
except Exception:
|
|
127
|
+
return # malformed frame — ignore
|
|
128
|
+
|
|
129
|
+
# Learn session_id from first server message
|
|
130
|
+
if not self._session_id and msg.get("session_id"):
|
|
131
|
+
self._session_id = msg["session_id"]
|
|
132
|
+
if self._session_state not in _TERMINAL_STATES:
|
|
133
|
+
self._session_state = "ACTIVE"
|
|
134
|
+
|
|
135
|
+
# Track highest sequence number
|
|
136
|
+
seq = msg.get("seq")
|
|
137
|
+
if isinstance(seq, int) and seq > self._last_seq:
|
|
138
|
+
self._last_seq = seq
|
|
139
|
+
|
|
140
|
+
self._update_session_state(msg)
|
|
141
|
+
|
|
142
|
+
# Fatal error detection
|
|
143
|
+
if msg.get("type") == "system::error":
|
|
144
|
+
self._handle_system_error(msg["payload"])
|
|
145
|
+
|
|
146
|
+
self._on_message(msg)
|
|
147
|
+
|
|
148
|
+
def _handle_system_error(self, payload: dict[str, object]) -> None:
|
|
149
|
+
code = payload.get("code", "session_terminal")
|
|
150
|
+
message = payload.get("message", "Runtime error")
|
|
151
|
+
entry = lookup_error(str(code))
|
|
152
|
+
if entry and entry.fatal:
|
|
153
|
+
self._set_session_state("FAILED")
|
|
154
|
+
err = make_error(str(code), str(message))
|
|
155
|
+
self._on_fatal_error(err)
|
|
156
|
+
|
|
157
|
+
def _update_session_state(self, msg: CortexMessage) -> None:
|
|
158
|
+
msg_type = msg.get("type")
|
|
159
|
+
|
|
160
|
+
if msg_type == "sandbox::snapshot":
|
|
161
|
+
state = msg["payload"].get("state")
|
|
162
|
+
if isinstance(state, str) and state.lower() == "waiting":
|
|
163
|
+
self._set_session_state("WAITING")
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
if msg_type == "sandbox::lifecycle":
|
|
167
|
+
status = msg["payload"].get("status")
|
|
168
|
+
if isinstance(status, str):
|
|
169
|
+
next_state = _LIFECYCLE_STATE_MAP.get(status.lower())
|
|
170
|
+
if next_state is not None:
|
|
171
|
+
self._set_session_state(next_state)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
if msg_type == "system::error":
|
|
175
|
+
code = msg["payload"].get("code")
|
|
176
|
+
if isinstance(code, str):
|
|
177
|
+
entry = lookup_error(code)
|
|
178
|
+
if entry and entry.fatal:
|
|
179
|
+
self._set_session_state("FAILED")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _now_iso() -> str:
|
|
183
|
+
from datetime import datetime, timezone
|
|
184
|
+
return datetime.now(timezone.utc).isoformat()
|
cortex_sdk/transport.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
import websockets
|
|
8
|
+
import websockets.asyncio.client
|
|
9
|
+
import websockets.exceptions
|
|
10
|
+
|
|
11
|
+
from .constants import WS_SUBPROTOCOL, WS_SUBPROTOCOL_JWT_PREFIX
|
|
12
|
+
from .errors import make_error
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Transport:
|
|
16
|
+
"""Thin WebSocket abstraction. Platform-agnostic — no business logic."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, connect_timeout: float, send_timeout: float) -> None:
|
|
19
|
+
self._connect_timeout = connect_timeout
|
|
20
|
+
self._send_timeout = send_timeout
|
|
21
|
+
self._ws: websockets.asyncio.client.ClientConnection | None = None
|
|
22
|
+
self._reader_task: asyncio.Task[None] | None = None
|
|
23
|
+
|
|
24
|
+
# Callbacks set by the consumer (client.py)
|
|
25
|
+
self.on_message: Callable[[str], None] | None = None
|
|
26
|
+
self.on_close: Callable[[int, str], None] | None = None
|
|
27
|
+
self.on_error: Callable[[Exception], None] | None = None
|
|
28
|
+
|
|
29
|
+
async def open(self, ws_url: str, access_token: str) -> None:
|
|
30
|
+
"""Open the WebSocket connection and start the reader loop."""
|
|
31
|
+
subprotocols: list[str] = [
|
|
32
|
+
WS_SUBPROTOCOL,
|
|
33
|
+
f"{WS_SUBPROTOCOL_JWT_PREFIX}{access_token}",
|
|
34
|
+
]
|
|
35
|
+
try:
|
|
36
|
+
from websockets.typing import Subprotocol
|
|
37
|
+
typed_protocols = [Subprotocol(p) for p in subprotocols]
|
|
38
|
+
ws = await asyncio.wait_for(
|
|
39
|
+
websockets.connect(
|
|
40
|
+
ws_url,
|
|
41
|
+
subprotocols=typed_protocols,
|
|
42
|
+
ping_interval=None, # SDK manages its own liveness
|
|
43
|
+
),
|
|
44
|
+
timeout=self._connect_timeout,
|
|
45
|
+
)
|
|
46
|
+
except asyncio.TimeoutError as exc:
|
|
47
|
+
raise make_error(
|
|
48
|
+
"transport_connect_timeout", "WebSocket connect timed out"
|
|
49
|
+
) from exc
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
raise make_error(
|
|
52
|
+
"transport_connect_timeout", f"WebSocket connect failed: {exc}"
|
|
53
|
+
) from exc
|
|
54
|
+
|
|
55
|
+
self._ws = ws
|
|
56
|
+
self._reader_task = asyncio.create_task(self._reader_loop())
|
|
57
|
+
|
|
58
|
+
async def send(self, message: dict[str, object]) -> None:
|
|
59
|
+
"""Serialize and send a message. Raises transport_send_timeout on timeout."""
|
|
60
|
+
ws = self._ws
|
|
61
|
+
if ws is None:
|
|
62
|
+
raise make_error("transport_send_timeout", "No open connection")
|
|
63
|
+
data = json.dumps(message)
|
|
64
|
+
try:
|
|
65
|
+
await asyncio.wait_for(ws.send(data), timeout=self._send_timeout)
|
|
66
|
+
except asyncio.TimeoutError as exc:
|
|
67
|
+
raise make_error("transport_send_timeout", "Send timed out") from exc
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
raise make_error("transport_send_timeout", str(exc)) from exc
|
|
70
|
+
|
|
71
|
+
def close(self, code: int = 1000, reason: str = "disconnect") -> None:
|
|
72
|
+
"""Synchronous close — cancels reader task and schedules WS close."""
|
|
73
|
+
task = self._reader_task
|
|
74
|
+
ws = self._ws
|
|
75
|
+
self._reader_task = None
|
|
76
|
+
self._ws = None
|
|
77
|
+
|
|
78
|
+
if task and not task.done():
|
|
79
|
+
task.cancel()
|
|
80
|
+
if ws is not None:
|
|
81
|
+
try:
|
|
82
|
+
loop = asyncio.get_running_loop()
|
|
83
|
+
loop.create_task(ws.close(code, reason))
|
|
84
|
+
except RuntimeError:
|
|
85
|
+
pass # no running loop — connection already gone
|
|
86
|
+
|
|
87
|
+
async def aclose(self, code: int = 1000, reason: str = "disconnect") -> None:
|
|
88
|
+
"""Async close — waits for reader task to finish."""
|
|
89
|
+
task = self._reader_task
|
|
90
|
+
ws = self._ws
|
|
91
|
+
self._reader_task = None
|
|
92
|
+
self._ws = None
|
|
93
|
+
|
|
94
|
+
if task and not task.done():
|
|
95
|
+
task.cancel()
|
|
96
|
+
try:
|
|
97
|
+
await task
|
|
98
|
+
except (asyncio.CancelledError, Exception):
|
|
99
|
+
pass
|
|
100
|
+
if ws is not None:
|
|
101
|
+
try:
|
|
102
|
+
await ws.close(code, reason)
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
async def _reader_loop(self) -> None:
|
|
107
|
+
ws = self._ws
|
|
108
|
+
if ws is None:
|
|
109
|
+
return
|
|
110
|
+
try:
|
|
111
|
+
async for raw in ws:
|
|
112
|
+
data = raw if isinstance(raw, str) else raw.decode()
|
|
113
|
+
if self.on_message:
|
|
114
|
+
self.on_message(data)
|
|
115
|
+
except websockets.exceptions.ConnectionClosed as exc:
|
|
116
|
+
# Only fire on_close if we weren't already cleaned up
|
|
117
|
+
if self._ws is None:
|
|
118
|
+
return # transport.close() was called — suppress callback
|
|
119
|
+
# .rcvd is the received close frame (None if connection was aborted)
|
|
120
|
+
rcvd = exc.rcvd
|
|
121
|
+
code = rcvd.code if rcvd is not None else 1006
|
|
122
|
+
reason = rcvd.reason if rcvd is not None else ""
|
|
123
|
+
if self.on_close:
|
|
124
|
+
self.on_close(code, reason)
|
|
125
|
+
except asyncio.CancelledError:
|
|
126
|
+
pass # clean shutdown from close() / aclose()
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
if self.on_error:
|
|
129
|
+
self.on_error(exc)
|
cortex_sdk/types.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Literal
|
|
4
|
+
|
|
5
|
+
from typing_extensions import TypedDict, NotRequired
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RuntimeBootstrap(TypedDict):
|
|
9
|
+
execution_mode: str
|
|
10
|
+
bundle_url: str
|
|
11
|
+
checksum: str
|
|
12
|
+
artifact_id: NotRequired[str | None]
|
|
13
|
+
artifact_kind: NotRequired[str | None]
|
|
14
|
+
run_mode: NotRequired[str | None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthTokenResponse(TypedDict):
|
|
18
|
+
ws_url: str
|
|
19
|
+
access_token: str
|
|
20
|
+
refresh_token: str
|
|
21
|
+
runtime_bootstrap: RuntimeBootstrap
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CortexMessage(TypedDict):
|
|
25
|
+
type: str
|
|
26
|
+
schema: str
|
|
27
|
+
session_id: str
|
|
28
|
+
seq: NotRequired[int | None]
|
|
29
|
+
payload: dict[str, object]
|
|
30
|
+
meta: NotRequired[dict[str, object] | None]
|
|
31
|
+
ts: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
SessionState = Literal[
|
|
35
|
+
"CREATED",
|
|
36
|
+
"INITIALIZING",
|
|
37
|
+
"ACTIVE",
|
|
38
|
+
"WAITING",
|
|
39
|
+
"COMPLETED",
|
|
40
|
+
"FAILED",
|
|
41
|
+
"STOPPED",
|
|
42
|
+
"TIMEOUT",
|
|
43
|
+
"CANCELLED",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
ChannelState = Literal[
|
|
47
|
+
"CONNECTING",
|
|
48
|
+
"OPEN",
|
|
49
|
+
"STALE",
|
|
50
|
+
"RECONNECTING",
|
|
51
|
+
"CLOSED",
|
|
52
|
+
"AUTH_FAILED",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
MessageCallback = Callable[["CortexMessage"], None]
|
cortex_sdk/upload.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import BinaryIO
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .errors import make_error
|
|
8
|
+
|
|
9
|
+
# Default upload endpoint — overridden via client's _upload_url for tests
|
|
10
|
+
_DEFAULT_UPLOAD_URL = "/upload"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def upload_file(
|
|
14
|
+
file: str | bytes | BinaryIO,
|
|
15
|
+
access_token: str,
|
|
16
|
+
upload_url: str = _DEFAULT_UPLOAD_URL,
|
|
17
|
+
) -> str:
|
|
18
|
+
"""Upload a file and return the attachment_id.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
file: file path string, raw bytes, or a file-like (BinaryIO) object.
|
|
22
|
+
access_token: Bearer token for Authorization header.
|
|
23
|
+
upload_url: full URL of the upload endpoint.
|
|
24
|
+
"""
|
|
25
|
+
if isinstance(file, str):
|
|
26
|
+
with open(file, "rb") as fh:
|
|
27
|
+
data = fh.read()
|
|
28
|
+
elif isinstance(file, bytes):
|
|
29
|
+
data = file
|
|
30
|
+
else:
|
|
31
|
+
data = file.read() # BinaryIO
|
|
32
|
+
|
|
33
|
+
files = {"file": ("upload", data, "application/octet-stream")}
|
|
34
|
+
|
|
35
|
+
async with httpx.AsyncClient() as client:
|
|
36
|
+
resp = await client.post(
|
|
37
|
+
upload_url,
|
|
38
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
39
|
+
files=files,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if not resp.is_success:
|
|
43
|
+
if resp.status_code == 413:
|
|
44
|
+
raise make_error("upload_too_large", "File exceeds the allowed size limit")
|
|
45
|
+
if resp.status_code == 415:
|
|
46
|
+
raise make_error("upload_type_rejected", "File type not accepted by the runtime")
|
|
47
|
+
raise make_error("upload_failed", f"Upload failed with status {resp.status_code}")
|
|
48
|
+
|
|
49
|
+
body = resp.json()
|
|
50
|
+
return str(body["attachment_id"])
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cortex-suite-sdk
|
|
3
|
+
Version: 1.0.2
|
|
4
|
+
Summary: Cortex SDK — transport client for Python
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: websockets>=12.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: anyio[trio]; extra == 'dev'
|
|
11
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
12
|
+
Requires-Dist: jsonschema>=4.23; extra == 'dev'
|
|
13
|
+
Requires-Dist: mypy>=1.9; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Cortex SDK for Python
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
`pip install cortex-suite-sdk`
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import asyncio
|
|
28
|
+
|
|
29
|
+
from cortex_sdk import CortexClient
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def on_message(msg):
|
|
33
|
+
print(msg["type"], msg["payload"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def main():
|
|
37
|
+
client = CortexClient(
|
|
38
|
+
api_key="your-api-key",
|
|
39
|
+
# auth_url="https://auth.cortexsuite.app", # optional override
|
|
40
|
+
on_message=on_message,
|
|
41
|
+
)
|
|
42
|
+
await client.connect()
|
|
43
|
+
await client.send_message(content="Hello")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
asyncio.run(main())
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## API
|
|
50
|
+
|
|
51
|
+
See the [full API reference](../docs/04_api_reference.md).
|
|
52
|
+
`auth_url` is optional; if omitted, the SDK uses its default auth base URL.
|
|
53
|
+
|
|
54
|
+
## Error handling
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from cortex_sdk import CortexClient, CortexError
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def on_message(msg):
|
|
61
|
+
if msg["type"] == "system::error":
|
|
62
|
+
print("Runtime error:", msg["payload"]["code"])
|
|
63
|
+
if msg["payload"].get("fatal"):
|
|
64
|
+
pass # handle unrecoverable session
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
client = CortexClient(
|
|
68
|
+
api_key="your-api-key",
|
|
69
|
+
on_message=on_message,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
await client.connect()
|
|
74
|
+
except CortexError as err:
|
|
75
|
+
if err.code in ("auth_invalid", "auth_refresh_failed"):
|
|
76
|
+
pass # prompt re-authentication
|
|
77
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
cortex_sdk/__init__.py,sha256=Uyo3yc5EOYIL-BKuBvxJryUB0QSdcp8cz8G1CmQu_Aw,108
|
|
2
|
+
cortex_sdk/_generated_constants.py,sha256=MFCjxFMCRETv58DJMrf2jQ_pJutFnXHcsa2xVdAAO3E,1033
|
|
3
|
+
cortex_sdk/_generated_errors.py,sha256=vLvglZq0IUwpM-FOxlGr_LdD_eeOCc2yjim07SD1ZMw,1315
|
|
4
|
+
cortex_sdk/auth.py,sha256=oKnHtQUB5156SQVL_doUEQ6xdTFt74RqQS2_YiFmHTw,3758
|
|
5
|
+
cortex_sdk/client.py,sha256=WkAC47psjKP648YI7p9pSLgTjx0LYiRgUUHY_cxfQHc,12421
|
|
6
|
+
cortex_sdk/constants.py,sha256=yUcfAazvIcQWOOIRpT0UYy9JLPHoEdkPBGRNrQcAhJY,1483
|
|
7
|
+
cortex_sdk/errors.py,sha256=D1Na6ZBIt8fWt6JsMsy3Gq-vYSVHRxDE9rPCjljRZYk,1415
|
|
8
|
+
cortex_sdk/liveness.py,sha256=kfHvNWIViwAyKko6PALs4p0uf1LBj2woPipb8DaOEdg,3725
|
|
9
|
+
cortex_sdk/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
10
|
+
cortex_sdk/session.py,sha256=Pjtxmr4t7VYU5IwkGEiucwWHqB4ryty6jtXfhax3xnA,6622
|
|
11
|
+
cortex_sdk/transport.py,sha256=NxS72EGKL77G81hrooMFT-p_ip8ejd8eOPF7U7U8Zq8,4890
|
|
12
|
+
cortex_sdk/types.py,sha256=98pTdLj-NIi5fPttpvfDaCeYwwoHgHJF_wrl65yD5DQ,1041
|
|
13
|
+
cortex_sdk/upload.py,sha256=vC72ogwmmunyEjY-cQOMcjDD2CQArsCpONGVy_gWMdw,1523
|
|
14
|
+
cortex_suite_sdk-1.0.2.dist-info/METADATA,sha256=5wYvE55J7uNHr7DqROQWyGL27a-MRQAf-XPxDL7BEjw,1712
|
|
15
|
+
cortex_suite_sdk-1.0.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
16
|
+
cortex_suite_sdk-1.0.2.dist-info/RECORD,,
|