sovant 1.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.
Potentially problematic release.
This version of sovant might be problematic. Click here for more details.
- sovant/__init__.py +63 -0
- sovant/base_client.py +256 -0
- sovant/client.py +87 -0
- sovant/exceptions.py +58 -0
- sovant/resources/__init__.py +6 -0
- sovant/resources/memories.py +317 -0
- sovant/resources/threads.py +362 -0
- sovant/types.py +224 -0
- sovant-1.0.0.dist-info/METADATA +492 -0
- sovant-1.0.0.dist-info/RECORD +13 -0
- sovant-1.0.0.dist-info/WHEEL +5 -0
- sovant-1.0.0.dist-info/licenses/LICENSE +21 -0
- sovant-1.0.0.dist-info/top_level.txt +1 -0
sovant/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sovant Python SDK
|
|
3
|
+
|
|
4
|
+
Official Python library for the Sovant Memory API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "1.0.0"
|
|
8
|
+
|
|
9
|
+
from .client import SovantClient, AsyncSovantClient
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
SovantError,
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
NetworkError,
|
|
17
|
+
)
|
|
18
|
+
from .types import (
|
|
19
|
+
Memory,
|
|
20
|
+
MemoryType,
|
|
21
|
+
EmotionType,
|
|
22
|
+
EmotionalContext,
|
|
23
|
+
CreateMemoryInput,
|
|
24
|
+
UpdateMemoryInput,
|
|
25
|
+
SearchOptions,
|
|
26
|
+
SearchResult,
|
|
27
|
+
Thread,
|
|
28
|
+
ThreadStatus,
|
|
29
|
+
CreateThreadInput,
|
|
30
|
+
UpdateThreadInput,
|
|
31
|
+
ThreadStats,
|
|
32
|
+
BatchCreateResult,
|
|
33
|
+
PaginatedResponse,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
# Client classes
|
|
38
|
+
"SovantClient",
|
|
39
|
+
"AsyncSovantClient",
|
|
40
|
+
# Exceptions
|
|
41
|
+
"SovantError",
|
|
42
|
+
"AuthenticationError",
|
|
43
|
+
"RateLimitError",
|
|
44
|
+
"ValidationError",
|
|
45
|
+
"NotFoundError",
|
|
46
|
+
"NetworkError",
|
|
47
|
+
# Types
|
|
48
|
+
"Memory",
|
|
49
|
+
"MemoryType",
|
|
50
|
+
"EmotionType",
|
|
51
|
+
"EmotionalContext",
|
|
52
|
+
"CreateMemoryInput",
|
|
53
|
+
"UpdateMemoryInput",
|
|
54
|
+
"SearchOptions",
|
|
55
|
+
"SearchResult",
|
|
56
|
+
"Thread",
|
|
57
|
+
"ThreadStatus",
|
|
58
|
+
"CreateThreadInput",
|
|
59
|
+
"UpdateThreadInput",
|
|
60
|
+
"ThreadStats",
|
|
61
|
+
"BatchCreateResult",
|
|
62
|
+
"PaginatedResponse",
|
|
63
|
+
]
|
sovant/base_client.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Base client for making API requests."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Dict, Optional, TypeVar, Union
|
|
7
|
+
from urllib.parse import urlencode
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
NetworkError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
SovantError,
|
|
17
|
+
ValidationError,
|
|
18
|
+
)
|
|
19
|
+
from .types import Config
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BaseClient:
|
|
25
|
+
"""Base client for synchronous API requests."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: Union[str, Config]):
|
|
28
|
+
if isinstance(config, str):
|
|
29
|
+
self.config = Config(api_key=config)
|
|
30
|
+
elif isinstance(config, Config):
|
|
31
|
+
self.config = config
|
|
32
|
+
else:
|
|
33
|
+
# Try to get from environment
|
|
34
|
+
api_key = os.environ.get("SOVANT_API_KEY")
|
|
35
|
+
if not api_key:
|
|
36
|
+
raise AuthenticationError("API key is required")
|
|
37
|
+
self.config = Config(api_key=api_key)
|
|
38
|
+
|
|
39
|
+
# Ensure HTTPS is used for security
|
|
40
|
+
if not self.config.base_url.startswith("https://"):
|
|
41
|
+
raise SovantError("API base URL must use HTTPS for security")
|
|
42
|
+
|
|
43
|
+
self.client = httpx.Client(
|
|
44
|
+
timeout=self.config.timeout,
|
|
45
|
+
headers=self._get_headers(),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
49
|
+
return {
|
|
50
|
+
"Authorization": f"Bearer {self.config.api_key}",
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
"X-SDK-Version": "1.0.0",
|
|
53
|
+
"X-SDK-Language": "python",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
def _build_url(self, path: str) -> str:
|
|
57
|
+
return f"{self.config.base_url}{path}"
|
|
58
|
+
|
|
59
|
+
def _build_query_string(self, params: Dict[str, Any]) -> str:
|
|
60
|
+
filtered_params = {
|
|
61
|
+
k: v for k, v in params.items()
|
|
62
|
+
if v is not None
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Handle list values
|
|
66
|
+
processed_params = {}
|
|
67
|
+
for k, v in filtered_params.items():
|
|
68
|
+
if isinstance(v, list):
|
|
69
|
+
# Convert list to comma-separated string
|
|
70
|
+
processed_params[k] = ",".join(str(item) for item in v)
|
|
71
|
+
elif isinstance(v, bool):
|
|
72
|
+
processed_params[k] = str(v).lower()
|
|
73
|
+
else:
|
|
74
|
+
processed_params[k] = str(v)
|
|
75
|
+
|
|
76
|
+
return f"?{urlencode(processed_params)}" if processed_params else ""
|
|
77
|
+
|
|
78
|
+
def _handle_response(self, response: httpx.Response) -> Any:
|
|
79
|
+
if response.status_code >= 200 and response.status_code < 300:
|
|
80
|
+
try:
|
|
81
|
+
return response.json()
|
|
82
|
+
except json.JSONDecodeError:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
# Handle error responses
|
|
86
|
+
try:
|
|
87
|
+
error_data = response.json()
|
|
88
|
+
message = error_data.get("message", f"HTTP {response.status_code}")
|
|
89
|
+
details = error_data.get("details", {})
|
|
90
|
+
except json.JSONDecodeError:
|
|
91
|
+
message = f"HTTP {response.status_code} {response.reason_phrase}"
|
|
92
|
+
details = {}
|
|
93
|
+
|
|
94
|
+
if response.status_code == 401:
|
|
95
|
+
raise AuthenticationError(message)
|
|
96
|
+
elif response.status_code == 404:
|
|
97
|
+
raise NotFoundError(message)
|
|
98
|
+
elif response.status_code == 429:
|
|
99
|
+
retry_after = response.headers.get("Retry-After")
|
|
100
|
+
raise RateLimitError(
|
|
101
|
+
message,
|
|
102
|
+
int(retry_after) if retry_after else None
|
|
103
|
+
)
|
|
104
|
+
elif response.status_code in (400, 422):
|
|
105
|
+
raise ValidationError(message, details.get("errors"))
|
|
106
|
+
else:
|
|
107
|
+
raise SovantError(message, response.status_code, details)
|
|
108
|
+
|
|
109
|
+
def request(
|
|
110
|
+
self,
|
|
111
|
+
method: str,
|
|
112
|
+
path: str,
|
|
113
|
+
data: Optional[Dict[str, Any]] = None,
|
|
114
|
+
retry_count: int = 0,
|
|
115
|
+
) -> Any:
|
|
116
|
+
url = self._build_url(path)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
response = self.client.request(
|
|
120
|
+
method,
|
|
121
|
+
url,
|
|
122
|
+
json=data if data else None,
|
|
123
|
+
)
|
|
124
|
+
return self._handle_response(response)
|
|
125
|
+
except httpx.TimeoutException:
|
|
126
|
+
raise NetworkError("Request timeout")
|
|
127
|
+
except httpx.RequestError as e:
|
|
128
|
+
if retry_count < self.config.max_retries:
|
|
129
|
+
# Exponential backoff
|
|
130
|
+
delay = self.config.retry_delay * (2 ** retry_count)
|
|
131
|
+
import time
|
|
132
|
+
time.sleep(delay)
|
|
133
|
+
return self.request(method, path, data, retry_count + 1)
|
|
134
|
+
raise NetworkError(str(e))
|
|
135
|
+
|
|
136
|
+
def __del__(self):
|
|
137
|
+
if hasattr(self, "client"):
|
|
138
|
+
self.client.close()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class AsyncBaseClient:
|
|
142
|
+
"""Base client for asynchronous API requests."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, config: Union[str, Config]):
|
|
145
|
+
if isinstance(config, str):
|
|
146
|
+
self.config = Config(api_key=config)
|
|
147
|
+
elif isinstance(config, Config):
|
|
148
|
+
self.config = config
|
|
149
|
+
else:
|
|
150
|
+
# Try to get from environment
|
|
151
|
+
api_key = os.environ.get("SOVANT_API_KEY")
|
|
152
|
+
if not api_key:
|
|
153
|
+
raise AuthenticationError("API key is required")
|
|
154
|
+
self.config = Config(api_key=api_key)
|
|
155
|
+
|
|
156
|
+
# Ensure HTTPS is used for security
|
|
157
|
+
if not self.config.base_url.startswith("https://"):
|
|
158
|
+
raise SovantError("API base URL must use HTTPS for security")
|
|
159
|
+
|
|
160
|
+
self.client = httpx.AsyncClient(
|
|
161
|
+
timeout=self.config.timeout,
|
|
162
|
+
headers=self._get_headers(),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
166
|
+
return {
|
|
167
|
+
"Authorization": f"Bearer {self.config.api_key}",
|
|
168
|
+
"Content-Type": "application/json",
|
|
169
|
+
"X-SDK-Version": "1.0.0",
|
|
170
|
+
"X-SDK-Language": "python",
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
def _build_url(self, path: str) -> str:
|
|
174
|
+
return f"{self.config.base_url}{path}"
|
|
175
|
+
|
|
176
|
+
def _build_query_string(self, params: Dict[str, Any]) -> str:
|
|
177
|
+
filtered_params = {
|
|
178
|
+
k: v for k, v in params.items()
|
|
179
|
+
if v is not None
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# Handle list values
|
|
183
|
+
processed_params = {}
|
|
184
|
+
for k, v in filtered_params.items():
|
|
185
|
+
if isinstance(v, list):
|
|
186
|
+
# Convert list to comma-separated string
|
|
187
|
+
processed_params[k] = ",".join(str(item) for item in v)
|
|
188
|
+
elif isinstance(v, bool):
|
|
189
|
+
processed_params[k] = str(v).lower()
|
|
190
|
+
else:
|
|
191
|
+
processed_params[k] = str(v)
|
|
192
|
+
|
|
193
|
+
return f"?{urlencode(processed_params)}" if processed_params else ""
|
|
194
|
+
|
|
195
|
+
async def _handle_response(self, response: httpx.Response) -> Any:
|
|
196
|
+
if response.status_code >= 200 and response.status_code < 300:
|
|
197
|
+
try:
|
|
198
|
+
return response.json()
|
|
199
|
+
except json.JSONDecodeError:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
# Handle error responses
|
|
203
|
+
try:
|
|
204
|
+
error_data = response.json()
|
|
205
|
+
message = error_data.get("message", f"HTTP {response.status_code}")
|
|
206
|
+
details = error_data.get("details", {})
|
|
207
|
+
except json.JSONDecodeError:
|
|
208
|
+
message = f"HTTP {response.status_code} {response.reason_phrase}"
|
|
209
|
+
details = {}
|
|
210
|
+
|
|
211
|
+
if response.status_code == 401:
|
|
212
|
+
raise AuthenticationError(message)
|
|
213
|
+
elif response.status_code == 404:
|
|
214
|
+
raise NotFoundError(message)
|
|
215
|
+
elif response.status_code == 429:
|
|
216
|
+
retry_after = response.headers.get("Retry-After")
|
|
217
|
+
raise RateLimitError(
|
|
218
|
+
message,
|
|
219
|
+
int(retry_after) if retry_after else None
|
|
220
|
+
)
|
|
221
|
+
elif response.status_code in (400, 422):
|
|
222
|
+
raise ValidationError(message, details.get("errors"))
|
|
223
|
+
else:
|
|
224
|
+
raise SovantError(message, response.status_code, details)
|
|
225
|
+
|
|
226
|
+
async def request(
|
|
227
|
+
self,
|
|
228
|
+
method: str,
|
|
229
|
+
path: str,
|
|
230
|
+
data: Optional[Dict[str, Any]] = None,
|
|
231
|
+
retry_count: int = 0,
|
|
232
|
+
) -> Any:
|
|
233
|
+
url = self._build_url(path)
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
response = await self.client.request(
|
|
237
|
+
method,
|
|
238
|
+
url,
|
|
239
|
+
json=data if data else None,
|
|
240
|
+
)
|
|
241
|
+
return await self._handle_response(response)
|
|
242
|
+
except httpx.TimeoutException:
|
|
243
|
+
raise NetworkError("Request timeout")
|
|
244
|
+
except httpx.RequestError as e:
|
|
245
|
+
if retry_count < self.config.max_retries:
|
|
246
|
+
# Exponential backoff
|
|
247
|
+
delay = self.config.retry_delay * (2 ** retry_count)
|
|
248
|
+
await asyncio.sleep(delay)
|
|
249
|
+
return await self.request(method, path, data, retry_count + 1)
|
|
250
|
+
raise NetworkError(str(e))
|
|
251
|
+
|
|
252
|
+
async def __aenter__(self):
|
|
253
|
+
return self
|
|
254
|
+
|
|
255
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
256
|
+
await self.client.aclose()
|
sovant/client.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Main client for the Sovant SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Union
|
|
4
|
+
|
|
5
|
+
from .base_client import AsyncBaseClient, BaseClient
|
|
6
|
+
from .resources import AsyncMemories, AsyncThreads, Memories, Threads
|
|
7
|
+
from .types import Config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SovantClient:
|
|
11
|
+
"""Synchronous client for the Sovant API."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, api_key: Union[str, Config, None] = None):
|
|
14
|
+
"""
|
|
15
|
+
Initialize the Sovant client.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
api_key: API key string, Config object, or None (uses env var)
|
|
19
|
+
"""
|
|
20
|
+
if api_key is None:
|
|
21
|
+
import os
|
|
22
|
+
api_key = os.environ.get("SOVANT_API_KEY", "")
|
|
23
|
+
|
|
24
|
+
if isinstance(api_key, str):
|
|
25
|
+
config = Config(api_key=api_key)
|
|
26
|
+
else:
|
|
27
|
+
config = api_key
|
|
28
|
+
|
|
29
|
+
self.memories = Memories(config)
|
|
30
|
+
self.threads = Threads(config)
|
|
31
|
+
self._config = config
|
|
32
|
+
|
|
33
|
+
def ping(self) -> Dict[str, str]:
|
|
34
|
+
"""Test API connection."""
|
|
35
|
+
client = BaseClient(self._config)
|
|
36
|
+
return client.request("GET", "/health")
|
|
37
|
+
|
|
38
|
+
def get_usage(self) -> Dict[str, Any]:
|
|
39
|
+
"""Get current API usage and quota."""
|
|
40
|
+
client = BaseClient(self._config)
|
|
41
|
+
return client.request("GET", "/usage")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AsyncSovantClient:
|
|
45
|
+
"""Asynchronous client for the Sovant API."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, api_key: Union[str, Config, None] = None):
|
|
48
|
+
"""
|
|
49
|
+
Initialize the async Sovant client.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
api_key: API key string, Config object, or None (uses env var)
|
|
53
|
+
"""
|
|
54
|
+
if api_key is None:
|
|
55
|
+
import os
|
|
56
|
+
api_key = os.environ.get("SOVANT_API_KEY", "")
|
|
57
|
+
|
|
58
|
+
if isinstance(api_key, str):
|
|
59
|
+
config = Config(api_key=api_key)
|
|
60
|
+
else:
|
|
61
|
+
config = api_key
|
|
62
|
+
|
|
63
|
+
self.memories = AsyncMemories(config)
|
|
64
|
+
self.threads = AsyncThreads(config)
|
|
65
|
+
self._config = config
|
|
66
|
+
|
|
67
|
+
async def ping(self) -> Dict[str, str]:
|
|
68
|
+
"""Test API connection."""
|
|
69
|
+
async with AsyncBaseClient(self._config) as client:
|
|
70
|
+
return await client.request("GET", "/health")
|
|
71
|
+
|
|
72
|
+
async def get_usage(self) -> Dict[str, Any]:
|
|
73
|
+
"""Get current API usage and quota."""
|
|
74
|
+
async with AsyncBaseClient(self._config) as client:
|
|
75
|
+
return await client.request("GET", "/usage")
|
|
76
|
+
|
|
77
|
+
async def __aenter__(self):
|
|
78
|
+
"""Context manager entry."""
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
82
|
+
"""Context manager exit."""
|
|
83
|
+
# Clean up any open connections
|
|
84
|
+
if hasattr(self.memories, "client"):
|
|
85
|
+
await self.memories.client.aclose()
|
|
86
|
+
if hasattr(self.threads, "client"):
|
|
87
|
+
await self.threads.client.aclose()
|
sovant/exceptions.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Exception classes for the Sovant SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SovantError(Exception):
|
|
7
|
+
"""Base exception for all Sovant errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
message: str,
|
|
12
|
+
status_code: Optional[int] = None,
|
|
13
|
+
details: Optional[Dict[str, Any]] = None,
|
|
14
|
+
):
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
self.details = details or {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthenticationError(SovantError):
|
|
21
|
+
"""Raised when authentication fails."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str = "Authentication failed"):
|
|
24
|
+
super().__init__(message, 401)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RateLimitError(SovantError):
|
|
28
|
+
"""Raised when rate limit is exceeded."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str = "Rate limit exceeded", retry_after: Optional[int] = None):
|
|
31
|
+
super().__init__(message, 429)
|
|
32
|
+
self.retry_after = retry_after
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ValidationError(SovantError):
|
|
36
|
+
"""Raised when validation fails."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
message: str = "Validation failed",
|
|
41
|
+
errors: Optional[Dict[str, list[str]]] = None,
|
|
42
|
+
):
|
|
43
|
+
super().__init__(message, 400)
|
|
44
|
+
self.errors = errors or {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NotFoundError(SovantError):
|
|
48
|
+
"""Raised when a resource is not found."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, message: str = "Resource not found"):
|
|
51
|
+
super().__init__(message, 404)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class NetworkError(SovantError):
|
|
55
|
+
"""Raised when a network error occurs."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, message: str = "Network request failed"):
|
|
58
|
+
super().__init__(message)
|