glaip-sdk 0.6.15b2__py3-none-any.whl → 0.6.16__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.
Files changed (154) hide show
  1. glaip_sdk/agents/__init__.py +27 -0
  2. glaip_sdk/agents/base.py +1196 -0
  3. glaip_sdk/cli/__init__.py +9 -0
  4. glaip_sdk/cli/account_store.py +540 -0
  5. glaip_sdk/cli/agent_config.py +78 -0
  6. glaip_sdk/cli/auth.py +699 -0
  7. glaip_sdk/cli/commands/__init__.py +5 -0
  8. glaip_sdk/cli/commands/accounts.py +746 -0
  9. glaip_sdk/cli/commands/agents.py +1509 -0
  10. glaip_sdk/cli/commands/common_config.py +104 -0
  11. glaip_sdk/cli/commands/configure.py +896 -0
  12. glaip_sdk/cli/commands/mcps.py +1356 -0
  13. glaip_sdk/cli/commands/models.py +69 -0
  14. glaip_sdk/cli/commands/tools.py +576 -0
  15. glaip_sdk/cli/commands/transcripts.py +755 -0
  16. glaip_sdk/cli/commands/update.py +61 -0
  17. glaip_sdk/cli/config.py +95 -0
  18. glaip_sdk/cli/constants.py +38 -0
  19. glaip_sdk/cli/context.py +150 -0
  20. glaip_sdk/cli/core/__init__.py +79 -0
  21. glaip_sdk/cli/core/context.py +124 -0
  22. glaip_sdk/cli/core/output.py +851 -0
  23. glaip_sdk/cli/core/prompting.py +649 -0
  24. glaip_sdk/cli/core/rendering.py +187 -0
  25. glaip_sdk/cli/display.py +355 -0
  26. glaip_sdk/cli/hints.py +57 -0
  27. glaip_sdk/cli/io.py +112 -0
  28. glaip_sdk/cli/main.py +615 -0
  29. glaip_sdk/cli/masking.py +136 -0
  30. glaip_sdk/cli/mcp_validators.py +287 -0
  31. glaip_sdk/cli/pager.py +266 -0
  32. glaip_sdk/cli/parsers/__init__.py +7 -0
  33. glaip_sdk/cli/parsers/json_input.py +177 -0
  34. glaip_sdk/cli/resolution.py +67 -0
  35. glaip_sdk/cli/rich_helpers.py +27 -0
  36. glaip_sdk/cli/slash/__init__.py +15 -0
  37. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  38. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  39. glaip_sdk/cli/slash/agent_session.py +285 -0
  40. glaip_sdk/cli/slash/prompt.py +256 -0
  41. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  42. glaip_sdk/cli/slash/session.py +1708 -0
  43. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  44. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  45. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  46. glaip_sdk/cli/slash/tui/loading.py +58 -0
  47. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  48. glaip_sdk/cli/transcript/__init__.py +31 -0
  49. glaip_sdk/cli/transcript/cache.py +536 -0
  50. glaip_sdk/cli/transcript/capture.py +329 -0
  51. glaip_sdk/cli/transcript/export.py +38 -0
  52. glaip_sdk/cli/transcript/history.py +815 -0
  53. glaip_sdk/cli/transcript/launcher.py +77 -0
  54. glaip_sdk/cli/transcript/viewer.py +374 -0
  55. glaip_sdk/cli/update_notifier.py +290 -0
  56. glaip_sdk/cli/utils.py +263 -0
  57. glaip_sdk/cli/validators.py +238 -0
  58. glaip_sdk/client/__init__.py +11 -0
  59. glaip_sdk/client/_agent_payloads.py +520 -0
  60. glaip_sdk/client/agent_runs.py +147 -0
  61. glaip_sdk/client/agents.py +1335 -0
  62. glaip_sdk/client/base.py +502 -0
  63. glaip_sdk/client/main.py +249 -0
  64. glaip_sdk/client/mcps.py +370 -0
  65. glaip_sdk/client/run_rendering.py +700 -0
  66. glaip_sdk/client/shared.py +21 -0
  67. glaip_sdk/client/tools.py +661 -0
  68. glaip_sdk/client/validators.py +198 -0
  69. glaip_sdk/config/constants.py +52 -0
  70. glaip_sdk/mcps/__init__.py +21 -0
  71. glaip_sdk/mcps/base.py +345 -0
  72. glaip_sdk/models/__init__.py +90 -0
  73. glaip_sdk/models/agent.py +47 -0
  74. glaip_sdk/models/agent_runs.py +116 -0
  75. glaip_sdk/models/common.py +42 -0
  76. glaip_sdk/models/mcp.py +33 -0
  77. glaip_sdk/models/tool.py +33 -0
  78. glaip_sdk/payload_schemas/__init__.py +7 -0
  79. glaip_sdk/payload_schemas/agent.py +85 -0
  80. glaip_sdk/registry/__init__.py +55 -0
  81. glaip_sdk/registry/agent.py +164 -0
  82. glaip_sdk/registry/base.py +139 -0
  83. glaip_sdk/registry/mcp.py +253 -0
  84. glaip_sdk/registry/tool.py +232 -0
  85. glaip_sdk/runner/__init__.py +59 -0
  86. glaip_sdk/runner/base.py +84 -0
  87. glaip_sdk/runner/deps.py +112 -0
  88. glaip_sdk/runner/langgraph.py +782 -0
  89. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  90. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  91. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  92. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  93. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  94. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  95. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  96. glaip_sdk/tools/__init__.py +22 -0
  97. glaip_sdk/tools/base.py +435 -0
  98. glaip_sdk/utils/__init__.py +86 -0
  99. glaip_sdk/utils/a2a/__init__.py +34 -0
  100. glaip_sdk/utils/a2a/event_processor.py +188 -0
  101. glaip_sdk/utils/agent_config.py +194 -0
  102. glaip_sdk/utils/bundler.py +267 -0
  103. glaip_sdk/utils/client.py +111 -0
  104. glaip_sdk/utils/client_utils.py +486 -0
  105. glaip_sdk/utils/datetime_helpers.py +58 -0
  106. glaip_sdk/utils/discovery.py +78 -0
  107. glaip_sdk/utils/display.py +135 -0
  108. glaip_sdk/utils/export.py +143 -0
  109. glaip_sdk/utils/general.py +61 -0
  110. glaip_sdk/utils/import_export.py +168 -0
  111. glaip_sdk/utils/import_resolver.py +492 -0
  112. glaip_sdk/utils/instructions.py +101 -0
  113. glaip_sdk/utils/rendering/__init__.py +115 -0
  114. glaip_sdk/utils/rendering/formatting.py +264 -0
  115. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  116. glaip_sdk/utils/rendering/layout/panels.py +156 -0
  117. glaip_sdk/utils/rendering/layout/progress.py +202 -0
  118. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  119. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  120. glaip_sdk/utils/rendering/models.py +85 -0
  121. glaip_sdk/utils/rendering/renderer/__init__.py +55 -0
  122. glaip_sdk/utils/rendering/renderer/base.py +1024 -0
  123. glaip_sdk/utils/rendering/renderer/config.py +27 -0
  124. glaip_sdk/utils/rendering/renderer/console.py +55 -0
  125. glaip_sdk/utils/rendering/renderer/debug.py +178 -0
  126. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  127. glaip_sdk/utils/rendering/renderer/stream.py +202 -0
  128. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  129. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  130. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  131. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  132. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  133. glaip_sdk/utils/rendering/state.py +204 -0
  134. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  135. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  136. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  137. glaip_sdk/utils/rendering/steps/format.py +176 -0
  138. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  139. glaip_sdk/utils/rendering/timing.py +36 -0
  140. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  141. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  142. glaip_sdk/utils/resource_refs.py +195 -0
  143. glaip_sdk/utils/run_renderer.py +41 -0
  144. glaip_sdk/utils/runtime_config.py +425 -0
  145. glaip_sdk/utils/serialization.py +424 -0
  146. glaip_sdk/utils/sync.py +142 -0
  147. glaip_sdk/utils/tool_detection.py +33 -0
  148. glaip_sdk/utils/validation.py +264 -0
  149. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/METADATA +4 -5
  150. glaip_sdk-0.6.16.dist-info/RECORD +160 -0
  151. glaip_sdk-0.6.15b2.dist-info/RECORD +0 -12
  152. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/WHEEL +0 -0
  153. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/entry_points.txt +0 -0
  154. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,502 @@
1
+ #!/usr/bin/env python3
2
+ """Base client for AIP SDK.
3
+
4
+ Authors:
5
+ Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
7
+ """
8
+
9
+ import logging
10
+ import os
11
+ from collections.abc import Iterable, Mapping
12
+ from typing import Any, NoReturn, Union
13
+
14
+ import httpx
15
+ from dotenv import load_dotenv
16
+
17
+ import glaip_sdk
18
+ from glaip_sdk._version import __version__ as SDK_VERSION
19
+ from glaip_sdk.config.constants import DEFAULT_ERROR_MESSAGE, SDK_NAME
20
+ from glaip_sdk.exceptions import (
21
+ AuthenticationError,
22
+ ConflictError,
23
+ ForbiddenError,
24
+ NotFoundError,
25
+ RateLimitError,
26
+ ServerError,
27
+ TimeoutError,
28
+ ValidationError,
29
+ )
30
+
31
+ # Set up logging without basicConfig (library best practice)
32
+ logger = logging.getLogger("glaip_sdk")
33
+ logger.addHandler(logging.NullHandler())
34
+
35
+ client_log = logging.getLogger("glaip_sdk.client")
36
+ client_log.addHandler(logging.NullHandler())
37
+
38
+
39
+ class BaseClient:
40
+ """Base client with HTTP operations and authentication."""
41
+
42
+ def __init__(
43
+ self,
44
+ api_url: str | None = None,
45
+ api_key: str | None = None,
46
+ timeout: float = 30.0,
47
+ *,
48
+ parent_client: Union["BaseClient", None] = None,
49
+ load_env: bool = True,
50
+ ):
51
+ """Initialize the base client.
52
+
53
+ Args:
54
+ api_url: API base URL
55
+ api_key: API authentication key
56
+ timeout: Request timeout in seconds
57
+ parent_client: Parent client to adopt session/config from
58
+ load_env: Whether to load environment variables
59
+ """
60
+ self._parent_client = parent_client
61
+ self._session_scoped = False # Mark as not session-scoped by default
62
+
63
+ if parent_client is not None:
64
+ # Adopt parent's session/config; DO NOT call super().__init__
65
+ client_log.debug("Adopting parent client configuration")
66
+ self.api_url = parent_client.api_url
67
+ self.api_key = parent_client.api_key
68
+ self._timeout = parent_client._timeout
69
+ self.http_client = parent_client.http_client
70
+ else:
71
+ # Initialize as standalone client
72
+ if load_env and not (api_url and api_key):
73
+ # Only load .env file if explicit credentials not provided
74
+ package_dir = os.path.dirname(glaip_sdk.__file__)
75
+ env_file = os.path.join(package_dir, ".env")
76
+ load_dotenv(env_file)
77
+
78
+ self.api_url = api_url or os.getenv("AIP_API_URL")
79
+ self.api_key = api_key or os.getenv("AIP_API_KEY")
80
+ self._timeout = timeout
81
+
82
+ if not self.api_url:
83
+ client_log.error("AIP_API_URL not found in environment or parameters")
84
+ raise ValueError("AIP_API_URL not found")
85
+ if not self.api_key:
86
+ client_log.error("AIP_API_KEY not found in environment or parameters")
87
+ raise ValueError("AIP_API_KEY not found")
88
+
89
+ client_log.info(f"Initializing client with API URL: {self.api_url}")
90
+ self.http_client = self._build_client(timeout)
91
+
92
+ def _build_client(self, timeout: float) -> httpx.Client:
93
+ """Build HTTP client with configuration."""
94
+ # For streaming operations, we need more generous read timeouts
95
+ # while keeping reasonable connect timeouts
96
+ timeout_config = httpx.Timeout(
97
+ timeout=timeout, # Total timeout
98
+ connect=min(30.0, timeout), # Connect timeout (max 30s)
99
+ read=timeout, # Read timeout (same as total for streaming)
100
+ write=min(30.0, timeout), # Write timeout (max 30s)
101
+ pool=timeout, # Pool timeout (same as total)
102
+ )
103
+
104
+ return httpx.Client(
105
+ base_url=self.api_url,
106
+ headers={
107
+ "X-API-Key": self.api_key,
108
+ "User-Agent": f"{SDK_NAME}/{SDK_VERSION}",
109
+ },
110
+ timeout=timeout_config,
111
+ follow_redirects=True,
112
+ http2=False,
113
+ limits=httpx.Limits(max_keepalive_connections=10, max_connections=100),
114
+ )
115
+
116
+ def _build_async_client(self, timeout: float) -> dict[str, Any]:
117
+ """Build async client configuration (returns dict of kwargs for httpx.AsyncClient).
118
+
119
+ Args:
120
+ timeout: Request timeout in seconds
121
+
122
+ Returns:
123
+ Dictionary of kwargs for httpx.AsyncClient
124
+ """
125
+ # For streaming operations, we need more generous read timeouts
126
+ # while keeping reasonable connect timeouts
127
+ timeout_config = httpx.Timeout(
128
+ timeout=timeout, # Total timeout
129
+ connect=min(30.0, timeout), # Connect timeout (max 30s)
130
+ read=timeout, # Read timeout (same as total for streaming)
131
+ write=min(30.0, timeout), # Write timeout (max 30s)
132
+ pool=timeout, # Pool timeout (same as total)
133
+ )
134
+
135
+ return {
136
+ "base_url": self.api_url,
137
+ "headers": {
138
+ "X-API-Key": self.api_key,
139
+ "User-Agent": f"{SDK_NAME}/{SDK_VERSION}",
140
+ },
141
+ "timeout": timeout_config,
142
+ "follow_redirects": True,
143
+ "http2": False,
144
+ "limits": httpx.Limits(max_keepalive_connections=10, max_connections=100),
145
+ }
146
+
147
+ @property
148
+ def timeout(self) -> float:
149
+ """Get current timeout value."""
150
+ return self._timeout
151
+
152
+ @timeout.setter
153
+ def timeout(self, value: float) -> None:
154
+ """Set timeout and rebuild client."""
155
+ self._timeout = value
156
+ if hasattr(self, "http_client") and self.http_client and not self._session_scoped and not self._parent_client:
157
+ self.http_client.close()
158
+ self.http_client = self._build_client(value)
159
+
160
+ def _post_then_fetch(
161
+ self,
162
+ id_key: str,
163
+ post_endpoint: str,
164
+ get_endpoint_fmt: str,
165
+ *,
166
+ json: Any | None = None,
167
+ data: Any | None = None,
168
+ files: Any | None = None,
169
+ **kwargs: Any,
170
+ ) -> Any:
171
+ """Helper for POST-then-GET pattern used in create methods.
172
+
173
+ Args:
174
+ id_key: Key in POST response containing the ID
175
+ post_endpoint: Endpoint for POST request
176
+ get_endpoint_fmt: Format string for GET endpoint (e.g., "/items/{id}")
177
+ json: JSON data for POST
178
+ data: Form data for POST
179
+ files: Files for POST
180
+ **kwargs: Additional kwargs for POST
181
+
182
+ Returns:
183
+ Full resource data from GET request
184
+ """
185
+ # Create the resource
186
+ post_kwargs = {}
187
+ if json is not None:
188
+ post_kwargs["json"] = json
189
+ if data is not None:
190
+ post_kwargs["data"] = data
191
+ if files is not None:
192
+ post_kwargs["files"] = files
193
+ post_kwargs.update(kwargs)
194
+
195
+ response_data = self._request("POST", post_endpoint, **post_kwargs)
196
+
197
+ # Extract the ID
198
+ if isinstance(response_data, dict):
199
+ resource_id = response_data.get(id_key)
200
+ else:
201
+ # Fallback: assume response_data is the ID directly
202
+ resource_id = str(response_data)
203
+
204
+ if not resource_id:
205
+ raise ValueError(f"Backend did not return {id_key}")
206
+
207
+ # Fetch the full resource details
208
+ get_endpoint = get_endpoint_fmt.format(id=resource_id)
209
+ return self._request("GET", get_endpoint)
210
+
211
+ def _ensure_client_alive(self) -> None:
212
+ """Ensure HTTP client is alive, recreate if needed."""
213
+ if not hasattr(self, "http_client") or self.http_client is None:
214
+ if not self._parent_client:
215
+ self.http_client = self._build_client(self._timeout)
216
+ return
217
+
218
+ # Check if client is closed by attempting a simple operation
219
+ try:
220
+ # Try to access a property that would fail if closed
221
+ _ = self.http_client.headers
222
+ except (RuntimeError, AttributeError) as e:
223
+ if "closed" in str(e).lower() or "NoneType" in str(e):
224
+ client_log.debug("HTTP client was closed, recreating")
225
+ if not self._parent_client:
226
+ self.http_client = self._build_client(self._timeout)
227
+ else:
228
+ raise
229
+
230
+ def _perform_request(
231
+ self,
232
+ method: str,
233
+ endpoint: str,
234
+ **kwargs: Any,
235
+ ) -> httpx.Response:
236
+ """Execute a raw HTTP request with retry handling."""
237
+ # Ensure client is alive before making request
238
+ self._ensure_client_alive()
239
+
240
+ client_log.debug(f"Making {method} request to {endpoint}")
241
+ try:
242
+ response = self.http_client.request(method, endpoint, **kwargs)
243
+ client_log.debug(f"Response status: {response.status_code}")
244
+ return response
245
+ except httpx.ConnectError as e:
246
+ client_log.warning(f"Connection error on {method} {endpoint}, retrying once: {e}")
247
+ try:
248
+ response = self.http_client.request(method, endpoint, **kwargs)
249
+ client_log.debug(f"Retry successful, response status: {response.status_code}")
250
+ return response
251
+ except httpx.ConnectError:
252
+ client_log.error(f"Retry failed for {method} {endpoint}: {e}")
253
+ raise
254
+
255
+ def _request(self, method: str, endpoint: str, **kwargs) -> Any:
256
+ """Make HTTP request with error handling and unwrap success envelopes."""
257
+ response = self._perform_request(method, endpoint, **kwargs)
258
+ return self._handle_response(response, unwrap=True)
259
+
260
+ def _request_with_envelope(
261
+ self,
262
+ method: str,
263
+ endpoint: str,
264
+ **kwargs: Any,
265
+ ) -> Any:
266
+ """Make HTTP request but return the full success envelope."""
267
+ response = self._perform_request(method, endpoint, **kwargs)
268
+ return self._handle_response(response, unwrap=False)
269
+
270
+ def _parse_response_content(self, response: httpx.Response) -> Any | None:
271
+ """Parse response content based on content type."""
272
+ if response.status_code == 204:
273
+ return None
274
+
275
+ content_type = response.headers.get("content-type", "").lower()
276
+ if "json" in content_type:
277
+ try:
278
+ return response.json()
279
+ except ValueError:
280
+ pass
281
+
282
+ if 200 <= response.status_code < 300:
283
+ return response.text
284
+ else:
285
+ return None # Let _handle_response deal with error status codes
286
+
287
+ def _handle_success_response(self, parsed: Any, *, unwrap: bool) -> Any:
288
+ """Handle successful response with success flag."""
289
+ if isinstance(parsed, dict) and "success" in parsed:
290
+ if parsed.get("success"):
291
+ return parsed.get("data", parsed) if unwrap else parsed
292
+ else:
293
+ error_type = parsed.get("error", "UnknownError")
294
+ message = self._format_error_dict({key: value for key, value in parsed.items() if key != "success"})
295
+ self._raise_api_error(
296
+ 400,
297
+ message,
298
+ error_type,
299
+ payload=parsed, # Using 400 as status since original response had error
300
+ )
301
+
302
+ return parsed
303
+
304
+ def _get_error_message(self, response: httpx.Response) -> str:
305
+ """Extract error message from response, preferring parsed content."""
306
+ parsed = self._parse_error_json(response)
307
+ if parsed is None:
308
+ return response.text
309
+
310
+ formatted = self._format_parsed_error(parsed)
311
+ return formatted if formatted is not None else response.text
312
+
313
+ def _parse_error_json(self, response: httpx.Response) -> Any | None:
314
+ """Safely parse JSON from an error response."""
315
+ try:
316
+ return response.json()
317
+ except (ValueError, TypeError):
318
+ return None
319
+
320
+ def _format_parsed_error(self, parsed: Any) -> str | None:
321
+ """Build a readable error message from parsed JSON payloads."""
322
+ if isinstance(parsed, dict):
323
+ return self._format_error_dict(parsed)
324
+ if isinstance(parsed, str):
325
+ return parsed
326
+ return str(parsed) if parsed else None
327
+
328
+ def _format_error_dict(self, parsed: dict[str, Any]) -> str:
329
+ """Format structured API error payloads."""
330
+ detail = parsed.get("detail")
331
+ if isinstance(detail, list):
332
+ validation_message = self._format_validation_errors(detail)
333
+ if validation_message:
334
+ return validation_message
335
+ return f"Validation error: {parsed}"
336
+
337
+ formatted_details = None
338
+ if "details" in parsed:
339
+ formatted_details = self._format_error_details(parsed["details"])
340
+
341
+ message = parsed.get("message")
342
+ if message:
343
+ if formatted_details:
344
+ return f"{message}\n{formatted_details}"
345
+ return message
346
+
347
+ if formatted_details:
348
+ return formatted_details
349
+
350
+ return str(parsed) if parsed else DEFAULT_ERROR_MESSAGE
351
+
352
+ def _format_error_details(self, details: Any) -> str | None:
353
+ """Render generic error details into a human-readable string."""
354
+ if details is None:
355
+ return None
356
+
357
+ if isinstance(details, dict):
358
+ return self._format_detail_mapping(details)
359
+
360
+ if isinstance(details, (list, tuple, set)):
361
+ return self._format_detail_iterable(details)
362
+
363
+ return f"Details: {details}"
364
+
365
+ @staticmethod
366
+ def _format_detail_mapping(details: Mapping[str, Any]) -> str | None:
367
+ """Format details provided as a mapping."""
368
+ entries = [f" {key}: {value}" for key, value in details.items()]
369
+ if not entries:
370
+ return None
371
+ return "Details:\n" + "\n".join(entries)
372
+
373
+ @staticmethod
374
+ def _format_detail_iterable(details: Iterable[Any]) -> str | None:
375
+ """Format details provided as an iterable collection."""
376
+ entries: list[str] = []
377
+ for item in details:
378
+ if isinstance(item, Mapping):
379
+ inner = ", ".join(f"{k}={v}" for k, v in item.items())
380
+ entries.append(f" - {inner if inner else '{}'}")
381
+ else:
382
+ entries.append(f" - {item}")
383
+
384
+ if not entries:
385
+ return None
386
+
387
+ return "Details:\n" + "\n".join(entries)
388
+
389
+ def _format_validation_errors(self, errors: list[Any]) -> str | None:
390
+ """Render validation errors into a human-readable string."""
391
+ entries: list[str] = []
392
+ for error in errors:
393
+ if isinstance(error, dict):
394
+ loc = " -> ".join(str(x) for x in error.get("loc", []))
395
+ msg = error.get("msg", DEFAULT_ERROR_MESSAGE)
396
+ error_type = error.get("type", "unknown")
397
+ prefix = loc if loc else "Field"
398
+ entries.append(f" {prefix}: {msg} ({error_type})")
399
+ else:
400
+ entries.append(f" {error}")
401
+
402
+ if not entries:
403
+ return None
404
+
405
+ return "Validation errors:\n" + "\n".join(entries)
406
+
407
+ @staticmethod
408
+ def _is_no_content_response(response: httpx.Response) -> bool:
409
+ """Return True when the response contains no content."""
410
+ return response.status_code == 204
411
+
412
+ @staticmethod
413
+ def _is_success_status(response: httpx.Response) -> bool:
414
+ """Return True for successful HTTP status codes."""
415
+ return 200 <= response.status_code < 300
416
+
417
+ def _handle_error_response(self, response: httpx.Response) -> None:
418
+ """Raise an API error for non-success responses."""
419
+ error_message = self._get_error_message(response)
420
+ parsed_content = self._parse_response_content(response)
421
+ self._raise_api_error(response.status_code, error_message, payload=parsed_content)
422
+
423
+ def _handle_response(
424
+ self,
425
+ response: httpx.Response,
426
+ *,
427
+ unwrap: bool = True,
428
+ ) -> Any:
429
+ """Handle HTTP response with proper error handling."""
430
+ # Handle no-content success before general error handling
431
+ if self._is_no_content_response(response):
432
+ return None
433
+
434
+ if self._is_success_status(response):
435
+ parsed = self._parse_response_content(response)
436
+ if parsed is None:
437
+ return None
438
+ return self._handle_success_response(parsed, unwrap=unwrap)
439
+
440
+ self._handle_error_response(response)
441
+ return None
442
+
443
+ def _raise_api_error(
444
+ self,
445
+ status: int,
446
+ message: str,
447
+ error_type: str | None = None,
448
+ *,
449
+ payload: Any | None = None,
450
+ ) -> NoReturn:
451
+ """Raise appropriate exception with rich context."""
452
+ request_id = None
453
+ try:
454
+ request_id = self.http_client.headers.get("X-Request-Id")
455
+ except Exception:
456
+ pass
457
+
458
+ mapping = {
459
+ 400: ValidationError,
460
+ 401: AuthenticationError,
461
+ 403: ForbiddenError,
462
+ 404: NotFoundError,
463
+ 408: TimeoutError,
464
+ 409: ConflictError,
465
+ 429: RateLimitError,
466
+ 500: ServerError,
467
+ 503: ServerError,
468
+ 504: TimeoutError,
469
+ }
470
+
471
+ exception_class = mapping.get(status, ValidationError)
472
+ error_msg = f"HTTP {status}: {message}"
473
+ if request_id:
474
+ error_msg += f" (Request ID: {request_id})"
475
+
476
+ raise exception_class(
477
+ error_msg,
478
+ status_code=status,
479
+ error_type=error_type,
480
+ payload=payload,
481
+ request_id=request_id,
482
+ )
483
+
484
+ def close(self) -> None:
485
+ """Close the HTTP client."""
486
+ if hasattr(self, "http_client") and self.http_client and not self._session_scoped and not self._parent_client:
487
+ self.http_client.close()
488
+
489
+ def __enter__(self) -> "BaseClient":
490
+ """Context manager entry."""
491
+ return self
492
+
493
+ def __exit__(
494
+ self,
495
+ _exc_type: type[BaseException] | None,
496
+ _exc_val: BaseException | None,
497
+ _exc_tb: Any,
498
+ ) -> None:
499
+ """Context manager exit."""
500
+ # Only close if this is not session-scoped
501
+ if not self._session_scoped:
502
+ self.close()