agentle 0.9.4__py3-none-any.whl → 0.9.28__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.
- agentle/agents/agent.py +175 -10
- agentle/agents/agent_run_output.py +8 -1
- agentle/agents/apis/__init__.py +79 -6
- agentle/agents/apis/api.py +342 -73
- agentle/agents/apis/api_key_authentication.py +43 -0
- agentle/agents/apis/api_key_location.py +11 -0
- agentle/agents/apis/api_metrics.py +16 -0
- agentle/agents/apis/auth_type.py +17 -0
- agentle/agents/apis/authentication.py +32 -0
- agentle/agents/apis/authentication_base.py +42 -0
- agentle/agents/apis/authentication_config.py +117 -0
- agentle/agents/apis/basic_authentication.py +34 -0
- agentle/agents/apis/bearer_authentication.py +52 -0
- agentle/agents/apis/cache_strategy.py +12 -0
- agentle/agents/apis/circuit_breaker.py +69 -0
- agentle/agents/apis/circuit_breaker_error.py +7 -0
- agentle/agents/apis/circuit_breaker_state.py +11 -0
- agentle/agents/apis/endpoint.py +413 -254
- agentle/agents/apis/file_upload.py +23 -0
- agentle/agents/apis/hmac_authentication.py +56 -0
- agentle/agents/apis/no_authentication.py +27 -0
- agentle/agents/apis/oauth2_authentication.py +111 -0
- agentle/agents/apis/oauth2_grant_type.py +12 -0
- agentle/agents/apis/object_schema.py +86 -1
- agentle/agents/apis/params/__init__.py +10 -1
- agentle/agents/apis/params/boolean_param.py +44 -0
- agentle/agents/apis/params/number_param.py +56 -0
- agentle/agents/apis/rate_limit_error.py +7 -0
- agentle/agents/apis/rate_limiter.py +57 -0
- agentle/agents/apis/request_config.py +126 -4
- agentle/agents/apis/request_hook.py +16 -0
- agentle/agents/apis/response_cache.py +49 -0
- agentle/agents/apis/retry_strategy.py +12 -0
- agentle/agents/whatsapp/human_delay_calculator.py +462 -0
- agentle/agents/whatsapp/models/audio_message.py +6 -4
- agentle/agents/whatsapp/models/key.py +2 -2
- agentle/agents/whatsapp/models/whatsapp_bot_config.py +375 -21
- agentle/agents/whatsapp/models/whatsapp_response_base.py +31 -0
- agentle/agents/whatsapp/models/whatsapp_webhook_payload.py +5 -1
- agentle/agents/whatsapp/providers/base/whatsapp_provider.py +51 -0
- agentle/agents/whatsapp/providers/evolution/evolution_api_provider.py +237 -10
- agentle/agents/whatsapp/providers/meta/meta_whatsapp_provider.py +126 -0
- agentle/agents/whatsapp/v2/batch_processor_manager.py +4 -0
- agentle/agents/whatsapp/v2/bot_config.py +188 -0
- agentle/agents/whatsapp/v2/message_limit.py +9 -0
- agentle/agents/whatsapp/v2/payload.py +0 -0
- agentle/agents/whatsapp/v2/whatsapp_bot.py +13 -0
- agentle/agents/whatsapp/v2/whatsapp_cloud_api_provider.py +0 -0
- agentle/agents/whatsapp/v2/whatsapp_provider.py +0 -0
- agentle/agents/whatsapp/whatsapp_bot.py +827 -45
- agentle/generations/providers/google/adapters/generate_generate_content_response_to_generation_adapter.py +13 -10
- agentle/generations/providers/google/google_generation_provider.py +35 -5
- agentle/generations/providers/openrouter/_adapters/openrouter_message_to_generated_assistant_message_adapter.py +35 -1
- agentle/mcp/servers/stdio_mcp_server.py +23 -4
- agentle/parsing/parsers/docx.py +8 -0
- agentle/parsing/parsers/file_parser.py +4 -0
- agentle/parsing/parsers/pdf.py +7 -1
- agentle/storage/__init__.py +11 -0
- agentle/storage/file_storage_manager.py +44 -0
- agentle/storage/local_file_storage_manager.py +122 -0
- agentle/storage/s3_file_storage_manager.py +124 -0
- agentle/tts/audio_format.py +6 -0
- agentle/tts/elevenlabs_tts_provider.py +108 -0
- agentle/tts/output_format_type.py +26 -0
- agentle/tts/speech_config.py +14 -0
- agentle/tts/speech_result.py +15 -0
- agentle/tts/tts_provider.py +16 -0
- agentle/tts/voice_settings.py +30 -0
- agentle/utils/parse_streaming_json.py +39 -13
- agentle/voice_cloning/__init__.py +0 -0
- agentle/voice_cloning/voice_cloner.py +0 -0
- agentle/web/extractor.py +282 -148
- {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/METADATA +1 -1
- {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/RECORD +78 -39
- agentle/tts/real_time/definitions/audio_data.py +0 -20
- agentle/tts/real_time/definitions/speech_config.py +0 -27
- agentle/tts/real_time/definitions/speech_result.py +0 -14
- agentle/tts/real_time/definitions/tts_stream_chunk.py +0 -15
- agentle/tts/real_time/definitions/voice_gender.py +0 -9
- agentle/tts/real_time/definitions/voice_info.py +0 -18
- agentle/tts/real_time/real_time_speech_to_text_provider.py +0 -66
- /agentle/{tts/real_time → agents/whatsapp/v2}/__init__.py +0 -0
- /agentle/{tts/real_time/definitions/__init__.py → agents/whatsapp/v2/in_memory_batch_processor_manager.py} +0 -0
- {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/WHEEL +0 -0
- {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/licenses/LICENSE +0 -0
agentle/agents/apis/endpoint.py
CHANGED
|
@@ -1,99 +1,46 @@
|
|
|
1
1
|
"""
|
|
2
|
-
API endpoint integration for Agentle framework.
|
|
3
|
-
|
|
4
|
-
This module provides
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
name="get_weather",
|
|
16
|
-
description="Get current weather for a location",
|
|
17
|
-
call_condition="when user asks about weather, current conditions, or temperature",
|
|
18
|
-
url="https://api.weather.com/v1/current",
|
|
19
|
-
method=HTTPMethod.GET,
|
|
20
|
-
parameters=[
|
|
21
|
-
EndpointParameter(
|
|
22
|
-
name="location",
|
|
23
|
-
description="City name or coordinates",
|
|
24
|
-
param_type="string",
|
|
25
|
-
location=ParameterLocation.QUERY,
|
|
26
|
-
required=True
|
|
27
|
-
),
|
|
28
|
-
EndpointParameter(
|
|
29
|
-
name="units",
|
|
30
|
-
description="Temperature units",
|
|
31
|
-
param_type="string",
|
|
32
|
-
location=ParameterLocation.QUERY,
|
|
33
|
-
default="metric"
|
|
34
|
-
)
|
|
35
|
-
]
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
# Or define an API with multiple endpoints
|
|
39
|
-
weather_api = API(
|
|
40
|
-
name="WeatherAPI",
|
|
41
|
-
base_url="https://api.weather.com/v1",
|
|
42
|
-
headers={"Authorization": "Bearer YOUR_TOKEN"},
|
|
43
|
-
endpoints=[
|
|
44
|
-
Endpoint(
|
|
45
|
-
name="get_current_weather",
|
|
46
|
-
description="Get current weather conditions",
|
|
47
|
-
call_condition="when user asks about current weather",
|
|
48
|
-
path="/current",
|
|
49
|
-
method=HTTPMethod.GET,
|
|
50
|
-
parameters=[
|
|
51
|
-
EndpointParameter("location", "Location to get weather for", "string",
|
|
52
|
-
ParameterLocation.QUERY, required=True)
|
|
53
|
-
]
|
|
54
|
-
),
|
|
55
|
-
Endpoint(
|
|
56
|
-
name="get_forecast",
|
|
57
|
-
description="Get weather forecast",
|
|
58
|
-
call_condition="when user asks about weather forecast or future weather",
|
|
59
|
-
path="/forecast",
|
|
60
|
-
method=HTTPMethod.GET,
|
|
61
|
-
parameters=[
|
|
62
|
-
EndpointParameter("location", "Location for forecast", "string",
|
|
63
|
-
ParameterLocation.QUERY, required=True),
|
|
64
|
-
EndpointParameter("days", "Number of days", "integer",
|
|
65
|
-
ParameterLocation.QUERY, default=5)
|
|
66
|
-
]
|
|
67
|
-
)
|
|
68
|
-
]
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
# Use with an agent
|
|
72
|
-
agent = Agent(
|
|
73
|
-
generation_provider=GoogleGenerationProvider(),
|
|
74
|
-
model="gemini-2.5-flash",
|
|
75
|
-
instructions="You are a weather assistant.",
|
|
76
|
-
tools=[weather_endpoint], # Individual endpoint
|
|
77
|
-
apis=[weather_api] # Full API with multiple endpoints
|
|
78
|
-
)
|
|
79
|
-
```
|
|
2
|
+
Complete enhanced API endpoint integration for Agentle framework.
|
|
3
|
+
|
|
4
|
+
This module provides comprehensive HTTP API endpoint support with:
|
|
5
|
+
- Multiple authentication methods
|
|
6
|
+
- Advanced retry strategies
|
|
7
|
+
- Circuit breaker pattern
|
|
8
|
+
- Rate limiting
|
|
9
|
+
- Response caching
|
|
10
|
+
- File uploads (multipart/form-data)
|
|
11
|
+
- Streaming responses
|
|
12
|
+
- Request/response hooks
|
|
13
|
+
- SSL/Proxy configuration
|
|
14
|
+
- and more...
|
|
80
15
|
"""
|
|
81
16
|
|
|
82
17
|
from __future__ import annotations
|
|
83
18
|
|
|
84
19
|
import asyncio
|
|
85
20
|
import logging
|
|
86
|
-
|
|
21
|
+
import random
|
|
22
|
+
from collections.abc import AsyncIterator, MutableMapping, Sequence
|
|
87
23
|
from typing import Any, Literal
|
|
88
24
|
|
|
89
25
|
import aiohttp
|
|
90
26
|
from rsb.models.base_model import BaseModel
|
|
91
27
|
from rsb.models.field import Field
|
|
92
28
|
|
|
29
|
+
from agentle.agents.apis.authentication import (
|
|
30
|
+
AuthenticationBase,
|
|
31
|
+
AuthenticationConfig,
|
|
32
|
+
NoAuthentication,
|
|
33
|
+
)
|
|
93
34
|
from agentle.agents.apis.endpoint_parameter import EndpointParameter
|
|
35
|
+
from agentle.agents.apis.file_upload import FileUpload
|
|
94
36
|
from agentle.agents.apis.http_method import HTTPMethod
|
|
95
37
|
from agentle.agents.apis.parameter_location import ParameterLocation
|
|
38
|
+
from agentle.agents.apis.circuit_breaker import CircuitBreaker
|
|
39
|
+
from agentle.agents.apis.circuit_breaker_error import CircuitBreakerError
|
|
40
|
+
from agentle.agents.apis.rate_limiter import RateLimiter
|
|
96
41
|
from agentle.agents.apis.request_config import RequestConfig
|
|
42
|
+
from agentle.agents.apis.response_cache import ResponseCache
|
|
43
|
+
from agentle.agents.apis.retry_strategy import RetryStrategy
|
|
97
44
|
from agentle.generations.tools.tool import Tool
|
|
98
45
|
|
|
99
46
|
logger = logging.getLogger(__name__)
|
|
@@ -101,11 +48,17 @@ logger = logging.getLogger(__name__)
|
|
|
101
48
|
|
|
102
49
|
class Endpoint(BaseModel):
|
|
103
50
|
"""
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
51
|
+
Enhanced HTTP API endpoint with comprehensive features.
|
|
52
|
+
|
|
53
|
+
Supports:
|
|
54
|
+
- Multiple authentication methods
|
|
55
|
+
- Advanced retry strategies with circuit breakers
|
|
56
|
+
- Rate limiting and quota management
|
|
57
|
+
- Response caching
|
|
58
|
+
- File uploads
|
|
59
|
+
- Streaming responses
|
|
60
|
+
- Request/response hooks
|
|
61
|
+
- SSL and proxy configuration
|
|
109
62
|
"""
|
|
110
63
|
|
|
111
64
|
name: str = Field(description="Unique name for this endpoint")
|
|
@@ -147,23 +100,74 @@ class Endpoint(BaseModel):
|
|
|
147
100
|
default_factory=RequestConfig,
|
|
148
101
|
)
|
|
149
102
|
|
|
150
|
-
|
|
103
|
+
auth_config: AuthenticationConfig | None = Field(
|
|
104
|
+
description="Authentication configuration",
|
|
105
|
+
default=None,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
response_format: Literal["json", "text", "bytes", "stream", "xml"] = Field(
|
|
151
109
|
description="Expected response format", default="json"
|
|
152
110
|
)
|
|
153
111
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
112
|
+
# File upload support
|
|
113
|
+
supports_file_upload: bool = Field(
|
|
114
|
+
description="Whether this endpoint supports file uploads", default=False
|
|
115
|
+
)
|
|
157
116
|
|
|
158
|
-
|
|
159
|
-
|
|
117
|
+
# Pagination support
|
|
118
|
+
supports_pagination: bool = Field(
|
|
119
|
+
description="Whether this endpoint supports pagination", default=False
|
|
120
|
+
)
|
|
121
|
+
pagination_param_name: str = Field(
|
|
122
|
+
description="Name of pagination parameter", default="page"
|
|
123
|
+
)
|
|
124
|
+
pagination_style: Literal["page", "offset", "cursor"] = Field(
|
|
125
|
+
description="Style of pagination", default="page"
|
|
126
|
+
)
|
|
160
127
|
|
|
161
|
-
|
|
162
|
-
|
|
128
|
+
# Response validation
|
|
129
|
+
validate_response_schema: bool = Field(
|
|
130
|
+
description="Whether to validate response against schema", default=False
|
|
131
|
+
)
|
|
132
|
+
response_schema: dict[str, Any] | None = Field(
|
|
133
|
+
description="JSON schema for response validation", default=None
|
|
134
|
+
)
|
|
163
135
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
""
|
|
136
|
+
# Advanced features
|
|
137
|
+
enable_hooks: bool = Field(
|
|
138
|
+
description="Enable request/response hooks", default=False
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Internal state (not serialized)
|
|
142
|
+
_auth_handler: AuthenticationBase | None = None
|
|
143
|
+
_circuit_breaker: CircuitBreaker | None = None
|
|
144
|
+
_rate_limiter: RateLimiter | None = None
|
|
145
|
+
_response_cache: ResponseCache | None = None
|
|
146
|
+
|
|
147
|
+
def model_post_init(self, __context: Any) -> None:
|
|
148
|
+
"""Initialize internal components."""
|
|
149
|
+
super().model_post_init(__context)
|
|
150
|
+
|
|
151
|
+
# Initialize authentication handler
|
|
152
|
+
if self.auth_config:
|
|
153
|
+
self._auth_handler = self.auth_config.create_handler()
|
|
154
|
+
else:
|
|
155
|
+
self._auth_handler = NoAuthentication()
|
|
156
|
+
|
|
157
|
+
# Initialize circuit breaker if enabled
|
|
158
|
+
if self.request_config.enable_circuit_breaker:
|
|
159
|
+
self._circuit_breaker = CircuitBreaker(self.request_config)
|
|
160
|
+
|
|
161
|
+
# Initialize rate limiter if enabled
|
|
162
|
+
if self.request_config.enable_rate_limiting:
|
|
163
|
+
self._rate_limiter = RateLimiter(self.request_config)
|
|
164
|
+
|
|
165
|
+
# Initialize cache if enabled
|
|
166
|
+
if self.request_config.enable_caching:
|
|
167
|
+
self._response_cache = ResponseCache(self.request_config)
|
|
168
|
+
|
|
169
|
+
def get_full_url(self, base_url: str | None = None) -> str:
|
|
170
|
+
"""Get the complete URL for this endpoint."""
|
|
167
171
|
if self.url:
|
|
168
172
|
return self.url
|
|
169
173
|
|
|
@@ -175,12 +179,7 @@ class Endpoint(BaseModel):
|
|
|
175
179
|
)
|
|
176
180
|
|
|
177
181
|
def get_enhanced_description(self) -> str:
|
|
178
|
-
"""
|
|
179
|
-
Get description enhanced with call condition.
|
|
180
|
-
|
|
181
|
-
Returns:
|
|
182
|
-
Description with call condition appended if available
|
|
183
|
-
"""
|
|
182
|
+
"""Get description enhanced with call condition."""
|
|
184
183
|
base_desc = self.description
|
|
185
184
|
|
|
186
185
|
if self.call_condition:
|
|
@@ -188,56 +187,124 @@ class Endpoint(BaseModel):
|
|
|
188
187
|
|
|
189
188
|
return base_desc
|
|
190
189
|
|
|
191
|
-
def
|
|
192
|
-
"""
|
|
193
|
-
|
|
190
|
+
def _calculate_retry_delay(self, attempt: int) -> float:
|
|
191
|
+
"""Calculate retry delay based on strategy."""
|
|
192
|
+
base_delay = self.request_config.retry_delay
|
|
194
193
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
"""
|
|
198
|
-
tool_params: dict[str, object] = {}
|
|
194
|
+
if self.request_config.retry_strategy == RetryStrategy.CONSTANT:
|
|
195
|
+
delay = base_delay
|
|
199
196
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
"type": param.param_type,
|
|
203
|
-
"description": param.description,
|
|
204
|
-
"required": param.required,
|
|
205
|
-
}
|
|
197
|
+
elif self.request_config.retry_strategy == RetryStrategy.LINEAR:
|
|
198
|
+
delay = base_delay * (attempt + 1)
|
|
206
199
|
|
|
207
|
-
|
|
208
|
-
|
|
200
|
+
elif self.request_config.retry_strategy == RetryStrategy.EXPONENTIAL:
|
|
201
|
+
delay = base_delay * (2**attempt)
|
|
209
202
|
|
|
210
|
-
|
|
211
|
-
|
|
203
|
+
elif self.request_config.retry_strategy == RetryStrategy.FIBONACCI:
|
|
204
|
+
# Calculate Fibonacci number for attempt
|
|
205
|
+
fib = [1, 1]
|
|
206
|
+
for _ in range(attempt):
|
|
207
|
+
fib.append(fib[-1] + fib[-2])
|
|
208
|
+
delay = base_delay * fib[-1]
|
|
212
209
|
|
|
213
|
-
|
|
210
|
+
else:
|
|
211
|
+
delay = base_delay
|
|
212
|
+
|
|
213
|
+
# Add jitter (±20%)
|
|
214
|
+
jitter = delay * 0.2 * (random.random() - 0.5) * 2
|
|
215
|
+
delay = delay + jitter
|
|
216
|
+
|
|
217
|
+
# Cap at 60 seconds
|
|
218
|
+
return min(delay, 60.0)
|
|
219
|
+
|
|
220
|
+
def _should_retry(
|
|
221
|
+
self, response: aiohttp.ClientResponse | None, exception: Exception | None
|
|
222
|
+
) -> bool:
|
|
223
|
+
"""Determine if request should be retried."""
|
|
224
|
+
# Retry on configured status codes
|
|
225
|
+
if response and response.status in self.request_config.retry_on_status_codes:
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
# Retry on exceptions if configured
|
|
229
|
+
if exception and self.request_config.retry_on_exceptions:
|
|
230
|
+
# Don't retry on certain exceptions
|
|
231
|
+
if isinstance(exception, (asyncio.CancelledError, KeyboardInterrupt)):
|
|
232
|
+
return False
|
|
233
|
+
return True
|
|
234
|
+
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
async def _parse_response(self, response: aiohttp.ClientResponse) -> Any:
|
|
238
|
+
"""Parse response based on format."""
|
|
239
|
+
if self.response_format == "json":
|
|
240
|
+
return await response.json()
|
|
241
|
+
elif self.response_format == "text":
|
|
242
|
+
return await response.text()
|
|
243
|
+
elif self.response_format == "bytes":
|
|
244
|
+
return await response.read()
|
|
245
|
+
elif self.response_format == "xml":
|
|
246
|
+
# Try to parse XML
|
|
247
|
+
try:
|
|
248
|
+
import xml.etree.ElementTree as ET
|
|
249
|
+
|
|
250
|
+
text = await response.text()
|
|
251
|
+
return ET.fromstring(text)
|
|
252
|
+
except Exception:
|
|
253
|
+
return await response.text()
|
|
254
|
+
else:
|
|
255
|
+
return await response.text()
|
|
256
|
+
|
|
257
|
+
async def _handle_streaming_response(
|
|
258
|
+
self, response: aiohttp.ClientResponse
|
|
259
|
+
) -> AsyncIterator[bytes]:
|
|
260
|
+
"""Handle streaming response."""
|
|
261
|
+
async for chunk in response.content.iter_chunked(8192):
|
|
262
|
+
yield chunk
|
|
263
|
+
|
|
264
|
+
async def _validate_response(self, data: Any) -> Any:
|
|
265
|
+
"""Validate response against schema if configured."""
|
|
266
|
+
if not self.validate_response_schema or not self.response_schema:
|
|
267
|
+
return data
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
import jsonschema
|
|
214
271
|
|
|
215
|
-
|
|
272
|
+
jsonschema.validate(instance=data, schema=self.response_schema)
|
|
273
|
+
return data
|
|
274
|
+
except Exception as e:
|
|
275
|
+
logger.warning(f"Response validation failed: {e}")
|
|
276
|
+
return data
|
|
216
277
|
|
|
217
|
-
async def
|
|
278
|
+
async def make_request(
|
|
218
279
|
self,
|
|
219
280
|
base_url: str | None = None,
|
|
220
281
|
global_headers: MutableMapping[str, str] | None = None,
|
|
221
282
|
**kwargs: Any,
|
|
222
283
|
) -> Any:
|
|
223
|
-
"""
|
|
224
|
-
Internal method to make the HTTP request.
|
|
225
|
-
|
|
226
|
-
Args:
|
|
227
|
-
base_url: Base URL if using path-based endpoint
|
|
228
|
-
global_headers: Global headers to merge with endpoint headers
|
|
229
|
-
**kwargs: Parameters for the request
|
|
230
|
-
|
|
231
|
-
Returns:
|
|
232
|
-
Response data based on response_format
|
|
233
|
-
"""
|
|
284
|
+
"""Internal method to make the HTTP request with all enhancements."""
|
|
234
285
|
url = self.get_full_url(base_url)
|
|
235
286
|
|
|
287
|
+
# Check rate limiter
|
|
288
|
+
if self._rate_limiter:
|
|
289
|
+
await self._rate_limiter.acquire()
|
|
290
|
+
|
|
291
|
+
# Check cache (only for GET requests if configured)
|
|
292
|
+
if (
|
|
293
|
+
self._response_cache
|
|
294
|
+
and self.method == HTTPMethod.GET
|
|
295
|
+
and self.request_config.cache_only_get
|
|
296
|
+
):
|
|
297
|
+
cached = await self._response_cache.get(url, kwargs)
|
|
298
|
+
if cached is not None:
|
|
299
|
+
logger.debug(f"Cache hit for {url}")
|
|
300
|
+
return cached
|
|
301
|
+
|
|
236
302
|
# Separate parameters by location
|
|
237
303
|
query_params: dict[str, Any] = {}
|
|
238
304
|
body_params: dict[str, Any] = {}
|
|
239
305
|
header_params: dict[str, str] = {}
|
|
240
306
|
path_params: dict[str, Any] = {}
|
|
307
|
+
files: dict[str, FileUpload] = {}
|
|
241
308
|
|
|
242
309
|
for param in self.parameters:
|
|
243
310
|
param_name = param.name
|
|
@@ -252,13 +319,27 @@ class Endpoint(BaseModel):
|
|
|
252
319
|
else:
|
|
253
320
|
continue
|
|
254
321
|
|
|
255
|
-
#
|
|
322
|
+
# Handle file uploads
|
|
323
|
+
if isinstance(value, FileUpload):
|
|
324
|
+
files[param_name] = value
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
# Place parameter in appropriate location with proper type handling
|
|
256
328
|
if param.location == ParameterLocation.QUERY:
|
|
257
|
-
|
|
329
|
+
# Handle boolean conversion for query params
|
|
330
|
+
if isinstance(value, bool):
|
|
331
|
+
# Convert Python bool to lowercase string for URL compatibility
|
|
332
|
+
query_params[param_name] = str(value).lower()
|
|
333
|
+
else:
|
|
334
|
+
query_params[param_name] = value
|
|
258
335
|
elif param.location == ParameterLocation.BODY:
|
|
259
336
|
body_params[param_name] = value
|
|
260
337
|
elif param.location == ParameterLocation.HEADER:
|
|
261
|
-
|
|
338
|
+
# Convert to string for headers
|
|
339
|
+
if isinstance(value, bool):
|
|
340
|
+
header_params[param_name] = str(value).lower()
|
|
341
|
+
else:
|
|
342
|
+
header_params[param_name] = str(value)
|
|
262
343
|
elif param.location == ParameterLocation.PATH:
|
|
263
344
|
path_params[param_name] = value
|
|
264
345
|
|
|
@@ -273,114 +354,189 @@ class Endpoint(BaseModel):
|
|
|
273
354
|
headers.update(self.headers)
|
|
274
355
|
headers.update(header_params)
|
|
275
356
|
|
|
276
|
-
#
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
"allow_redirects": self.request_config.follow_redirects,
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if query_params:
|
|
284
|
-
request_kwargs["params"] = query_params
|
|
357
|
+
# Apply authentication
|
|
358
|
+
if self._auth_handler:
|
|
359
|
+
await self._auth_handler.refresh_if_needed()
|
|
360
|
+
await self._auth_handler.apply_auth(None, url, headers, query_params) # type: ignore
|
|
285
361
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if "Content-Type" not in headers:
|
|
293
|
-
headers["Content-Type"] = "application/json"
|
|
362
|
+
# Prepare connector kwargs (will be used to create fresh connector for each attempt)
|
|
363
|
+
connector_kwargs: dict[str, Any] = {
|
|
364
|
+
"limit": 10,
|
|
365
|
+
"limit_per_host": 5,
|
|
366
|
+
"ttl_dns_cache": 300,
|
|
367
|
+
}
|
|
294
368
|
|
|
295
|
-
|
|
296
|
-
|
|
369
|
+
if not self.request_config.verify_ssl:
|
|
370
|
+
connector_kwargs["ssl"] = False
|
|
297
371
|
|
|
298
|
-
#
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
use_dns_cache=True,
|
|
372
|
+
# Prepare timeout
|
|
373
|
+
timeout = aiohttp.ClientTimeout(
|
|
374
|
+
total=self.request_config.timeout,
|
|
375
|
+
connect=self.request_config.connect_timeout,
|
|
376
|
+
sock_read=self.request_config.read_timeout,
|
|
304
377
|
)
|
|
305
378
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
379
|
+
# Define the request function for circuit breaker
|
|
380
|
+
async def make_single_request() -> Any:
|
|
381
|
+
"""Make a single request attempt."""
|
|
382
|
+
# Create a fresh connector for each request attempt to avoid "Session is closed" errors on retries
|
|
383
|
+
connector = aiohttp.TCPConnector(**connector_kwargs)
|
|
384
|
+
session = None
|
|
385
|
+
try:
|
|
386
|
+
session = aiohttp.ClientSession(connector=connector, timeout=timeout)
|
|
387
|
+
# Prepare request kwargs
|
|
388
|
+
request_kwargs: dict[str, Any] = {
|
|
389
|
+
"headers": headers,
|
|
390
|
+
"allow_redirects": self.request_config.follow_redirects,
|
|
391
|
+
"max_redirects": self.request_config.max_redirects,
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if query_params:
|
|
395
|
+
request_kwargs["params"] = query_params
|
|
396
|
+
|
|
397
|
+
# Handle different content types
|
|
398
|
+
if files and self.supports_file_upload:
|
|
399
|
+
# Multipart form-data
|
|
400
|
+
form_data = aiohttp.FormData()
|
|
401
|
+
for key, file in files.items():
|
|
402
|
+
form_data.add_field(
|
|
403
|
+
key,
|
|
404
|
+
file.content,
|
|
405
|
+
filename=file.filename,
|
|
406
|
+
content_type=file.mime_type or "application/octet-stream",
|
|
407
|
+
)
|
|
408
|
+
for key, value in body_params.items():
|
|
409
|
+
form_data.add_field(key, str(value))
|
|
410
|
+
request_kwargs["data"] = form_data
|
|
411
|
+
|
|
412
|
+
elif body_params and self.method in [
|
|
413
|
+
HTTPMethod.POST,
|
|
414
|
+
HTTPMethod.PUT,
|
|
415
|
+
HTTPMethod.PATCH,
|
|
416
|
+
]:
|
|
417
|
+
# JSON body
|
|
418
|
+
request_kwargs["json"] = body_params
|
|
419
|
+
if "Content-Type" not in headers:
|
|
420
|
+
headers["Content-Type"] = "application/json"
|
|
421
|
+
|
|
422
|
+
# Proxy configuration
|
|
423
|
+
if self.request_config.proxy_url:
|
|
424
|
+
request_kwargs["proxy"] = self.request_config.proxy_url
|
|
425
|
+
if self.request_config.proxy_auth:
|
|
426
|
+
request_kwargs["proxy_auth"] = aiohttp.BasicAuth(
|
|
427
|
+
*self.request_config.proxy_auth
|
|
317
428
|
)
|
|
318
429
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
430
|
+
# Log request if enabled
|
|
431
|
+
if self.request_config.enable_request_logging:
|
|
432
|
+
logger.info(f"Request: {self.method} {url}")
|
|
433
|
+
logger.debug(f"Headers: {headers}")
|
|
434
|
+
logger.debug(f"Params: {query_params}")
|
|
435
|
+
|
|
436
|
+
# Make request
|
|
437
|
+
async with session.request(
|
|
438
|
+
method=self.method.value, url=url, **request_kwargs
|
|
439
|
+
) as response:
|
|
440
|
+
# Log response if enabled
|
|
441
|
+
if self.request_config.enable_response_logging:
|
|
442
|
+
logger.info(f"Response: {response.status} from {url}")
|
|
443
|
+
|
|
444
|
+
# Handle HTTP errors
|
|
445
|
+
if response.status >= 400:
|
|
446
|
+
error_text = await response.text()
|
|
447
|
+
|
|
448
|
+
# Check for Retry-After header
|
|
449
|
+
if (
|
|
450
|
+
response.status == 429
|
|
451
|
+
and self.request_config.respect_retry_after
|
|
452
|
+
):
|
|
453
|
+
retry_after = response.headers.get("Retry-After")
|
|
454
|
+
if retry_after:
|
|
455
|
+
try:
|
|
456
|
+
wait_time = int(retry_after)
|
|
457
|
+
logger.warning(
|
|
458
|
+
f"Rate limited. Waiting {wait_time}s as per Retry-After header"
|
|
459
|
+
)
|
|
460
|
+
await asyncio.sleep(wait_time)
|
|
461
|
+
except ValueError:
|
|
462
|
+
pass
|
|
463
|
+
|
|
464
|
+
raise aiohttp.ClientResponseError(
|
|
465
|
+
request_info=response.request_info,
|
|
466
|
+
history=response.history,
|
|
467
|
+
status=response.status,
|
|
468
|
+
message=f"HTTP {response.status}: {error_text}",
|
|
350
469
|
)
|
|
351
470
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
471
|
+
# Handle streaming responses
|
|
472
|
+
if self.response_format == "stream":
|
|
473
|
+
chunks: list[bytes] = []
|
|
474
|
+
async for chunk in self._handle_streaming_response(response):
|
|
475
|
+
chunks.append(chunk)
|
|
476
|
+
return b"".join(chunks)
|
|
477
|
+
|
|
478
|
+
# Parse response
|
|
479
|
+
result = await self._parse_response(response)
|
|
480
|
+
|
|
481
|
+
# Validate response
|
|
482
|
+
result = await self._validate_response(result)
|
|
483
|
+
|
|
484
|
+
# Cache response if configured
|
|
485
|
+
if self._response_cache and self.method == HTTPMethod.GET:
|
|
486
|
+
await self._response_cache.set(url, kwargs, result)
|
|
487
|
+
|
|
488
|
+
return result
|
|
489
|
+
finally:
|
|
490
|
+
# Always close the session to prevent "Session is closed" errors on retries
|
|
491
|
+
if session is not None:
|
|
492
|
+
await session.close()
|
|
493
|
+
# Give the connector time to close properly
|
|
494
|
+
await asyncio.sleep(0.01)
|
|
495
|
+
|
|
496
|
+
# Execute with retries
|
|
497
|
+
last_exception = None
|
|
498
|
+
|
|
499
|
+
for attempt in range(self.request_config.max_retries + 1):
|
|
500
|
+
try:
|
|
501
|
+
# Execute with circuit breaker if enabled
|
|
502
|
+
if self._circuit_breaker:
|
|
503
|
+
result = await self._circuit_breaker.call(make_single_request)
|
|
504
|
+
else:
|
|
505
|
+
result = await make_single_request()
|
|
506
|
+
|
|
507
|
+
return result
|
|
508
|
+
|
|
509
|
+
except asyncio.CancelledError:
|
|
510
|
+
logger.debug(f"Request to {url} was cancelled")
|
|
511
|
+
raise
|
|
512
|
+
|
|
513
|
+
except CircuitBreakerError:
|
|
514
|
+
# Don't retry if circuit is open
|
|
515
|
+
raise
|
|
516
|
+
|
|
517
|
+
except Exception as e:
|
|
518
|
+
last_exception = e
|
|
519
|
+
|
|
520
|
+
# Check if we should retry
|
|
521
|
+
should_retry = self._should_retry(None, e)
|
|
522
|
+
|
|
523
|
+
if not should_retry or attempt >= self.request_config.max_retries:
|
|
524
|
+
break
|
|
525
|
+
|
|
526
|
+
# Calculate delay and wait
|
|
527
|
+
delay = self._calculate_retry_delay(attempt)
|
|
528
|
+
logger.warning(
|
|
529
|
+
f"Request failed (attempt {attempt + 1}/{self.request_config.max_retries + 1}). "
|
|
530
|
+
+ f"Retrying in {delay:.2f}s: {str(e)}"
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
await asyncio.sleep(delay)
|
|
535
|
+
except asyncio.CancelledError:
|
|
536
|
+
logger.debug("Sleep interrupted by cancellation")
|
|
537
|
+
raise
|
|
538
|
+
|
|
539
|
+
# All retries exhausted
|
|
384
540
|
if last_exception:
|
|
385
541
|
raise last_exception
|
|
386
542
|
else:
|
|
@@ -391,23 +547,11 @@ class Endpoint(BaseModel):
|
|
|
391
547
|
base_url: str | None = None,
|
|
392
548
|
global_headers: MutableMapping[str, str] | None = None,
|
|
393
549
|
) -> Tool[Any]:
|
|
394
|
-
"""
|
|
395
|
-
Convert this endpoint to a Tool instance with proper parameter mapping.
|
|
396
|
-
|
|
397
|
-
This fixed version creates tool parameters directly from endpoint parameters
|
|
398
|
-
instead of relying on function signature analysis of **kwargs.
|
|
399
|
-
|
|
400
|
-
Args:
|
|
401
|
-
base_url: Base URL for path-based endpoints
|
|
402
|
-
global_headers: Global headers to include in requests
|
|
403
|
-
|
|
404
|
-
Returns:
|
|
405
|
-
Tool instance that can be used by agents
|
|
406
|
-
"""
|
|
550
|
+
"""Convert this endpoint to a Tool instance."""
|
|
407
551
|
|
|
408
552
|
async def endpoint_callable(**kwargs: Any) -> Any:
|
|
409
553
|
"""Callable function for the tool."""
|
|
410
|
-
return await self.
|
|
554
|
+
return await self.make_request(
|
|
411
555
|
base_url=base_url, global_headers=global_headers, **kwargs
|
|
412
556
|
)
|
|
413
557
|
|
|
@@ -415,11 +559,11 @@ class Endpoint(BaseModel):
|
|
|
415
559
|
tool_parameters: dict[str, object] = {}
|
|
416
560
|
|
|
417
561
|
for param in self.parameters:
|
|
418
|
-
# Use the parameter's to_tool_parameter_schema method if available
|
|
419
562
|
if hasattr(param, "to_tool_parameter_schema"):
|
|
563
|
+
# Use the parameter's own schema conversion method
|
|
420
564
|
tool_parameters[param.name] = param.to_tool_parameter_schema()
|
|
421
565
|
else:
|
|
422
|
-
# Fallback for
|
|
566
|
+
# Fallback for parameters without schema method
|
|
423
567
|
param_info: dict[str, object] = {
|
|
424
568
|
"type": getattr(param, "param_type", "string") or "string",
|
|
425
569
|
"description": param.description,
|
|
@@ -432,20 +576,35 @@ class Endpoint(BaseModel):
|
|
|
432
576
|
if hasattr(param, "enum") and param.enum:
|
|
433
577
|
param_info["enum"] = list(param.enum)
|
|
434
578
|
|
|
579
|
+
# Add constraints for number/primitive types
|
|
580
|
+
if hasattr(param, "parameter_schema") and param.parameter_schema:
|
|
581
|
+
from agentle.agents.apis.primitive_schema import PrimitiveSchema
|
|
582
|
+
|
|
583
|
+
schema = param.parameter_schema
|
|
584
|
+
# Only PrimitiveSchema has minimum, maximum, format
|
|
585
|
+
if isinstance(schema, PrimitiveSchema):
|
|
586
|
+
if schema.minimum is not None:
|
|
587
|
+
param_info["minimum"] = schema.minimum
|
|
588
|
+
if schema.maximum is not None:
|
|
589
|
+
param_info["maximum"] = schema.maximum
|
|
590
|
+
if schema.format:
|
|
591
|
+
param_info["format"] = schema.format
|
|
592
|
+
|
|
435
593
|
tool_parameters[param.name] = param_info
|
|
436
594
|
|
|
437
|
-
|
|
595
|
+
tool_name = "_".join(self.name.lower().split())
|
|
596
|
+
|
|
597
|
+
# Create the tool
|
|
438
598
|
tool = Tool(
|
|
439
|
-
name=
|
|
599
|
+
name=tool_name,
|
|
440
600
|
description=self.get_enhanced_description(),
|
|
441
601
|
parameters=tool_parameters,
|
|
442
602
|
)
|
|
443
603
|
|
|
444
|
-
# Set the callable reference manually
|
|
445
604
|
tool.set_callable_ref(endpoint_callable)
|
|
446
605
|
|
|
447
606
|
logger.debug(
|
|
448
|
-
f"Created tool '{self.name}' with {len(tool_parameters)} parameters
|
|
607
|
+
f"Created tool '{self.name}' with {len(tool_parameters)} parameters"
|
|
449
608
|
)
|
|
450
609
|
|
|
451
610
|
return tool
|