amigo_sdk 0.62.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.
- amigo_sdk/__init__.py +4 -0
- amigo_sdk/_retry_utils.py +70 -0
- amigo_sdk/auth.py +48 -0
- amigo_sdk/config.py +48 -0
- amigo_sdk/errors.py +163 -0
- amigo_sdk/generated/model.py +16255 -0
- amigo_sdk/http_client.py +380 -0
- amigo_sdk/models.py +1 -0
- amigo_sdk/resources/conversation.py +361 -0
- amigo_sdk/resources/organization.py +34 -0
- amigo_sdk/resources/service.py +46 -0
- amigo_sdk/resources/user.py +97 -0
- amigo_sdk/sdk_client.py +187 -0
- amigo_sdk-0.62.0.dist-info/METADATA +260 -0
- amigo_sdk-0.62.0.dist-info/RECORD +18 -0
- amigo_sdk-0.62.0.dist-info/WHEEL +4 -0
- amigo_sdk-0.62.0.dist-info/entry_points.txt +3 -0
- amigo_sdk-0.62.0.dist-info/licenses/LICENSE +21 -0
amigo_sdk/http_client.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import datetime as dt
|
|
3
|
+
import random
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import AsyncIterator, Iterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from email.utils import parsedate_to_datetime
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from amigo_sdk.auth import sign_in_with_api_key, sign_in_with_api_key_async
|
|
14
|
+
from amigo_sdk.config import AmigoConfig
|
|
15
|
+
from amigo_sdk.errors import (
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
get_error_class_for_status_code,
|
|
18
|
+
raise_for_status,
|
|
19
|
+
)
|
|
20
|
+
from amigo_sdk.generated.model import UserSignInWithApiKeyResponse
|
|
21
|
+
|
|
22
|
+
# -----------------------------
|
|
23
|
+
# Shared helpers and structures
|
|
24
|
+
# -----------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class _RetryConfig:
|
|
29
|
+
max_attempts: int
|
|
30
|
+
backoff_base: float
|
|
31
|
+
max_delay_seconds: float
|
|
32
|
+
on_status: set[int]
|
|
33
|
+
on_methods: set[str]
|
|
34
|
+
|
|
35
|
+
def is_retryable_method(self, method: str) -> bool:
|
|
36
|
+
return method.upper() in self.on_methods
|
|
37
|
+
|
|
38
|
+
def is_retryable_response(self, method: str, resp: httpx.Response) -> bool:
|
|
39
|
+
status = resp.status_code
|
|
40
|
+
if (
|
|
41
|
+
method.upper() == "POST"
|
|
42
|
+
and status == 429
|
|
43
|
+
and resp.headers.get("Retry-After")
|
|
44
|
+
):
|
|
45
|
+
return True
|
|
46
|
+
return self.is_retryable_method(method) and status in self.on_status
|
|
47
|
+
|
|
48
|
+
def parse_retry_after_seconds(self, resp: httpx.Response) -> float | None:
|
|
49
|
+
retry_after = resp.headers.get("Retry-After")
|
|
50
|
+
if not retry_after:
|
|
51
|
+
return None
|
|
52
|
+
# Numeric seconds
|
|
53
|
+
try:
|
|
54
|
+
seconds = float(retry_after)
|
|
55
|
+
return max(0.0, seconds)
|
|
56
|
+
except ValueError:
|
|
57
|
+
pass
|
|
58
|
+
# HTTP-date format
|
|
59
|
+
try:
|
|
60
|
+
target_dt = parsedate_to_datetime(retry_after)
|
|
61
|
+
if target_dt is None:
|
|
62
|
+
return None
|
|
63
|
+
if target_dt.tzinfo is None:
|
|
64
|
+
target_dt = target_dt.replace(tzinfo=dt.UTC)
|
|
65
|
+
now = dt.datetime.now(dt.UTC)
|
|
66
|
+
delta_seconds = (target_dt - now).total_seconds()
|
|
67
|
+
# Round to milliseconds to avoid borderline off-by-epsilon in tests
|
|
68
|
+
delta_seconds = round(delta_seconds, 3)
|
|
69
|
+
return max(0.0, delta_seconds)
|
|
70
|
+
except Exception:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def retry_delay_seconds(self, attempt: int, resp: httpx.Response | None) -> float:
|
|
74
|
+
# Honor Retry-After when present (numeric or HTTP-date), clamped by max delay
|
|
75
|
+
if resp is not None:
|
|
76
|
+
ra_seconds = self.parse_retry_after_seconds(resp)
|
|
77
|
+
if ra_seconds is not None:
|
|
78
|
+
return min(self.max_delay_seconds, ra_seconds)
|
|
79
|
+
# Exponential backoff with full jitter: U(0, min(cap, base * 2^(attempt-1)))
|
|
80
|
+
window = self.backoff_base * (2 ** (attempt - 1))
|
|
81
|
+
window = min(window, self.max_delay_seconds)
|
|
82
|
+
return random.uniform(0.0, window)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _should_refresh_token(token: Optional[UserSignInWithApiKeyResponse]) -> bool:
|
|
86
|
+
if not token:
|
|
87
|
+
return True
|
|
88
|
+
return dt.datetime.now(dt.UTC) > token.expires_at - dt.timedelta(minutes=5)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _raise_status_with_body_async(resp: httpx.Response) -> None:
|
|
92
|
+
if 200 <= resp.status_code < 300:
|
|
93
|
+
return
|
|
94
|
+
try:
|
|
95
|
+
await resp.aread()
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
if hasattr(resp, "is_success"):
|
|
99
|
+
raise_for_status(resp)
|
|
100
|
+
error_class = get_error_class_for_status_code(getattr(resp, "status_code", 0))
|
|
101
|
+
raise error_class(
|
|
102
|
+
f"HTTP {getattr(resp, 'status_code', 'unknown')} error",
|
|
103
|
+
status_code=getattr(resp, "status_code", None),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _raise_status_with_body_sync(resp: httpx.Response) -> None:
|
|
108
|
+
if 200 <= resp.status_code < 300:
|
|
109
|
+
return
|
|
110
|
+
try:
|
|
111
|
+
_ = resp.text
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
if hasattr(resp, "is_success"):
|
|
115
|
+
raise_for_status(resp)
|
|
116
|
+
error_class = get_error_class_for_status_code(getattr(resp, "status_code", 0))
|
|
117
|
+
raise error_class(
|
|
118
|
+
f"HTTP {getattr(resp, 'status_code', 'unknown')} error",
|
|
119
|
+
status_code=getattr(resp, "status_code", None),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class AmigoAsyncHttpClient:
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
cfg: AmigoConfig,
|
|
127
|
+
*,
|
|
128
|
+
retry_max_attempts: int = 3,
|
|
129
|
+
retry_backoff_base: float = 0.25,
|
|
130
|
+
retry_max_delay_seconds: float = 30.0,
|
|
131
|
+
retry_on_status: set[int] | None = None,
|
|
132
|
+
retry_on_methods: set[str] | None = None,
|
|
133
|
+
**httpx_kwargs: Any,
|
|
134
|
+
) -> None:
|
|
135
|
+
self._cfg = cfg
|
|
136
|
+
self._token: Optional[UserSignInWithApiKeyResponse] = None
|
|
137
|
+
self._client = httpx.AsyncClient(
|
|
138
|
+
base_url=cfg.base_url,
|
|
139
|
+
**httpx_kwargs,
|
|
140
|
+
)
|
|
141
|
+
# Retry configuration
|
|
142
|
+
self._retry_cfg = _RetryConfig(
|
|
143
|
+
max(1, retry_max_attempts),
|
|
144
|
+
retry_backoff_base,
|
|
145
|
+
max(0.0, retry_max_delay_seconds),
|
|
146
|
+
retry_on_status or {408, 429, 500, 502, 503, 504},
|
|
147
|
+
{m.upper() for m in (retry_on_methods or {"GET"})},
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
async def _ensure_token(self) -> str:
|
|
151
|
+
"""Fetch or refresh bearer token ~5 min before expiry."""
|
|
152
|
+
if _should_refresh_token(self._token):
|
|
153
|
+
try:
|
|
154
|
+
self._token = await sign_in_with_api_key_async(self._cfg)
|
|
155
|
+
except Exception as e:
|
|
156
|
+
raise AuthenticationError(
|
|
157
|
+
"API-key exchange failed",
|
|
158
|
+
) from e
|
|
159
|
+
|
|
160
|
+
return self._token.id_token
|
|
161
|
+
|
|
162
|
+
async def request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
|
163
|
+
kwargs.setdefault("headers", {})
|
|
164
|
+
attempt = 1
|
|
165
|
+
|
|
166
|
+
while True:
|
|
167
|
+
kwargs["headers"]["Authorization"] = f"Bearer {await self._ensure_token()}"
|
|
168
|
+
|
|
169
|
+
resp: httpx.Response | None = None
|
|
170
|
+
try:
|
|
171
|
+
resp = await self._client.request(method, path, **kwargs)
|
|
172
|
+
|
|
173
|
+
# On 401 refresh token once and retry immediately
|
|
174
|
+
if resp.status_code == 401:
|
|
175
|
+
self._token = None
|
|
176
|
+
kwargs["headers"]["Authorization"] = (
|
|
177
|
+
f"Bearer {await self._ensure_token()}"
|
|
178
|
+
)
|
|
179
|
+
resp = await self._client.request(method, path, **kwargs)
|
|
180
|
+
|
|
181
|
+
except (httpx.TimeoutException, httpx.TransportError):
|
|
182
|
+
# Retry only if method is allowed (e.g., GET); POST not retried for transport/timeouts
|
|
183
|
+
if (
|
|
184
|
+
not self._retry_cfg.is_retryable_method(method)
|
|
185
|
+
or attempt >= self._retry_cfg.max_attempts
|
|
186
|
+
):
|
|
187
|
+
raise
|
|
188
|
+
await asyncio.sleep(self._retry_cfg.retry_delay_seconds(attempt, None))
|
|
189
|
+
attempt += 1
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
# Retry on configured HTTP status codes
|
|
193
|
+
if (
|
|
194
|
+
self._retry_cfg.is_retryable_response(method, resp)
|
|
195
|
+
and attempt < self._retry_cfg.max_attempts
|
|
196
|
+
):
|
|
197
|
+
await asyncio.sleep(self._retry_cfg.retry_delay_seconds(attempt, resp))
|
|
198
|
+
attempt += 1
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
# Check response status and raise appropriate errors
|
|
202
|
+
raise_for_status(resp)
|
|
203
|
+
return resp
|
|
204
|
+
|
|
205
|
+
async def stream_lines(
|
|
206
|
+
self,
|
|
207
|
+
method: str,
|
|
208
|
+
path: str,
|
|
209
|
+
abort_event: asyncio.Event | None = None,
|
|
210
|
+
**kwargs,
|
|
211
|
+
) -> AsyncIterator[str]:
|
|
212
|
+
"""Stream response lines without buffering the full body.
|
|
213
|
+
|
|
214
|
+
- Adds Authorization and sensible streaming headers
|
|
215
|
+
- Retries once on 401 by refreshing the token
|
|
216
|
+
- Raises mapped errors for non-2xx without consuming the body
|
|
217
|
+
"""
|
|
218
|
+
kwargs.setdefault("headers", {})
|
|
219
|
+
headers = kwargs["headers"]
|
|
220
|
+
headers["Authorization"] = f"Bearer {await self._ensure_token()}"
|
|
221
|
+
headers.setdefault("Accept", "application/x-ndjson")
|
|
222
|
+
|
|
223
|
+
async def _yield_from_response(resp: httpx.Response) -> AsyncIterator[str]:
|
|
224
|
+
await _raise_status_with_body_async(resp)
|
|
225
|
+
if abort_event and abort_event.is_set():
|
|
226
|
+
return
|
|
227
|
+
async for line in resp.aiter_lines():
|
|
228
|
+
if abort_event and abort_event.is_set():
|
|
229
|
+
return
|
|
230
|
+
line_stripped = line.strip()
|
|
231
|
+
if not line_stripped:
|
|
232
|
+
continue
|
|
233
|
+
yield line_stripped
|
|
234
|
+
|
|
235
|
+
# First attempt
|
|
236
|
+
if abort_event and abort_event.is_set():
|
|
237
|
+
return
|
|
238
|
+
async with self._client.stream(method, path, **kwargs) as resp:
|
|
239
|
+
if resp.status_code == 401:
|
|
240
|
+
# Refresh token and retry once
|
|
241
|
+
self._token = None
|
|
242
|
+
headers["Authorization"] = f"Bearer {await self._ensure_token()}"
|
|
243
|
+
if abort_event and abort_event.is_set():
|
|
244
|
+
return
|
|
245
|
+
async with self._client.stream(method, path, **kwargs) as retry_resp:
|
|
246
|
+
async for ln in _yield_from_response(retry_resp):
|
|
247
|
+
yield ln
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
async for ln in _yield_from_response(resp):
|
|
251
|
+
yield ln
|
|
252
|
+
|
|
253
|
+
async def aclose(self) -> None:
|
|
254
|
+
await self._client.aclose()
|
|
255
|
+
|
|
256
|
+
# async-context-manager sugar
|
|
257
|
+
async def __aenter__(self): # → async with AmigoAsyncHttpClient(...) as http:
|
|
258
|
+
return self
|
|
259
|
+
|
|
260
|
+
async def __aexit__(self, *_):
|
|
261
|
+
await self.aclose()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class AmigoHttpClient:
|
|
265
|
+
def __init__(
|
|
266
|
+
self,
|
|
267
|
+
cfg: AmigoConfig,
|
|
268
|
+
*,
|
|
269
|
+
retry_max_attempts: int = 3,
|
|
270
|
+
retry_backoff_base: float = 0.25,
|
|
271
|
+
retry_max_delay_seconds: float = 30.0,
|
|
272
|
+
retry_on_status: set[int] | None = None,
|
|
273
|
+
retry_on_methods: set[str] | None = None,
|
|
274
|
+
**httpx_kwargs: Any,
|
|
275
|
+
) -> None:
|
|
276
|
+
self._cfg = cfg
|
|
277
|
+
self._token: Optional[UserSignInWithApiKeyResponse] = None
|
|
278
|
+
self._client = httpx.Client(base_url=cfg.base_url, **httpx_kwargs)
|
|
279
|
+
# Retry configuration
|
|
280
|
+
self._retry_cfg = _RetryConfig(
|
|
281
|
+
max(1, retry_max_attempts),
|
|
282
|
+
retry_backoff_base,
|
|
283
|
+
max(0.0, retry_max_delay_seconds),
|
|
284
|
+
retry_on_status or {408, 429, 500, 502, 503, 504},
|
|
285
|
+
{m.upper() for m in (retry_on_methods or {"GET"})},
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def _ensure_token(self) -> str:
|
|
289
|
+
if _should_refresh_token(self._token):
|
|
290
|
+
try:
|
|
291
|
+
self._token = sign_in_with_api_key(self._cfg)
|
|
292
|
+
except Exception as e:
|
|
293
|
+
raise AuthenticationError("API-key exchange failed") from e
|
|
294
|
+
return self._token.id_token
|
|
295
|
+
|
|
296
|
+
def request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
|
297
|
+
kwargs.setdefault("headers", {})
|
|
298
|
+
attempt = 1
|
|
299
|
+
|
|
300
|
+
while True:
|
|
301
|
+
kwargs["headers"]["Authorization"] = f"Bearer {self._ensure_token()}"
|
|
302
|
+
|
|
303
|
+
resp: httpx.Response | None = None
|
|
304
|
+
try:
|
|
305
|
+
resp = self._client.request(method, path, **kwargs)
|
|
306
|
+
if resp.status_code == 401:
|
|
307
|
+
self._token = None
|
|
308
|
+
kwargs["headers"]["Authorization"] = (
|
|
309
|
+
f"Bearer {self._ensure_token()}"
|
|
310
|
+
)
|
|
311
|
+
resp = self._client.request(method, path, **kwargs)
|
|
312
|
+
|
|
313
|
+
except (httpx.TimeoutException, httpx.TransportError):
|
|
314
|
+
if (
|
|
315
|
+
not self._retry_cfg.is_retryable_method(method)
|
|
316
|
+
) or attempt >= self._retry_cfg.max_attempts:
|
|
317
|
+
raise
|
|
318
|
+
time.sleep(self._retry_cfg.retry_delay_seconds(attempt, None))
|
|
319
|
+
attempt += 1
|
|
320
|
+
continue
|
|
321
|
+
|
|
322
|
+
if (
|
|
323
|
+
self._retry_cfg.is_retryable_response(method, resp)
|
|
324
|
+
and attempt < self._retry_cfg.max_attempts
|
|
325
|
+
):
|
|
326
|
+
time.sleep(self._retry_cfg.retry_delay_seconds(attempt, resp))
|
|
327
|
+
attempt += 1
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
raise_for_status(resp)
|
|
331
|
+
return resp
|
|
332
|
+
|
|
333
|
+
def stream_lines(
|
|
334
|
+
self,
|
|
335
|
+
method: str,
|
|
336
|
+
path: str,
|
|
337
|
+
abort_event: threading.Event | None = None,
|
|
338
|
+
**kwargs,
|
|
339
|
+
) -> Iterator[str]:
|
|
340
|
+
kwargs.setdefault("headers", {})
|
|
341
|
+
headers = kwargs["headers"]
|
|
342
|
+
headers["Authorization"] = f"Bearer {self._ensure_token()}"
|
|
343
|
+
headers.setdefault("Accept", "application/x-ndjson")
|
|
344
|
+
|
|
345
|
+
def _yield_from_response(resp: httpx.Response) -> Iterator[str]:
|
|
346
|
+
_raise_status_with_body_sync(resp)
|
|
347
|
+
if abort_event and abort_event.is_set():
|
|
348
|
+
return
|
|
349
|
+
for line in resp.iter_lines():
|
|
350
|
+
if abort_event and abort_event.is_set():
|
|
351
|
+
return
|
|
352
|
+
line_stripped = (line or "").strip()
|
|
353
|
+
if not line_stripped:
|
|
354
|
+
continue
|
|
355
|
+
yield line_stripped
|
|
356
|
+
|
|
357
|
+
if abort_event and abort_event.is_set():
|
|
358
|
+
return iter(())
|
|
359
|
+
with self._client.stream(method, path, **kwargs) as resp:
|
|
360
|
+
if resp.status_code == 401:
|
|
361
|
+
self._token = None
|
|
362
|
+
headers["Authorization"] = f"Bearer {self._ensure_token()}"
|
|
363
|
+
if abort_event and abort_event.is_set():
|
|
364
|
+
return iter(())
|
|
365
|
+
with self._client.stream(method, path, **kwargs) as retry_resp:
|
|
366
|
+
for ln in _yield_from_response(retry_resp):
|
|
367
|
+
yield ln
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
for ln in _yield_from_response(resp):
|
|
371
|
+
yield ln
|
|
372
|
+
|
|
373
|
+
def aclose(self) -> None:
|
|
374
|
+
self._client.close()
|
|
375
|
+
|
|
376
|
+
def __enter__(self):
|
|
377
|
+
return self
|
|
378
|
+
|
|
379
|
+
def __exit__(self, *_):
|
|
380
|
+
self.aclose()
|
amigo_sdk/models.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .generated.model import * # noqa: F403
|