amigo_sdk 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.
Potentially problematic release.
This version of amigo_sdk might be problematic. Click here for more details.
- amigo_sdk/__init__.py +1 -0
- amigo_sdk/auth.py +30 -0
- amigo_sdk/config.py +48 -0
- amigo_sdk/errors.py +163 -0
- amigo_sdk/generated/model.py +15683 -0
- amigo_sdk/http_client.py +228 -0
- amigo_sdk/resources/conversation.py +208 -0
- amigo_sdk/resources/organization.py +22 -0
- amigo_sdk/resources/service.py +30 -0
- amigo_sdk/resources/user.py +57 -0
- amigo_sdk/sdk_client.py +105 -0
- amigo_sdk-0.1.1.dist-info/METADATA +192 -0
- amigo_sdk-0.1.1.dist-info/RECORD +16 -0
- amigo_sdk-0.1.1.dist-info/WHEEL +4 -0
- amigo_sdk-0.1.1.dist-info/entry_points.txt +3 -0
- amigo_sdk-0.1.1.dist-info/licenses/LICENSE +21 -0
amigo_sdk/http_client.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import datetime as dt
|
|
3
|
+
import random
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from email.utils import parsedate_to_datetime
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from amigo_sdk.auth import sign_in_with_api_key
|
|
11
|
+
from amigo_sdk.config import AmigoConfig
|
|
12
|
+
from amigo_sdk.errors import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
get_error_class_for_status_code,
|
|
15
|
+
raise_for_status,
|
|
16
|
+
)
|
|
17
|
+
from amigo_sdk.generated.model import UserSignInWithApiKeyResponse
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AmigoHttpClient:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
cfg: AmigoConfig,
|
|
24
|
+
*,
|
|
25
|
+
retry_max_attempts: int = 3,
|
|
26
|
+
retry_backoff_base: float = 0.25,
|
|
27
|
+
retry_max_delay_seconds: float = 30.0,
|
|
28
|
+
retry_on_status: set[int] | None = None,
|
|
29
|
+
retry_on_methods: set[str] | None = None,
|
|
30
|
+
**httpx_kwargs: Any,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._cfg = cfg
|
|
33
|
+
self._token: Optional[UserSignInWithApiKeyResponse] = None
|
|
34
|
+
self._client = httpx.AsyncClient(
|
|
35
|
+
base_url=cfg.base_url,
|
|
36
|
+
**httpx_kwargs,
|
|
37
|
+
)
|
|
38
|
+
# Retry configuration
|
|
39
|
+
self._retry_max_attempts = max(1, retry_max_attempts)
|
|
40
|
+
self._retry_backoff_base = retry_backoff_base
|
|
41
|
+
self._retry_max_delay_seconds = max(0.0, retry_max_delay_seconds)
|
|
42
|
+
self._retry_on_status = retry_on_status or {408, 429, 500, 502, 503, 504}
|
|
43
|
+
# Default to GET only; POST is handled specially for 429 + Retry-After
|
|
44
|
+
self._retry_on_methods = {m.upper() for m in (retry_on_methods or {"GET"})}
|
|
45
|
+
|
|
46
|
+
def _is_retryable_method(self, method: str) -> bool:
|
|
47
|
+
return method.upper() in self._retry_on_methods
|
|
48
|
+
|
|
49
|
+
def _is_retryable_response(self, method: str, resp: httpx.Response) -> bool:
|
|
50
|
+
status = resp.status_code
|
|
51
|
+
# Allow POST retry only for 429 when Retry-After header is present
|
|
52
|
+
if (
|
|
53
|
+
method.upper() == "POST"
|
|
54
|
+
and status == 429
|
|
55
|
+
and resp.headers.get("Retry-After")
|
|
56
|
+
):
|
|
57
|
+
return True
|
|
58
|
+
return self._is_retryable_method(method) and status in self._retry_on_status
|
|
59
|
+
|
|
60
|
+
def _parse_retry_after_seconds(self, resp: httpx.Response) -> float | None:
|
|
61
|
+
retry_after = resp.headers.get("Retry-After")
|
|
62
|
+
if not retry_after:
|
|
63
|
+
return None
|
|
64
|
+
# Numeric seconds
|
|
65
|
+
try:
|
|
66
|
+
seconds = float(retry_after)
|
|
67
|
+
return max(0.0, seconds)
|
|
68
|
+
except ValueError:
|
|
69
|
+
pass
|
|
70
|
+
# HTTP-date format
|
|
71
|
+
try:
|
|
72
|
+
target_dt = parsedate_to_datetime(retry_after)
|
|
73
|
+
if target_dt is None:
|
|
74
|
+
return None
|
|
75
|
+
if target_dt.tzinfo is None:
|
|
76
|
+
target_dt = target_dt.replace(tzinfo=dt.UTC)
|
|
77
|
+
now = dt.datetime.now(dt.UTC)
|
|
78
|
+
delta_seconds = (target_dt - now).total_seconds()
|
|
79
|
+
return max(0.0, delta_seconds)
|
|
80
|
+
except Exception:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def _retry_delay_seconds(self, attempt: int, resp: httpx.Response | None) -> float:
|
|
84
|
+
# Honor Retry-After when present (numeric or HTTP-date), clamped by max delay
|
|
85
|
+
if resp is not None:
|
|
86
|
+
ra_seconds = self._parse_retry_after_seconds(resp)
|
|
87
|
+
if ra_seconds is not None:
|
|
88
|
+
return min(self._retry_max_delay_seconds, ra_seconds)
|
|
89
|
+
# Exponential backoff with full jitter: U(0, min(cap, base * 2^(attempt-1)))
|
|
90
|
+
window = self._retry_backoff_base * (2 ** (attempt - 1))
|
|
91
|
+
window = min(window, self._retry_max_delay_seconds)
|
|
92
|
+
return random.uniform(0.0, window)
|
|
93
|
+
|
|
94
|
+
async def _ensure_token(self) -> str:
|
|
95
|
+
"""Fetch or refresh bearer token ~5 min before expiry."""
|
|
96
|
+
if not self._token or dt.datetime.now(
|
|
97
|
+
dt.UTC
|
|
98
|
+
) > self._token.expires_at - dt.timedelta(minutes=5):
|
|
99
|
+
try:
|
|
100
|
+
self._token = await sign_in_with_api_key(self._cfg)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
raise AuthenticationError(
|
|
103
|
+
"API-key exchange failed",
|
|
104
|
+
) from e
|
|
105
|
+
|
|
106
|
+
return self._token.id_token
|
|
107
|
+
|
|
108
|
+
async def request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
|
109
|
+
kwargs.setdefault("headers", {})
|
|
110
|
+
attempt = 1
|
|
111
|
+
|
|
112
|
+
while True:
|
|
113
|
+
kwargs["headers"]["Authorization"] = f"Bearer {await self._ensure_token()}"
|
|
114
|
+
|
|
115
|
+
resp: httpx.Response | None = None
|
|
116
|
+
try:
|
|
117
|
+
resp = await self._client.request(method, path, **kwargs)
|
|
118
|
+
|
|
119
|
+
# On 401 refresh token once and retry immediately
|
|
120
|
+
if resp.status_code == 401:
|
|
121
|
+
self._token = None
|
|
122
|
+
kwargs["headers"]["Authorization"] = (
|
|
123
|
+
f"Bearer {await self._ensure_token()}"
|
|
124
|
+
)
|
|
125
|
+
resp = await self._client.request(method, path, **kwargs)
|
|
126
|
+
|
|
127
|
+
except (httpx.TimeoutException, httpx.TransportError):
|
|
128
|
+
# Retry only if method is allowed (e.g., GET); POST not retried for transport/timeouts
|
|
129
|
+
if (
|
|
130
|
+
not self._is_retryable_method(method)
|
|
131
|
+
or attempt >= self._retry_max_attempts
|
|
132
|
+
):
|
|
133
|
+
raise
|
|
134
|
+
await asyncio.sleep(self._retry_delay_seconds(attempt, None))
|
|
135
|
+
attempt += 1
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
# Retry on configured HTTP status codes
|
|
139
|
+
if (
|
|
140
|
+
self._is_retryable_response(method, resp)
|
|
141
|
+
and attempt < self._retry_max_attempts
|
|
142
|
+
):
|
|
143
|
+
await asyncio.sleep(self._retry_delay_seconds(attempt, resp))
|
|
144
|
+
attempt += 1
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
# Check response status and raise appropriate errors
|
|
148
|
+
raise_for_status(resp)
|
|
149
|
+
return resp
|
|
150
|
+
|
|
151
|
+
async def stream_lines(
|
|
152
|
+
self,
|
|
153
|
+
method: str,
|
|
154
|
+
path: str,
|
|
155
|
+
abort_event: asyncio.Event | None = None,
|
|
156
|
+
**kwargs,
|
|
157
|
+
) -> AsyncIterator[str]:
|
|
158
|
+
"""Stream response lines without buffering the full body.
|
|
159
|
+
|
|
160
|
+
- Adds Authorization and sensible streaming headers
|
|
161
|
+
- Retries once on 401 by refreshing the token
|
|
162
|
+
- Raises mapped errors for non-2xx without consuming the body
|
|
163
|
+
"""
|
|
164
|
+
kwargs.setdefault("headers", {})
|
|
165
|
+
headers = kwargs["headers"]
|
|
166
|
+
headers["Authorization"] = f"Bearer {await self._ensure_token()}"
|
|
167
|
+
headers.setdefault("Accept", "application/x-ndjson")
|
|
168
|
+
|
|
169
|
+
async def _raise_status_with_body(resp: httpx.Response) -> None:
|
|
170
|
+
"""Ensure response body is buffered, then raise mapped error with details."""
|
|
171
|
+
if 200 <= resp.status_code < 300:
|
|
172
|
+
return
|
|
173
|
+
# Fully buffer the body so raise_for_status() can extract JSON/text safely
|
|
174
|
+
try:
|
|
175
|
+
await resp.aread()
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
# If this is a real httpx.Response, use our rich raise_for_status
|
|
179
|
+
if hasattr(resp, "is_success"):
|
|
180
|
+
raise_for_status(resp)
|
|
181
|
+
# Otherwise, fall back to lightweight error mapping used in tests' mock responses
|
|
182
|
+
error_class = get_error_class_for_status_code(
|
|
183
|
+
getattr(resp, "status_code", 0)
|
|
184
|
+
)
|
|
185
|
+
raise error_class(
|
|
186
|
+
f"HTTP {getattr(resp, 'status_code', 'unknown')} error",
|
|
187
|
+
status_code=getattr(resp, "status_code", None),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
async def _yield_from_response(resp: httpx.Response) -> AsyncIterator[str]:
|
|
191
|
+
await _raise_status_with_body(resp)
|
|
192
|
+
if abort_event and abort_event.is_set():
|
|
193
|
+
return
|
|
194
|
+
async for line in resp.aiter_lines():
|
|
195
|
+
if abort_event and abort_event.is_set():
|
|
196
|
+
return
|
|
197
|
+
line_stripped = line.strip()
|
|
198
|
+
if not line_stripped:
|
|
199
|
+
continue
|
|
200
|
+
yield line_stripped
|
|
201
|
+
|
|
202
|
+
# First attempt
|
|
203
|
+
if abort_event and abort_event.is_set():
|
|
204
|
+
return
|
|
205
|
+
async with self._client.stream(method, path, **kwargs) as resp:
|
|
206
|
+
if resp.status_code == 401:
|
|
207
|
+
# Refresh token and retry once
|
|
208
|
+
self._token = None
|
|
209
|
+
headers["Authorization"] = f"Bearer {await self._ensure_token()}"
|
|
210
|
+
if abort_event and abort_event.is_set():
|
|
211
|
+
return
|
|
212
|
+
async with self._client.stream(method, path, **kwargs) as retry_resp:
|
|
213
|
+
async for ln in _yield_from_response(retry_resp):
|
|
214
|
+
yield ln
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
async for ln in _yield_from_response(resp):
|
|
218
|
+
yield ln
|
|
219
|
+
|
|
220
|
+
async def aclose(self) -> None:
|
|
221
|
+
await self._client.aclose()
|
|
222
|
+
|
|
223
|
+
# async-context-manager sugar
|
|
224
|
+
async def __aenter__(self): # → async with AmigoHTTPClient(...) as http:
|
|
225
|
+
return self
|
|
226
|
+
|
|
227
|
+
async def __aexit__(self, *_):
|
|
228
|
+
await self.aclose()
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import AsyncGenerator
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import AnyUrl, BaseModel
|
|
7
|
+
|
|
8
|
+
from amigo_sdk.generated.model import (
|
|
9
|
+
ConversationCreateConversationRequest,
|
|
10
|
+
ConversationCreateConversationResponse,
|
|
11
|
+
ConversationGenerateConversationStarterRequest,
|
|
12
|
+
ConversationGenerateConversationStarterResponse,
|
|
13
|
+
ConversationGetConversationMessagesResponse,
|
|
14
|
+
ConversationGetConversationsResponse,
|
|
15
|
+
ConversationGetInteractionInsightsResponse,
|
|
16
|
+
ConversationInteractWithConversationResponse,
|
|
17
|
+
ConversationRecommendResponsesForInteractionResponse,
|
|
18
|
+
CreateConversationParametersQuery,
|
|
19
|
+
Format,
|
|
20
|
+
GetConversationMessagesParametersQuery,
|
|
21
|
+
GetConversationsParametersQuery,
|
|
22
|
+
InteractWithConversationParametersQuery,
|
|
23
|
+
)
|
|
24
|
+
from amigo_sdk.http_client import AmigoHttpClient
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GetMessageSourceResponse(BaseModel):
|
|
28
|
+
"""
|
|
29
|
+
Response model for the `get_message_source` endpoint.
|
|
30
|
+
TODO: Remove once the OpenAPI spec contains the correct response model for this endpoint.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
url: AnyUrl
|
|
34
|
+
expires_at: datetime
|
|
35
|
+
content_type: Literal["audio/mpeg", "audio/wav"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConversationResource:
|
|
39
|
+
"""Conversation resource for Amigo API operations."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, http_client: AmigoHttpClient, organization_id: str) -> None:
|
|
42
|
+
self._http = http_client
|
|
43
|
+
self._organization_id = organization_id
|
|
44
|
+
|
|
45
|
+
async def create_conversation(
|
|
46
|
+
self,
|
|
47
|
+
body: ConversationCreateConversationRequest,
|
|
48
|
+
params: CreateConversationParametersQuery,
|
|
49
|
+
abort_event: asyncio.Event | None = None,
|
|
50
|
+
) -> "AsyncGenerator[ConversationCreateConversationResponse, None]":
|
|
51
|
+
"""Create a new conversation and stream NDJSON events.
|
|
52
|
+
|
|
53
|
+
Returns an async generator yielding `ConversationCreateConversationResponse` events.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
async def _generator():
|
|
57
|
+
async for line in self._http.stream_lines(
|
|
58
|
+
"POST",
|
|
59
|
+
f"/v1/{self._organization_id}/conversation/",
|
|
60
|
+
params=params.model_dump(mode="json", exclude_none=True),
|
|
61
|
+
json=body.model_dump(mode="json", exclude_none=True),
|
|
62
|
+
headers={"Accept": "application/x-ndjson"},
|
|
63
|
+
abort_event=abort_event,
|
|
64
|
+
):
|
|
65
|
+
# Each line is a JSON object representing a discriminated union event
|
|
66
|
+
yield ConversationCreateConversationResponse.model_validate_json(line)
|
|
67
|
+
|
|
68
|
+
return _generator()
|
|
69
|
+
|
|
70
|
+
async def interact_with_conversation(
|
|
71
|
+
self,
|
|
72
|
+
conversation_id: str,
|
|
73
|
+
params: InteractWithConversationParametersQuery,
|
|
74
|
+
abort_event: asyncio.Event | None = None,
|
|
75
|
+
*,
|
|
76
|
+
text_message: str | None = None,
|
|
77
|
+
audio_bytes: bytes | None = None,
|
|
78
|
+
audio_content_type: Literal["audio/mpeg", "audio/wav"] | None = None,
|
|
79
|
+
) -> "AsyncGenerator[ConversationInteractWithConversationResponse, None]":
|
|
80
|
+
"""Interact with a conversation and stream NDJSON events.
|
|
81
|
+
|
|
82
|
+
Returns an async generator yielding `ConversationInteractWithConversationResponse` events.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
async def _generator():
|
|
86
|
+
request_kwargs: dict[str, Any] = {
|
|
87
|
+
"params": params.model_dump(mode="json", exclude_none=True),
|
|
88
|
+
"abort_event": abort_event,
|
|
89
|
+
"headers": {"Accept": "application/x-ndjson"},
|
|
90
|
+
}
|
|
91
|
+
# Route based on requested format
|
|
92
|
+
req_format = getattr(params, "request_format", None)
|
|
93
|
+
if req_format == Format.text:
|
|
94
|
+
if text_message is None:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
"text_message is required when request_format is 'text'"
|
|
97
|
+
)
|
|
98
|
+
text_bytes = text_message.encode("utf-8")
|
|
99
|
+
request_kwargs["files"] = {
|
|
100
|
+
"recorded_message": (
|
|
101
|
+
"message.txt",
|
|
102
|
+
text_bytes,
|
|
103
|
+
"text/plain; charset=utf-8",
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
elif req_format == Format.voice:
|
|
107
|
+
if audio_bytes is None or audio_content_type is None:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"audio_bytes and audio_content_type are required when request_format is 'voice'"
|
|
110
|
+
)
|
|
111
|
+
# Send raw bytes with appropriate content type
|
|
112
|
+
request_kwargs["content"] = audio_bytes
|
|
113
|
+
request_kwargs.setdefault("headers", {})
|
|
114
|
+
request_kwargs["headers"]["Content-Type"] = audio_content_type
|
|
115
|
+
else:
|
|
116
|
+
raise ValueError("Unsupported or missing request_format in params")
|
|
117
|
+
|
|
118
|
+
async for line in self._http.stream_lines(
|
|
119
|
+
"POST",
|
|
120
|
+
f"/v1/{self._organization_id}/conversation/{conversation_id}/interact",
|
|
121
|
+
**request_kwargs,
|
|
122
|
+
):
|
|
123
|
+
# Each line is a JSON object representing a discriminated union event
|
|
124
|
+
yield ConversationInteractWithConversationResponse.model_validate_json(
|
|
125
|
+
line
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return _generator()
|
|
129
|
+
|
|
130
|
+
async def finish_conversation(self, conversation_id: str) -> None:
|
|
131
|
+
"""Finish a conversation."""
|
|
132
|
+
await self._http.request(
|
|
133
|
+
"POST",
|
|
134
|
+
f"/v1/{self._organization_id}/conversation/{conversation_id}/finish/",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
async def get_conversations(
|
|
138
|
+
self, params: GetConversationsParametersQuery
|
|
139
|
+
) -> ConversationGetConversationsResponse:
|
|
140
|
+
"""Get conversations."""
|
|
141
|
+
response = await self._http.request(
|
|
142
|
+
"GET",
|
|
143
|
+
f"/v1/{self._organization_id}/conversation/",
|
|
144
|
+
params=params.model_dump(mode="json", exclude_none=True),
|
|
145
|
+
)
|
|
146
|
+
return ConversationGetConversationsResponse.model_validate_json(response.text)
|
|
147
|
+
|
|
148
|
+
async def get_conversation_messages(
|
|
149
|
+
self, conversation_id: str, params: GetConversationMessagesParametersQuery
|
|
150
|
+
) -> ConversationGetConversationMessagesResponse:
|
|
151
|
+
"""Get conversation messages."""
|
|
152
|
+
response = await self._http.request(
|
|
153
|
+
"GET",
|
|
154
|
+
f"/v1/{self._organization_id}/conversation/{conversation_id}/messages/",
|
|
155
|
+
params=params.model_dump(
|
|
156
|
+
mode="json", exclude_none=True, exclude_defaults=True
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
return ConversationGetConversationMessagesResponse.model_validate_json(
|
|
160
|
+
response.text
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
async def recommend_responses_for_interaction(
|
|
164
|
+
self, conversation_id: str, interaction_id: str
|
|
165
|
+
) -> ConversationRecommendResponsesForInteractionResponse:
|
|
166
|
+
"""Recommend responses for an interaction."""
|
|
167
|
+
response = await self._http.request(
|
|
168
|
+
"GET",
|
|
169
|
+
f"/v1/{self._organization_id}/conversation/{conversation_id}/interaction/{interaction_id}/recommend_responses",
|
|
170
|
+
)
|
|
171
|
+
return ConversationRecommendResponsesForInteractionResponse.model_validate_json(
|
|
172
|
+
response.text
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
async def get_interaction_insights(
|
|
176
|
+
self, conversation_id: str, interaction_id: str
|
|
177
|
+
) -> ConversationGetInteractionInsightsResponse:
|
|
178
|
+
"""Get insights for an interaction."""
|
|
179
|
+
response = await self._http.request(
|
|
180
|
+
"GET",
|
|
181
|
+
f"/v1/{self._organization_id}/conversation/{conversation_id}/interaction/{interaction_id}/insights",
|
|
182
|
+
)
|
|
183
|
+
return ConversationGetInteractionInsightsResponse.model_validate_json(
|
|
184
|
+
response.text
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
async def get_message_source(
|
|
188
|
+
self, conversation_id: str, message_id: str
|
|
189
|
+
) -> GetMessageSourceResponse:
|
|
190
|
+
"""Get the source of a message."""
|
|
191
|
+
response = await self._http.request(
|
|
192
|
+
"GET",
|
|
193
|
+
f"/v1/{self._organization_id}/conversation/{conversation_id}/messages/{message_id}/source",
|
|
194
|
+
)
|
|
195
|
+
return GetMessageSourceResponse.model_validate_json(response.text)
|
|
196
|
+
|
|
197
|
+
async def generate_conversation_starters(
|
|
198
|
+
self, body: ConversationGenerateConversationStarterRequest
|
|
199
|
+
) -> ConversationGenerateConversationStarterResponse:
|
|
200
|
+
"""Generate conversation starters."""
|
|
201
|
+
response = await self._http.request(
|
|
202
|
+
"POST",
|
|
203
|
+
f"/v1/{self._organization_id}/conversation/conversation_starter",
|
|
204
|
+
json=body.model_dump(mode="json", exclude_none=True),
|
|
205
|
+
)
|
|
206
|
+
return ConversationGenerateConversationStarterResponse.model_validate_json(
|
|
207
|
+
response.text
|
|
208
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from amigo_sdk.generated.model import (
|
|
2
|
+
OrganizationGetOrganizationResponse,
|
|
3
|
+
)
|
|
4
|
+
from amigo_sdk.http_client import AmigoHttpClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class OrganizationResource:
|
|
8
|
+
"""Organization resource for Amigo API operations."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, http_client: AmigoHttpClient, organization_id: str) -> None:
|
|
11
|
+
self._http = http_client
|
|
12
|
+
self._organization_id = organization_id
|
|
13
|
+
|
|
14
|
+
async def get(self) -> OrganizationGetOrganizationResponse:
|
|
15
|
+
"""
|
|
16
|
+
Get the details of an organization.
|
|
17
|
+
"""
|
|
18
|
+
response = await self._http.request(
|
|
19
|
+
"GET", f"/v1/{self._organization_id}/organization/"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
return OrganizationGetOrganizationResponse.model_validate_json(response.text)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from amigo_sdk.generated.model import (
|
|
4
|
+
GetServicesParametersQuery,
|
|
5
|
+
ServiceGetServicesResponse,
|
|
6
|
+
)
|
|
7
|
+
from amigo_sdk.http_client import AmigoHttpClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ServiceResource:
|
|
11
|
+
"""Service resource for Amigo API operations."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self, http_client: AmigoHttpClient, organization_id: str
|
|
15
|
+
) -> ServiceGetServicesResponse:
|
|
16
|
+
self._http = http_client
|
|
17
|
+
self._organization_id = organization_id
|
|
18
|
+
|
|
19
|
+
async def get_services(
|
|
20
|
+
self, params: Optional[GetServicesParametersQuery] = None
|
|
21
|
+
) -> ServiceGetServicesResponse:
|
|
22
|
+
"""Get all services."""
|
|
23
|
+
response = await self._http.request(
|
|
24
|
+
"GET",
|
|
25
|
+
f"/v1/{self._organization_id}/service/",
|
|
26
|
+
params=params.model_dump(mode="json", exclude_none=True)
|
|
27
|
+
if params
|
|
28
|
+
else None,
|
|
29
|
+
)
|
|
30
|
+
return ServiceGetServicesResponse.model_validate_json(response.text)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from amigo_sdk.generated.model import (
|
|
4
|
+
GetUsersParametersQuery,
|
|
5
|
+
UserCreateInvitedUserRequest,
|
|
6
|
+
UserCreateInvitedUserResponse,
|
|
7
|
+
UserGetUsersResponse,
|
|
8
|
+
UserUpdateUserInfoRequest,
|
|
9
|
+
)
|
|
10
|
+
from amigo_sdk.http_client import AmigoHttpClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UserResource:
|
|
14
|
+
"""User resource for Amigo API operations."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, http_client: AmigoHttpClient, organization_id: str) -> None:
|
|
17
|
+
self._http = http_client
|
|
18
|
+
self._organization_id = organization_id
|
|
19
|
+
|
|
20
|
+
async def get_users(
|
|
21
|
+
self, params: Optional[GetUsersParametersQuery] = None
|
|
22
|
+
) -> UserGetUsersResponse:
|
|
23
|
+
"""Get a list of users in the organization."""
|
|
24
|
+
response = await self._http.request(
|
|
25
|
+
"GET",
|
|
26
|
+
f"/v1/{self._organization_id}/user/",
|
|
27
|
+
params=params.model_dump(mode="json", exclude_none=True)
|
|
28
|
+
if params
|
|
29
|
+
else None,
|
|
30
|
+
)
|
|
31
|
+
return UserGetUsersResponse.model_validate_json(response.text)
|
|
32
|
+
|
|
33
|
+
async def create_user(
|
|
34
|
+
self, body: UserCreateInvitedUserRequest
|
|
35
|
+
) -> UserCreateInvitedUserResponse:
|
|
36
|
+
"""Create (invite) a new user to the organization."""
|
|
37
|
+
response = await self._http.request(
|
|
38
|
+
"POST",
|
|
39
|
+
f"/v1/{self._organization_id}/user/invite",
|
|
40
|
+
json=body.model_dump(mode="json", exclude_none=True),
|
|
41
|
+
)
|
|
42
|
+
return UserCreateInvitedUserResponse.model_validate_json(response.text)
|
|
43
|
+
|
|
44
|
+
async def delete_user(self, user_id: str) -> None:
|
|
45
|
+
"""Delete a user by ID. Returns None on success (e.g., 204)."""
|
|
46
|
+
await self._http.request(
|
|
47
|
+
"DELETE",
|
|
48
|
+
f"/v1/{self._organization_id}/user/{user_id}",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
async def update_user(self, user_id: str, body: UserUpdateUserInfoRequest) -> None:
|
|
52
|
+
"""Update user information. Returns None on success (e.g., 204)."""
|
|
53
|
+
await self._http.request(
|
|
54
|
+
"POST",
|
|
55
|
+
f"/v1/{self._organization_id}/user/{user_id}/user",
|
|
56
|
+
json=body.model_dump(mode="json", exclude_none=True),
|
|
57
|
+
)
|
amigo_sdk/sdk_client.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
|
|
3
|
+
from amigo_sdk.config import AmigoConfig
|
|
4
|
+
from amigo_sdk.http_client import AmigoHttpClient
|
|
5
|
+
from amigo_sdk.resources.conversation import ConversationResource
|
|
6
|
+
from amigo_sdk.resources.organization import OrganizationResource
|
|
7
|
+
from amigo_sdk.resources.service import ServiceResource
|
|
8
|
+
from amigo_sdk.resources.user import UserResource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AmigoClient:
|
|
12
|
+
"""
|
|
13
|
+
Amigo API client
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
api_key: Optional[str] = None,
|
|
20
|
+
api_key_id: Optional[str] = None,
|
|
21
|
+
user_id: Optional[str] = None,
|
|
22
|
+
organization_id: Optional[str] = None,
|
|
23
|
+
base_url: Optional[str] = None,
|
|
24
|
+
config: Optional[AmigoConfig] = None,
|
|
25
|
+
**httpx_kwargs: Any,
|
|
26
|
+
):
|
|
27
|
+
"""
|
|
28
|
+
Initialize the Amigo SDK client.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
api_key: API key for authentication (or set AMIGO_API_KEY env var)
|
|
32
|
+
api_key_id: API key ID for authentication (or set AMIGO_API_KEY_ID env var)
|
|
33
|
+
user_id: User ID for API requests (or set AMIGO_USER_ID env var)
|
|
34
|
+
organization_id: Organization ID for API requests (or set AMIGO_ORGANIZATION_ID env var)
|
|
35
|
+
base_url: Base URL for the API (or set AMIGO_BASE_URL env var)
|
|
36
|
+
config: Pre-configured AmigoConfig instance (overrides individual params)
|
|
37
|
+
**httpx_kwargs: Additional arguments passed to httpx.AsyncClient
|
|
38
|
+
"""
|
|
39
|
+
if config:
|
|
40
|
+
self._cfg = config
|
|
41
|
+
else:
|
|
42
|
+
# Build config from individual parameters, falling back to env vars
|
|
43
|
+
cfg_dict: dict[str, Any] = {
|
|
44
|
+
k: v
|
|
45
|
+
for k, v in [
|
|
46
|
+
("api_key", api_key),
|
|
47
|
+
("api_key_id", api_key_id),
|
|
48
|
+
("user_id", user_id),
|
|
49
|
+
("organization_id", organization_id),
|
|
50
|
+
("base_url", base_url),
|
|
51
|
+
]
|
|
52
|
+
if v is not None
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
self._cfg = AmigoConfig(**cfg_dict)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
"AmigoClient configuration incomplete. "
|
|
60
|
+
"Provide api_key, api_key_id, user_id, organization_id, base_url "
|
|
61
|
+
"either as kwargs or environment variables."
|
|
62
|
+
) from e
|
|
63
|
+
|
|
64
|
+
# Initialize HTTP client and resources
|
|
65
|
+
self._http = AmigoHttpClient(self._cfg, **httpx_kwargs)
|
|
66
|
+
self._organization = OrganizationResource(self._http, self._cfg.organization_id)
|
|
67
|
+
self._service = ServiceResource(self._http, self._cfg.organization_id)
|
|
68
|
+
self._conversation = ConversationResource(self._http, self._cfg.organization_id)
|
|
69
|
+
self._users = UserResource(self._http, self._cfg.organization_id)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def config(self) -> AmigoConfig:
|
|
73
|
+
"""Access the configuration object."""
|
|
74
|
+
return self._cfg
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def organization(self) -> OrganizationResource:
|
|
78
|
+
"""Access organization resource."""
|
|
79
|
+
return self._organization
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def service(self) -> ServiceResource:
|
|
83
|
+
"""Access service resource."""
|
|
84
|
+
return self._service
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def conversation(self) -> ConversationResource:
|
|
88
|
+
"""Access conversation resource."""
|
|
89
|
+
return self._conversation
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def users(self) -> UserResource:
|
|
93
|
+
"""Access user resource."""
|
|
94
|
+
return self._users
|
|
95
|
+
|
|
96
|
+
async def aclose(self) -> None:
|
|
97
|
+
"""Close the HTTP client."""
|
|
98
|
+
await self._http.aclose()
|
|
99
|
+
|
|
100
|
+
# async-context-manager sugar
|
|
101
|
+
async def __aenter__(self):
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
async def __aexit__(self, *_):
|
|
105
|
+
await self.aclose()
|