magickmind 0.1.1__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.
- magick_mind/__init__.py +39 -0
- magick_mind/auth/__init__.py +9 -0
- magick_mind/auth/base.py +46 -0
- magick_mind/auth/email_password.py +268 -0
- magick_mind/client.py +188 -0
- magick_mind/config.py +28 -0
- magick_mind/exceptions.py +107 -0
- magick_mind/http/__init__.py +5 -0
- magick_mind/http/client.py +313 -0
- magick_mind/models/__init__.py +17 -0
- magick_mind/models/auth.py +30 -0
- magick_mind/models/common.py +32 -0
- magick_mind/models/errors.py +73 -0
- magick_mind/models/v1/__init__.py +83 -0
- magick_mind/models/v1/api_keys.py +115 -0
- magick_mind/models/v1/artifact.py +151 -0
- magick_mind/models/v1/chat.py +104 -0
- magick_mind/models/v1/corpus.py +82 -0
- magick_mind/models/v1/end_user.py +75 -0
- magick_mind/models/v1/history.py +94 -0
- magick_mind/models/v1/mindspace.py +130 -0
- magick_mind/models/v1/model.py +25 -0
- magick_mind/models/v1/project.py +73 -0
- magick_mind/realtime/__init__.py +5 -0
- magick_mind/realtime/client.py +202 -0
- magick_mind/realtime/handler.py +122 -0
- magick_mind/resources/README.md +201 -0
- magick_mind/resources/__init__.py +42 -0
- magick_mind/resources/base.py +31 -0
- magick_mind/resources/v1/__init__.py +19 -0
- magick_mind/resources/v1/api_keys.py +181 -0
- magick_mind/resources/v1/artifact.py +287 -0
- magick_mind/resources/v1/chat.py +120 -0
- magick_mind/resources/v1/corpus.py +156 -0
- magick_mind/resources/v1/end_user.py +181 -0
- magick_mind/resources/v1/history.py +88 -0
- magick_mind/resources/v1/mindspace.py +331 -0
- magick_mind/resources/v1/model.py +19 -0
- magick_mind/resources/v1/project.py +155 -0
- magick_mind/routes.py +76 -0
- magickmind-0.1.1.dist-info/METADATA +593 -0
- magickmind-0.1.1.dist-info/RECORD +43 -0
- magickmind-0.1.1.dist-info/WHEEL +4 -0
magick_mind/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Magick Mind SDK - Python client for Bifrost Magick Mind AI platform.
|
|
3
|
+
|
|
4
|
+
Simple, powerful SDK for authentication and interaction with the Magick Mind API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from magick_mind.client import MagickMind
|
|
8
|
+
from magick_mind.exceptions import (
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
MagickMindError,
|
|
11
|
+
ProblemDetailsException,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
TokenExpiredError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
)
|
|
16
|
+
from magick_mind.models.v1 import (
|
|
17
|
+
ChatPayload,
|
|
18
|
+
ChatSendRequest,
|
|
19
|
+
ChatSendResponse,
|
|
20
|
+
ChatHistoryMessage,
|
|
21
|
+
HistoryResponse,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"MagickMind",
|
|
28
|
+
"AuthenticationError",
|
|
29
|
+
"MagickMindError",
|
|
30
|
+
"ProblemDetailsException",
|
|
31
|
+
"RateLimitError",
|
|
32
|
+
"TokenExpiredError",
|
|
33
|
+
"ValidationError",
|
|
34
|
+
"ChatSendRequest",
|
|
35
|
+
"ChatPayload",
|
|
36
|
+
"ChatSendResponse",
|
|
37
|
+
"ChatHistoryMessage",
|
|
38
|
+
"HistoryResponse",
|
|
39
|
+
]
|
magick_mind/auth/base.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Base authentication provider interface."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Dict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AuthProvider(ABC):
|
|
8
|
+
"""Base class for authentication providers."""
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def get_headers(self) -> Dict[str, str]:
|
|
12
|
+
"""
|
|
13
|
+
Get authentication headers for API requests.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Dictionary of HTTP headers to include in requests
|
|
17
|
+
"""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def is_authenticated(self) -> bool:
|
|
22
|
+
"""
|
|
23
|
+
Check if the provider is currently authenticated.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
True if authenticated, False otherwise
|
|
27
|
+
"""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def get_token(self) -> str:
|
|
32
|
+
"""
|
|
33
|
+
Get the raw access token.
|
|
34
|
+
Should handle refresh if needed.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Raw access token string
|
|
38
|
+
"""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
def refresh_if_needed(self) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Refresh authentication credentials if needed.
|
|
44
|
+
Override this method in subclasses that support token refresh.
|
|
45
|
+
"""
|
|
46
|
+
pass
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Email/password authentication provider."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Dict, Optional
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from magick_mind.auth.base import AuthProvider
|
|
8
|
+
from magick_mind.exceptions import AuthenticationError, TokenExpiredError
|
|
9
|
+
from magick_mind.models.auth import LoginRequest, RefreshRequest, TokenResponse
|
|
10
|
+
from magick_mind.routes import Routes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EmailPasswordAuth(AuthProvider):
|
|
19
|
+
"""
|
|
20
|
+
Email/password authentication using bifrost's /v1/auth/login endpoint.
|
|
21
|
+
|
|
22
|
+
Automatically handles token refresh when the access token expires.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
auth = EmailPasswordAuth(
|
|
26
|
+
email="user@example.com",
|
|
27
|
+
password="your_password",
|
|
28
|
+
base_url="https://bifrost.example.com"
|
|
29
|
+
)
|
|
30
|
+
# Login happens automatically on first request
|
|
31
|
+
headers = auth.get_headers()
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, email: str, password: str, base_url: str, timeout: float = 30.0):
|
|
35
|
+
"""
|
|
36
|
+
Initialize email/password authentication.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
email: User email address
|
|
40
|
+
password: User password
|
|
41
|
+
base_url: Base URL of the Bifrost API
|
|
42
|
+
timeout: Request timeout in seconds
|
|
43
|
+
"""
|
|
44
|
+
if not email or not password:
|
|
45
|
+
raise ValueError("Email and password are required")
|
|
46
|
+
|
|
47
|
+
self.email = email
|
|
48
|
+
self.password = password
|
|
49
|
+
self.base_url = base_url.rstrip("/")
|
|
50
|
+
self.timeout = timeout
|
|
51
|
+
|
|
52
|
+
# Token storage
|
|
53
|
+
self._access_token: Optional[str] = None
|
|
54
|
+
self._refresh_token: Optional[str] = None
|
|
55
|
+
self._token_expires_at: float = 0.0
|
|
56
|
+
self._refresh_expires_at: float = 0.0
|
|
57
|
+
|
|
58
|
+
def get_headers(self) -> Dict[str, str]:
|
|
59
|
+
"""
|
|
60
|
+
Get authorization header with access token.
|
|
61
|
+
Automatically logs in or refreshes token if needed.
|
|
62
|
+
"""
|
|
63
|
+
self.refresh_if_needed()
|
|
64
|
+
|
|
65
|
+
if not self._access_token:
|
|
66
|
+
raise AuthenticationError(
|
|
67
|
+
"Not authenticated. Failed to obtain access token."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return {"Authorization": f"Bearer {self._access_token}"}
|
|
71
|
+
|
|
72
|
+
def is_authenticated(self) -> bool:
|
|
73
|
+
"""Check if currently authenticated with a valid token."""
|
|
74
|
+
return bool(self._access_token) and time.time() < self._token_expires_at
|
|
75
|
+
|
|
76
|
+
def get_token(self) -> str:
|
|
77
|
+
"""Get raw access token, refreshing if needed."""
|
|
78
|
+
self.refresh_if_needed()
|
|
79
|
+
if not self._access_token:
|
|
80
|
+
raise AuthenticationError(
|
|
81
|
+
"Not authenticated. Failed to obtain access token."
|
|
82
|
+
)
|
|
83
|
+
return self._access_token
|
|
84
|
+
|
|
85
|
+
def refresh_if_needed(self) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Refresh authentication if needed.
|
|
88
|
+
|
|
89
|
+
Logic:
|
|
90
|
+
1. If no access token, perform login
|
|
91
|
+
2. If access token expired but refresh token valid, refresh
|
|
92
|
+
3. If both expired, perform login
|
|
93
|
+
"""
|
|
94
|
+
current_time = time.time()
|
|
95
|
+
|
|
96
|
+
# No token yet - need to login
|
|
97
|
+
if not self._access_token:
|
|
98
|
+
self._login()
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
# Access token still valid
|
|
102
|
+
if current_time < self._token_expires_at:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# Access token expired - try refresh if refresh token is valid
|
|
106
|
+
if self._refresh_token and current_time < self._refresh_expires_at:
|
|
107
|
+
try:
|
|
108
|
+
self._refresh()
|
|
109
|
+
return
|
|
110
|
+
except Exception:
|
|
111
|
+
# Refresh failed, fall back to login
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
# Refresh not available or failed - do full login
|
|
115
|
+
self._login()
|
|
116
|
+
|
|
117
|
+
def _login(self) -> None:
|
|
118
|
+
"""Perform login to get initial tokens."""
|
|
119
|
+
login_url = f"{self.base_url}{Routes.AUTH_LOGIN}"
|
|
120
|
+
|
|
121
|
+
payload = LoginRequest(email=self.email, password=self.password)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
125
|
+
response = client.post(login_url, json=payload.model_dump())
|
|
126
|
+
response.raise_for_status()
|
|
127
|
+
|
|
128
|
+
# Parse and validate response (flat TokenResponse, no wrapper)
|
|
129
|
+
data = TokenResponse(**response.json())
|
|
130
|
+
self._store_tokens(data)
|
|
131
|
+
|
|
132
|
+
except httpx.HTTPStatusError as e:
|
|
133
|
+
if e.response.status_code == 401:
|
|
134
|
+
raise AuthenticationError("Invalid email or password", status_code=401)
|
|
135
|
+
raise AuthenticationError(
|
|
136
|
+
f"Login failed: {str(e)}", status_code=e.response.status_code
|
|
137
|
+
)
|
|
138
|
+
except httpx.RequestError as e:
|
|
139
|
+
raise AuthenticationError(f"Network error during login: {str(e)}")
|
|
140
|
+
|
|
141
|
+
def _refresh(self) -> None:
|
|
142
|
+
"""Refresh the access token using the refresh token."""
|
|
143
|
+
# Note: Bifrost might have a refresh endpoint, but if not,
|
|
144
|
+
# we'll just re-login. This can be updated when the endpoint is available.
|
|
145
|
+
refresh_url = f"{self.base_url}{Routes.AUTH_REFRESH}"
|
|
146
|
+
|
|
147
|
+
if not self._refresh_token:
|
|
148
|
+
raise TokenExpiredError("No refresh token available")
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
refresh_req = RefreshRequest(refresh_token=self._refresh_token)
|
|
152
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
153
|
+
response = client.post(refresh_url, json=refresh_req.model_dump())
|
|
154
|
+
response.raise_for_status()
|
|
155
|
+
|
|
156
|
+
# Parse and validate response (flat TokenResponse, no wrapper)
|
|
157
|
+
data = TokenResponse(**response.json())
|
|
158
|
+
self._store_tokens(data)
|
|
159
|
+
except httpx.HTTPStatusError as e:
|
|
160
|
+
if e.response.status_code == 401:
|
|
161
|
+
raise TokenExpiredError("Refresh token expired or invalid")
|
|
162
|
+
raise AuthenticationError(
|
|
163
|
+
f"Token refresh failed: {str(e)}", status_code=e.response.status_code
|
|
164
|
+
)
|
|
165
|
+
except httpx.RequestError as e:
|
|
166
|
+
raise AuthenticationError(f"Network error during token refresh: {str(e)}")
|
|
167
|
+
|
|
168
|
+
def _store_tokens(self, token_data: TokenResponse) -> None:
|
|
169
|
+
"""Store tokens and calculate expiration times."""
|
|
170
|
+
current_time = time.time()
|
|
171
|
+
|
|
172
|
+
# Access via attributes (Pydantic model)
|
|
173
|
+
self._access_token = token_data.access_token
|
|
174
|
+
self._refresh_token = token_data.refresh_token
|
|
175
|
+
|
|
176
|
+
# Add buffer of 10 seconds to avoid edge cases
|
|
177
|
+
# Default fallback values handled by Pydantic model structure if strict
|
|
178
|
+
# But here fields are required except options.
|
|
179
|
+
# API should ensure these exist.
|
|
180
|
+
expires_in = token_data.expires_in - 10
|
|
181
|
+
self._token_expires_at = current_time + max(expires_in, 0)
|
|
182
|
+
|
|
183
|
+
refresh_expires_in = token_data.refresh_expires_in - 10
|
|
184
|
+
self._refresh_expires_at = current_time + max(refresh_expires_in, 0)
|
|
185
|
+
|
|
186
|
+
async def get_token_async(self) -> str:
|
|
187
|
+
"""Get raw access token asynchronously, refreshing if needed."""
|
|
188
|
+
await self.refresh_if_needed_async()
|
|
189
|
+
if not self._access_token:
|
|
190
|
+
raise AuthenticationError(
|
|
191
|
+
"Not authenticated. Failed to obtain access token."
|
|
192
|
+
)
|
|
193
|
+
return self._access_token
|
|
194
|
+
|
|
195
|
+
async def refresh_if_needed_async(self) -> None:
|
|
196
|
+
"""Async version of refresh_if_needed."""
|
|
197
|
+
current_time = time.time()
|
|
198
|
+
|
|
199
|
+
if not self._access_token:
|
|
200
|
+
await self._login_async()
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
if current_time < self._token_expires_at:
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
if self._refresh_token and current_time < self._refresh_expires_at:
|
|
207
|
+
try:
|
|
208
|
+
await self._refresh_async()
|
|
209
|
+
return
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
await self._login_async()
|
|
214
|
+
|
|
215
|
+
async def _login_async(self) -> None:
|
|
216
|
+
"""Async login."""
|
|
217
|
+
login_url = f"{self.base_url}{Routes.AUTH_LOGIN}"
|
|
218
|
+
payload = LoginRequest(email=self.email, password=self.password)
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
222
|
+
response = await client.post(login_url, json=payload.model_dump())
|
|
223
|
+
response.raise_for_status()
|
|
224
|
+
|
|
225
|
+
# Parse and validate response (flat TokenResponse, no wrapper)
|
|
226
|
+
data = TokenResponse(**response.json())
|
|
227
|
+
self._store_tokens(data)
|
|
228
|
+
except httpx.HTTPStatusError as e:
|
|
229
|
+
if e.response.status_code == 401:
|
|
230
|
+
raise AuthenticationError("Invalid email or password", status_code=401)
|
|
231
|
+
raise AuthenticationError(
|
|
232
|
+
f"Login failed: {str(e)}", status_code=e.response.status_code
|
|
233
|
+
)
|
|
234
|
+
except httpx.RequestError as e:
|
|
235
|
+
raise AuthenticationError(f"Network error during login: {str(e)}")
|
|
236
|
+
|
|
237
|
+
async def _refresh_async(self) -> None:
|
|
238
|
+
"""Async refresh."""
|
|
239
|
+
refresh_url = f"{self.base_url}{Routes.AUTH_REFRESH}"
|
|
240
|
+
if not self._refresh_token:
|
|
241
|
+
raise TokenExpiredError("No refresh token available")
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
refresh_req = RefreshRequest(refresh_token=self._refresh_token)
|
|
245
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
246
|
+
response = await client.post(refresh_url, json=refresh_req.model_dump())
|
|
247
|
+
response.raise_for_status()
|
|
248
|
+
|
|
249
|
+
# Parse and validate response (flat TokenResponse, no wrapper)
|
|
250
|
+
data = TokenResponse(**response.json())
|
|
251
|
+
self._store_tokens(data)
|
|
252
|
+
except httpx.HTTPStatusError as e:
|
|
253
|
+
if e.response.status_code == 401:
|
|
254
|
+
raise TokenExpiredError("Refresh token expired or invalid")
|
|
255
|
+
raise AuthenticationError(
|
|
256
|
+
f"Token refresh failed: {str(e)}", status_code=e.response.status_code
|
|
257
|
+
)
|
|
258
|
+
except httpx.RequestError as e:
|
|
259
|
+
raise AuthenticationError(f"Network error during token refresh: {str(e)}")
|
|
260
|
+
|
|
261
|
+
async def get_headers_async(self) -> Dict[str, str]:
|
|
262
|
+
"""
|
|
263
|
+
Async version of get_headers.
|
|
264
|
+
"""
|
|
265
|
+
await self.refresh_if_needed_async()
|
|
266
|
+
if not self._access_token:
|
|
267
|
+
raise AuthenticationError("Not authenticated.")
|
|
268
|
+
return {"Authorization": f"Bearer {self._access_token}"}
|
magick_mind/client.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Main Magick Mind SDK client."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from magick_mind.auth import AuthProvider, EmailPasswordAuth
|
|
6
|
+
from magick_mind.config import SDKConfig
|
|
7
|
+
from magick_mind.http import HTTPClient
|
|
8
|
+
from magick_mind.realtime import RealtimeClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MagickMind:
|
|
12
|
+
"""
|
|
13
|
+
Main client for the Magick Mind SDK.
|
|
14
|
+
|
|
15
|
+
This is the primary interface for interacting with the Bifrost Magick Mind API.
|
|
16
|
+
|
|
17
|
+
Provides:
|
|
18
|
+
- Authentication (email/password with JWT, automatic refresh)
|
|
19
|
+
- Typed resources (v1.chat, etc.) with Pydantic validation
|
|
20
|
+
- HTTP client for direct API access
|
|
21
|
+
- Realtime client for WebSocket connections (async)
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
# Initialize client
|
|
25
|
+
client = MagickMind(
|
|
26
|
+
email="user@example.com",
|
|
27
|
+
password="your_password",
|
|
28
|
+
base_url="https://bifrost.example.com"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Use typed resources (recommended)
|
|
32
|
+
response = client.v1.chat.send(
|
|
33
|
+
api_key="sk-...",
|
|
34
|
+
mindspace_id="mind-123",
|
|
35
|
+
message="Hello!",
|
|
36
|
+
sender_id="user-456"
|
|
37
|
+
)
|
|
38
|
+
print(response.content.content) # AI response
|
|
39
|
+
|
|
40
|
+
# Or use convenience alias
|
|
41
|
+
response = client.chat.send(...)
|
|
42
|
+
|
|
43
|
+
# Use HTTP client directly for experimental endpoints
|
|
44
|
+
response = client.http.post("/experimental/endpoint", json={...})
|
|
45
|
+
|
|
46
|
+
# Use Realtime client (in async context)
|
|
47
|
+
async def main():
|
|
48
|
+
await client.realtime.connect(events=MyHandler())
|
|
49
|
+
await client.realtime.subscribe(target_user_id="user-456")
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
base_url: str,
|
|
55
|
+
email: str,
|
|
56
|
+
password: str,
|
|
57
|
+
timeout: float = 30.0,
|
|
58
|
+
verify_ssl: bool = True,
|
|
59
|
+
ws_endpoint: Optional[str] = None,
|
|
60
|
+
):
|
|
61
|
+
"""
|
|
62
|
+
Initialize the Magick Mind client.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
base_url: Base URL of the Bifrost API (e.g., https://bifrost.example.com)
|
|
66
|
+
email: User email for authentication
|
|
67
|
+
password: User password for authentication
|
|
68
|
+
timeout: Request timeout in seconds
|
|
69
|
+
verify_ssl: Whether to verify SSL certificates
|
|
70
|
+
ws_endpoint: WebSocket URL (Required for .realtime usage)
|
|
71
|
+
"""
|
|
72
|
+
if not email or not password:
|
|
73
|
+
raise ValueError("Email and password are required for authentication")
|
|
74
|
+
|
|
75
|
+
# Create configuration
|
|
76
|
+
self.config = SDKConfig(
|
|
77
|
+
base_url=base_url,
|
|
78
|
+
timeout=timeout,
|
|
79
|
+
verify_ssl=verify_ssl,
|
|
80
|
+
ws_endpoint=ws_endpoint,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Create authentication provider (email/password with JWT)
|
|
84
|
+
self.auth: AuthProvider = EmailPasswordAuth(
|
|
85
|
+
email=email, password=password, base_url=base_url, timeout=timeout
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Create HTTP client (private, accessed via property)
|
|
89
|
+
self._http = HTTPClient(config=self.config, auth=self.auth)
|
|
90
|
+
|
|
91
|
+
# Create Realtime client (private, accessed via property)
|
|
92
|
+
self._realtime = RealtimeClient(auth=self.auth, ws_url=ws_endpoint)
|
|
93
|
+
|
|
94
|
+
# Initialize typed resources
|
|
95
|
+
from magick_mind.resources import V1Resources
|
|
96
|
+
|
|
97
|
+
self.v1 = V1Resources(self._http)
|
|
98
|
+
|
|
99
|
+
# Convenience alias for default version
|
|
100
|
+
self.chat = self.v1.chat
|
|
101
|
+
self.mindspace = self.v1.mindspace
|
|
102
|
+
self.models = self.v1.models
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def http(self) -> HTTPClient:
|
|
106
|
+
"""
|
|
107
|
+
Low-level HTTP client bound to this MagickMind instance.
|
|
108
|
+
|
|
109
|
+
Features:
|
|
110
|
+
- Uses same base_url and configuration
|
|
111
|
+
- Automatically attaches authentication tokens
|
|
112
|
+
- Applies centralized error mapping
|
|
113
|
+
- Auto-refreshes expired tokens
|
|
114
|
+
|
|
115
|
+
Intended for:
|
|
116
|
+
- Bifrost developers testing new endpoints
|
|
117
|
+
- Power users needing direct API access
|
|
118
|
+
- Experimenting with endpoints before implementing resources
|
|
119
|
+
|
|
120
|
+
Example:
|
|
121
|
+
# Test a new endpoint directly
|
|
122
|
+
response = client.http.post(
|
|
123
|
+
"/experimental/new-feature",
|
|
124
|
+
json={"test": "data"}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Quick one-off calls
|
|
128
|
+
response = client.http.get("/v1/status")
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
HTTPClient: Configured HTTP client instance
|
|
132
|
+
"""
|
|
133
|
+
return self._http
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def realtime(self) -> RealtimeClient:
|
|
137
|
+
"""
|
|
138
|
+
Realtime WebSocket client.
|
|
139
|
+
|
|
140
|
+
Note: This client is ASYNC. You must use it within an async context.
|
|
141
|
+
|
|
142
|
+
Features:
|
|
143
|
+
- Authenticated WebSocket connection
|
|
144
|
+
- RPC subscriptions (Bifrost specific)
|
|
145
|
+
- Handling disconnects/reconnects (via centrifuge-python)
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
RealtimeClient: Configured async realtime client
|
|
149
|
+
"""
|
|
150
|
+
return self._realtime
|
|
151
|
+
|
|
152
|
+
def test_connection(self) -> bool:
|
|
153
|
+
"""Test the connection to the API."""
|
|
154
|
+
try:
|
|
155
|
+
# This assumes there's a health check or similar endpoint
|
|
156
|
+
response = self.http.get("/health")
|
|
157
|
+
return response.get("success", False)
|
|
158
|
+
except Exception:
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
def is_authenticated(self) -> bool:
|
|
162
|
+
"""
|
|
163
|
+
Check if the client is authenticated.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
True if authenticated, False otherwise
|
|
167
|
+
"""
|
|
168
|
+
"""Check if the client is authenticated."""
|
|
169
|
+
return self.auth.is_authenticated()
|
|
170
|
+
|
|
171
|
+
def close(self) -> None:
|
|
172
|
+
"""Close the client and cleanup resources."""
|
|
173
|
+
self._http.close()
|
|
174
|
+
# Realtime client might need async close?
|
|
175
|
+
# But close() here is typically sync.
|
|
176
|
+
# User should probably manage realtime lifecycle themselves if async.
|
|
177
|
+
|
|
178
|
+
def __enter__(self):
|
|
179
|
+
"""Context manager entry."""
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
183
|
+
"""Context manager exit."""
|
|
184
|
+
self.close()
|
|
185
|
+
|
|
186
|
+
def __repr__(self) -> str:
|
|
187
|
+
"""String representation of the client."""
|
|
188
|
+
return f"MagickMind(base_url='{self.config.base_url}', auth='EmailPassword')"
|
magick_mind/config.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Configuration models for Magick Mind SDK."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class SDKConfig:
|
|
9
|
+
"""Configuration for the Magick Mind SDK."""
|
|
10
|
+
|
|
11
|
+
base_url: str
|
|
12
|
+
"""Base URL for the Bifrost API (e.g., https://bifrost.example.com)"""
|
|
13
|
+
|
|
14
|
+
timeout: float = 30.0
|
|
15
|
+
"""Request timeout in seconds"""
|
|
16
|
+
|
|
17
|
+
max_retries: int = 3
|
|
18
|
+
"""Maximum number of retries for failed requests"""
|
|
19
|
+
|
|
20
|
+
verify_ssl: bool = True
|
|
21
|
+
"""Whether to verify SSL certificates"""
|
|
22
|
+
|
|
23
|
+
ws_endpoint: Optional[str] = None
|
|
24
|
+
"""Explicit WebSocket endpoint URL (optional)"""
|
|
25
|
+
|
|
26
|
+
def normalized_base_url(self) -> str:
|
|
27
|
+
"""Return base URL without trailing slash."""
|
|
28
|
+
return self.base_url.rstrip("/")
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Custom exceptions for Magick Mind SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from magick_mind.models.errors import ProblemDetails
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MagickMindError(Exception):
|
|
13
|
+
"""Base exception for all Magick Mind SDK errors."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
16
|
+
self.message = message
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
super().__init__(self.message)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AuthenticationError(MagickMindError):
|
|
22
|
+
"""Raised when authentication fails."""
|
|
23
|
+
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TokenExpiredError(AuthenticationError):
|
|
28
|
+
"""Raised when a token has expired."""
|
|
29
|
+
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RateLimitError(MagickMindError):
|
|
34
|
+
"""Raised when rate limit is exceeded."""
|
|
35
|
+
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ProblemDetailsException(MagickMindError):
|
|
40
|
+
"""RFC 7807 Problem Details error from Bifrost."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
problem: ProblemDetails,
|
|
45
|
+
raw_response: dict | None = None,
|
|
46
|
+
):
|
|
47
|
+
self.type_uri = problem.type
|
|
48
|
+
self.title = problem.title
|
|
49
|
+
self.status = problem.status
|
|
50
|
+
self.detail = problem.detail
|
|
51
|
+
self.instance = problem.instance
|
|
52
|
+
self.request_id = problem.request_id
|
|
53
|
+
self.validation_errors = problem.errors
|
|
54
|
+
self.problem = problem # Full Pydantic model
|
|
55
|
+
|
|
56
|
+
# Log with request_id for tracing
|
|
57
|
+
logger.debug(
|
|
58
|
+
"API error: %s [%d] %s (request_id=%s, instance=%s)",
|
|
59
|
+
self.title,
|
|
60
|
+
self.status,
|
|
61
|
+
self.detail,
|
|
62
|
+
self.request_id or "none",
|
|
63
|
+
self.instance or "none",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
super().__init__(self.detail, status_code=self.status)
|
|
67
|
+
self.response_data = raw_response
|
|
68
|
+
|
|
69
|
+
def __str__(self) -> str:
|
|
70
|
+
msg = f"[{self.status}] {self.title}: {self.detail}"
|
|
71
|
+
if self.request_id:
|
|
72
|
+
msg += f" (request_id: {self.request_id})"
|
|
73
|
+
if self.validation_errors:
|
|
74
|
+
msg += f"\nValidation errors ({len(self.validation_errors)}):"
|
|
75
|
+
for err in self.validation_errors:
|
|
76
|
+
msg += f"\n - {err.field}: {err.message}"
|
|
77
|
+
return msg
|
|
78
|
+
|
|
79
|
+
def __repr__(self) -> str:
|
|
80
|
+
return f"ProblemDetailsException(status={self.status}, title={self.title!r}, request_id={self.request_id!r})"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ValidationError(ProblemDetailsException):
|
|
84
|
+
"""400 Bad Request with field-level validation errors."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, problem: ProblemDetails, raw_response: dict | None = None):
|
|
87
|
+
if problem.status != 400:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"ValidationError must have status 400, got {problem.status}"
|
|
90
|
+
)
|
|
91
|
+
if not problem.errors:
|
|
92
|
+
logger.warning("ValidationError created without field errors")
|
|
93
|
+
super().__init__(problem, raw_response)
|
|
94
|
+
|
|
95
|
+
def get_field_errors(self) -> dict[str, list[str]]:
|
|
96
|
+
"""
|
|
97
|
+
Get errors grouped by field name for UI display.
|
|
98
|
+
|
|
99
|
+
Note: Returns simplified dict[field, messages]. Access validation_errors
|
|
100
|
+
directly if you need error codes (e.g., "required", "invalid_format").
|
|
101
|
+
"""
|
|
102
|
+
errors_by_field: dict[str, list[str]] = {}
|
|
103
|
+
for err in self.validation_errors:
|
|
104
|
+
if err.field not in errors_by_field:
|
|
105
|
+
errors_by_field[err.field] = []
|
|
106
|
+
errors_by_field[err.field].append(err.message)
|
|
107
|
+
return errors_by_field
|