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/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import datetime as dt
|
|
2
|
+
import random
|
|
3
|
+
from email.utils import parsedate_to_datetime
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
DEFAULT_RETRYABLE_STATUS: set[int] = {429, 500, 502, 503, 504}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_retry_after_seconds(retry_after: Optional[str]) -> float | None:
|
|
10
|
+
"""Parse Retry-After header into seconds.
|
|
11
|
+
|
|
12
|
+
Supports both numeric seconds and HTTP-date formats. Returns None when
|
|
13
|
+
header is missing or invalid.
|
|
14
|
+
"""
|
|
15
|
+
if not retry_after:
|
|
16
|
+
return None
|
|
17
|
+
# Numeric seconds
|
|
18
|
+
try:
|
|
19
|
+
seconds = float(retry_after)
|
|
20
|
+
return max(0.0, seconds)
|
|
21
|
+
except Exception:
|
|
22
|
+
pass
|
|
23
|
+
# HTTP-date format
|
|
24
|
+
try:
|
|
25
|
+
target_dt = parsedate_to_datetime(retry_after)
|
|
26
|
+
if target_dt is None:
|
|
27
|
+
return None
|
|
28
|
+
if target_dt.tzinfo is None:
|
|
29
|
+
target_dt = target_dt.replace(tzinfo=dt.UTC)
|
|
30
|
+
now = dt.datetime.now(dt.UTC)
|
|
31
|
+
delta_seconds = (target_dt - now).total_seconds()
|
|
32
|
+
return max(0.0, delta_seconds)
|
|
33
|
+
except Exception:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_retryable_response(
|
|
38
|
+
method: str,
|
|
39
|
+
status_code: int,
|
|
40
|
+
headers: dict,
|
|
41
|
+
retry_on_methods: set[str],
|
|
42
|
+
retry_on_status: set[int],
|
|
43
|
+
) -> bool:
|
|
44
|
+
"""Determine if the response is retryable under our policy.
|
|
45
|
+
|
|
46
|
+
Special case: allow POST retry only on 429 when Retry-After is present.
|
|
47
|
+
"""
|
|
48
|
+
method_upper = method.upper()
|
|
49
|
+
if method_upper == "POST" and status_code == 429 and headers.get("Retry-After"):
|
|
50
|
+
return True
|
|
51
|
+
return method_upper in retry_on_methods and status_code in retry_on_status
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def compute_retry_delay_seconds(
|
|
55
|
+
attempt: int,
|
|
56
|
+
backoff_base: float,
|
|
57
|
+
max_delay_seconds: float,
|
|
58
|
+
retry_after_header: Optional[str],
|
|
59
|
+
) -> float:
|
|
60
|
+
"""Compute delay for a given retry attempt.
|
|
61
|
+
|
|
62
|
+
If Retry-After is present, honor it (clamped by max). Otherwise, use
|
|
63
|
+
exponential backoff with full jitter.
|
|
64
|
+
"""
|
|
65
|
+
ra_seconds = parse_retry_after_seconds(retry_after_header)
|
|
66
|
+
if ra_seconds is not None:
|
|
67
|
+
return min(max_delay_seconds, ra_seconds)
|
|
68
|
+
window = backoff_base * (2 ** (attempt - 1))
|
|
69
|
+
window = min(window, max_delay_seconds)
|
|
70
|
+
return random.uniform(0.0, window)
|
amigo_sdk/auth.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from amigo_sdk.config import AmigoConfig
|
|
4
|
+
from amigo_sdk.errors import AuthenticationError
|
|
5
|
+
from amigo_sdk.generated.model import UserSignInWithApiKeyResponse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _signin_url_headers(cfg: AmigoConfig) -> tuple[str, dict[str, str]]:
|
|
9
|
+
url = f"{cfg.base_url}/v1/{cfg.organization_id}/user/signin_with_api_key"
|
|
10
|
+
headers = {
|
|
11
|
+
"x-api-key": cfg.api_key,
|
|
12
|
+
"x-api-key-id": cfg.api_key_id,
|
|
13
|
+
"x-user-id": cfg.user_id,
|
|
14
|
+
}
|
|
15
|
+
return url, headers
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_signin_response_text(
|
|
19
|
+
response: httpx.Response,
|
|
20
|
+
) -> UserSignInWithApiKeyResponse:
|
|
21
|
+
try:
|
|
22
|
+
return UserSignInWithApiKeyResponse.model_validate_json(response.text)
|
|
23
|
+
except Exception as e:
|
|
24
|
+
raise AuthenticationError(f"Invalid response format: {e}") from e
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def sign_in_with_api_key(cfg: AmigoConfig) -> UserSignInWithApiKeyResponse:
|
|
28
|
+
"""Sign in with API key (sync)."""
|
|
29
|
+
url, headers = _signin_url_headers(cfg)
|
|
30
|
+
with httpx.Client() as client:
|
|
31
|
+
try:
|
|
32
|
+
response = client.post(url, headers=headers)
|
|
33
|
+
response.raise_for_status()
|
|
34
|
+
except httpx.HTTPStatusError as e:
|
|
35
|
+
raise AuthenticationError(f"Sign in with API key failed: {e}") from e
|
|
36
|
+
return _parse_signin_response_text(response)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def sign_in_with_api_key_async(cfg: AmigoConfig) -> UserSignInWithApiKeyResponse:
|
|
40
|
+
"""Sign in with API key (async)."""
|
|
41
|
+
url, headers = _signin_url_headers(cfg)
|
|
42
|
+
async with httpx.AsyncClient() as client:
|
|
43
|
+
try:
|
|
44
|
+
response = await client.post(url, headers=headers)
|
|
45
|
+
response.raise_for_status()
|
|
46
|
+
except httpx.HTTPStatusError as e:
|
|
47
|
+
raise AuthenticationError(f"Sign in with API key failed: {e}") from e
|
|
48
|
+
return _parse_signin_response_text(response)
|
amigo_sdk/config.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from pydantic import Field
|
|
2
|
+
from pydantic_settings import BaseSettings
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AmigoConfig(BaseSettings):
|
|
6
|
+
"""
|
|
7
|
+
Configuration for the Amigo API client.
|
|
8
|
+
|
|
9
|
+
Can be configured via three methods (in order of precedence):
|
|
10
|
+
1. Constructor parameters (highest precedence)
|
|
11
|
+
2. Environment variables with AMIGO_ prefix
|
|
12
|
+
3. .env file in the current working directory (lowest precedence)
|
|
13
|
+
|
|
14
|
+
Environment variables:
|
|
15
|
+
- AMIGO_API_KEY
|
|
16
|
+
- AMIGO_API_KEY_ID
|
|
17
|
+
- AMIGO_USER_ID
|
|
18
|
+
- AMIGO_BASE_URL
|
|
19
|
+
- AMIGO_ORGANIZATION_ID
|
|
20
|
+
|
|
21
|
+
Example .env file:
|
|
22
|
+
```
|
|
23
|
+
AMIGO_API_KEY=your_api_key_here
|
|
24
|
+
AMIGO_API_KEY_ID=your_api_key_id_here
|
|
25
|
+
AMIGO_USER_ID=your_user_id_here
|
|
26
|
+
AMIGO_ORGANIZATION_ID=your_org_id_here
|
|
27
|
+
AMIGO_BASE_URL=https://api.amigo.ai
|
|
28
|
+
```
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
api_key: str = Field(..., description="API key for authentication")
|
|
32
|
+
api_key_id: str = Field(..., description="API key ID for authentication")
|
|
33
|
+
user_id: str = Field(..., description="User ID for API requests")
|
|
34
|
+
organization_id: str = Field(..., description="Organization ID for API requests")
|
|
35
|
+
base_url: str = Field(
|
|
36
|
+
default="https://api.amigo.ai",
|
|
37
|
+
description="Base URL for the Amigo API",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
model_config = {
|
|
41
|
+
"env_prefix": "AMIGO_",
|
|
42
|
+
"env_file": ".env",
|
|
43
|
+
"env_file_encoding": "utf-8",
|
|
44
|
+
"case_sensitive": False,
|
|
45
|
+
"validate_assignment": True,
|
|
46
|
+
"frozen": True,
|
|
47
|
+
"extra": "ignore", # Ignore extra fields in .env file
|
|
48
|
+
}
|
amigo_sdk/errors.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AmigoError(Exception):
|
|
5
|
+
"""
|
|
6
|
+
Base class for Amigo API errors.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
message: str,
|
|
12
|
+
*,
|
|
13
|
+
status_code: Optional[int] = None,
|
|
14
|
+
error_code: Optional[str] = None,
|
|
15
|
+
response_body: Optional[Any] = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.error_code = error_code
|
|
20
|
+
self.response_body = response_body
|
|
21
|
+
|
|
22
|
+
def __str__(self) -> str:
|
|
23
|
+
parts = [super().__str__()]
|
|
24
|
+
if self.status_code:
|
|
25
|
+
parts.append(f"(HTTP {self.status_code})")
|
|
26
|
+
if self.error_code:
|
|
27
|
+
parts.append(f"[{self.error_code}]")
|
|
28
|
+
return " ".join(parts)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---- 4xx client errors ------------------------------------------------------
|
|
32
|
+
class BadRequestError(AmigoError): # 400
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AuthenticationError(AmigoError): # 401
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PermissionError(AmigoError): # 403
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NotFoundError(AmigoError): # 404
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ConflictError(AmigoError): # 409
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RateLimitError(AmigoError): # 429
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---- Validation / semantic errors ------------------------------------------
|
|
57
|
+
class ValidationError(BadRequestError): # 422 or 400 with `errors` list
|
|
58
|
+
def __init__(self, *args, field_errors: Optional[dict[str, str]] = None, **kwargs):
|
|
59
|
+
super().__init__(*args, **kwargs)
|
|
60
|
+
self.field_errors = field_errors or {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---- 5xx server errors ------------------------------------------------------
|
|
64
|
+
class ServerError(AmigoError): # 500
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ServiceUnavailableError(ServerError): # 503 / maintenance
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---- Internal SDK issues ----------------------------------------------------
|
|
73
|
+
class SDKInternalError(AmigoError):
|
|
74
|
+
"""JSON decoding failure, Pydantic model mismatch, etc."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---- Status code mapping ----------------------------------------------------
|
|
78
|
+
def get_error_class_for_status_code(status_code: int) -> type[AmigoError]:
|
|
79
|
+
"""Map HTTP status codes to appropriate AmigoError classes."""
|
|
80
|
+
error_map = {
|
|
81
|
+
400: BadRequestError,
|
|
82
|
+
401: AuthenticationError,
|
|
83
|
+
403: PermissionError,
|
|
84
|
+
404: NotFoundError,
|
|
85
|
+
409: ConflictError,
|
|
86
|
+
422: ValidationError,
|
|
87
|
+
429: RateLimitError,
|
|
88
|
+
500: ServerError,
|
|
89
|
+
503: ServiceUnavailableError,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# Default to appropriate base class for status code ranges
|
|
93
|
+
if status_code in error_map:
|
|
94
|
+
return error_map[status_code]
|
|
95
|
+
elif 400 <= status_code < 500:
|
|
96
|
+
return BadRequestError
|
|
97
|
+
elif 500 <= status_code < 600:
|
|
98
|
+
return ServerError
|
|
99
|
+
else:
|
|
100
|
+
return AmigoError
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def raise_for_status(response, message: str = None) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Raise an appropriate AmigoError for non-2xx status codes.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
response: httpx.Response object
|
|
109
|
+
message: Optional custom error message
|
|
110
|
+
"""
|
|
111
|
+
if response.is_success:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
status_code = response.status_code
|
|
115
|
+
error_class = get_error_class_for_status_code(status_code)
|
|
116
|
+
|
|
117
|
+
# Try to extract error details from response
|
|
118
|
+
error_code = None
|
|
119
|
+
response_body = None
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
response_body = response.json()
|
|
123
|
+
# Try to extract error code if it exists in response
|
|
124
|
+
if isinstance(response_body, dict):
|
|
125
|
+
error_code = response_body.get("error_code") or response_body.get("code")
|
|
126
|
+
except Exception:
|
|
127
|
+
# If JSON parsing fails, use text content
|
|
128
|
+
try:
|
|
129
|
+
response_body = response.text
|
|
130
|
+
except Exception:
|
|
131
|
+
response_body = None
|
|
132
|
+
|
|
133
|
+
# Use provided message or generate default
|
|
134
|
+
if not message:
|
|
135
|
+
message = f"HTTP {status_code} error"
|
|
136
|
+
if isinstance(response_body, dict):
|
|
137
|
+
# Prefer common API error fields, including FastAPI's "detail"
|
|
138
|
+
for key in ("message", "error", "detail"):
|
|
139
|
+
api_message = response_body.get(key)
|
|
140
|
+
if api_message:
|
|
141
|
+
message = str(api_message)
|
|
142
|
+
break
|
|
143
|
+
elif isinstance(response_body, str) and response_body.strip():
|
|
144
|
+
# If the server returned a plain-text or JSON string body, surface it
|
|
145
|
+
message = response_body.strip()
|
|
146
|
+
|
|
147
|
+
# Handle ValidationError special case for field errors
|
|
148
|
+
if error_class == ValidationError and isinstance(response_body, dict):
|
|
149
|
+
field_errors = response_body.get("errors") or response_body.get("field_errors")
|
|
150
|
+
raise error_class(
|
|
151
|
+
message,
|
|
152
|
+
status_code=status_code,
|
|
153
|
+
error_code=error_code,
|
|
154
|
+
response_body=response_body,
|
|
155
|
+
field_errors=field_errors,
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
raise error_class(
|
|
159
|
+
message,
|
|
160
|
+
status_code=status_code,
|
|
161
|
+
error_code=error_code,
|
|
162
|
+
response_body=response_body,
|
|
163
|
+
)
|