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.
@@ -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
@@ -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