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.

@@ -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
+ )
@@ -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()