lionagi 0.12.2__py3-none-any.whl → 0.12.4__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.
- lionagi/config.py +123 -0
- lionagi/fields/file.py +1 -1
- lionagi/fields/reason.py +1 -1
- lionagi/libs/file/concat.py +1 -6
- lionagi/libs/file/concat_files.py +1 -5
- lionagi/libs/file/save.py +1 -1
- lionagi/libs/package/imports.py +8 -177
- lionagi/libs/parse.py +30 -0
- lionagi/libs/schema/load_pydantic_model_from_schema.py +259 -0
- lionagi/libs/token_transform/perplexity.py +2 -4
- lionagi/libs/token_transform/synthlang_/resources/frameworks/framework_options.json +46 -46
- lionagi/libs/token_transform/synthlang_/translate_to_synthlang.py +1 -1
- lionagi/operations/chat/chat.py +2 -2
- lionagi/operations/communicate/communicate.py +20 -5
- lionagi/operations/parse/parse.py +131 -43
- lionagi/protocols/generic/log.py +1 -2
- lionagi/protocols/generic/pile.py +18 -4
- lionagi/protocols/messages/assistant_response.py +20 -1
- lionagi/protocols/messages/templates/README.md +6 -10
- lionagi/service/connections/__init__.py +15 -0
- lionagi/service/connections/api_calling.py +230 -0
- lionagi/service/connections/endpoint.py +410 -0
- lionagi/service/connections/endpoint_config.py +137 -0
- lionagi/service/connections/header_factory.py +56 -0
- lionagi/service/connections/match_endpoint.py +49 -0
- lionagi/service/connections/providers/__init__.py +3 -0
- lionagi/service/connections/providers/anthropic_.py +87 -0
- lionagi/service/connections/providers/exa_.py +33 -0
- lionagi/service/connections/providers/oai_.py +166 -0
- lionagi/service/connections/providers/ollama_.py +122 -0
- lionagi/service/connections/providers/perplexity_.py +29 -0
- lionagi/service/imodel.py +36 -144
- lionagi/service/manager.py +1 -7
- lionagi/service/{endpoints/rate_limited_processor.py → rate_limited_processor.py} +4 -2
- lionagi/service/resilience.py +545 -0
- lionagi/service/third_party/README.md +71 -0
- lionagi/service/third_party/__init__.py +0 -0
- lionagi/service/third_party/anthropic_models.py +159 -0
- lionagi/service/third_party/exa_models.py +165 -0
- lionagi/service/third_party/openai_models.py +18241 -0
- lionagi/service/third_party/pplx_models.py +156 -0
- lionagi/service/types.py +5 -4
- lionagi/session/branch.py +12 -7
- lionagi/tools/file/reader.py +1 -1
- lionagi/tools/memory/tools.py +497 -0
- lionagi/utils.py +921 -123
- lionagi/version.py +1 -1
- {lionagi-0.12.2.dist-info → lionagi-0.12.4.dist-info}/METADATA +33 -16
- {lionagi-0.12.2.dist-info → lionagi-0.12.4.dist-info}/RECORD +53 -63
- lionagi/libs/file/create_path.py +0 -80
- lionagi/libs/file/file_util.py +0 -358
- lionagi/libs/parse/__init__.py +0 -3
- lionagi/libs/parse/fuzzy_parse_json.py +0 -117
- lionagi/libs/parse/to_dict.py +0 -336
- lionagi/libs/parse/to_json.py +0 -61
- lionagi/libs/parse/to_num.py +0 -378
- lionagi/libs/parse/to_xml.py +0 -57
- lionagi/libs/parse/xml_parser.py +0 -148
- lionagi/libs/schema/breakdown_pydantic_annotation.py +0 -48
- lionagi/service/endpoints/__init__.py +0 -3
- lionagi/service/endpoints/base.py +0 -706
- lionagi/service/endpoints/chat_completion.py +0 -116
- lionagi/service/endpoints/match_endpoint.py +0 -72
- lionagi/service/providers/__init__.py +0 -3
- lionagi/service/providers/anthropic_/__init__.py +0 -3
- lionagi/service/providers/anthropic_/messages.py +0 -99
- lionagi/service/providers/exa_/models.py +0 -3
- lionagi/service/providers/exa_/search.py +0 -80
- lionagi/service/providers/exa_/types.py +0 -7
- lionagi/service/providers/groq_/__init__.py +0 -3
- lionagi/service/providers/groq_/chat_completions.py +0 -56
- lionagi/service/providers/ollama_/__init__.py +0 -3
- lionagi/service/providers/ollama_/chat_completions.py +0 -134
- lionagi/service/providers/openai_/__init__.py +0 -3
- lionagi/service/providers/openai_/chat_completions.py +0 -101
- lionagi/service/providers/openai_/spec.py +0 -14
- lionagi/service/providers/openrouter_/__init__.py +0 -3
- lionagi/service/providers/openrouter_/chat_completions.py +0 -62
- lionagi/service/providers/perplexity_/__init__.py +0 -3
- lionagi/service/providers/perplexity_/chat_completions.py +0 -44
- lionagi/service/providers/perplexity_/models.py +0 -5
- lionagi/service/providers/types.py +0 -17
- /lionagi/{service/providers/exa_/__init__.py → py.typed} +0 -0
- /lionagi/service/{endpoints/token_calculator.py → token_calculator.py} +0 -0
- {lionagi-0.12.2.dist-info → lionagi-0.12.4.dist-info}/WHEEL +0 -0
- {lionagi-0.12.2.dist-info → lionagi-0.12.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,545 @@
|
|
1
|
+
# Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
|
2
|
+
#
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
4
|
+
|
5
|
+
"""
|
6
|
+
Resilience patterns for API clients.
|
7
|
+
|
8
|
+
This module provides resilience patterns for API clients, including
|
9
|
+
the CircuitBreaker pattern and retry with exponential backoff.
|
10
|
+
"""
|
11
|
+
|
12
|
+
import asyncio
|
13
|
+
import functools
|
14
|
+
import logging
|
15
|
+
import random
|
16
|
+
import time
|
17
|
+
from collections.abc import Awaitable, Callable
|
18
|
+
from enum import Enum
|
19
|
+
from typing import Any, TypeVar
|
20
|
+
|
21
|
+
T = TypeVar("T")
|
22
|
+
logger = logging.getLogger(__name__)
|
23
|
+
|
24
|
+
|
25
|
+
class APIClientError(Exception):
|
26
|
+
"""Base exception for all API client errors."""
|
27
|
+
|
28
|
+
def __init__(
|
29
|
+
self,
|
30
|
+
message: str,
|
31
|
+
status_code: int | None = None,
|
32
|
+
headers: dict[str, str] | None = None,
|
33
|
+
response_data: dict[str, Any] | None = None,
|
34
|
+
):
|
35
|
+
"""
|
36
|
+
Initialize the API client error.
|
37
|
+
|
38
|
+
Args:
|
39
|
+
message: The error message.
|
40
|
+
status_code: The HTTP status code, if applicable.
|
41
|
+
headers: The response headers, if applicable.
|
42
|
+
response_data: The response data, if applicable.
|
43
|
+
"""
|
44
|
+
self.message = message
|
45
|
+
self.status_code = status_code
|
46
|
+
self.headers = headers or {}
|
47
|
+
self.response_data = response_data or {}
|
48
|
+
super().__init__(message)
|
49
|
+
|
50
|
+
|
51
|
+
class CircuitBreakerOpenError(APIClientError):
|
52
|
+
"""Exception raised when a circuit breaker is open."""
|
53
|
+
|
54
|
+
def __init__(self, message: str, retry_after: float | None = None):
|
55
|
+
"""
|
56
|
+
Initialize the circuit breaker open error.
|
57
|
+
|
58
|
+
Args:
|
59
|
+
message: The error message.
|
60
|
+
retry_after: The time to wait before retrying, in seconds.
|
61
|
+
"""
|
62
|
+
super().__init__(message)
|
63
|
+
self.retry_after = retry_after
|
64
|
+
|
65
|
+
|
66
|
+
class CircuitState(Enum):
|
67
|
+
"""Circuit breaker states."""
|
68
|
+
|
69
|
+
CLOSED = "closed" # Normal operation
|
70
|
+
OPEN = "open" # Failing, rejecting requests
|
71
|
+
HALF_OPEN = "half_open" # Testing if service recovered
|
72
|
+
|
73
|
+
|
74
|
+
class CircuitBreaker:
|
75
|
+
"""
|
76
|
+
Circuit breaker pattern implementation for preventing calls to failing services.
|
77
|
+
|
78
|
+
The circuit breaker pattern prevents repeated calls to a failing service,
|
79
|
+
based on the principle of "fail fast" for better system resilience. When
|
80
|
+
a service fails repeatedly, the circuit opens and rejects requests for a
|
81
|
+
period of time, then transitions to a half-open state to test if the
|
82
|
+
service has recovered.
|
83
|
+
|
84
|
+
Example:
|
85
|
+
```python
|
86
|
+
# Create a circuit breaker with a failure threshold of 5
|
87
|
+
# and a recovery time of 30 seconds
|
88
|
+
breaker = CircuitBreaker(failure_threshold=5, recovery_time=30.0)
|
89
|
+
|
90
|
+
# Execute a function with circuit breaker protection
|
91
|
+
try:
|
92
|
+
result = await breaker.execute(my_async_function, arg1, arg2, kwarg1=value1)
|
93
|
+
except CircuitBreakerOpenError:
|
94
|
+
# Handle the case where the circuit is open
|
95
|
+
with contextlib.suppress(Exception):
|
96
|
+
# Alternative approach using contextlib.suppress
|
97
|
+
pass
|
98
|
+
```
|
99
|
+
"""
|
100
|
+
|
101
|
+
def __init__(
|
102
|
+
self,
|
103
|
+
failure_threshold: int = 5,
|
104
|
+
recovery_time: float = 30.0,
|
105
|
+
half_open_max_calls: int = 1,
|
106
|
+
excluded_exceptions: set[type[Exception]] | None = None,
|
107
|
+
name: str = "default",
|
108
|
+
):
|
109
|
+
"""
|
110
|
+
Initialize the circuit breaker.
|
111
|
+
|
112
|
+
Args:
|
113
|
+
failure_threshold: Number of failures before opening the circuit.
|
114
|
+
recovery_time: Time in seconds to wait before transitioning to half-open.
|
115
|
+
half_open_max_calls: Maximum number of calls allowed in half-open state.
|
116
|
+
excluded_exceptions: Set of exception types that should not count as failures.
|
117
|
+
name: Name of the circuit breaker for logging and metrics.
|
118
|
+
"""
|
119
|
+
self.failure_threshold = failure_threshold
|
120
|
+
self.recovery_time = recovery_time
|
121
|
+
self.half_open_max_calls = half_open_max_calls
|
122
|
+
self.excluded_exceptions = excluded_exceptions or set()
|
123
|
+
self.name = name
|
124
|
+
|
125
|
+
# State variables
|
126
|
+
self.failure_count = 0
|
127
|
+
self.state = CircuitState.CLOSED
|
128
|
+
self.last_failure_time = 0
|
129
|
+
self._half_open_calls = 0
|
130
|
+
self._lock = asyncio.Lock()
|
131
|
+
|
132
|
+
# Metrics
|
133
|
+
self._metrics = {
|
134
|
+
"success_count": 0,
|
135
|
+
"failure_count": 0,
|
136
|
+
"rejected_count": 0,
|
137
|
+
"state_changes": [],
|
138
|
+
}
|
139
|
+
|
140
|
+
logger.debug(
|
141
|
+
f"Initialized CircuitBreaker '{self.name}' with failure_threshold={failure_threshold}, "
|
142
|
+
f"recovery_time={recovery_time}, half_open_max_calls={half_open_max_calls}"
|
143
|
+
)
|
144
|
+
|
145
|
+
@property
|
146
|
+
def metrics(self) -> dict[str, Any]:
|
147
|
+
"""Get circuit breaker metrics."""
|
148
|
+
return self._metrics.copy()
|
149
|
+
|
150
|
+
def to_dict(self):
|
151
|
+
return {
|
152
|
+
"failure_threshold": self.failure_threshold,
|
153
|
+
"recovery_time": self.recovery_time,
|
154
|
+
"half_open_max_calls": self.half_open_max_calls,
|
155
|
+
"name": self.name,
|
156
|
+
}
|
157
|
+
|
158
|
+
async def _change_state(self, new_state: CircuitState) -> None:
|
159
|
+
"""
|
160
|
+
Change circuit state with logging and metrics tracking.
|
161
|
+
|
162
|
+
Args:
|
163
|
+
new_state: The new circuit state.
|
164
|
+
"""
|
165
|
+
old_state = self.state
|
166
|
+
if new_state != old_state:
|
167
|
+
self.state = new_state
|
168
|
+
self._metrics["state_changes"].append(
|
169
|
+
{
|
170
|
+
"time": time.time(),
|
171
|
+
"from": old_state,
|
172
|
+
"to": new_state,
|
173
|
+
}
|
174
|
+
)
|
175
|
+
|
176
|
+
logger.info(
|
177
|
+
f"Circuit '{self.name}' state changed from {old_state.value} to {new_state.value}"
|
178
|
+
)
|
179
|
+
|
180
|
+
# Reset counters on state change
|
181
|
+
if new_state == CircuitState.HALF_OPEN:
|
182
|
+
self._half_open_calls = 0
|
183
|
+
elif new_state == CircuitState.CLOSED:
|
184
|
+
self.failure_count = 0
|
185
|
+
|
186
|
+
async def _check_state(self) -> bool:
|
187
|
+
"""
|
188
|
+
Check circuit state and determine if request can proceed.
|
189
|
+
|
190
|
+
Returns:
|
191
|
+
True if request can proceed, False otherwise.
|
192
|
+
"""
|
193
|
+
async with self._lock:
|
194
|
+
now = time.time()
|
195
|
+
|
196
|
+
if self.state == CircuitState.OPEN:
|
197
|
+
# Check if recovery time has elapsed
|
198
|
+
if now - self.last_failure_time >= self.recovery_time:
|
199
|
+
await self._change_state(CircuitState.HALF_OPEN)
|
200
|
+
else:
|
201
|
+
recovery_remaining = self.recovery_time - (
|
202
|
+
now - self.last_failure_time
|
203
|
+
)
|
204
|
+
self._metrics["rejected_count"] += 1
|
205
|
+
|
206
|
+
logger.warning(
|
207
|
+
f"Circuit '{self.name}' is OPEN, rejecting request. "
|
208
|
+
f"Try again in {recovery_remaining:.2f}s"
|
209
|
+
)
|
210
|
+
|
211
|
+
return False
|
212
|
+
|
213
|
+
if self.state == CircuitState.HALF_OPEN:
|
214
|
+
# Only allow a limited number of calls in half-open state
|
215
|
+
if self._half_open_calls >= self.half_open_max_calls:
|
216
|
+
self._metrics["rejected_count"] += 1
|
217
|
+
|
218
|
+
logger.warning(
|
219
|
+
f"Circuit '{self.name}' is HALF_OPEN and at capacity. "
|
220
|
+
f"Try again later."
|
221
|
+
)
|
222
|
+
|
223
|
+
return False
|
224
|
+
|
225
|
+
self._half_open_calls += 1
|
226
|
+
|
227
|
+
return True
|
228
|
+
|
229
|
+
async def execute(
|
230
|
+
self, func: Callable[..., Awaitable[T]], *args: Any, **kwargs: Any
|
231
|
+
) -> T:
|
232
|
+
"""
|
233
|
+
Execute a coroutine with circuit breaker protection.
|
234
|
+
|
235
|
+
Args:
|
236
|
+
func: The coroutine function to execute.
|
237
|
+
*args: Positional arguments for the function.
|
238
|
+
**kwargs: Keyword arguments for the function.
|
239
|
+
|
240
|
+
Returns:
|
241
|
+
The result of the function execution.
|
242
|
+
|
243
|
+
Raises:
|
244
|
+
CircuitBreakerOpenError: If the circuit is open.
|
245
|
+
Exception: Any exception raised by the function.
|
246
|
+
"""
|
247
|
+
# Check if circuit allows this call
|
248
|
+
can_proceed = await self._check_state()
|
249
|
+
if not can_proceed:
|
250
|
+
remaining = self.recovery_time - (
|
251
|
+
time.time() - self.last_failure_time
|
252
|
+
)
|
253
|
+
raise CircuitBreakerOpenError(
|
254
|
+
f"Circuit breaker '{self.name}' is open. Retry after {remaining:.2f} seconds",
|
255
|
+
retry_after=remaining,
|
256
|
+
)
|
257
|
+
|
258
|
+
try:
|
259
|
+
logger.debug(
|
260
|
+
f"Executing {func.__name__} with circuit '{self.name}' state: {self.state.value}"
|
261
|
+
)
|
262
|
+
result = await func(*args, **kwargs)
|
263
|
+
|
264
|
+
# Handle success
|
265
|
+
async with self._lock:
|
266
|
+
self._metrics["success_count"] += 1
|
267
|
+
|
268
|
+
# On success in half-open state, close the circuit
|
269
|
+
if self.state == CircuitState.HALF_OPEN:
|
270
|
+
await self._change_state(CircuitState.CLOSED)
|
271
|
+
|
272
|
+
return result
|
273
|
+
|
274
|
+
except Exception as e:
|
275
|
+
# Determine if this exception should count as a circuit failure
|
276
|
+
is_excluded = any(
|
277
|
+
isinstance(e, exc_type)
|
278
|
+
for exc_type in self.excluded_exceptions
|
279
|
+
)
|
280
|
+
|
281
|
+
if not is_excluded:
|
282
|
+
async with self._lock:
|
283
|
+
self.failure_count += 1
|
284
|
+
self.last_failure_time = time.time()
|
285
|
+
self._metrics["failure_count"] += 1
|
286
|
+
|
287
|
+
# Log failure
|
288
|
+
logger.warning(
|
289
|
+
f"Circuit '{self.name}' failure: {e}. "
|
290
|
+
f"Count: {self.failure_count}/{self.failure_threshold}"
|
291
|
+
)
|
292
|
+
|
293
|
+
# Check if we need to open the circuit
|
294
|
+
if (
|
295
|
+
self.state == CircuitState.CLOSED
|
296
|
+
and self.failure_count >= self.failure_threshold
|
297
|
+
) or self.state == CircuitState.HALF_OPEN:
|
298
|
+
await self._change_state(CircuitState.OPEN)
|
299
|
+
|
300
|
+
logger.exception(f"Circuit breaker '{self.name}' caught exception")
|
301
|
+
raise
|
302
|
+
|
303
|
+
|
304
|
+
class RetryConfig:
|
305
|
+
"""Configuration for retry behavior."""
|
306
|
+
|
307
|
+
def __init__(
|
308
|
+
self,
|
309
|
+
max_retries: int = 3,
|
310
|
+
base_delay: float = 1.0,
|
311
|
+
max_delay: float = 60.0,
|
312
|
+
backoff_factor: float = 2.0,
|
313
|
+
jitter: bool = True,
|
314
|
+
jitter_factor: float = 0.2,
|
315
|
+
retry_exceptions: tuple[type[Exception], ...] = (Exception,),
|
316
|
+
exclude_exceptions: tuple[type[Exception], ...] = (),
|
317
|
+
):
|
318
|
+
"""
|
319
|
+
Initialize retry configuration.
|
320
|
+
|
321
|
+
Args:
|
322
|
+
max_retries: Maximum number of retry attempts.
|
323
|
+
base_delay: Initial delay between retries in seconds.
|
324
|
+
max_delay: Maximum delay between retries in seconds.
|
325
|
+
backoff_factor: Multiplier applied to delay after each retry.
|
326
|
+
jitter: Whether to add randomness to delay timings.
|
327
|
+
jitter_factor: How much randomness to add as a percentage.
|
328
|
+
retry_exceptions: Tuple of exception types that should trigger retry.
|
329
|
+
exclude_exceptions: Tuple of exception types that should not be retried.
|
330
|
+
"""
|
331
|
+
self.max_retries = max_retries
|
332
|
+
self.base_delay = base_delay
|
333
|
+
self.max_delay = max_delay
|
334
|
+
self.backoff_factor = backoff_factor
|
335
|
+
self.jitter = jitter
|
336
|
+
self.jitter_factor = jitter_factor
|
337
|
+
self.retry_exceptions = retry_exceptions
|
338
|
+
self.exclude_exceptions = exclude_exceptions
|
339
|
+
|
340
|
+
def to_dict(self) -> dict[str, Any]:
|
341
|
+
"""
|
342
|
+
Convert configuration to a dictionary.
|
343
|
+
|
344
|
+
Returns:
|
345
|
+
Dictionary representation of the configuration.
|
346
|
+
"""
|
347
|
+
return {
|
348
|
+
"max_retries": self.max_retries,
|
349
|
+
"base_delay": self.base_delay,
|
350
|
+
"max_delay": self.max_delay,
|
351
|
+
"backoff_factor": self.backoff_factor,
|
352
|
+
"jitter": self.jitter,
|
353
|
+
"jitter_factor": self.jitter_factor,
|
354
|
+
}
|
355
|
+
|
356
|
+
def as_kwargs(self) -> dict[str, Any]:
|
357
|
+
"""
|
358
|
+
Convert configuration to keyword arguments for retry_with_backoff.
|
359
|
+
|
360
|
+
Returns:
|
361
|
+
Dictionary of keyword arguments.
|
362
|
+
"""
|
363
|
+
return {
|
364
|
+
"max_retries": self.max_retries,
|
365
|
+
"base_delay": self.base_delay,
|
366
|
+
"max_delay": self.max_delay,
|
367
|
+
"backoff_factor": self.backoff_factor,
|
368
|
+
"jitter": self.jitter,
|
369
|
+
"retry_exceptions": self.retry_exceptions,
|
370
|
+
"exclude_exceptions": self.exclude_exceptions,
|
371
|
+
}
|
372
|
+
|
373
|
+
|
374
|
+
async def retry_with_backoff(
|
375
|
+
func: Callable[..., Awaitable[T]],
|
376
|
+
*args: Any,
|
377
|
+
retry_exceptions: tuple[type[Exception], ...] = (Exception,),
|
378
|
+
exclude_exceptions: tuple[type[Exception], ...] = (),
|
379
|
+
max_retries: int = 3,
|
380
|
+
base_delay: float = 1.0,
|
381
|
+
max_delay: float = 60.0,
|
382
|
+
backoff_factor: float = 2.0,
|
383
|
+
jitter: bool = True,
|
384
|
+
jitter_factor: float = 0.2,
|
385
|
+
**kwargs: Any,
|
386
|
+
) -> T:
|
387
|
+
"""
|
388
|
+
Retry an async function with exponential backoff.
|
389
|
+
|
390
|
+
Args:
|
391
|
+
func: The async function to retry.
|
392
|
+
*args: Positional arguments for the function.
|
393
|
+
retry_exceptions: Tuple of exception types to retry.
|
394
|
+
exclude_exceptions: Tuple of exception types to not retry.
|
395
|
+
max_retries: Maximum number of retries.
|
396
|
+
base_delay: Initial delay between retries in seconds.
|
397
|
+
max_delay: Maximum delay between retries in seconds.
|
398
|
+
backoff_factor: Factor to increase delay with each retry.
|
399
|
+
jitter: Whether to add randomness to the delay.
|
400
|
+
jitter_factor: How much randomness to add as a percentage.
|
401
|
+
**kwargs: Keyword arguments for the function.
|
402
|
+
|
403
|
+
Returns:
|
404
|
+
The result of the function execution.
|
405
|
+
|
406
|
+
Raises:
|
407
|
+
Exception: The last exception raised by the function after all retries.
|
408
|
+
"""
|
409
|
+
retries = 0
|
410
|
+
delay = base_delay
|
411
|
+
|
412
|
+
while True:
|
413
|
+
try:
|
414
|
+
return await func(*args, **kwargs)
|
415
|
+
except exclude_exceptions:
|
416
|
+
# Don't retry these exceptions
|
417
|
+
logger.debug(
|
418
|
+
f"Not retrying {func.__name__} for excluded exception type"
|
419
|
+
)
|
420
|
+
raise
|
421
|
+
except retry_exceptions as e:
|
422
|
+
# No need to store the exception since we're raising it if max retries reached
|
423
|
+
retries += 1
|
424
|
+
if retries > max_retries:
|
425
|
+
logger.warning(
|
426
|
+
f"Maximum retries ({max_retries}) reached for {func.__name__}"
|
427
|
+
)
|
428
|
+
raise
|
429
|
+
|
430
|
+
# Calculate backoff with optional jitter
|
431
|
+
if jitter:
|
432
|
+
# This is not used for cryptographic purposes, just for jitter
|
433
|
+
jitter_amount = random.uniform(
|
434
|
+
1.0 - jitter_factor, 1.0 + jitter_factor
|
435
|
+
) # noqa: S311
|
436
|
+
current_delay = min(delay * jitter_amount, max_delay)
|
437
|
+
else:
|
438
|
+
current_delay = min(delay, max_delay)
|
439
|
+
|
440
|
+
logger.info(
|
441
|
+
f"Retry {retries}/{max_retries} for {func.__name__} "
|
442
|
+
f"after {current_delay:.2f}s delay. Error: {e!s}"
|
443
|
+
)
|
444
|
+
|
445
|
+
# Increase delay for next iteration
|
446
|
+
delay = delay * backoff_factor
|
447
|
+
|
448
|
+
# Wait before retrying
|
449
|
+
await asyncio.sleep(current_delay)
|
450
|
+
|
451
|
+
|
452
|
+
def circuit_breaker(
|
453
|
+
failure_threshold: int = 5,
|
454
|
+
recovery_time: float = 30.0,
|
455
|
+
half_open_max_calls: int = 1,
|
456
|
+
excluded_exceptions: set[type[Exception]] | None = None,
|
457
|
+
name: str | None = None,
|
458
|
+
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
|
459
|
+
"""
|
460
|
+
Decorator to apply circuit breaker pattern to an async function.
|
461
|
+
|
462
|
+
Args:
|
463
|
+
failure_threshold: Number of failures before opening the circuit.
|
464
|
+
recovery_time: Time in seconds to wait before transitioning to half-open.
|
465
|
+
half_open_max_calls: Maximum number of calls allowed in half-open state.
|
466
|
+
excluded_exceptions: Set of exception types that should not count as failures.
|
467
|
+
name: Name of the circuit breaker for logging and metrics.
|
468
|
+
|
469
|
+
Returns:
|
470
|
+
Decorator function that applies circuit breaker pattern.
|
471
|
+
"""
|
472
|
+
|
473
|
+
def decorator(
|
474
|
+
func: Callable[..., Awaitable[T]],
|
475
|
+
) -> Callable[..., Awaitable[T]]:
|
476
|
+
# Create a unique name for the circuit breaker if not provided
|
477
|
+
cb_name = name or f"cb_{func.__module__}_{func.__qualname__}"
|
478
|
+
|
479
|
+
# Create circuit breaker instance
|
480
|
+
cb = CircuitBreaker(
|
481
|
+
failure_threshold=failure_threshold,
|
482
|
+
recovery_time=recovery_time,
|
483
|
+
half_open_max_calls=half_open_max_calls,
|
484
|
+
excluded_exceptions=excluded_exceptions,
|
485
|
+
name=cb_name,
|
486
|
+
)
|
487
|
+
|
488
|
+
@functools.wraps(func)
|
489
|
+
async def wrapper(*args: Any, **kwargs: Any) -> T:
|
490
|
+
return await cb.execute(func, *args, **kwargs)
|
491
|
+
|
492
|
+
return wrapper
|
493
|
+
|
494
|
+
return decorator
|
495
|
+
|
496
|
+
|
497
|
+
def with_retry(
|
498
|
+
max_retries: int = 3,
|
499
|
+
base_delay: float = 1.0,
|
500
|
+
max_delay: float = 60.0,
|
501
|
+
backoff_factor: float = 2.0,
|
502
|
+
jitter: bool = True,
|
503
|
+
jitter_factor: float = 0.2,
|
504
|
+
retry_exceptions: tuple[type[Exception], ...] = (Exception,),
|
505
|
+
exclude_exceptions: tuple[type[Exception], ...] = (),
|
506
|
+
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
|
507
|
+
"""
|
508
|
+
Decorator to apply retry with backoff pattern to an async function.
|
509
|
+
|
510
|
+
Args:
|
511
|
+
max_retries: Maximum number of retry attempts.
|
512
|
+
base_delay: Initial delay between retries in seconds.
|
513
|
+
max_delay: Maximum delay between retries in seconds.
|
514
|
+
backoff_factor: Multiplier applied to delay after each retry.
|
515
|
+
jitter: Whether to add randomness to delay timings.
|
516
|
+
jitter_factor: How much randomness to add as a percentage.
|
517
|
+
retry_exceptions: Tuple of exception types that should trigger retry.
|
518
|
+
exclude_exceptions: Tuple of exception types that should not be retried.
|
519
|
+
|
520
|
+
Returns:
|
521
|
+
Decorator function that applies retry pattern.
|
522
|
+
"""
|
523
|
+
|
524
|
+
def decorator(
|
525
|
+
func: Callable[..., Awaitable[T]],
|
526
|
+
) -> Callable[..., Awaitable[T]]:
|
527
|
+
@functools.wraps(func)
|
528
|
+
async def wrapper(*args: Any, **kwargs: Any) -> T:
|
529
|
+
return await retry_with_backoff(
|
530
|
+
func,
|
531
|
+
*args,
|
532
|
+
retry_exceptions=retry_exceptions,
|
533
|
+
exclude_exceptions=exclude_exceptions,
|
534
|
+
max_retries=max_retries,
|
535
|
+
base_delay=base_delay,
|
536
|
+
max_delay=max_delay,
|
537
|
+
backoff_factor=backoff_factor,
|
538
|
+
jitter=jitter,
|
539
|
+
jitter_factor=jitter_factor,
|
540
|
+
**kwargs,
|
541
|
+
)
|
542
|
+
|
543
|
+
return wrapper
|
544
|
+
|
545
|
+
return decorator
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# Generated OpenAI Models
|
2
|
+
|
3
|
+
This directory contains generated schema files for OpenAI API models.
|
4
|
+
|
5
|
+
## OpenAI Models
|
6
|
+
|
7
|
+
The `openai_models.py` file is generated from the OpenAI API schema and is not
|
8
|
+
committed to the repository. It is generated during the build process.
|
9
|
+
|
10
|
+
### Generation Command
|
11
|
+
|
12
|
+
To generate the OpenAI models, use the following command:
|
13
|
+
|
14
|
+
```bash
|
15
|
+
# Use exact version to guarantee byte-for-byte generation
|
16
|
+
python -m pip install 'datamodel-code-generator[http]==0.30.1'
|
17
|
+
|
18
|
+
python -m datamodel-codegen \
|
19
|
+
--url https://raw.githubusercontent.com/openai/openai-openapi/master/openapi.yaml \
|
20
|
+
--output lionagi/services/third_party/openai_models.py \
|
21
|
+
--allow-population-by-field-name \
|
22
|
+
--output-model-type pydantic_v2.BaseModel \
|
23
|
+
--field-constraints \
|
24
|
+
--use-schema-description \
|
25
|
+
--input-file-type openapi \
|
26
|
+
--use-field-description \
|
27
|
+
--use-one-literal-as-default \
|
28
|
+
--enum-field-as-literal all \
|
29
|
+
--use-union-operator \
|
30
|
+
--no-alias
|
31
|
+
```
|
32
|
+
|
33
|
+
**Note**: if `bytes_aliased` and `float_aliased` are not generated, you can add
|
34
|
+
them manually onto the top of the file
|
35
|
+
|
36
|
+
```python
|
37
|
+
from typing import Annotated
|
38
|
+
from pydantic import Field
|
39
|
+
|
40
|
+
bytes_aliased = Annotated[bytes, Field(alias="bytes")]
|
41
|
+
float_aliased = Annotated[float, Field(alias="float")]
|
42
|
+
```
|
43
|
+
|
44
|
+
### Avoiding Pydantic Alias Warnings
|
45
|
+
|
46
|
+
After generation, add the following line at the top of the file to avoid
|
47
|
+
Pydantic alias warnings:
|
48
|
+
|
49
|
+
```python
|
50
|
+
from __future__ import annotations # noqa: D401,F401
|
51
|
+
```
|
52
|
+
|
53
|
+
This prevents warnings about "alias must be outermost" that can appear if the
|
54
|
+
generated OpenAI models are imported before Pydantic's patched typing fix.
|
55
|
+
|
56
|
+
### Why Not Committed
|
57
|
+
|
58
|
+
The OpenAI schema file is large and frequently updated. Rather than committing
|
59
|
+
this large, auto-generated file to the repository, we generate it during the
|
60
|
+
CI/CD build process. This approach:
|
61
|
+
|
62
|
+
1. Keeps the repository size smaller
|
63
|
+
2. Makes it easier to update to the latest OpenAI API version
|
64
|
+
3. Avoids merge conflicts on auto-generated code
|
65
|
+
4. Follows best practices for generated code
|
66
|
+
|
67
|
+
### CI Integration
|
68
|
+
|
69
|
+
The file is generated as part of the pre-build step in CI, ensuring that the
|
70
|
+
wheel distribution still contains the models even though they're not in the
|
71
|
+
repository.
|
File without changes
|