omnimancer-cli 0.1.0__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.
- omnimancer/__init__.py +42 -0
- omnimancer/__main__.py +9 -0
- omnimancer/cli/__init__.py +8 -0
- omnimancer/cli/agent_manager.py +826 -0
- omnimancer/cli/agent_wizard.py +944 -0
- omnimancer/cli/approval_formatter.py +734 -0
- omnimancer/cli/approval_integration.py +734 -0
- omnimancer/cli/approval_prompt.py +1286 -0
- omnimancer/cli/batch_approval_display.py +617 -0
- omnimancer/cli/batch_approval_filters.py +729 -0
- omnimancer/cli/batch_operation_monitor.py +775 -0
- omnimancer/cli/commands/performance_cmd.py +624 -0
- omnimancer/cli/commands.py +757 -0
- omnimancer/cli/completion_handler.py +281 -0
- omnimancer/cli/config_setup_wizard.py +783 -0
- omnimancer/cli/dynamic_commands.py +371 -0
- omnimancer/cli/handlers/__init__.py +18 -0
- omnimancer/cli/handlers/agent_handler.py +865 -0
- omnimancer/cli/handlers/agent_persona_handler.py +1048 -0
- omnimancer/cli/handlers/custom_agent_handler.py +352 -0
- omnimancer/cli/handlers/permissions_handler.py +1609 -0
- omnimancer/cli/interface.py +6695 -0
- omnimancer/cli/performance_dashboard.py +612 -0
- omnimancer/core/__init__.py +34 -0
- omnimancer/core/agent/__init__.py +256 -0
- omnimancer/core/agent/agent_config.py +715 -0
- omnimancer/core/agent/agent_switcher.py +668 -0
- omnimancer/core/agent/approval_context.py +460 -0
- omnimancer/core/agent/approval_dialog.py +902 -0
- omnimancer/core/agent/approval_interface.py +628 -0
- omnimancer/core/agent/approval_manager.py +760 -0
- omnimancer/core/agent/cli_integration.py +470 -0
- omnimancer/core/agent/config.py +845 -0
- omnimancer/core/agent/custom_agent_integration.py +654 -0
- omnimancer/core/agent/diff_renderer.py +706 -0
- omnimancer/core/agent/file_content_display.py +503 -0
- omnimancer/core/agent/file_modification_workflow.py +658 -0
- omnimancer/core/agent/file_system_manager.py +1790 -0
- omnimancer/core/agent/input_handler.py +614 -0
- omnimancer/core/agent/metrics_collector.py +771 -0
- omnimancer/core/agent/optimization_engine.py +729 -0
- omnimancer/core/agent/performance_monitor.py +717 -0
- omnimancer/core/agent/persona.py +870 -0
- omnimancer/core/agent/persona_validator.py +672 -0
- omnimancer/core/agent/program_executor.py +748 -0
- omnimancer/core/agent/proposed_changes_integration.py +663 -0
- omnimancer/core/agent/read_before_write_errors.py +492 -0
- omnimancer/core/agent/read_before_write_ui.py +647 -0
- omnimancer/core/agent/rich_renderer.py +763 -0
- omnimancer/core/agent/status_config.py +238 -0
- omnimancer/core/agent/status_core.py +446 -0
- omnimancer/core/agent/status_integrated_engine.py +540 -0
- omnimancer/core/agent/status_manager.py +878 -0
- omnimancer/core/agent/status_ui_components.py +535 -0
- omnimancer/core/agent/template_applicator.py +548 -0
- omnimancer/core/agent/terminal_status_listener.py +394 -0
- omnimancer/core/agent/token_tracker.py +679 -0
- omnimancer/core/agent/types.py +60 -0
- omnimancer/core/agent/web_client.py +745 -0
- omnimancer/core/agent/workflow_orchestrator.py +826 -0
- omnimancer/core/agent_engine.py +1625 -0
- omnimancer/core/agent_mode_manager.py +706 -0
- omnimancer/core/agent_progress_ui.py +545 -0
- omnimancer/core/chat_manager.py +141 -0
- omnimancer/core/config_manager.py +2199 -0
- omnimancer/core/config_migration.py +772 -0
- omnimancer/core/config_provider.py +1974 -0
- omnimancer/core/config_security_validator.py +393 -0
- omnimancer/core/config_validator.py +768 -0
- omnimancer/core/conversation_manager.py +352 -0
- omnimancer/core/engine.py +787 -0
- omnimancer/core/enhanced_input.py +523 -0
- omnimancer/core/env_loader.py +83 -0
- omnimancer/core/fallback_manager.py +568 -0
- omnimancer/core/health_monitor.py +462 -0
- omnimancer/core/history_manager.py +462 -0
- omnimancer/core/mcp_integration_layer.py +878 -0
- omnimancer/core/models.py +1889 -0
- omnimancer/core/provider_capabilities.py +403 -0
- omnimancer/core/provider_initializer.py +450 -0
- omnimancer/core/provider_registry.py +432 -0
- omnimancer/core/security/__init__.py +16 -0
- omnimancer/core/security/approval_workflow.py +406 -0
- omnimancer/core/security/audit_logger.py +511 -0
- omnimancer/core/security/permission_controller.py +511 -0
- omnimancer/core/security/sandbox_manager.py +379 -0
- omnimancer/core/security/security_manager.py +530 -0
- omnimancer/core/setup_wizard.py +345 -0
- omnimancer/core/setup_wizard_core.py +449 -0
- omnimancer/core/setup_wizard_guide_generator.py +376 -0
- omnimancer/core/setup_wizard_provider_setup.py +454 -0
- omnimancer/core/setup_wizard_ui.py +317 -0
- omnimancer/core/setup_wizard_validation.py +371 -0
- omnimancer/core/signal_handler.py +174 -0
- omnimancer/mcp/__init__.py +11 -0
- omnimancer/mcp/client.py +725 -0
- omnimancer/mcp/manager.py +649 -0
- omnimancer/providers/__init__.py +24 -0
- omnimancer/providers/azure.py +608 -0
- omnimancer/providers/base.py +262 -0
- omnimancer/providers/bedrock.py +711 -0
- omnimancer/providers/claude.py +426 -0
- omnimancer/providers/claude_code.py +406 -0
- omnimancer/providers/cohere.py +412 -0
- omnimancer/providers/factory.py +988 -0
- omnimancer/providers/gemini.py +708 -0
- omnimancer/providers/mistral.py +554 -0
- omnimancer/providers/ollama.py +510 -0
- omnimancer/providers/openai.py +362 -0
- omnimancer/providers/openrouter.py +709 -0
- omnimancer/providers/perplexity.py +476 -0
- omnimancer/providers/vertex.py +617 -0
- omnimancer/providers/xai.py +484 -0
- omnimancer/ui/__init__.py +6 -0
- omnimancer/ui/cancellation_handler.py +235 -0
- omnimancer/ui/progress_indicator.py +318 -0
- omnimancer/utils/__init__.py +22 -0
- omnimancer/utils/error_handler.py +589 -0
- omnimancer/utils/errors.py +667 -0
- omnimancer/utils/retry.py +238 -0
- omnimancer_cli-0.1.0.dist-info/METADATA +413 -0
- omnimancer_cli-0.1.0.dist-info/RECORD +126 -0
- omnimancer_cli-0.1.0.dist-info/WHEEL +5 -0
- omnimancer_cli-0.1.0.dist-info/entry_points.txt +4 -0
- omnimancer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- omnimancer_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enhanced Provider Fallback and Retry Logic for Omnimancer.
|
|
3
|
+
|
|
4
|
+
This module provides intelligent fallback system with provider health monitoring,
|
|
5
|
+
context preservation, and seamless failover capabilities.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import random
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
|
15
|
+
|
|
16
|
+
from ..utils.errors import (
|
|
17
|
+
NetworkError,
|
|
18
|
+
ProviderError,
|
|
19
|
+
ProviderUnavailableError,
|
|
20
|
+
)
|
|
21
|
+
from .health_monitor import HealthMonitor
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FallbackReason(Enum):
|
|
27
|
+
"""Reasons for provider fallback."""
|
|
28
|
+
|
|
29
|
+
PROVIDER_ERROR = "provider_error"
|
|
30
|
+
NETWORK_ERROR = "network_error"
|
|
31
|
+
RATE_LIMIT = "rate_limit"
|
|
32
|
+
QUOTA_EXCEEDED = "quota_exceeded"
|
|
33
|
+
MODEL_UNAVAILABLE = "model_unavailable"
|
|
34
|
+
TIMEOUT = "timeout"
|
|
35
|
+
HEALTH_CHECK_FAILED = "health_check_failed"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProviderRank(Enum):
|
|
39
|
+
"""Provider ranking based on reliability and performance."""
|
|
40
|
+
|
|
41
|
+
PRIMARY = 1 # Best performance, highest reliability
|
|
42
|
+
SECONDARY = 2 # Good performance, good reliability
|
|
43
|
+
TERTIARY = 3 # Acceptable performance, lower reliability
|
|
44
|
+
EMERGENCY = 4 # Last resort, basic functionality
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class FallbackAttempt:
|
|
49
|
+
"""Record of a fallback attempt."""
|
|
50
|
+
|
|
51
|
+
provider_name: str
|
|
52
|
+
reason: FallbackReason
|
|
53
|
+
error: Optional[str] = None
|
|
54
|
+
timestamp: float = field(default_factory=time.time)
|
|
55
|
+
retry_count: int = 0
|
|
56
|
+
success: bool = False
|
|
57
|
+
response_time: Optional[float] = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class ProviderStats:
|
|
62
|
+
"""Statistics for provider performance tracking."""
|
|
63
|
+
|
|
64
|
+
success_count: int = 0
|
|
65
|
+
failure_count: int = 0
|
|
66
|
+
avg_response_time: float = 0.0
|
|
67
|
+
last_success: Optional[float] = None
|
|
68
|
+
last_failure: Optional[float] = None
|
|
69
|
+
rank: ProviderRank = ProviderRank.SECONDARY
|
|
70
|
+
consecutive_failures: int = 0
|
|
71
|
+
total_requests: int = 0
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def success_rate(self) -> float:
|
|
75
|
+
"""Calculate success rate."""
|
|
76
|
+
if self.total_requests == 0:
|
|
77
|
+
return 1.0
|
|
78
|
+
return self.success_count / self.total_requests
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def reliability_score(self) -> float:
|
|
82
|
+
"""Calculate reliability score (0.0 to 1.0)."""
|
|
83
|
+
base_score = self.success_rate
|
|
84
|
+
|
|
85
|
+
# Penalize consecutive failures
|
|
86
|
+
failure_penalty = min(self.consecutive_failures * 0.1, 0.8)
|
|
87
|
+
|
|
88
|
+
# Bonus for recent success
|
|
89
|
+
recency_bonus = 0.0
|
|
90
|
+
if self.last_success:
|
|
91
|
+
time_since_success = time.time() - self.last_success
|
|
92
|
+
if time_since_success < 300: # 5 minutes
|
|
93
|
+
recency_bonus = 0.1
|
|
94
|
+
|
|
95
|
+
return max(0.0, base_score - failure_penalty + recency_bonus)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class EnhancedProviderFallback:
|
|
99
|
+
"""
|
|
100
|
+
Enhanced provider fallback system with intelligent provider selection,
|
|
101
|
+
health monitoring, and context preservation.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, core_engine, health_monitor: Optional[HealthMonitor] = None):
|
|
105
|
+
"""
|
|
106
|
+
Initialize the enhanced fallback manager.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
core_engine: Reference to CoreEngine instance
|
|
110
|
+
health_monitor: Optional health monitor instance
|
|
111
|
+
"""
|
|
112
|
+
self.core_engine = core_engine
|
|
113
|
+
self.health_monitor = health_monitor or HealthMonitor()
|
|
114
|
+
|
|
115
|
+
# Configuration
|
|
116
|
+
self.max_retry_attempts = 3
|
|
117
|
+
self.base_retry_delay = 1.0
|
|
118
|
+
self.max_retry_delay = 32.0
|
|
119
|
+
self.jitter_factor = 0.1
|
|
120
|
+
self.health_check_interval = 300 # 5 minutes
|
|
121
|
+
|
|
122
|
+
# Provider management
|
|
123
|
+
self.fallback_providers: List[str] = []
|
|
124
|
+
self.provider_rankings: Dict[str, ProviderRank] = {}
|
|
125
|
+
self.provider_stats: Dict[str, ProviderStats] = {}
|
|
126
|
+
self.circuit_breaker_threshold = 5 # failures before circuit break
|
|
127
|
+
self.circuit_breaker_recovery_time = 600 # 10 minutes
|
|
128
|
+
|
|
129
|
+
# Context preservation
|
|
130
|
+
self.context_preservation_enabled = True
|
|
131
|
+
self.preserved_context: Optional[Dict[str, Any]] = None
|
|
132
|
+
|
|
133
|
+
# Fallback history
|
|
134
|
+
self.fallback_history: List[FallbackAttempt] = []
|
|
135
|
+
self.max_history_size = 100
|
|
136
|
+
|
|
137
|
+
# Excluded providers (temporarily disabled)
|
|
138
|
+
self.excluded_providers: Set[str] = set()
|
|
139
|
+
self.exclusion_expiry: Dict[str, float] = {}
|
|
140
|
+
|
|
141
|
+
def set_fallback_providers(
|
|
142
|
+
self,
|
|
143
|
+
providers: List[str],
|
|
144
|
+
rankings: Optional[Dict[str, ProviderRank]] = None,
|
|
145
|
+
):
|
|
146
|
+
"""
|
|
147
|
+
Set the list of fallback providers with optional rankings.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
providers: List of provider names in priority order
|
|
151
|
+
rankings: Optional dict mapping provider names to rankings
|
|
152
|
+
"""
|
|
153
|
+
self.fallback_providers = providers.copy()
|
|
154
|
+
|
|
155
|
+
if rankings:
|
|
156
|
+
self.provider_rankings.update(rankings)
|
|
157
|
+
else:
|
|
158
|
+
# Auto-assign rankings based on order
|
|
159
|
+
for i, provider in enumerate(providers):
|
|
160
|
+
if i == 0:
|
|
161
|
+
self.provider_rankings[provider] = ProviderRank.PRIMARY
|
|
162
|
+
elif i < 3:
|
|
163
|
+
self.provider_rankings[provider] = ProviderRank.SECONDARY
|
|
164
|
+
elif i < 6:
|
|
165
|
+
self.provider_rankings[provider] = ProviderRank.TERTIARY
|
|
166
|
+
else:
|
|
167
|
+
self.provider_rankings[provider] = ProviderRank.EMERGENCY
|
|
168
|
+
|
|
169
|
+
# Initialize stats for new providers
|
|
170
|
+
for provider in providers:
|
|
171
|
+
if provider not in self.provider_stats:
|
|
172
|
+
self.provider_stats[provider] = ProviderStats()
|
|
173
|
+
|
|
174
|
+
async def execute_with_fallback(
|
|
175
|
+
self,
|
|
176
|
+
operation_func: Callable,
|
|
177
|
+
*args,
|
|
178
|
+
preserve_context: bool = True,
|
|
179
|
+
**kwargs,
|
|
180
|
+
) -> Any:
|
|
181
|
+
"""
|
|
182
|
+
Execute operation with intelligent fallback and retry logic.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
operation_func: Function to execute
|
|
186
|
+
*args: Arguments for the function
|
|
187
|
+
preserve_context: Whether to preserve conversation context
|
|
188
|
+
**kwargs: Keyword arguments for the function
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Result from successful operation execution
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
Exception: If all providers and retries fail
|
|
195
|
+
"""
|
|
196
|
+
original_provider = self.core_engine.current_provider
|
|
197
|
+
original_provider_name = (
|
|
198
|
+
original_provider.get_provider_name() if original_provider else None
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Preserve context if enabled
|
|
202
|
+
if preserve_context and self.context_preservation_enabled:
|
|
203
|
+
await self._preserve_context()
|
|
204
|
+
|
|
205
|
+
# Get ranked list of providers to try
|
|
206
|
+
providers_to_try = await self._get_ranked_providers()
|
|
207
|
+
|
|
208
|
+
last_error = None
|
|
209
|
+
fallback_attempts = []
|
|
210
|
+
|
|
211
|
+
for provider_name in providers_to_try:
|
|
212
|
+
# Skip excluded providers
|
|
213
|
+
if self._is_provider_excluded(provider_name):
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
# Switch to provider if needed
|
|
218
|
+
if not original_provider or original_provider_name != provider_name:
|
|
219
|
+
switch_success = await self.core_engine.switch_model(provider_name)
|
|
220
|
+
if not switch_success:
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
# Execute with retries for this provider
|
|
224
|
+
result = await self._execute_with_retries(
|
|
225
|
+
operation_func, provider_name, *args, **kwargs
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Record success
|
|
229
|
+
self._record_success(provider_name, time.time())
|
|
230
|
+
|
|
231
|
+
# Restore context if needed
|
|
232
|
+
if preserve_context and self.preserved_context:
|
|
233
|
+
await self._restore_context()
|
|
234
|
+
|
|
235
|
+
return result
|
|
236
|
+
|
|
237
|
+
except Exception as e:
|
|
238
|
+
last_error = e
|
|
239
|
+
reason = self._classify_error(e)
|
|
240
|
+
|
|
241
|
+
# Record failure
|
|
242
|
+
self._record_failure(provider_name, reason, str(e))
|
|
243
|
+
|
|
244
|
+
# Add to fallback attempts
|
|
245
|
+
attempt = FallbackAttempt(
|
|
246
|
+
provider_name=provider_name,
|
|
247
|
+
reason=reason,
|
|
248
|
+
error=str(e),
|
|
249
|
+
success=False,
|
|
250
|
+
)
|
|
251
|
+
fallback_attempts.append(attempt)
|
|
252
|
+
|
|
253
|
+
# Check if provider should be temporarily excluded
|
|
254
|
+
if self._should_exclude_provider(provider_name):
|
|
255
|
+
self._exclude_provider_temporarily(provider_name)
|
|
256
|
+
|
|
257
|
+
logger.warning(f"Provider {provider_name} failed: {e}")
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
# All providers failed
|
|
261
|
+
self._record_fallback_history(fallback_attempts)
|
|
262
|
+
|
|
263
|
+
# Try to restore original provider
|
|
264
|
+
if original_provider_name:
|
|
265
|
+
try:
|
|
266
|
+
await self.core_engine.switch_model(original_provider_name)
|
|
267
|
+
except Exception:
|
|
268
|
+
pass # Best effort restore
|
|
269
|
+
|
|
270
|
+
# Raise the last error
|
|
271
|
+
if last_error:
|
|
272
|
+
raise ProviderUnavailableError(
|
|
273
|
+
f"All providers failed. Last error: {last_error}",
|
|
274
|
+
fallback_providers=[a.provider_name for a in fallback_attempts],
|
|
275
|
+
)
|
|
276
|
+
else:
|
|
277
|
+
raise ProviderError("No providers available for fallback")
|
|
278
|
+
|
|
279
|
+
async def _execute_with_retries(
|
|
280
|
+
self, operation_func: Callable, provider_name: str, *args, **kwargs
|
|
281
|
+
) -> Any:
|
|
282
|
+
"""Execute operation with exponential backoff retry logic."""
|
|
283
|
+
last_error = None
|
|
284
|
+
|
|
285
|
+
for attempt in range(self.max_retry_attempts):
|
|
286
|
+
try:
|
|
287
|
+
start_time = time.time()
|
|
288
|
+
result = await operation_func(*args, **kwargs)
|
|
289
|
+
|
|
290
|
+
# Record response time
|
|
291
|
+
response_time = time.time() - start_time
|
|
292
|
+
self._update_response_time(provider_name, response_time)
|
|
293
|
+
|
|
294
|
+
return result
|
|
295
|
+
|
|
296
|
+
except Exception as e:
|
|
297
|
+
last_error = e
|
|
298
|
+
|
|
299
|
+
# Don't retry certain types of errors
|
|
300
|
+
if self._is_non_retryable_error(e):
|
|
301
|
+
raise
|
|
302
|
+
|
|
303
|
+
# Calculate delay with exponential backoff and jitter
|
|
304
|
+
if attempt < self.max_retry_attempts - 1:
|
|
305
|
+
delay = min(
|
|
306
|
+
self.base_retry_delay * (2**attempt),
|
|
307
|
+
self.max_retry_delay,
|
|
308
|
+
)
|
|
309
|
+
# Add jitter
|
|
310
|
+
jitter = delay * self.jitter_factor * (random.random() - 0.5)
|
|
311
|
+
delay += jitter
|
|
312
|
+
|
|
313
|
+
logger.debug(
|
|
314
|
+
f"Retrying {provider_name} in {delay:.2f}s (attempt {attempt + 1})"
|
|
315
|
+
)
|
|
316
|
+
await asyncio.sleep(delay)
|
|
317
|
+
|
|
318
|
+
# All retries failed
|
|
319
|
+
if last_error:
|
|
320
|
+
raise last_error
|
|
321
|
+
else:
|
|
322
|
+
raise ProviderError(
|
|
323
|
+
f"All retry attempts failed for provider {provider_name}"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
async def _get_ranked_providers(self) -> List[str]:
|
|
327
|
+
"""Get list of providers ranked by health and performance."""
|
|
328
|
+
available_providers = []
|
|
329
|
+
|
|
330
|
+
# Start with current provider if available
|
|
331
|
+
if self.core_engine.current_provider:
|
|
332
|
+
current_name = self.core_engine.current_provider.get_provider_name()
|
|
333
|
+
if current_name not in self.excluded_providers:
|
|
334
|
+
available_providers.append(current_name)
|
|
335
|
+
|
|
336
|
+
# Add fallback providers
|
|
337
|
+
for provider in self.fallback_providers:
|
|
338
|
+
if (
|
|
339
|
+
provider not in available_providers
|
|
340
|
+
and provider not in self.excluded_providers
|
|
341
|
+
):
|
|
342
|
+
available_providers.append(provider)
|
|
343
|
+
|
|
344
|
+
# Sort by reliability score and ranking
|
|
345
|
+
def sort_key(provider_name: str) -> Tuple[float, int]:
|
|
346
|
+
stats = self.provider_stats.get(provider_name, ProviderStats())
|
|
347
|
+
ranking = self.provider_rankings.get(provider_name, ProviderRank.SECONDARY)
|
|
348
|
+
return (-stats.reliability_score, ranking.value)
|
|
349
|
+
|
|
350
|
+
available_providers.sort(key=sort_key)
|
|
351
|
+
|
|
352
|
+
logger.debug(f"Ranked providers: {available_providers}")
|
|
353
|
+
return available_providers
|
|
354
|
+
|
|
355
|
+
def _classify_error(self, error: Exception) -> FallbackReason:
|
|
356
|
+
"""Classify error to determine appropriate fallback strategy."""
|
|
357
|
+
error_str = str(error).lower()
|
|
358
|
+
type(error).__name__
|
|
359
|
+
|
|
360
|
+
if "rate limit" in error_str or "429" in error_str:
|
|
361
|
+
return FallbackReason.RATE_LIMIT
|
|
362
|
+
elif "quota" in error_str or "usage limit" in error_str:
|
|
363
|
+
return FallbackReason.QUOTA_EXCEEDED
|
|
364
|
+
elif "timeout" in error_str or "timed out" in error_str:
|
|
365
|
+
return FallbackReason.TIMEOUT
|
|
366
|
+
elif "network" in error_str or "connection" in error_str:
|
|
367
|
+
return FallbackReason.NETWORK_ERROR
|
|
368
|
+
elif "model" in error_str and (
|
|
369
|
+
"not found" in error_str or "unavailable" in error_str
|
|
370
|
+
):
|
|
371
|
+
return FallbackReason.MODEL_UNAVAILABLE
|
|
372
|
+
elif isinstance(error, NetworkError):
|
|
373
|
+
return FallbackReason.NETWORK_ERROR
|
|
374
|
+
else:
|
|
375
|
+
return FallbackReason.PROVIDER_ERROR
|
|
376
|
+
|
|
377
|
+
def _is_non_retryable_error(self, error: Exception) -> bool:
|
|
378
|
+
"""Check if error should not be retried."""
|
|
379
|
+
# Don't retry authentication errors, quota exceeded, etc.
|
|
380
|
+
error_str = str(error).lower()
|
|
381
|
+
non_retryable_patterns = [
|
|
382
|
+
"invalid api key",
|
|
383
|
+
"authentication failed",
|
|
384
|
+
"unauthorized",
|
|
385
|
+
"quota exceeded",
|
|
386
|
+
"model not found",
|
|
387
|
+
"invalid model",
|
|
388
|
+
]
|
|
389
|
+
|
|
390
|
+
return any(pattern in error_str for pattern in non_retryable_patterns)
|
|
391
|
+
|
|
392
|
+
def _record_success(self, provider_name: str, response_time: float):
|
|
393
|
+
"""Record successful operation for provider."""
|
|
394
|
+
if provider_name not in self.provider_stats:
|
|
395
|
+
self.provider_stats[provider_name] = ProviderStats()
|
|
396
|
+
|
|
397
|
+
stats = self.provider_stats[provider_name]
|
|
398
|
+
stats.success_count += 1
|
|
399
|
+
stats.total_requests += 1
|
|
400
|
+
stats.last_success = time.time()
|
|
401
|
+
stats.consecutive_failures = 0 # Reset failure counter
|
|
402
|
+
|
|
403
|
+
logger.debug(f"Recorded success for {provider_name}")
|
|
404
|
+
|
|
405
|
+
def _record_failure(
|
|
406
|
+
self, provider_name: str, reason: FallbackReason, error_msg: str
|
|
407
|
+
):
|
|
408
|
+
"""Record failed operation for provider."""
|
|
409
|
+
if provider_name not in self.provider_stats:
|
|
410
|
+
self.provider_stats[provider_name] = ProviderStats()
|
|
411
|
+
|
|
412
|
+
stats = self.provider_stats[provider_name]
|
|
413
|
+
stats.failure_count += 1
|
|
414
|
+
stats.total_requests += 1
|
|
415
|
+
stats.last_failure = time.time()
|
|
416
|
+
stats.consecutive_failures += 1
|
|
417
|
+
|
|
418
|
+
logger.debug(f"Recorded failure for {provider_name}: {reason.value}")
|
|
419
|
+
|
|
420
|
+
def _update_response_time(self, provider_name: str, response_time: float):
|
|
421
|
+
"""Update average response time for provider."""
|
|
422
|
+
if provider_name not in self.provider_stats:
|
|
423
|
+
self.provider_stats[provider_name] = ProviderStats()
|
|
424
|
+
|
|
425
|
+
stats = self.provider_stats[provider_name]
|
|
426
|
+
if stats.avg_response_time == 0:
|
|
427
|
+
stats.avg_response_time = response_time
|
|
428
|
+
else:
|
|
429
|
+
# Exponential moving average
|
|
430
|
+
alpha = 0.3
|
|
431
|
+
stats.avg_response_time = (
|
|
432
|
+
alpha * response_time + (1 - alpha) * stats.avg_response_time
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
def _should_exclude_provider(self, provider_name: str) -> bool:
|
|
436
|
+
"""Check if provider should be temporarily excluded."""
|
|
437
|
+
stats = self.provider_stats.get(provider_name)
|
|
438
|
+
if not stats:
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
return stats.consecutive_failures >= self.circuit_breaker_threshold
|
|
442
|
+
|
|
443
|
+
def _exclude_provider_temporarily(self, provider_name: str):
|
|
444
|
+
"""Temporarily exclude provider from fallback list."""
|
|
445
|
+
self.excluded_providers.add(provider_name)
|
|
446
|
+
self.exclusion_expiry[provider_name] = (
|
|
447
|
+
time.time() + self.circuit_breaker_recovery_time
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
logger.warning(
|
|
451
|
+
f"Temporarily excluded provider {provider_name} due to consecutive failures"
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
def _is_provider_excluded(self, provider_name: str) -> bool:
|
|
455
|
+
"""Check if provider is currently excluded."""
|
|
456
|
+
if provider_name not in self.excluded_providers:
|
|
457
|
+
return False
|
|
458
|
+
|
|
459
|
+
# Check if exclusion has expired
|
|
460
|
+
if provider_name in self.exclusion_expiry:
|
|
461
|
+
if time.time() > self.exclusion_expiry[provider_name]:
|
|
462
|
+
self.excluded_providers.discard(provider_name)
|
|
463
|
+
del self.exclusion_expiry[provider_name]
|
|
464
|
+
logger.info(
|
|
465
|
+
f"Re-enabled provider {provider_name} after recovery period"
|
|
466
|
+
)
|
|
467
|
+
return False
|
|
468
|
+
|
|
469
|
+
return True
|
|
470
|
+
|
|
471
|
+
async def _preserve_context(self):
|
|
472
|
+
"""Preserve current conversation context."""
|
|
473
|
+
try:
|
|
474
|
+
if hasattr(self.core_engine, "chat_manager"):
|
|
475
|
+
context = self.core_engine.chat_manager.get_current_context()
|
|
476
|
+
self.preserved_context = {
|
|
477
|
+
"messages": (
|
|
478
|
+
context.messages.copy() if hasattr(context, "messages") else []
|
|
479
|
+
),
|
|
480
|
+
"session_id": getattr(context, "session_id", None),
|
|
481
|
+
"metadata": getattr(context, "metadata", {}),
|
|
482
|
+
}
|
|
483
|
+
logger.debug("Preserved conversation context")
|
|
484
|
+
except Exception as e:
|
|
485
|
+
logger.warning(f"Failed to preserve context: {e}")
|
|
486
|
+
|
|
487
|
+
async def _restore_context(self):
|
|
488
|
+
"""Restore preserved conversation context."""
|
|
489
|
+
try:
|
|
490
|
+
if self.preserved_context and hasattr(self.core_engine, "chat_manager"):
|
|
491
|
+
# This would require implementation in ChatManager
|
|
492
|
+
# For now, just log the attempt
|
|
493
|
+
logger.debug("Context restoration would be implemented here")
|
|
494
|
+
except Exception as e:
|
|
495
|
+
logger.warning(f"Failed to restore context: {e}")
|
|
496
|
+
|
|
497
|
+
def _record_fallback_history(self, attempts: List[FallbackAttempt]):
|
|
498
|
+
"""Record fallback attempts in history."""
|
|
499
|
+
self.fallback_history.extend(attempts)
|
|
500
|
+
|
|
501
|
+
# Trim history if too large
|
|
502
|
+
if len(self.fallback_history) > self.max_history_size:
|
|
503
|
+
self.fallback_history = self.fallback_history[-self.max_history_size :]
|
|
504
|
+
|
|
505
|
+
def get_provider_stats(self) -> Dict[str, Dict[str, Any]]:
|
|
506
|
+
"""Get statistics for all providers."""
|
|
507
|
+
stats = {}
|
|
508
|
+
for provider_name, provider_stats in self.provider_stats.items():
|
|
509
|
+
stats[provider_name] = {
|
|
510
|
+
"success_rate": provider_stats.success_rate,
|
|
511
|
+
"reliability_score": provider_stats.reliability_score,
|
|
512
|
+
"avg_response_time": provider_stats.avg_response_time,
|
|
513
|
+
"total_requests": provider_stats.total_requests,
|
|
514
|
+
"consecutive_failures": provider_stats.consecutive_failures,
|
|
515
|
+
"rank": provider_stats.rank.name,
|
|
516
|
+
"excluded": provider_name in self.excluded_providers,
|
|
517
|
+
}
|
|
518
|
+
return stats
|
|
519
|
+
|
|
520
|
+
def get_fallback_history(self, limit: int = 10) -> List[Dict[str, Any]]:
|
|
521
|
+
"""Get recent fallback history."""
|
|
522
|
+
recent_history = (
|
|
523
|
+
self.fallback_history[-limit:] if limit else self.fallback_history
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
return [
|
|
527
|
+
{
|
|
528
|
+
"provider": attempt.provider_name,
|
|
529
|
+
"reason": attempt.reason.value,
|
|
530
|
+
"error": attempt.error,
|
|
531
|
+
"timestamp": attempt.timestamp,
|
|
532
|
+
"success": attempt.success,
|
|
533
|
+
}
|
|
534
|
+
for attempt in recent_history
|
|
535
|
+
]
|
|
536
|
+
|
|
537
|
+
async def health_check_all_providers(self) -> Dict[str, Dict[str, Any]]:
|
|
538
|
+
"""Perform health check on all configured providers."""
|
|
539
|
+
if not hasattr(self.core_engine, "config_manager"):
|
|
540
|
+
return {}
|
|
541
|
+
|
|
542
|
+
try:
|
|
543
|
+
config = self.core_engine.config_manager.get_config()
|
|
544
|
+
health_result = await self.health_monitor.check_all_providers_health(
|
|
545
|
+
config.providers, force=True
|
|
546
|
+
)
|
|
547
|
+
return health_result
|
|
548
|
+
except Exception as e:
|
|
549
|
+
logger.error(f"Health check failed: {e}")
|
|
550
|
+
return {}
|
|
551
|
+
|
|
552
|
+
def reset_provider_stats(self, provider_name: Optional[str] = None):
|
|
553
|
+
"""Reset statistics for specific provider or all providers."""
|
|
554
|
+
if provider_name:
|
|
555
|
+
if provider_name in self.provider_stats:
|
|
556
|
+
self.provider_stats[provider_name] = ProviderStats()
|
|
557
|
+
logger.info(f"Reset stats for provider {provider_name}")
|
|
558
|
+
else:
|
|
559
|
+
self.provider_stats.clear()
|
|
560
|
+
logger.info("Reset stats for all providers")
|
|
561
|
+
|
|
562
|
+
def configure_circuit_breaker(self, threshold: int = 5, recovery_time: int = 600):
|
|
563
|
+
"""Configure circuit breaker settings."""
|
|
564
|
+
self.circuit_breaker_threshold = threshold
|
|
565
|
+
self.circuit_breaker_recovery_time = recovery_time
|
|
566
|
+
logger.info(
|
|
567
|
+
f"Circuit breaker configured: threshold={threshold}, recovery_time={recovery_time}s"
|
|
568
|
+
)
|