centive-sdk 2.0.0__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.
- centive_sdk/__init__.py +62 -0
- centive_sdk/_http.py +34 -0
- centive_sdk/_logging.py +98 -0
- centive_sdk/_retry.py +57 -0
- centive_sdk/async_client.py +179 -0
- centive_sdk/client.py +87 -0
- centive_sdk/config.py +114 -0
- centive_sdk/exceptions.py +68 -0
- centive_sdk/models/__init__.py +10 -0
- centive_sdk/models/requests.py +88 -0
- centive_sdk/models/responses.py +61 -0
- centive_sdk/py.typed +0 -0
- centive_sdk/resources/__init__.py +5 -0
- centive_sdk/resources/async_sessions.py +479 -0
- centive_sdk/resources/message_accumulator.py +509 -0
- centive_sdk/resources/sessions.py +411 -0
- centive_sdk/resources/websocket_server.py +1255 -0
- centive_sdk-2.0.0.dist-info/METADATA +711 -0
- centive_sdk-2.0.0.dist-info/RECORD +21 -0
- centive_sdk-2.0.0.dist-info/WHEEL +4 -0
- centive_sdk-2.0.0.dist-info/licenses/LICENSE +21 -0
centive_sdk/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from .async_client import AsyncCentiveClient
|
|
2
|
+
from .client import CentiveClient
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
AuthError,
|
|
5
|
+
CentiveError,
|
|
6
|
+
NetworkError,
|
|
7
|
+
RateLimitError,
|
|
8
|
+
ServerError,
|
|
9
|
+
ValidationError,
|
|
10
|
+
)
|
|
11
|
+
from .models.requests import (
|
|
12
|
+
MessageHistoryEvent,
|
|
13
|
+
MessageStreamEvent,
|
|
14
|
+
SaveMessagesRequest,
|
|
15
|
+
SessionEndEvent,
|
|
16
|
+
SessionMessage,
|
|
17
|
+
SessionMetadata,
|
|
18
|
+
StreamMessage,
|
|
19
|
+
ToolMappingRequest,
|
|
20
|
+
TriggerSessionRequest,
|
|
21
|
+
)
|
|
22
|
+
from .models.responses import (
|
|
23
|
+
PauseStatusResponse,
|
|
24
|
+
SaveMessagesResponse,
|
|
25
|
+
ToolMappingResponse,
|
|
26
|
+
TriggerSessionResponse,
|
|
27
|
+
)
|
|
28
|
+
from .resources.message_accumulator import MessageAccumulator
|
|
29
|
+
from .resources.websocket_server import WebSocketServer
|
|
30
|
+
|
|
31
|
+
__version__ = "2.0.0"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
# Clients
|
|
35
|
+
"CentiveClient",
|
|
36
|
+
"AsyncCentiveClient",
|
|
37
|
+
# Request Models
|
|
38
|
+
"ToolMappingRequest",
|
|
39
|
+
"TriggerSessionRequest",
|
|
40
|
+
"SaveMessagesRequest",
|
|
41
|
+
"SessionMessage",
|
|
42
|
+
"SessionMetadata",
|
|
43
|
+
"StreamMessage",
|
|
44
|
+
"MessageHistoryEvent",
|
|
45
|
+
"MessageStreamEvent",
|
|
46
|
+
"SessionEndEvent",
|
|
47
|
+
# Response Models
|
|
48
|
+
"ToolMappingResponse",
|
|
49
|
+
"TriggerSessionResponse",
|
|
50
|
+
"SaveMessagesResponse",
|
|
51
|
+
"PauseStatusResponse",
|
|
52
|
+
# Resources
|
|
53
|
+
"WebSocketServer",
|
|
54
|
+
"MessageAccumulator",
|
|
55
|
+
# Exceptions
|
|
56
|
+
"CentiveError",
|
|
57
|
+
"AuthError",
|
|
58
|
+
"ValidationError",
|
|
59
|
+
"RateLimitError",
|
|
60
|
+
"ServerError",
|
|
61
|
+
"NetworkError",
|
|
62
|
+
]
|
centive_sdk/_http.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def join_url(base: str, path: str) -> str:
|
|
5
|
+
"""Safely joins a base URL with a path, avoiding double slashes.
|
|
6
|
+
|
|
7
|
+
Ensures exactly one slash between base and path components,
|
|
8
|
+
preventing malformed URLs from trailing/leading slash mismatches.
|
|
9
|
+
|
|
10
|
+
Examples:
|
|
11
|
+
join_url("https://api.test", "/path") -> "https://api.test/path"
|
|
12
|
+
join_url("https://api.test/", "/path") -> "https://api.test/path"
|
|
13
|
+
join_url("https://api.test", "path") -> "https://api.test/path"
|
|
14
|
+
"""
|
|
15
|
+
base = base.rstrip("/")
|
|
16
|
+
path = path.lstrip("/")
|
|
17
|
+
return f"{base}/{path}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_headers(api_key: str, idempotency_key: Optional[str] = None) -> dict:
|
|
21
|
+
"""Builds HTTP headers for Centive API requests.
|
|
22
|
+
|
|
23
|
+
Includes X-API-Key header for authentication and optional
|
|
24
|
+
Idempotency-Key header for safe request retries.
|
|
25
|
+
"""
|
|
26
|
+
headers = {
|
|
27
|
+
"X-API-Key": api_key,
|
|
28
|
+
"Content-Type": "application/json",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if idempotency_key:
|
|
32
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
33
|
+
|
|
34
|
+
return headers
|
centive_sdk/_logging.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
# Connection tokens travel in the websocket URL's query string, so the transport
|
|
7
|
+
# layer's own debug logging would otherwise write them to the host application's
|
|
8
|
+
# log files verbatim.
|
|
9
|
+
_TOKEN_IN_URL = re.compile(r"(token=)[^&\s\"'>]+")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _scrub(value: Any) -> Any:
|
|
13
|
+
if isinstance(value, str) and "token=" in value:
|
|
14
|
+
return _TOKEN_IN_URL.sub(r"\1[REDACTED]", value)
|
|
15
|
+
return value
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TokenRedactingFilter(logging.Filter):
|
|
19
|
+
"""Strips connection tokens out of log records before they are emitted."""
|
|
20
|
+
|
|
21
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
22
|
+
record.msg = _scrub(record.msg)
|
|
23
|
+
if record.args:
|
|
24
|
+
if isinstance(record.args, dict):
|
|
25
|
+
record.args = {k: _scrub(v) for k, v in record.args.items()}
|
|
26
|
+
elif isinstance(record.args, tuple):
|
|
27
|
+
record.args = tuple(_scrub(a) for a in record.args)
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def transport_logger(parent: Optional[Any] = None) -> logging.Logger:
|
|
32
|
+
"""Returns the logger handed to the websocket transport, with token redaction.
|
|
33
|
+
|
|
34
|
+
Child of the caller's logger when one is supplied, so the host application's
|
|
35
|
+
handlers and level still apply, but never carries raw tokens.
|
|
36
|
+
"""
|
|
37
|
+
if isinstance(parent, logging.Logger):
|
|
38
|
+
logger = parent.getChild("centive_ws")
|
|
39
|
+
else:
|
|
40
|
+
logger = logging.getLogger("centive_sdk.websocket")
|
|
41
|
+
|
|
42
|
+
if not any(isinstance(f, TokenRedactingFilter) for f in logger.filters):
|
|
43
|
+
logger.addFilter(TokenRedactingFilter())
|
|
44
|
+
return logger
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def mask_string(s: str) -> str:
|
|
48
|
+
"""Masks a string by showing only the first 2 characters followed by ***.
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
"Harrison" -> "Ha***"
|
|
52
|
+
"TheAgentic" -> "Th***"
|
|
53
|
+
"AB" -> "AB***"
|
|
54
|
+
"A" -> "A***"
|
|
55
|
+
"""
|
|
56
|
+
if len(s) <= 2:
|
|
57
|
+
return f"{s}***"
|
|
58
|
+
return f"{s[:2]}***"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def redact_request_data(data: dict) -> dict:
|
|
62
|
+
"""Deep copies a dictionary and redacts sensitive fields.
|
|
63
|
+
|
|
64
|
+
Masks user_name and company_name, completely redacts api_key.
|
|
65
|
+
Preserves the structure of the original dictionary.
|
|
66
|
+
"""
|
|
67
|
+
redacted = deepcopy(data)
|
|
68
|
+
|
|
69
|
+
if "user_name" in redacted:
|
|
70
|
+
redacted["user_name"] = mask_string(str(redacted["user_name"]))
|
|
71
|
+
|
|
72
|
+
if "company_name" in redacted:
|
|
73
|
+
redacted["company_name"] = mask_string(str(redacted["company_name"]))
|
|
74
|
+
|
|
75
|
+
for secret_field in ("api_key", "token", "connection_token", "ws_handshake_secret"):
|
|
76
|
+
if secret_field in redacted:
|
|
77
|
+
redacted[secret_field] = "[REDACTED]"
|
|
78
|
+
|
|
79
|
+
return redacted
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def safe_log(logger: Optional[Any], level: str, message: str, extra: Optional[dict] = None) -> None:
|
|
83
|
+
"""Safely logs a message with automatic PII redaction.
|
|
84
|
+
|
|
85
|
+
Only logs if a logger is provided. Applies redaction to any extra
|
|
86
|
+
data before logging to prevent accidental exposure of secrets or PII.
|
|
87
|
+
"""
|
|
88
|
+
if logger is None:
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
redacted_extra = redact_request_data(extra) if extra else {}
|
|
92
|
+
|
|
93
|
+
log_method = getattr(logger, level.lower(), None)
|
|
94
|
+
if log_method and callable(log_method):
|
|
95
|
+
if redacted_extra:
|
|
96
|
+
log_method(f"{message} {redacted_extra}")
|
|
97
|
+
else:
|
|
98
|
+
log_method(message)
|
centive_sdk/_retry.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def should_retry(status_code: int, attempt: int, max_retries: int) -> bool:
|
|
7
|
+
"""Determines if a request should be retried based on status code and attempt count.
|
|
8
|
+
|
|
9
|
+
Retries are allowed for:
|
|
10
|
+
- 429 (rate limit)
|
|
11
|
+
- 5xx (server errors)
|
|
12
|
+
- Network errors (handled separately)
|
|
13
|
+
|
|
14
|
+
Returns False if max_retries has been reached.
|
|
15
|
+
"""
|
|
16
|
+
if attempt >= max_retries:
|
|
17
|
+
return False
|
|
18
|
+
|
|
19
|
+
return status_code == 429 or status_code >= 500
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def calculate_backoff(attempt: int, initial: float, max_delay: float) -> float:
|
|
23
|
+
"""Calculates exponential backoff with jitter for retry delays.
|
|
24
|
+
|
|
25
|
+
Uses exponential backoff (initial * 2^attempt) capped at max_delay,
|
|
26
|
+
with added random jitter to prevent thundering herd.
|
|
27
|
+
|
|
28
|
+
Formula: min(max_delay, initial * 2^attempt) + random(0, 1)
|
|
29
|
+
"""
|
|
30
|
+
delay = min(max_delay, initial * (2**attempt))
|
|
31
|
+
jitter = random.uniform(0, 1)
|
|
32
|
+
return delay + jitter
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def extract_retry_after(headers: dict) -> Optional[int]:
|
|
36
|
+
"""Extracts Retry-After header value in seconds.
|
|
37
|
+
|
|
38
|
+
Supports both:
|
|
39
|
+
- Integer seconds: "Retry-After: 120"
|
|
40
|
+
- HTTP date format: "Retry-After: Wed, 21 Oct 2015 07:28:00 GMT"
|
|
41
|
+
|
|
42
|
+
Returns None if header is missing or cannot be parsed.
|
|
43
|
+
"""
|
|
44
|
+
retry_after = headers.get("Retry-After") or headers.get("retry-after")
|
|
45
|
+
|
|
46
|
+
if not retry_after:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
return int(retry_after)
|
|
51
|
+
except ValueError:
|
|
52
|
+
try:
|
|
53
|
+
retry_date = datetime.strptime(retry_after, "%a, %d %b %Y %H:%M:%S %Z")
|
|
54
|
+
delta = retry_date - datetime.utcnow()
|
|
55
|
+
return max(0, int(delta.total_seconds()))
|
|
56
|
+
except (ValueError, OverflowError):
|
|
57
|
+
return None
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from ._logging import safe_log
|
|
7
|
+
from .config import ClientConfig
|
|
8
|
+
from .models.responses import PauseStatusResponse
|
|
9
|
+
from .resources.async_sessions import AsyncSessions
|
|
10
|
+
from .resources.websocket_server import WebSocketServer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AsyncCentiveClient:
|
|
14
|
+
"""Asynchronous client for the Centive API.
|
|
15
|
+
|
|
16
|
+
Flow:
|
|
17
|
+
1. At login: call sessions.tool_mapping()
|
|
18
|
+
2. From an authenticated endpoint: call initialize_websocket(user_id)
|
|
19
|
+
-> returns a connection token bound to that user
|
|
20
|
+
3. SDK internally checks if Aria is paused
|
|
21
|
+
4. If paused: WebSocket is NOT started and no token is issued
|
|
22
|
+
5. If not paused: WebSocket starts; return the token to your frontend as
|
|
23
|
+
part of its websocket URL (wss://your-host/ws?token=<value>) and the
|
|
24
|
+
FE SDK connects with it
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
client = AsyncCentiveClient(api_key="sk_live_...")
|
|
28
|
+
|
|
29
|
+
# At login (user comes from YOUR authentication, e.g. session/JWT)
|
|
30
|
+
await client.sessions.tool_mapping(ToolMappingRequest(...))
|
|
31
|
+
|
|
32
|
+
@app.post("/api/login")
|
|
33
|
+
async def login(user = Depends(get_current_user)):
|
|
34
|
+
token = await client.initialize_websocket(user_id=user.id)
|
|
35
|
+
return {"aria_websocket_url": f"wss://your-host/ws?token={token}" if token else None}
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, **options):
|
|
39
|
+
"""Initializes the async Centive client.
|
|
40
|
+
|
|
41
|
+
api_key and base_url fall back to the CENTIVE_API_KEY and CENTIVE_BASE_URL
|
|
42
|
+
environment variables when not passed explicitly.
|
|
43
|
+
"""
|
|
44
|
+
if api_key is not None:
|
|
45
|
+
options["api_key"] = api_key
|
|
46
|
+
if base_url is not None:
|
|
47
|
+
options["base_url"] = base_url
|
|
48
|
+
self.config = ClientConfig(**options)
|
|
49
|
+
self._http_client = httpx.AsyncClient()
|
|
50
|
+
self._logger = options.get("logger")
|
|
51
|
+
self.sessions = AsyncSessions(self._http_client, self.config, self._logger)
|
|
52
|
+
self._ws_server: Optional[WebSocketServer] = None
|
|
53
|
+
self._last_pause_status: Optional[PauseStatusResponse] = None
|
|
54
|
+
# One process serves many users; serialize server startup so concurrent
|
|
55
|
+
# first logins cannot race to bind the same port.
|
|
56
|
+
self._ws_lock = asyncio.Lock()
|
|
57
|
+
|
|
58
|
+
async def initialize_websocket(
|
|
59
|
+
self,
|
|
60
|
+
user_id: str,
|
|
61
|
+
host: Optional[str] = None,
|
|
62
|
+
port: Optional[int] = None,
|
|
63
|
+
**ws_options,
|
|
64
|
+
) -> Optional[str]:
|
|
65
|
+
"""Initializes WebSocket for real-time FE SDK communication.
|
|
66
|
+
|
|
67
|
+
Internally checks if Aria is paused for this user:
|
|
68
|
+
- If paused: no WebSocket server starts. FE cannot connect, so the
|
|
69
|
+
avatar button stays hidden.
|
|
70
|
+
- If active: WebSocket starts, FE connects, session triggered.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
user_id: ID of the user (from your existing authentication — never
|
|
74
|
+
from an unauthenticated request parameter)
|
|
75
|
+
host: WebSocket server host (default from config)
|
|
76
|
+
port: WebSocket server port (default from config)
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The connection token for this user (default ws_auth_mode="token").
|
|
80
|
+
Include it in the websocket URL your frontend passes to the FE SDK,
|
|
81
|
+
e.g. wss://your-host/ws?token=<value>. The connection is bound to
|
|
82
|
+
this user and the token expires after ws_token_ttl_seconds.
|
|
83
|
+
Returns None when Aria is paused or in legacy ws_auth_mode="open".
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
pause_status = await self.sessions.get_pause_status(user_id)
|
|
87
|
+
self._last_pause_status = pause_status
|
|
88
|
+
|
|
89
|
+
if pause_status.is_paused:
|
|
90
|
+
safe_log(
|
|
91
|
+
self._logger,
|
|
92
|
+
"info",
|
|
93
|
+
"Aria is paused for this user, withholding access",
|
|
94
|
+
{
|
|
95
|
+
"user_id": user_id,
|
|
96
|
+
"pause_source": pause_status.pause_source,
|
|
97
|
+
"paused_until": pause_status.paused_until,
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
# Affect only this user: revoke their tokens and close their
|
|
101
|
+
# sockets. The server is shared, so stopping it would disconnect
|
|
102
|
+
# every other user mid-conversation.
|
|
103
|
+
if self._ws_server is not None and self._ws_server.is_running:
|
|
104
|
+
await self._ws_server.disconnect_user(user_id, reason="aria paused")
|
|
105
|
+
return None
|
|
106
|
+
except Exception as e:
|
|
107
|
+
if not self.config.pause_check_fail_open:
|
|
108
|
+
safe_log(
|
|
109
|
+
self._logger,
|
|
110
|
+
"error",
|
|
111
|
+
f"Pause status check failed and fail-open is disabled: {e}",
|
|
112
|
+
{"user_id": user_id},
|
|
113
|
+
)
|
|
114
|
+
return None
|
|
115
|
+
safe_log(
|
|
116
|
+
self._logger,
|
|
117
|
+
"warning",
|
|
118
|
+
f"Failed to check pause status, proceeding as active: {e}",
|
|
119
|
+
{"user_id": user_id},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
ws_host = host or self.config.ws_host
|
|
123
|
+
ws_port = port or self.config.ws_port
|
|
124
|
+
|
|
125
|
+
async with self._ws_lock:
|
|
126
|
+
if self._ws_server is None or not self._ws_server.is_running:
|
|
127
|
+
server = WebSocketServer(
|
|
128
|
+
sessions=self.sessions,
|
|
129
|
+
config=self.config,
|
|
130
|
+
logger=self._logger,
|
|
131
|
+
)
|
|
132
|
+
# Publish only after a successful bind, so a failed start does
|
|
133
|
+
# not leave a dead server in place and wedge every later call.
|
|
134
|
+
await server.start(host=ws_host, port=ws_port, **ws_options)
|
|
135
|
+
self._ws_server = server
|
|
136
|
+
|
|
137
|
+
return self._ws_server.register_user(user_id)
|
|
138
|
+
|
|
139
|
+
async def get_aria_status(self, user_id: str) -> PauseStatusResponse:
|
|
140
|
+
"""Checks if Aria is paused for a user (for UI display purposes).
|
|
141
|
+
|
|
142
|
+
Use this if you want to show pause status in your UI. Otherwise,
|
|
143
|
+
initialize_websocket() handles everything automatically.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
user_id: External user ID to check
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
PauseStatusResponse with is_paused, pause_source, message, etc.
|
|
150
|
+
"""
|
|
151
|
+
return await self.sessions.get_pause_status(user_id)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def last_pause_status(self) -> Optional[PauseStatusResponse]:
|
|
155
|
+
"""Returns the pause status from the last initialize_websocket() call.
|
|
156
|
+
|
|
157
|
+
Useful if you want to check the status after calling initialize_websocket()
|
|
158
|
+
without making another API call.
|
|
159
|
+
"""
|
|
160
|
+
return self._last_pause_status
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def websocket_server(self) -> Optional[WebSocketServer]:
|
|
164
|
+
"""Returns the WebSocket server instance if initialized."""
|
|
165
|
+
return self._ws_server
|
|
166
|
+
|
|
167
|
+
async def aclose(self) -> None:
|
|
168
|
+
"""Closes the async HTTP client, WebSocket server, and releases resources."""
|
|
169
|
+
if self._ws_server is not None:
|
|
170
|
+
await self._ws_server.stop()
|
|
171
|
+
self._ws_server = None
|
|
172
|
+
|
|
173
|
+
await self._http_client.aclose()
|
|
174
|
+
|
|
175
|
+
async def __aenter__(self) -> "AsyncCentiveClient":
|
|
176
|
+
return self
|
|
177
|
+
|
|
178
|
+
async def __aexit__(self, *args) -> None:
|
|
179
|
+
await self.aclose()
|
centive_sdk/client.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from .config import ClientConfig
|
|
6
|
+
from .models.responses import PauseStatusResponse
|
|
7
|
+
from .resources.sessions import Sessions
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CentiveClient:
|
|
11
|
+
"""Synchronous client for the Centive API.
|
|
12
|
+
|
|
13
|
+
Provides a high-level interface for interacting with Centive services,
|
|
14
|
+
including tool mapping, session triggering, and pause status checking.
|
|
15
|
+
Note that WebSocket functionality is only available in the async client.
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
client = CentiveClient(api_key="sk_live_...")
|
|
19
|
+
|
|
20
|
+
# Map user to tool at login
|
|
21
|
+
result = client.sessions.tool_mapping(ToolMappingRequest(...))
|
|
22
|
+
|
|
23
|
+
# Check if Aria is paused
|
|
24
|
+
status = client.get_aria_status("user_123")
|
|
25
|
+
if not status.is_paused:
|
|
26
|
+
# Trigger session when needed
|
|
27
|
+
session = client.sessions.trigger_session(TriggerSessionRequest(...))
|
|
28
|
+
|
|
29
|
+
client.close()
|
|
30
|
+
|
|
31
|
+
# Or using context manager:
|
|
32
|
+
with CentiveClient(api_key="sk_live_...") as client:
|
|
33
|
+
client.sessions.tool_mapping(...)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, **options):
|
|
37
|
+
"""Initializes the Centive client.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
api_key: API key for authentication (falls back to the
|
|
41
|
+
CENTIVE_API_KEY environment variable)
|
|
42
|
+
base_url: Base URL for the Centive API (falls back to CENTIVE_BASE_URL,
|
|
43
|
+
then to Centive production)
|
|
44
|
+
**options: Additional configuration options including:
|
|
45
|
+
- tool_mapping_path: Custom path for tool mapping endpoint
|
|
46
|
+
- trigger_session_path: Custom path for trigger session endpoint
|
|
47
|
+
- pause_status_path: Custom path for pause status endpoint
|
|
48
|
+
- timeout_seconds: HTTP request timeout
|
|
49
|
+
- max_retries: Maximum retry attempts
|
|
50
|
+
- initial_retry_delay: Initial delay between retries
|
|
51
|
+
- max_retry_delay: Maximum delay between retries
|
|
52
|
+
- logger: Custom logger instance
|
|
53
|
+
"""
|
|
54
|
+
if api_key is not None:
|
|
55
|
+
options["api_key"] = api_key
|
|
56
|
+
if base_url is not None:
|
|
57
|
+
options["base_url"] = base_url
|
|
58
|
+
self.config = ClientConfig(**options)
|
|
59
|
+
self._http_client = httpx.Client()
|
|
60
|
+
self._logger = options.get("logger")
|
|
61
|
+
self.sessions = Sessions(self._http_client, self.config, self._logger)
|
|
62
|
+
|
|
63
|
+
def get_aria_status(self, user_id: str) -> PauseStatusResponse:
|
|
64
|
+
"""Checks if Aria is paused for a user.
|
|
65
|
+
|
|
66
|
+
Use this to check Aria's availability before triggering sessions
|
|
67
|
+
or to display pause status in your UI.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
user_id: External user ID to check
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
PauseStatusResponse with is_paused, pause_source, message, etc.
|
|
74
|
+
"""
|
|
75
|
+
return self.sessions.get_pause_status(user_id)
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
"""Closes the HTTP client and releases resources."""
|
|
79
|
+
self._http_client.close()
|
|
80
|
+
|
|
81
|
+
def __enter__(self) -> "CentiveClient":
|
|
82
|
+
"""Enters the context manager."""
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def __exit__(self, *args) -> None:
|
|
86
|
+
"""Exits the context manager and closes the client."""
|
|
87
|
+
self.close()
|
centive_sdk/config.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Literal, Optional
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
5
|
+
|
|
6
|
+
# Known Centive API environments. Selection precedence for the base URL:
|
|
7
|
+
# base_url param > CENTIVE_BASE_URL env > environment param > CENTIVE_ENVIRONMENT env > "prod"
|
|
8
|
+
ENVIRONMENT_URLS = {
|
|
9
|
+
"prod": "https://centive-prod-api.theagentic.ai/api",
|
|
10
|
+
"dev": "https://centive-api.theagentic.ai/api",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _env_environment() -> str:
|
|
15
|
+
return os.environ.get("CENTIVE_ENVIRONMENT", "prod")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _env_base_url() -> Optional[str]:
|
|
19
|
+
return os.environ.get("CENTIVE_BASE_URL")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _env_api_key() -> str:
|
|
23
|
+
return os.environ.get("CENTIVE_API_KEY", "")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ClientConfig(BaseModel):
|
|
27
|
+
"""configuration for the Centive SDK client with API, websocket, and accumulator settings.
|
|
28
|
+
|
|
29
|
+
api_key falls back to the CENTIVE_API_KEY environment variable. The target
|
|
30
|
+
API is chosen via environment ("prod" / "dev", or the CENTIVE_ENVIRONMENT
|
|
31
|
+
env var); an explicit base_url or CENTIVE_BASE_URL overrides it.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
environment: Literal["prod", "dev"] = Field(default_factory=_env_environment)
|
|
35
|
+
base_url: Optional[str] = Field(default_factory=_env_base_url)
|
|
36
|
+
api_key: str = Field(default_factory=_env_api_key, validate_default=True)
|
|
37
|
+
tool_mapping_path: str = "/anam/tool-mapping"
|
|
38
|
+
trigger_session_path: str = "/anam/trigger-session"
|
|
39
|
+
save_messages_path: str = "/anam/save-messages"
|
|
40
|
+
pause_status_path: str = "/anam/pause-status"
|
|
41
|
+
timeout_seconds: float = 10.0
|
|
42
|
+
max_retries: int = 3
|
|
43
|
+
initial_retry_delay: float = 0.5
|
|
44
|
+
max_retry_delay: float = 8.0
|
|
45
|
+
|
|
46
|
+
ws_host: str = "0.0.0.0" # bind address; set 127.0.0.1 when fronted by a proxy
|
|
47
|
+
ws_port: int = 8765
|
|
48
|
+
ws_path: str = "/ws"
|
|
49
|
+
# Identity binding for FE connections:
|
|
50
|
+
# "token" (default): register_user() issues a per-user connection token; the FE
|
|
51
|
+
# must connect with ?token=<value> and the socket is bound to that user only.
|
|
52
|
+
# "open" (deprecated): legacy pre-2.0 behavior — first socket to connect is
|
|
53
|
+
# assigned the next registered user and client frames are trusted. Insecure;
|
|
54
|
+
# kept only as a migration escape hatch.
|
|
55
|
+
ws_auth_mode: Literal["token", "open"] = "token"
|
|
56
|
+
# How long an issued connection token stays valid. Tokens are reusable within
|
|
57
|
+
# their TTL so the FE SDK's automatic reconnect keeps working mid-conversation.
|
|
58
|
+
ws_token_ttl_seconds: float = 3600.0
|
|
59
|
+
# When set, the server rejects WS handshakes whose Origin is not allow-listed.
|
|
60
|
+
ws_allowed_origins: Optional[list[str]] = None
|
|
61
|
+
# Static shared secret checked as ?token= on the handshake. Only honored in
|
|
62
|
+
# ws_auth_mode="open"; in "token" mode the per-user token replaces it.
|
|
63
|
+
ws_handshake_secret: Optional[str] = None
|
|
64
|
+
ws_ping_interval: float = 20.0
|
|
65
|
+
ws_ping_timeout: float = 20.0
|
|
66
|
+
ws_close_timeout: float = 10.0
|
|
67
|
+
ws_max_size: int = 1048576
|
|
68
|
+
ws_compression: Optional[str] = "deflate"
|
|
69
|
+
|
|
70
|
+
auto_save_interval_seconds: float = 300.0
|
|
71
|
+
circuit_breaker_threshold: int = 5
|
|
72
|
+
circuit_breaker_recovery_seconds: float = 60.0
|
|
73
|
+
|
|
74
|
+
# Memory and abuse bounds so no single end-user can exhaust the host process.
|
|
75
|
+
max_sessions_per_user: int = 10
|
|
76
|
+
max_stream_messages_per_session: int = 10000
|
|
77
|
+
# Accumulated message bytes retained per session. Counts actual content, not
|
|
78
|
+
# message count, so a few very large frames cannot blow past the limit.
|
|
79
|
+
max_session_bytes: int = 2_000_000
|
|
80
|
+
# Longest single message accepted. Frames above this are rejected outright.
|
|
81
|
+
max_message_content_chars: int = 32_768
|
|
82
|
+
max_session_id_chars: int = 200
|
|
83
|
+
# Concurrent websocket connections accepted per user.
|
|
84
|
+
max_connections_per_user: int = 5
|
|
85
|
+
|
|
86
|
+
# When the pause-status check fails (upstream 5xx, rate limit, network), the
|
|
87
|
+
# SDK proceeds as active so a Centive outage does not hide every avatar. Set
|
|
88
|
+
# False to fail closed and withhold connections instead.
|
|
89
|
+
pause_check_fail_open: bool = True
|
|
90
|
+
|
|
91
|
+
incremental_save_enabled: bool = Field(
|
|
92
|
+
default=False,
|
|
93
|
+
description="enable incremental message saving (sends messages to API immediately as they arrive)"
|
|
94
|
+
)
|
|
95
|
+
incremental_save_mode: str = Field(
|
|
96
|
+
default="immediate",
|
|
97
|
+
description="mode for incremental saves: 'immediate' sends each message right away"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@field_validator("api_key")
|
|
101
|
+
@classmethod
|
|
102
|
+
def _api_key_required(cls, v: str) -> str:
|
|
103
|
+
if not v:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
"api_key is required: pass api_key=... or set the CENTIVE_API_KEY "
|
|
106
|
+
"environment variable"
|
|
107
|
+
)
|
|
108
|
+
return v
|
|
109
|
+
|
|
110
|
+
@model_validator(mode="after")
|
|
111
|
+
def _resolve_base_url(self) -> "ClientConfig":
|
|
112
|
+
if not self.base_url:
|
|
113
|
+
self.base_url = ENVIRONMENT_URLS[self.environment]
|
|
114
|
+
return self
|