lollmsbot 0.0.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.
@@ -0,0 +1,498 @@
1
+ """
2
+ HTTP tool for LollmsBot.
3
+
4
+ This module provides the HttpTool class for making HTTP requests with
5
+ built-in timeout, retry logic, and response parsing. Supports GET, POST,
6
+ PUT, and DELETE operations with safe URL validation.
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ from dataclasses import dataclass
12
+ from typing import Any, Dict, List, Optional, Union
13
+ from urllib.parse import urlparse
14
+
15
+ import aiohttp
16
+ from aiohttp import ClientTimeout, ClientError, ClientResponse
17
+
18
+ from lollmsbot.agent import Tool, ToolResult, ToolError
19
+
20
+
21
+ @dataclass
22
+ class RetryConfig:
23
+ """Configuration for retry behavior."""
24
+ max_retries: int = 3
25
+ base_delay: float = 1.0
26
+ max_delay: float = 10.0
27
+ exponential_base: float = 2.0
28
+
29
+
30
+ class HttpTool(Tool):
31
+ """Tool for making HTTP requests with retry logic and safe URL validation.
32
+
33
+ This tool provides methods for GET, POST, PUT, and DELETE HTTP operations
34
+ with built-in timeout handling, automatic retries with exponential backoff,
35
+ and intelligent response parsing (JSON or text).
36
+
37
+ Attributes:
38
+ name: Unique identifier for the tool.
39
+ description: Human-readable description of what the tool does.
40
+ parameters: JSON Schema describing expected parameters.
41
+ default_timeout: Default request timeout in seconds.
42
+ retry_config: Configuration for retry behavior.
43
+ allowed_schemes: Set of allowed URL schemes for security.
44
+ max_response_size: Maximum response size in bytes.
45
+ """
46
+
47
+ name: str = "http"
48
+ description: str = (
49
+ "Make HTTP requests (GET, POST, PUT, DELETE) to external APIs "
50
+ "and web services. Automatically parses JSON responses and "
51
+ "handles timeouts, retries, and errors gracefully."
52
+ )
53
+
54
+ parameters: dict[str, Any] = {
55
+ "type": "object",
56
+ "properties": {
57
+ "method": {
58
+ "type": "string",
59
+ "enum": ["get", "post", "put", "delete"],
60
+ "description": "HTTP method to use",
61
+ },
62
+ "url": {
63
+ "type": "string",
64
+ "description": "Target URL for the request",
65
+ },
66
+ "data": {
67
+ "type": "object",
68
+ "description": "Request body data (for POST, PUT)",
69
+ },
70
+ "headers": {
71
+ "type": "object",
72
+ "description": "Additional HTTP headers",
73
+ },
74
+ "params": {
75
+ "type": "object",
76
+ "description": "URL query parameters",
77
+ },
78
+ },
79
+ "required": ["method", "url"],
80
+ }
81
+
82
+ def __init__(
83
+ self,
84
+ default_timeout: float = 30.0,
85
+ retry_config: Optional[RetryConfig] = None,
86
+ allowed_schemes: Optional[set[str]] = None,
87
+ max_response_size: int = 10 * 1024 * 1024, # 10 MB
88
+ ) -> None:
89
+ """Initialize the HttpTool.
90
+
91
+ Args:
92
+ default_timeout: Default request timeout in seconds.
93
+ retry_config: Retry configuration. Uses defaults if None.
94
+ allowed_schemes: Set of allowed URL schemes. Defaults to http, https.
95
+ max_response_size: Maximum response size in bytes.
96
+ """
97
+ self.default_timeout: float = default_timeout
98
+ self.retry_config: RetryConfig = retry_config or RetryConfig()
99
+ self.allowed_schemes: set[str] = allowed_schemes or {"http", "https"}
100
+ self.max_response_size: int = max_response_size
101
+
102
+ # Create session with connection pooling
103
+ self._session: Optional[aiohttp.ClientSession] = None
104
+ self._session_lock: asyncio.Lock = asyncio.Lock()
105
+
106
+ async def _get_session(self) -> aiohttp.ClientSession:
107
+ """Get or create aiohttp session with connection pooling."""
108
+ async with self._session_lock:
109
+ if self._session is None or self._session.closed:
110
+ timeout = ClientTimeout(total=self.default_timeout)
111
+ connector = aiohttp.TCPConnector(
112
+ limit=10,
113
+ limit_per_host=5,
114
+ enable_cleanup_closed=True,
115
+ force_close=True,
116
+ )
117
+ self._session = aiohttp.ClientSession(
118
+ timeout=timeout,
119
+ connector=connector,
120
+ headers={
121
+ "User-Agent": "LollmsBot-HttpTool/0.1.0",
122
+ "Accept": "application/json, text/plain, */*",
123
+ },
124
+ )
125
+ return self._session
126
+
127
+ def _validate_url(self, url: str) -> tuple[bool, Optional[str]]:
128
+ """Validate URL scheme and format.
129
+
130
+ Args:
131
+ url: URL to validate.
132
+
133
+ Returns:
134
+ Tuple of (is_valid, error_message).
135
+ """
136
+ try:
137
+ parsed = urlparse(url)
138
+
139
+ # Check scheme
140
+ if parsed.scheme not in self.allowed_schemes:
141
+ allowed = ", ".join(self.allowed_schemes)
142
+ return False, f"URL scheme '{parsed.scheme}' not allowed. Allowed: {allowed}"
143
+
144
+ # Check netloc (host)
145
+ if not parsed.netloc:
146
+ return False, "URL must include a host"
147
+
148
+ # Block localhost and private IPs by default for security
149
+ hostname = parsed.hostname
150
+ if hostname:
151
+ blocked_hosts = {"localhost", "127.0.0.1", "localhost", "::1"}
152
+ if hostname in blocked_hosts:
153
+ return False, f"Access to '{hostname}' is not allowed"
154
+
155
+ # Check for private IP ranges
156
+ if hostname.startswith("10.") or hostname.startswith("192.168.") or hostname.startswith("172."):
157
+ return False, f"Access to private IP '{hostname}' is not allowed"
158
+
159
+ return True, None
160
+
161
+ except ValueError as exc:
162
+ return False, f"Invalid URL format: {str(exc)}"
163
+
164
+ async def _execute_with_retry(
165
+ self,
166
+ method: str,
167
+ url: str,
168
+ **kwargs: Any,
169
+ ) -> tuple[bool, Any, Optional[str], Dict[str, Any]]:
170
+ """Execute HTTP request with retry logic.
171
+
172
+ Args:
173
+ method: HTTP method (get, post, put, delete).
174
+ url: Target URL.
175
+ **kwargs: Additional arguments for aiohttp request.
176
+
177
+ Returns:
178
+ Tuple of (success, output_data, error_message, metadata).
179
+ """
180
+ session = await self._get_session()
181
+
182
+ last_error: Optional[str] = None
183
+ metadata: Dict[str, Any] = {
184
+ "attempts": 0,
185
+ "url": url,
186
+ "method": method.upper(),
187
+ }
188
+
189
+ for attempt in range(self.retry_config.max_retries):
190
+ metadata["attempts"] = attempt + 1
191
+
192
+ try:
193
+ async with session.request(method, url, **kwargs) as response:
194
+ return await self._handle_response(response, metadata)
195
+
196
+ except asyncio.TimeoutError:
197
+ last_error = f"Request timeout after {self.default_timeout}s"
198
+
199
+ except ClientError as exc:
200
+ last_error = f"HTTP client error: {str(exc)}"
201
+
202
+ except Exception as exc:
203
+ last_error = f"Unexpected error: {str(exc)}"
204
+
205
+ # Calculate delay with exponential backoff
206
+ if attempt < self.retry_config.max_retries - 1:
207
+ delay = min(
208
+ self.retry_config.base_delay * (self.retry_config.exponential_base ** attempt),
209
+ self.retry_config.max_delay,
210
+ )
211
+ await asyncio.sleep(delay)
212
+
213
+ # All retries exhausted
214
+ return False, None, last_error, metadata
215
+
216
+ async def _handle_response(
217
+ self,
218
+ response: ClientResponse,
219
+ metadata: Dict[str, Any],
220
+ ) -> tuple[bool, Any, Optional[str], Dict[str, Any]]:
221
+ """Process HTTP response and parse content.
222
+
223
+ Args:
224
+ response: aiohttp ClientResponse.
225
+ metadata: Metadata dict to augment.
226
+
227
+ Returns:
228
+ Tuple of (success, output_data, error_message, metadata).
229
+ """
230
+ metadata["status_code"] = response.status
231
+ metadata["content_type"] = response.content_type or "unknown"
232
+
233
+ # Check for HTTP error status
234
+ if response.status >= 400:
235
+ try:
236
+ error_text = await response.text()
237
+ except Exception:
238
+ error_text = "Could not read error response"
239
+
240
+ return False, None, f"HTTP {response.status}: {error_text[:500]}", metadata
241
+
242
+ # Check response size
243
+ content_length = response.content_length
244
+ if content_length and content_length > self.max_response_size:
245
+ return False, None, f"Response too large: {content_length} bytes", metadata
246
+
247
+ # Read response content with size limit
248
+ try:
249
+ text = await response.text()
250
+
251
+ if len(text.encode("utf-8")) > self.max_response_size:
252
+ return False, None, f"Response exceeds max size of {self.max_response_size} bytes", metadata
253
+
254
+ except Exception as exc:
255
+ return False, None, f"Failed to read response: {str(exc)}", metadata
256
+
257
+ # Try to parse as JSON
258
+ try:
259
+ parsed = json.loads(text)
260
+ metadata["parsed_as"] = "json"
261
+ return True, parsed, None, metadata
262
+ except json.JSONDecodeError:
263
+ # Return as text
264
+ metadata["parsed_as"] = "text"
265
+ return True, text, None, metadata
266
+
267
+ async def get(
268
+ self,
269
+ url: str,
270
+ headers: Optional[Dict[str, str]] = None,
271
+ params: Optional[Dict[str, Any]] = None,
272
+ ) -> ToolResult:
273
+ """Execute GET request.
274
+
275
+ Args:
276
+ url: Target URL.
277
+ headers: Optional additional headers.
278
+ params: Optional query parameters.
279
+
280
+ Returns:
281
+ ToolResult with parsed response data.
282
+ """
283
+ # Validate URL
284
+ is_valid, error = self._validate_url(url)
285
+ if not is_valid:
286
+ return ToolResult(success=False, output=None, error=error)
287
+
288
+ # Prepare request arguments
289
+ kwargs: Dict[str, Any] = {}
290
+ if headers:
291
+ kwargs["headers"] = headers
292
+ if params:
293
+ kwargs["params"] = params
294
+
295
+ success, output, error, metadata = await self._execute_with_retry("GET", url, **kwargs)
296
+
297
+ return ToolResult(
298
+ success=success,
299
+ output=output,
300
+ error=error,
301
+ execution_time=0.0, # Tracked per attempt internally
302
+ )
303
+
304
+ async def post(
305
+ self,
306
+ url: str,
307
+ data: Optional[Union[Dict[str, Any], str]] = None,
308
+ headers: Optional[Dict[str, str]] = None,
309
+ params: Optional[Dict[str, Any]] = None,
310
+ ) -> ToolResult:
311
+ """Execute POST request.
312
+
313
+ Args:
314
+ url: Target URL.
315
+ data: Request body data (dict for JSON, str for raw body).
316
+ headers: Optional additional headers.
317
+ params: Optional query parameters.
318
+
319
+ Returns:
320
+ ToolResult with parsed response data.
321
+ """
322
+ # Validate URL
323
+ is_valid, error = self._validate_url(url)
324
+ if not is_valid:
325
+ return ToolResult(success=False, output=None, error=error)
326
+
327
+ # Prepare request arguments
328
+ kwargs: Dict[str, Any] = {}
329
+
330
+ # Determine content type and format data
331
+ if data is not None:
332
+ if isinstance(data, dict):
333
+ kwargs["json"] = data
334
+ else:
335
+ kwargs["data"] = data
336
+
337
+ if headers:
338
+ kwargs["headers"] = headers
339
+ if params:
340
+ kwargs["params"] = params
341
+
342
+ success, output, error, metadata = await self._execute_with_retry("POST", url, **kwargs)
343
+
344
+ return ToolResult(
345
+ success=success,
346
+ output=output,
347
+ error=error,
348
+ )
349
+
350
+ async def put(
351
+ self,
352
+ url: str,
353
+ data: Optional[Union[Dict[str, Any], str]] = None,
354
+ headers: Optional[Dict[str, str]] = None,
355
+ params: Optional[Dict[str, Any]] = None,
356
+ ) -> ToolResult:
357
+ """Execute PUT request.
358
+
359
+ Args:
360
+ url: Target URL.
361
+ data: Request body data (dict for JSON, str for raw body).
362
+ headers: Optional additional headers.
363
+ params: Optional query parameters.
364
+
365
+ Returns:
366
+ ToolResult with parsed response data.
367
+ """
368
+ # Validate URL
369
+ is_valid, error = self._validate_url(url)
370
+ if not is_valid:
371
+ return ToolResult(success=False, output=None, error=error)
372
+
373
+ # Prepare request arguments
374
+ kwargs: Dict[str, Any] = {}
375
+
376
+ if data is not None:
377
+ if isinstance(data, dict):
378
+ kwargs["json"] = data
379
+ else:
380
+ kwargs["data"] = data
381
+
382
+ if headers:
383
+ kwargs["headers"] = headers
384
+ if params:
385
+ kwargs["params"] = params
386
+
387
+ success, output, error, metadata = await self._execute_with_retry("PUT", url, **kwargs)
388
+
389
+ return ToolResult(
390
+ success=success,
391
+ output=output,
392
+ error=error,
393
+ )
394
+
395
+ async def delete(
396
+ self,
397
+ url: str,
398
+ headers: Optional[Dict[str, str]] = None,
399
+ params: Optional[Dict[str, Any]] = None,
400
+ ) -> ToolResult:
401
+ """Execute DELETE request.
402
+
403
+ Args:
404
+ url: Target URL.
405
+ headers: Optional additional headers.
406
+ params: Optional query parameters.
407
+
408
+ Returns:
409
+ ToolResult with parsed response data.
410
+ """
411
+ # Validate URL
412
+ is_valid, error = self._validate_url(url)
413
+ if not is_valid:
414
+ return ToolResult(success=False, output=None, error=error)
415
+
416
+ # Prepare request arguments
417
+ kwargs: Dict[str, Any] = {}
418
+ if headers:
419
+ kwargs["headers"] = headers
420
+ if params:
421
+ kwargs["params"] = params
422
+
423
+ success, output, error, metadata = await self._execute_with_retry("DELETE", url, **kwargs)
424
+
425
+ return ToolResult(
426
+ success=success,
427
+ output=output,
428
+ error=error,
429
+ )
430
+
431
+ async def execute(self, **params: Any) -> ToolResult:
432
+ """Execute HTTP request based on parameters.
433
+
434
+ Main entry point for Tool base class. Dispatches to appropriate
435
+ HTTP method based on the 'method' parameter.
436
+
437
+ Args:
438
+ **params: Parameters must include:
439
+ - method: HTTP method (get, post, put, delete)
440
+ - url: Target URL
441
+ - data: Request body (for POST, PUT)
442
+ - headers: Additional headers
443
+ - params: Query parameters
444
+
445
+ Returns:
446
+ ToolResult from the executed HTTP request.
447
+ """
448
+ method = params.get("method", "").lower()
449
+ url = params.get("url")
450
+
451
+ if not method:
452
+ return ToolResult(
453
+ success=False,
454
+ output=None,
455
+ error="Missing required parameter: 'method'",
456
+ )
457
+
458
+ if not url:
459
+ return ToolResult(
460
+ success=False,
461
+ output=None,
462
+ error="Missing required parameter: 'url'",
463
+ )
464
+
465
+ # Extract optional parameters
466
+ data = params.get("data")
467
+ headers = params.get("headers")
468
+ query_params = params.get("params")
469
+
470
+ # Dispatch to appropriate method
471
+ if method == "get":
472
+ return await self.get(url, headers=headers, params=query_params)
473
+
474
+ elif method == "post":
475
+ return await self.post(url, data=data, headers=headers, params=query_params)
476
+
477
+ elif method == "put":
478
+ return await self.put(url, data=data, headers=headers, params=query_params)
479
+
480
+ elif method == "delete":
481
+ return await self.delete(url, headers=headers, params=query_params)
482
+
483
+ else:
484
+ return ToolResult(
485
+ success=False,
486
+ output=None,
487
+ error=f"Unknown HTTP method: '{method}'. Valid methods: get, post, put, delete",
488
+ )
489
+
490
+ async def close(self) -> None:
491
+ """Close the aiohttp session and cleanup resources."""
492
+ async with self._session_lock:
493
+ if self._session and not self._session.closed:
494
+ await self._session.close()
495
+ self._session = None
496
+
497
+ def __repr__(self) -> str:
498
+ return f"HttpTool(timeout={self.default_timeout}, max_retries={self.retry_config.max_retries})"