cct-cli 0.7.9.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.
- calc_terminal/__init__.py +14 -0
- calc_terminal/__main__.py +14 -0
- calc_terminal/activity.py +1334 -0
- calc_terminal/agent.py +3387 -0
- calc_terminal/agent_runtime.py +519 -0
- calc_terminal/ai_context.py +447 -0
- calc_terminal/ai_modes.py +752 -0
- calc_terminal/ai_personalization.py +286 -0
- calc_terminal/ai_preview_feedback.py +213 -0
- calc_terminal/aicore.py +2572 -0
- calc_terminal/anim.py +367 -0
- calc_terminal/app.py +3685 -0
- calc_terminal/art.py +639 -0
- calc_terminal/atomsim.py +368 -0
- calc_terminal/attachments.py +743 -0
- calc_terminal/benchmark_system.py +414 -0
- calc_terminal/browser/__init__.py +36 -0
- calc_terminal/browser/browser_state.py +346 -0
- calc_terminal/browser/devserver.py +176 -0
- calc_terminal/browser/engine.py +494 -0
- calc_terminal/browser/navigation.py +84 -0
- calc_terminal/browser/preview.py +429 -0
- calc_terminal/browser/preview_entry.py +95 -0
- calc_terminal/browser/project_detector.py +144 -0
- calc_terminal/browser/server.py +449 -0
- calc_terminal/browser/state.py +75 -0
- calc_terminal/browser/watcher.py +99 -0
- calc_terminal/browser_gui/__init__.py +1 -0
- calc_terminal/browser_gui/__main__.py +3 -0
- calc_terminal/browser_gui/launcher.py +173 -0
- calc_terminal/browser_gui/playwright_browser.py +117 -0
- calc_terminal/browser_gui/qt_browser.py +1501 -0
- calc_terminal/browser_gui/webview_browser.py +57 -0
- calc_terminal/capabilities/__init__.py +35 -0
- calc_terminal/capabilities/adapters/__init__.py +33 -0
- calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
- calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
- calc_terminal/capabilities/adapters/filesystem.py +206 -0
- calc_terminal/capabilities/adapters/git_adapter.py +202 -0
- calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
- calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
- calc_terminal/capabilities/adapters/platforms.py +200 -0
- calc_terminal/capabilities/adapters/python_exec.py +93 -0
- calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
- calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
- calc_terminal/capabilities/adapters/structural_bio.py +161 -0
- calc_terminal/capabilities/adapters/terminal.py +99 -0
- calc_terminal/capabilities/bus.py +178 -0
- calc_terminal/capabilities/discovery.py +207 -0
- calc_terminal/capabilities/schema.py +221 -0
- calc_terminal/cat.ico +0 -0
- calc_terminal/cat_browser.py +2018 -0
- calc_terminal/chat_store.py +703 -0
- calc_terminal/cli.py +1178 -0
- calc_terminal/code_editor.py +640 -0
- calc_terminal/collaboration.py +723 -0
- calc_terminal/commands_data.py +139 -0
- calc_terminal/compatibility_engine.py +352 -0
- calc_terminal/compute/__init__.py +31 -0
- calc_terminal/compute/fabric.py +350 -0
- calc_terminal/config.py +227 -0
- calc_terminal/core/__init__.py +41 -0
- calc_terminal/core/checkpoint.py +156 -0
- calc_terminal/core/input/__init__.py +45 -0
- calc_terminal/core/mode_registry.py +300 -0
- calc_terminal/core/project_graph.py +172 -0
- calc_terminal/core/recovery.py +129 -0
- calc_terminal/core/security_layer.py +112 -0
- calc_terminal/core/task_graph.py +202 -0
- calc_terminal/core/unified_runtime.py +184 -0
- calc_terminal/core/verification.py +257 -0
- calc_terminal/customization.py +1566 -0
- calc_terminal/derivations.py +153 -0
- calc_terminal/device_control.py +263 -0
- calc_terminal/diagnostics/__init__.py +27 -0
- calc_terminal/diagnostics/doctor_engine.py +382 -0
- calc_terminal/diagnostics/self_test.py +247 -0
- calc_terminal/doctor.py +519 -0
- calc_terminal/easter_eggs.py +274 -0
- calc_terminal/editor/__init__.py +1 -0
- calc_terminal/editor/actions.py +263 -0
- calc_terminal/editor/commands.py +160 -0
- calc_terminal/editor/shortcuts.py +226 -0
- calc_terminal/engine.py +259 -0
- calc_terminal/errors.py +120 -0
- calc_terminal/event_stream.py +146 -0
- calc_terminal/eventbus.py +133 -0
- calc_terminal/extensions.py +733 -0
- calc_terminal/fallback_cli.py +1321 -0
- calc_terminal/first_run.py +265 -0
- calc_terminal/fomoji_auth.py +1043 -0
- calc_terminal/formulas.py +82 -0
- calc_terminal/fs_cache.py +121 -0
- calc_terminal/fs_watcher.py +277 -0
- calc_terminal/game.py +193 -0
- calc_terminal/gen1.py +5 -0
- calc_terminal/generators.py +245 -0
- calc_terminal/gestures/__init__.py +42 -0
- calc_terminal/gestures/bindings.py +175 -0
- calc_terminal/gestures/manager.py +477 -0
- calc_terminal/goodbye.py +363 -0
- calc_terminal/gpu3d.py +290 -0
- calc_terminal/graphs.py +358 -0
- calc_terminal/hardware_analyzer.py +440 -0
- calc_terminal/host/__init__.py +30 -0
- calc_terminal/host/browser_manager.py +187 -0
- calc_terminal/host/desktop.py +1386 -0
- calc_terminal/host/launcher.py +395 -0
- calc_terminal/host/terminal.py +279 -0
- calc_terminal/identity.py +216 -0
- calc_terminal/input/__init__.py +54 -0
- calc_terminal/input/capabilities.py +258 -0
- calc_terminal/input/focus.py +87 -0
- calc_terminal/input/gestures.py +64 -0
- calc_terminal/input/pointer.py +114 -0
- calc_terminal/input/touch.py +345 -0
- calc_terminal/keys.py +84 -0
- calc_terminal/live_automation.py +165 -0
- calc_terminal/mathtext.py +433 -0
- calc_terminal/mcp.py +386 -0
- calc_terminal/memory.py +337 -0
- calc_terminal/memory_v2.py +479 -0
- calc_terminal/metrics.py +333 -0
- calc_terminal/mode_detection.py +146 -0
- calc_terminal/model.py +2431 -0
- calc_terminal/model_router.py +665 -0
- calc_terminal/models/__init__.py +0 -0
- calc_terminal/models/active_state.py +187 -0
- calc_terminal/models/dynamic_registry.py +584 -0
- calc_terminal/models/manager.py +781 -0
- calc_terminal/models/model_metadata.json +3526 -0
- calc_terminal/models/profiles.py +194 -0
- calc_terminal/models/registry.py +265 -0
- calc_terminal/models/schema.py +197 -0
- calc_terminal/models/validator.py +287 -0
- calc_terminal/models/verification_engine.py +368 -0
- calc_terminal/native_picker.py +215 -0
- calc_terminal/ollama_catalog.py +279 -0
- calc_terminal/ollama_download.py +233 -0
- calc_terminal/orchestrator.py +304 -0
- calc_terminal/package_research.py +322 -0
- calc_terminal/packages.py +1024 -0
- calc_terminal/pc_specs.py +116 -0
- calc_terminal/permissions.py +334 -0
- calc_terminal/pet.py +106 -0
- calc_terminal/pipeline.py +505 -0
- calc_terminal/platform/__init__.py +491 -0
- calc_terminal/platform/desktop.py +491 -0
- calc_terminal/platform/web.py +781 -0
- calc_terminal/preview/__init__.py +1 -0
- calc_terminal/preview/dev_server.py +303 -0
- calc_terminal/preview/diagnostics.py +131 -0
- calc_terminal/preview/live_reload.py +66 -0
- calc_terminal/preview/manager.py +129 -0
- calc_terminal/project_stats.py +209 -0
- calc_terminal/projects.py +328 -0
- calc_terminal/providers/__init__.py +0 -0
- calc_terminal/providers/adapters/__init__.py +80 -0
- calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
- calc_terminal/providers/adapters/base.py +106 -0
- calc_terminal/providers/adapters/chinese_adapters.py +420 -0
- calc_terminal/providers/adapters/gemini_adapter.py +101 -0
- calc_terminal/providers/adapters/ollama_adapter.py +83 -0
- calc_terminal/providers/adapters/openai_adapter.py +159 -0
- calc_terminal/providers/adapters/other_adapters.py +246 -0
- calc_terminal/providers/anthropic_provider.py +172 -0
- calc_terminal/providers/auto_update.py +416 -0
- calc_terminal/providers/base_provider.py +105 -0
- calc_terminal/providers/discovery_manager.py +207 -0
- calc_terminal/providers/gemini_provider.py +178 -0
- calc_terminal/providers/lifecycle.py +767 -0
- calc_terminal/providers/ollama_adapter.py +707 -0
- calc_terminal/providers/openai_provider.py +254 -0
- calc_terminal/providers/provider_manager.py +1827 -0
- calc_terminal/providers/providers.json +4075 -0
- calc_terminal/reactionsim.py +279 -0
- calc_terminal/registry.py +337 -0
- calc_terminal/report.py +162 -0
- calc_terminal/research/__init__.py +45 -0
- calc_terminal/research/artifact_intel.py +126 -0
- calc_terminal/research/data_lineage.py +123 -0
- calc_terminal/research/experiment_ledger.py +303 -0
- calc_terminal/research/reproducibility.py +131 -0
- calc_terminal/resilience/__init__.py +47 -0
- calc_terminal/resilience/agent_state.py +121 -0
- calc_terminal/resilience/capability_matcher.py +174 -0
- calc_terminal/resilience/circuit_breaker.py +158 -0
- calc_terminal/resilience/failover_engine.py +230 -0
- calc_terminal/resilience/health_monitor.py +192 -0
- calc_terminal/resilience/ollama_adapter.py +125 -0
- calc_terminal/resilience/orchestrator.py +312 -0
- calc_terminal/resilience/types.py +134 -0
- calc_terminal/sandbox.py +98 -0
- calc_terminal/scires.py +558 -0
- calc_terminal/security_scanner.py +126 -0
- calc_terminal/session.py +294 -0
- calc_terminal/sim3d.py +206 -0
- calc_terminal/solver.py +276 -0
- calc_terminal/sound.py +127 -0
- calc_terminal/task_reports.py +287 -0
- calc_terminal/terminal_host.py +201 -0
- calc_terminal/terminal_identity.py +411 -0
- calc_terminal/test_ai_mode_reliability.py +344 -0
- calc_terminal/test_browser.py +368 -0
- calc_terminal/test_code_editor_upgrade.py +485 -0
- calc_terminal/test_customization.py +1148 -0
- calc_terminal/test_customization_ui.py +612 -0
- calc_terminal/test_dynamic_registry.py +304 -0
- calc_terminal/test_extensions.py +436 -0
- calc_terminal/test_overhaul.py +557 -0
- calc_terminal/test_project_detect.py +255 -0
- calc_terminal/test_root_cause_fix.py +527 -0
- calc_terminal/test_stability.py +532 -0
- calc_terminal/test_terminal_identity.py +132 -0
- calc_terminal/test_v079_speed.py +460 -0
- calc_terminal/theme.py +1107 -0
- calc_terminal/timeline.py +139 -0
- calc_terminal/todos.py +246 -0
- calc_terminal/tool_call_normalizer.py +419 -0
- calc_terminal/tui.py +104 -0
- calc_terminal/ui/__init__.py +8 -0
- calc_terminal/ui/activity_panel.py +231 -0
- calc_terminal/ui/activity_stream_panel.py +238 -0
- calc_terminal/ui/animations.py +122 -0
- calc_terminal/ui/app.py +7271 -0
- calc_terminal/ui/attach_panel.py +597 -0
- calc_terminal/ui/attachments.py +424 -0
- calc_terminal/ui/backup_panel.py +810 -0
- calc_terminal/ui/browser_shell.py +887 -0
- calc_terminal/ui/cat_agent.py +357 -0
- calc_terminal/ui/chats_panel.py +899 -0
- calc_terminal/ui/command_palette.py +125 -0
- calc_terminal/ui/command_palette_modal.py +166 -0
- calc_terminal/ui/composer.py +1141 -0
- calc_terminal/ui/context_menu.py +197 -0
- calc_terminal/ui/conversation.py +1435 -0
- calc_terminal/ui/customization_panel.py +1229 -0
- calc_terminal/ui/dashboard.py +404 -0
- calc_terminal/ui/design_system.py +557 -0
- calc_terminal/ui/diff_panel.py +213 -0
- calc_terminal/ui/editor.py +2102 -0
- calc_terminal/ui/empty_state.py +302 -0
- calc_terminal/ui/events.py +487 -0
- calc_terminal/ui/extensions_panel.py +815 -0
- calc_terminal/ui/footer.py +166 -0
- calc_terminal/ui/gestures_panel.py +383 -0
- calc_terminal/ui/goodbye_screen.py +100 -0
- calc_terminal/ui/header.py +1034 -0
- calc_terminal/ui/help_panel.py +254 -0
- calc_terminal/ui/live_activities.py +914 -0
- calc_terminal/ui/mcp_panel.py +570 -0
- calc_terminal/ui/memory_center.py +524 -0
- calc_terminal/ui/mode_colors_panel.py +525 -0
- calc_terminal/ui/nav_screens.py +747 -0
- calc_terminal/ui/ollama_panel.py +536 -0
- calc_terminal/ui/palette.py +221 -0
- calc_terminal/ui/permission_panel.py +269 -0
- calc_terminal/ui/personalization_panel.py +517 -0
- calc_terminal/ui/personalize_center.py +1568 -0
- calc_terminal/ui/preview_panel.py +441 -0
- calc_terminal/ui/resizers.py +402 -0
- calc_terminal/ui/sidebar.py +1285 -0
- calc_terminal/ui/statusbar.py +168 -0
- calc_terminal/ui/theme_css.py +1396 -0
- calc_terminal/ui/thinking.py +226 -0
- calc_terminal/ui/timeline_panel.py +102 -0
- calc_terminal/ui/todo_panel.py +193 -0
- calc_terminal/ui/viewport.py +136 -0
- calc_terminal/ui/vision_panel.py +489 -0
- calc_terminal/ui/welcome_modal.py +343 -0
- calc_terminal/ui/widgets.py +160 -0
- calc_terminal/ui/workspace.py +831 -0
- calc_terminal/viewers/__init__.py +1 -0
- calc_terminal/viewers/document_viewer.py +252 -0
- calc_terminal/viewers/image_viewer.py +241 -0
- calc_terminal/viewers/pdf_viewer.py +203 -0
- calc_terminal/viewers/presentation_viewer.py +164 -0
- calc_terminal/viewers/registry.py +120 -0
- calc_terminal/viewers/spreadsheet_viewer.py +204 -0
- calc_terminal/vision/__init__.py +89 -0
- calc_terminal/vision/analysis.py +194 -0
- calc_terminal/vision/annotations.py +297 -0
- calc_terminal/vision/capture.py +171 -0
- calc_terminal/vision/context.py +231 -0
- calc_terminal/vision/correlation.py +169 -0
- calc_terminal/vision/cursor.py +258 -0
- calc_terminal/vision/events.py +66 -0
- calc_terminal/vision/frame_pipeline.py +259 -0
- calc_terminal/vision/priority.py +218 -0
- calc_terminal/vision/provider.py +180 -0
- calc_terminal/vision/safety.py +149 -0
- calc_terminal/vision/session.py +281 -0
- calc_terminal/vision/verify.py +162 -0
- calc_terminal/vision.py +514 -0
- calc_terminal/vscode_integration.py +113 -0
- calc_terminal/web/__init__.py +8 -0
- calc_terminal/web/cat_runtime.py +710 -0
- calc_terminal/web/server.py +2891 -0
- calc_terminal/web/static/css/app.css +3152 -0
- calc_terminal/web/static/icons/badge-72.png +0 -0
- calc_terminal/web/static/icons/cat.ico +0 -0
- calc_terminal/web/static/icons/icon-128.png +0 -0
- calc_terminal/web/static/icons/icon-144.png +0 -0
- calc_terminal/web/static/icons/icon-152.png +0 -0
- calc_terminal/web/static/icons/icon-192.png +0 -0
- calc_terminal/web/static/icons/icon-384.png +0 -0
- calc_terminal/web/static/icons/icon-512.png +0 -0
- calc_terminal/web/static/icons/icon-72.png +0 -0
- calc_terminal/web/static/icons/icon-96.png +0 -0
- calc_terminal/web/static/icons/icon.svg +34 -0
- calc_terminal/web/static/icons/new-project.png +0 -0
- calc_terminal/web/static/icons/open-project.png +0 -0
- calc_terminal/web/static/index.html +734 -0
- calc_terminal/web/static/js/app.js +2403 -0
- calc_terminal/web/static/manifest.json +88 -0
- calc_terminal/web/static/sw.js +230 -0
- calc_terminal/workflow_engine.py +769 -0
- calc_terminal/workspace.py +593 -0
- calc_terminal/workspace_index.py +385 -0
- cct_cli-0.7.9.0.dist-info/METADATA +210 -0
- cct_cli-0.7.9.0.dist-info/RECORD +325 -0
- cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
- cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
- cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
- cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CAT — Backup Provider & Resilience System: Capability Matcher
|
|
3
|
+
Author: Kazi Zillani
|
|
4
|
+
|
|
5
|
+
Ensures backup selection is intelligent rather than sequential:
|
|
6
|
+
Compares Task Requirements + Provider Capabilities + Privacy Policy + Cost Policy
|
|
7
|
+
to select only backups that can actually perform the user's task.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from ..model_router import (
|
|
13
|
+
POLICY_BEST_AVAILABLE,
|
|
14
|
+
POLICY_CLOUD_FIRST,
|
|
15
|
+
POLICY_LOCAL_FIRST,
|
|
16
|
+
POLICY_NEVER_CLOUD,
|
|
17
|
+
ModelCapabilities,
|
|
18
|
+
_capabilities_for,
|
|
19
|
+
)
|
|
20
|
+
from .health_monitor import get_health_monitor
|
|
21
|
+
from .types import CandidateTarget, CostPolicy, SecurityLevel, TaskRequirements
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CapabilityMatcher:
|
|
25
|
+
"""Matches task requirements against candidate provider/model endpoints."""
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
self.health_monitor = get_health_monitor()
|
|
29
|
+
|
|
30
|
+
def is_local_provider(self, provider: str) -> bool:
|
|
31
|
+
return str(provider or "").strip().lower() in ("ollama", "local", "vllm", "llamacpp")
|
|
32
|
+
|
|
33
|
+
def is_free_provider(self, provider: str, model: str = "") -> bool:
|
|
34
|
+
p_low = str(provider or "").strip().lower()
|
|
35
|
+
if self.is_local_provider(p_low):
|
|
36
|
+
return True
|
|
37
|
+
# Free cloud providers/tiers
|
|
38
|
+
if p_low in ("groq", "cerebras", "gemini"):
|
|
39
|
+
return True
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
def evaluate_target(
|
|
43
|
+
self,
|
|
44
|
+
config: dict,
|
|
45
|
+
requirements: TaskRequirements,
|
|
46
|
+
privacy_policy: str = POLICY_LOCAL_FIRST,
|
|
47
|
+
cost_policy: CostPolicy = CostPolicy.ANY,
|
|
48
|
+
security_level: SecurityLevel = SecurityLevel.BALANCED,
|
|
49
|
+
entry: Optional[dict] = None,
|
|
50
|
+
is_backup: bool = True,
|
|
51
|
+
tier: int = 1,
|
|
52
|
+
) -> Optional[CandidateTarget]:
|
|
53
|
+
"""Verify eligibility and score a candidate target. Returns CandidateTarget or None."""
|
|
54
|
+
provider = str(config.get("provider") or "").strip().lower()
|
|
55
|
+
model = str(config.get("model") or "").strip()
|
|
56
|
+
if not provider or not model:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
is_local = self.is_local_provider(provider)
|
|
60
|
+
|
|
61
|
+
# 1. Privacy Policy Enforcement (ABSOLUTE RULE: User privacy overrides failover)
|
|
62
|
+
if privacy_policy == POLICY_NEVER_CLOUD and not is_local:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
# 2. Cost Policy Enforcement
|
|
66
|
+
if cost_policy == CostPolicy.LOCAL_ONLY and not is_local:
|
|
67
|
+
return None
|
|
68
|
+
if cost_policy == CostPolicy.FREE_ONLY and not self.is_free_provider(provider, model):
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
# 3. Credential Check (Ollama needs no key; cloud needs API key)
|
|
72
|
+
needs_key = config.get("needs_key", not is_local)
|
|
73
|
+
if is_local:
|
|
74
|
+
needs_key = False
|
|
75
|
+
api_key = str(config.get("api_key") or "").strip()
|
|
76
|
+
if needs_key and not api_key:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
# 4. Capability Check
|
|
80
|
+
caps = _capabilities_for(config, is_backup=is_backup)
|
|
81
|
+
|
|
82
|
+
# Vision requirement
|
|
83
|
+
if requirements.vision and not caps.vision:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
# Tools requirement (e.g. coding agent requires tool loop compatibility)
|
|
87
|
+
if requirements.tools:
|
|
88
|
+
# Base models that cannot follow instructions should be skipped
|
|
89
|
+
m_low = model.lower()
|
|
90
|
+
if any(b in m_low for b in ("-base", "_base", "base-q", ":base")):
|
|
91
|
+
return None
|
|
92
|
+
if not caps.tools:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
# Context Window requirement
|
|
96
|
+
if requirements.min_context > 0 and caps.context_window < requirements.min_context:
|
|
97
|
+
return None
|
|
98
|
+
if requirements.long_context and caps.context_window < 60000:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
# 5. Circuit Breaker & Health Check
|
|
102
|
+
health = self.health_monitor.get_health(provider, model)
|
|
103
|
+
if health.circuit_open and not self.health_monitor.is_healthy(provider, model):
|
|
104
|
+
# Barred by active cooldown
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
# 6. Scoring
|
|
108
|
+
score = self.calculate_score(caps, health, requirements, privacy_policy, is_local)
|
|
109
|
+
|
|
110
|
+
return CandidateTarget(
|
|
111
|
+
provider=provider,
|
|
112
|
+
model=model,
|
|
113
|
+
config=dict(config),
|
|
114
|
+
entry=entry,
|
|
115
|
+
is_backup=is_backup,
|
|
116
|
+
is_local=is_local,
|
|
117
|
+
score=score,
|
|
118
|
+
tier=tier,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def calculate_score(
|
|
122
|
+
self,
|
|
123
|
+
caps: ModelCapabilities,
|
|
124
|
+
health: Any,
|
|
125
|
+
requirements: TaskRequirements,
|
|
126
|
+
privacy_policy: str,
|
|
127
|
+
is_local: bool,
|
|
128
|
+
) -> float:
|
|
129
|
+
"""Dynamic routing score (0 to 100) based on observable CAT state."""
|
|
130
|
+
score = 50.0
|
|
131
|
+
|
|
132
|
+
# Capability fitness (up to +25)
|
|
133
|
+
if requirements.coding:
|
|
134
|
+
m_low = caps.model.lower()
|
|
135
|
+
if any(k in m_low for k in ("coder", "code", "qwen", "deepseek", "sonnet")):
|
|
136
|
+
score += 15.0
|
|
137
|
+
if requirements.reasoning and caps.reasoning:
|
|
138
|
+
score += 10.0
|
|
139
|
+
if requirements.vision and caps.vision:
|
|
140
|
+
score += 10.0
|
|
141
|
+
|
|
142
|
+
# Privacy fitness (up to +20)
|
|
143
|
+
if privacy_policy == POLICY_LOCAL_FIRST:
|
|
144
|
+
score += 20.0 if is_local else 0.0
|
|
145
|
+
elif privacy_policy == POLICY_CLOUD_FIRST:
|
|
146
|
+
score += 20.0 if not is_local else 5.0
|
|
147
|
+
elif privacy_policy == POLICY_BEST_AVAILABLE:
|
|
148
|
+
score += 10.0
|
|
149
|
+
|
|
150
|
+
# Health & Latency fitness (up to +20)
|
|
151
|
+
if health.status in ("healthy", "local"):
|
|
152
|
+
score += 15.0
|
|
153
|
+
elif health.status == "slow":
|
|
154
|
+
score += 5.0
|
|
155
|
+
if health.latency_ms:
|
|
156
|
+
if health.latency_ms < 1000:
|
|
157
|
+
score += 5.0
|
|
158
|
+
elif health.latency_ms > 4000:
|
|
159
|
+
score -= 10.0
|
|
160
|
+
|
|
161
|
+
# Consecutive failures penalty
|
|
162
|
+
score -= min(30.0, health.consecutive_failures * 10.0)
|
|
163
|
+
|
|
164
|
+
return max(0.0, min(100.0, score))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
_GLOBAL_MATCHER: Optional[CapabilityMatcher] = None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_capability_matcher() -> CapabilityMatcher:
|
|
171
|
+
global _GLOBAL_MATCHER
|
|
172
|
+
if _GLOBAL_MATCHER is None:
|
|
173
|
+
_GLOBAL_MATCHER = CapabilityMatcher()
|
|
174
|
+
return _GLOBAL_MATCHER
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CAT — Backup Provider & Resilience System: Circuit Breaker
|
|
3
|
+
Author: Kazi Zillani
|
|
4
|
+
|
|
5
|
+
Prevents CAT from wasting precious seconds or burning rate limits on broken,
|
|
6
|
+
unreachable, or rate-limited AI endpoints.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from typing import Dict, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CircuitBreakerState:
|
|
15
|
+
CLOSED = "closed" # Normal operation: requests pass through
|
|
16
|
+
OPEN = "open" # Tripped: requests fail immediately to prevent stalls
|
|
17
|
+
HALF_OPEN = "half_open" # Cooldown expired: single probe attempt allowed
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CircuitBreaker:
|
|
21
|
+
"""Per-endpoint circuit breaker guarding providers and models."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
failure_threshold: int = 3,
|
|
26
|
+
default_cooldown: float = 30.0,
|
|
27
|
+
rate_limit_cooldown: float = 60.0,
|
|
28
|
+
):
|
|
29
|
+
self.failure_threshold = max(1, failure_threshold)
|
|
30
|
+
self.default_cooldown = max(0.01, default_cooldown)
|
|
31
|
+
self.rate_limit_cooldown = max(0.01, rate_limit_cooldown)
|
|
32
|
+
self._lock = threading.Lock()
|
|
33
|
+
# (provider, model) -> dict
|
|
34
|
+
self._endpoints: Dict[Tuple[str, str], dict] = {}
|
|
35
|
+
|
|
36
|
+
def _key(self, provider: str, model: str) -> Tuple[str, str]:
|
|
37
|
+
return (str(provider or "").strip().lower(), str(model or "").strip().lower())
|
|
38
|
+
|
|
39
|
+
def _get_record(self, provider: str, model: str) -> dict:
|
|
40
|
+
k = self._key(provider, model)
|
|
41
|
+
if k not in self._endpoints:
|
|
42
|
+
self._endpoints[k] = {
|
|
43
|
+
"state": CircuitBreakerState.CLOSED,
|
|
44
|
+
"consecutive_failures": 0,
|
|
45
|
+
"cooldown_until": 0.0,
|
|
46
|
+
"last_failure_ts": 0.0,
|
|
47
|
+
"last_success_ts": 0.0,
|
|
48
|
+
"last_error": "",
|
|
49
|
+
"probe_in_flight": False,
|
|
50
|
+
}
|
|
51
|
+
return self._endpoints[k]
|
|
52
|
+
|
|
53
|
+
def can_attempt(self, provider: str, model: str) -> bool:
|
|
54
|
+
"""True if the endpoint is allowed to receive a request."""
|
|
55
|
+
with self._lock:
|
|
56
|
+
rec = self._get_record(provider, model)
|
|
57
|
+
now = time.time()
|
|
58
|
+
state = rec["state"]
|
|
59
|
+
|
|
60
|
+
if state == CircuitBreakerState.CLOSED:
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
if state == CircuitBreakerState.OPEN:
|
|
64
|
+
if now >= rec["cooldown_until"]:
|
|
65
|
+
# Cooldown elapsed -> transition to half_open for a probe
|
|
66
|
+
rec["state"] = CircuitBreakerState.HALF_OPEN
|
|
67
|
+
rec["probe_in_flight"] = True
|
|
68
|
+
return True
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
if state == CircuitBreakerState.HALF_OPEN:
|
|
72
|
+
# Only allow one probe at a time
|
|
73
|
+
if not rec["probe_in_flight"]:
|
|
74
|
+
rec["probe_in_flight"] = True
|
|
75
|
+
return True
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
def record_success(self, provider: str, model: str, latency_ms: Optional[float] = None) -> None:
|
|
81
|
+
"""Reset breaker to CLOSED upon a successful turn."""
|
|
82
|
+
with self._lock:
|
|
83
|
+
rec = self._get_record(provider, model)
|
|
84
|
+
rec["state"] = CircuitBreakerState.CLOSED
|
|
85
|
+
rec["consecutive_failures"] = 0
|
|
86
|
+
rec["cooldown_until"] = 0.0
|
|
87
|
+
rec["last_success_ts"] = time.time()
|
|
88
|
+
rec["probe_in_flight"] = False
|
|
89
|
+
|
|
90
|
+
def record_failure(
|
|
91
|
+
self,
|
|
92
|
+
provider: str,
|
|
93
|
+
model: str,
|
|
94
|
+
error: str = "",
|
|
95
|
+
is_rate_limit: bool = False,
|
|
96
|
+
cooldown_seconds: Optional[float] = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Increment failure counter and trip breaker if threshold exceeded."""
|
|
99
|
+
with self._lock:
|
|
100
|
+
rec = self._get_record(provider, model)
|
|
101
|
+
rec["consecutive_failures"] += 1
|
|
102
|
+
now = time.time()
|
|
103
|
+
rec["last_failure_ts"] = now
|
|
104
|
+
rec["last_error"] = str(error)[:160]
|
|
105
|
+
rec["probe_in_flight"] = False
|
|
106
|
+
|
|
107
|
+
cooldown = cooldown_seconds or (
|
|
108
|
+
self.rate_limit_cooldown if is_rate_limit else self.default_cooldown
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# In HALF_OPEN, any failure immediately trips back to OPEN with full cooldown
|
|
112
|
+
if rec["state"] == CircuitBreakerState.HALF_OPEN:
|
|
113
|
+
rec["state"] = CircuitBreakerState.OPEN
|
|
114
|
+
rec["cooldown_until"] = now + cooldown
|
|
115
|
+
elif is_rate_limit or rec["consecutive_failures"] >= self.failure_threshold:
|
|
116
|
+
rec["state"] = CircuitBreakerState.OPEN
|
|
117
|
+
rec["cooldown_until"] = now + cooldown
|
|
118
|
+
|
|
119
|
+
def get_state(self, provider: str, model: str) -> str:
|
|
120
|
+
"""Return current breaker state string."""
|
|
121
|
+
with self._lock:
|
|
122
|
+
rec = self._get_record(provider, model)
|
|
123
|
+
now = time.time()
|
|
124
|
+
if rec["state"] == CircuitBreakerState.OPEN and now >= rec["cooldown_until"]:
|
|
125
|
+
return CircuitBreakerState.HALF_OPEN
|
|
126
|
+
return rec["state"]
|
|
127
|
+
|
|
128
|
+
def is_in_cooldown(self, provider: str, model: str) -> bool:
|
|
129
|
+
"""True if the endpoint is strictly barred by an active cooldown."""
|
|
130
|
+
with self._lock:
|
|
131
|
+
rec = self._get_record(provider, model)
|
|
132
|
+
return rec["state"] == CircuitBreakerState.OPEN and time.time() < rec["cooldown_until"]
|
|
133
|
+
|
|
134
|
+
def reset(self, provider: str, model: str) -> None:
|
|
135
|
+
with self._lock:
|
|
136
|
+
rec = self._get_record(provider, model)
|
|
137
|
+
rec["state"] = CircuitBreakerState.CLOSED
|
|
138
|
+
rec["consecutive_failures"] = 0
|
|
139
|
+
rec["cooldown_until"] = 0.0
|
|
140
|
+
rec["probe_in_flight"] = False
|
|
141
|
+
|
|
142
|
+
def reset_all(self) -> None:
|
|
143
|
+
with self._lock:
|
|
144
|
+
for rec in self._endpoints.values():
|
|
145
|
+
rec["state"] = CircuitBreakerState.CLOSED
|
|
146
|
+
rec["consecutive_failures"] = 0
|
|
147
|
+
rec["cooldown_until"] = 0.0
|
|
148
|
+
rec["probe_in_flight"] = False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
_GLOBAL_BREAKER: Optional[CircuitBreaker] = None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_circuit_breaker() -> CircuitBreaker:
|
|
155
|
+
global _GLOBAL_BREAKER
|
|
156
|
+
if _GLOBAL_BREAKER is None:
|
|
157
|
+
_GLOBAL_BREAKER = CircuitBreaker()
|
|
158
|
+
return _GLOBAL_BREAKER
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CAT — Backup Provider & Resilience System: Failover Engine
|
|
3
|
+
Author: Kazi Zillani
|
|
4
|
+
|
|
5
|
+
Handles intelligent error classification and two-tier failover orchestration:
|
|
6
|
+
- Tier 1: Alternate fallback models within the SAME provider.
|
|
7
|
+
- Tier 2: Priority-ordered backup providers from the dynamic backup pool.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from .capability_matcher import get_capability_matcher
|
|
16
|
+
from .health_monitor import get_health_monitor, sanitize_error
|
|
17
|
+
from .types import CandidateTarget, CostPolicy, FailureType, SecurityLevel, TaskRequirements
|
|
18
|
+
|
|
19
|
+
_LOG = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Error pattern classifiers
|
|
22
|
+
_RATE_LIMIT_HINTS = (
|
|
23
|
+
"rate limit", "rate_limit", "429", "too many requests", "resource has been exhausted",
|
|
24
|
+
"insufficient_quota", "quota exceeded", "exceeded your current quota", "tokens per minute",
|
|
25
|
+
"requests per minute", "tpm limit", "rpm limit",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_AUTH_HINTS = (
|
|
29
|
+
"401", "403", "unauthorized", "api key", "invalid api key", "authentication failed",
|
|
30
|
+
"forbidden", "permission denied", "invalid token", "bad api key",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_MODEL_UNAVAILABLE_HINTS = (
|
|
34
|
+
"model not found", "does not exist", "model unavailable", "not loaded", "no such model",
|
|
35
|
+
"404", "model_not_found", "unknown model", "deprecated",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_CONTEXT_HINTS = (
|
|
39
|
+
"maximum context length", "context length exceeded", "too many tokens",
|
|
40
|
+
"token limit", "prompt is too long", "exceeds maximum context",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
_TRANSIENT_HINTS = (
|
|
44
|
+
"timed out", "timeout", "connection reset", "connection refused", "connection aborted",
|
|
45
|
+
"broken pipe", "server disconnected", "502", "503", "504", "bad gateway",
|
|
46
|
+
"service unavailable", "remote end closed", "incomplete read",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
_SERVER_ERROR_HINTS = (
|
|
50
|
+
"500", "internal server error", "server error", "backend error",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FailoverEngine:
|
|
55
|
+
"""Classifies provider failures and generates optimal two-tier candidate targets."""
|
|
56
|
+
|
|
57
|
+
def __init__(self):
|
|
58
|
+
self.matcher = get_capability_matcher()
|
|
59
|
+
self.health_monitor = get_health_monitor()
|
|
60
|
+
|
|
61
|
+
def classify_failure(self, error_text: str, status_code: Optional[int] = None) -> FailureType:
|
|
62
|
+
"""Classify raw error message or HTTP response into standardized FailureType."""
|
|
63
|
+
if not error_text and not status_code:
|
|
64
|
+
return FailureType.UNKNOWN
|
|
65
|
+
|
|
66
|
+
code = status_code or 0
|
|
67
|
+
if code == 429:
|
|
68
|
+
return FailureType.RATE_LIMIT
|
|
69
|
+
if code in (401, 403):
|
|
70
|
+
return FailureType.AUTH_FAILURE
|
|
71
|
+
if code == 404:
|
|
72
|
+
return FailureType.MODEL_UNAVAILABLE
|
|
73
|
+
if code in (502, 503, 504):
|
|
74
|
+
return FailureType.TRANSIENT
|
|
75
|
+
if code == 500:
|
|
76
|
+
return FailureType.SERVER_ERROR
|
|
77
|
+
|
|
78
|
+
low = (error_text or "").lower()
|
|
79
|
+
|
|
80
|
+
if any(h in low for h in _RATE_LIMIT_HINTS):
|
|
81
|
+
return FailureType.RATE_LIMIT
|
|
82
|
+
if any(h in low for h in _AUTH_HINTS):
|
|
83
|
+
return FailureType.AUTH_FAILURE
|
|
84
|
+
if any(h in low for h in _MODEL_UNAVAILABLE_HINTS):
|
|
85
|
+
return FailureType.MODEL_UNAVAILABLE
|
|
86
|
+
if any(h in low for h in _CONTEXT_HINTS):
|
|
87
|
+
return FailureType.CONTEXT_TOO_LARGE
|
|
88
|
+
if any(h in low for h in _TRANSIENT_HINTS):
|
|
89
|
+
return FailureType.TRANSIENT
|
|
90
|
+
if any(h in low for h in _SERVER_ERROR_HINTS):
|
|
91
|
+
return FailureType.SERVER_ERROR
|
|
92
|
+
|
|
93
|
+
return FailureType.UNKNOWN
|
|
94
|
+
|
|
95
|
+
def is_retryable(self, failure_type: FailureType) -> bool:
|
|
96
|
+
"""True if failure is transient and warrants an immediate backoff retry on same model."""
|
|
97
|
+
return failure_type in (FailureType.TRANSIENT, FailureType.TIMEOUT)
|
|
98
|
+
|
|
99
|
+
def should_failover(self, result_text: str) -> bool:
|
|
100
|
+
"""True if the assistant response represents an error needing failover."""
|
|
101
|
+
if not result_text or not isinstance(result_text, str):
|
|
102
|
+
return True
|
|
103
|
+
if not result_text.strip():
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
from ..aicore import is_error_response
|
|
107
|
+
if is_error_response(result_text):
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
ftype = self.classify_failure(result_text)
|
|
111
|
+
return ftype != FailureType.UNKNOWN
|
|
112
|
+
|
|
113
|
+
def get_provider_fallback_models(self, provider_id: str) -> List[str]:
|
|
114
|
+
"""Fetch alternative models configured for the given provider.
|
|
115
|
+
For local providers like Ollama, dynamically checks actually-installed
|
|
116
|
+
models so non-existent catalog entries never cause failover loops."""
|
|
117
|
+
try:
|
|
118
|
+
pid = str(provider_id or "").strip().lower()
|
|
119
|
+
if pid == "ollama":
|
|
120
|
+
try:
|
|
121
|
+
from ..providers.adapters.ollama_adapter import OllamaAdapter
|
|
122
|
+
adapter = OllamaAdapter()
|
|
123
|
+
live = adapter.discover_models(timeout=1.5)
|
|
124
|
+
if live:
|
|
125
|
+
installed = []
|
|
126
|
+
for m in live:
|
|
127
|
+
mid = str(getattr(m, "model_id", "") or "").strip()
|
|
128
|
+
if mid and mid not in installed:
|
|
129
|
+
installed.append(mid)
|
|
130
|
+
if installed:
|
|
131
|
+
return installed
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
from ..models.manager import get_provider
|
|
135
|
+
prov = get_provider(provider_id)
|
|
136
|
+
if prov and prov.get("fallback_models"):
|
|
137
|
+
return [str(m).strip() for m in prov["fallback_models"] if str(m).strip()]
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
return []
|
|
141
|
+
|
|
142
|
+
def build_failover_targets(
|
|
143
|
+
self,
|
|
144
|
+
primary_config: dict,
|
|
145
|
+
requirements: Optional[TaskRequirements] = None,
|
|
146
|
+
privacy_policy: str = "local_first",
|
|
147
|
+
cost_policy: CostPolicy = CostPolicy.ANY,
|
|
148
|
+
security_level: SecurityLevel = SecurityLevel.BALANCED,
|
|
149
|
+
) -> List[CandidateTarget]:
|
|
150
|
+
"""Build the complete, two-tier failover walk:
|
|
151
|
+
Tier 1: Primary provider (active model, then alternative models on same provider).
|
|
152
|
+
Tier 2: Backup pool providers in configured priority order.
|
|
153
|
+
"""
|
|
154
|
+
req = requirements or TaskRequirements()
|
|
155
|
+
targets: List[CandidateTarget] = []
|
|
156
|
+
seen_endpoints = set()
|
|
157
|
+
|
|
158
|
+
def _add_target(cfg: dict, entry: Optional[dict], is_backup: bool, tier: int):
|
|
159
|
+
prov = str(cfg.get("provider") or "").strip().lower()
|
|
160
|
+
model = str(cfg.get("model") or "").strip()
|
|
161
|
+
if not prov or not model:
|
|
162
|
+
return
|
|
163
|
+
key = (prov, model)
|
|
164
|
+
if key in seen_endpoints:
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
candidate = self.matcher.evaluate_target(
|
|
168
|
+
config=cfg,
|
|
169
|
+
requirements=req,
|
|
170
|
+
privacy_policy=privacy_policy,
|
|
171
|
+
cost_policy=cost_policy,
|
|
172
|
+
security_level=security_level,
|
|
173
|
+
entry=entry,
|
|
174
|
+
is_backup=is_backup,
|
|
175
|
+
tier=tier,
|
|
176
|
+
)
|
|
177
|
+
if candidate:
|
|
178
|
+
targets.append(candidate)
|
|
179
|
+
seen_endpoints.add(key)
|
|
180
|
+
|
|
181
|
+
# 1. Primary endpoint
|
|
182
|
+
prim = dict(primary_config or {})
|
|
183
|
+
prim_prov = str(prim.get("provider") or "").strip().lower()
|
|
184
|
+
if prim_prov:
|
|
185
|
+
# Ensure API key resolved
|
|
186
|
+
if not (prim.get("api_key") or "").strip():
|
|
187
|
+
try:
|
|
188
|
+
from ..providers.provider_manager import get_env_api_key
|
|
189
|
+
k = get_env_api_key(prim_prov)
|
|
190
|
+
if k:
|
|
191
|
+
prim["api_key"] = k
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
_add_target(prim, entry=None, is_backup=False, tier=1)
|
|
195
|
+
|
|
196
|
+
# Tier 1 Fallback Models on the SAME provider (capped to at most 2 to avoid cascading hangs)
|
|
197
|
+
fb_models = self.get_provider_fallback_models(prim_prov)
|
|
198
|
+
for fb_model in fb_models[:2]:
|
|
199
|
+
if fb_model.lower() != str(prim.get("model", "")).lower():
|
|
200
|
+
tier1_cfg = dict(prim)
|
|
201
|
+
tier1_cfg["model"] = fb_model
|
|
202
|
+
_add_target(tier1_cfg, entry=None, is_backup=False, tier=1)
|
|
203
|
+
|
|
204
|
+
# 2. Tier 2: Dynamic Backup Pool
|
|
205
|
+
try:
|
|
206
|
+
from ..providers.provider_manager import backup_configs
|
|
207
|
+
for bcfg, entry in backup_configs():
|
|
208
|
+
_add_target(bcfg, entry=entry, is_backup=True, tier=2)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
_LOG.debug("Error loading backup configs: %s", e)
|
|
211
|
+
|
|
212
|
+
# If strict security, only allow explicitly configured backup providers
|
|
213
|
+
if security_level == SecurityLevel.STRICT:
|
|
214
|
+
targets = [t for t in targets if not t.is_backup or (t.entry and t.entry.get("enabled"))]
|
|
215
|
+
|
|
216
|
+
# Prioritize fresh endpoints over endpoints recently in cooldown
|
|
217
|
+
fresh = [t for t in targets if self.health_monitor.is_healthy(t.provider, t.model)]
|
|
218
|
+
demoted = [t for t in targets if t not in fresh]
|
|
219
|
+
|
|
220
|
+
return fresh + demoted
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
_GLOBAL_ENGINE: Optional[FailoverEngine] = None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def get_failover_engine() -> FailoverEngine:
|
|
227
|
+
global _GLOBAL_ENGINE
|
|
228
|
+
if _GLOBAL_ENGINE is None:
|
|
229
|
+
_GLOBAL_ENGINE = FailoverEngine()
|
|
230
|
+
return _GLOBAL_ENGINE
|