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 ADDED
@@ -0,0 +1,4 @@
1
+ from .client import CortexClient
2
+ from .errors import CortexError
3
+
4
+ __all__ = ["CortexClient", "CortexError"]
@@ -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", "", ""))
@@ -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()
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any