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,192 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CAT — Backup Provider & Resilience System: Health Monitor
|
|
3
|
+
Author: Kazi Zillani
|
|
4
|
+
|
|
5
|
+
Lightweight, real-time provider and model health tracking based on actual
|
|
6
|
+
CAT observations. Strictly guarantees ZERO credential exposure.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from .circuit_breaker import CircuitBreakerState, get_circuit_breaker
|
|
15
|
+
from .types import ProviderHealthState
|
|
16
|
+
|
|
17
|
+
_KEY_CLEAN_RE = re.compile(r"(sk-[a-zA-Z0-9_\-]{8,}|gsk_[a-zA-Z0-9_\-]{8,}|AIzaSy[a-zA-Z0-9_\-]{8,}|[a-f0-9]{32,})", re.I)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sanitize_error(text: str) -> str:
|
|
21
|
+
"""Mask any API keys, credentials, or auth tokens from error strings."""
|
|
22
|
+
if not text or not isinstance(text, str):
|
|
23
|
+
return ""
|
|
24
|
+
cleaned = _KEY_CLEAN_RE.sub("[CREDENTIAL_REDACTED]", text)
|
|
25
|
+
# Remove authorization headers or token params if present
|
|
26
|
+
cleaned = re.sub(
|
|
27
|
+
r"((?:authorization[:=]\s*)?bearer\s+|api[_\-\s]?key[:=\s]+|authorization[:=]\s*)['\"]?[a-zA-Z0-9_\.\-]+['\"]?",
|
|
28
|
+
r"\1[REDACTED]",
|
|
29
|
+
cleaned,
|
|
30
|
+
flags=re.I,
|
|
31
|
+
)
|
|
32
|
+
return cleaned[:200]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class HealthMonitor:
|
|
36
|
+
"""Central registry of observable AI health state across CAT."""
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self._lock = threading.Lock()
|
|
40
|
+
# (provider, model) -> ProviderHealthState
|
|
41
|
+
self._states: Dict[Tuple[str, str], ProviderHealthState] = {}
|
|
42
|
+
self._breaker = get_circuit_breaker()
|
|
43
|
+
|
|
44
|
+
def _key(self, provider: str, model: str) -> Tuple[str, str]:
|
|
45
|
+
return (str(provider or "").strip().lower(), str(model or "").strip().lower())
|
|
46
|
+
|
|
47
|
+
def record_turn_start(self, provider: str, model: str) -> None:
|
|
48
|
+
"""Mark turn attempt initiation."""
|
|
49
|
+
with self._lock:
|
|
50
|
+
k = self._key(provider, model)
|
|
51
|
+
if k not in self._states:
|
|
52
|
+
self._states[k] = ProviderHealthState(provider=provider, model=model)
|
|
53
|
+
self._states[k].last_checked = time.time()
|
|
54
|
+
|
|
55
|
+
def record_success(self, provider: str, model: str, latency_ms: Optional[float] = None) -> None:
|
|
56
|
+
"""Record successful inference turn."""
|
|
57
|
+
p_low = str(provider or "").strip().lower()
|
|
58
|
+
is_local = p_low in ("ollama", "local", "vllm")
|
|
59
|
+
now = time.time()
|
|
60
|
+
|
|
61
|
+
with self._lock:
|
|
62
|
+
k = self._key(provider, model)
|
|
63
|
+
state = self._states.get(k)
|
|
64
|
+
if not state:
|
|
65
|
+
state = ProviderHealthState(provider=provider, model=model)
|
|
66
|
+
self._states[k] = state
|
|
67
|
+
|
|
68
|
+
state.last_success = now
|
|
69
|
+
state.last_checked = now
|
|
70
|
+
state.consecutive_failures = 0
|
|
71
|
+
state.success_count += 1
|
|
72
|
+
state.circuit_open = False
|
|
73
|
+
state.cooldown_until = 0.0
|
|
74
|
+
|
|
75
|
+
if latency_ms is not None:
|
|
76
|
+
if state.latency_ms is None:
|
|
77
|
+
state.latency_ms = latency_ms
|
|
78
|
+
else:
|
|
79
|
+
# Exponential smoothing
|
|
80
|
+
state.latency_ms = round(0.7 * latency_ms + 0.3 * state.latency_ms, 1)
|
|
81
|
+
|
|
82
|
+
if is_local:
|
|
83
|
+
state.status = "local"
|
|
84
|
+
elif state.latency_ms and state.latency_ms > 3500.0:
|
|
85
|
+
state.status = "slow"
|
|
86
|
+
else:
|
|
87
|
+
state.status = "healthy"
|
|
88
|
+
|
|
89
|
+
self._breaker.record_success(provider, model, latency_ms)
|
|
90
|
+
|
|
91
|
+
def record_failure(
|
|
92
|
+
self,
|
|
93
|
+
provider: str,
|
|
94
|
+
model: str,
|
|
95
|
+
error_message: str = "",
|
|
96
|
+
is_rate_limit: bool = False,
|
|
97
|
+
cooldown_seconds: Optional[float] = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Record a failure without leaking any credential details."""
|
|
100
|
+
now = time.time()
|
|
101
|
+
safe_msg = sanitize_error(error_message)
|
|
102
|
+
|
|
103
|
+
with self._lock:
|
|
104
|
+
k = self._key(provider, model)
|
|
105
|
+
state = self._states.get(k)
|
|
106
|
+
if not state:
|
|
107
|
+
state = ProviderHealthState(provider=provider, model=model)
|
|
108
|
+
self._states[k] = state
|
|
109
|
+
|
|
110
|
+
state.last_checked = now
|
|
111
|
+
state.consecutive_failures += 1
|
|
112
|
+
state.failure_count += 1
|
|
113
|
+
state.last_error = safe_msg
|
|
114
|
+
|
|
115
|
+
if is_rate_limit:
|
|
116
|
+
state.status = "rate_limited"
|
|
117
|
+
cd = cooldown_seconds or 60.0
|
|
118
|
+
state.cooldown_until = now + cd
|
|
119
|
+
state.circuit_open = True
|
|
120
|
+
else:
|
|
121
|
+
if state.consecutive_failures >= self._breaker.failure_threshold:
|
|
122
|
+
state.status = "offline"
|
|
123
|
+
cd = cooldown_seconds or self._breaker.default_cooldown
|
|
124
|
+
state.cooldown_until = now + cd
|
|
125
|
+
state.circuit_open = True
|
|
126
|
+
else:
|
|
127
|
+
state.status = "slow"
|
|
128
|
+
|
|
129
|
+
self._breaker.record_failure(
|
|
130
|
+
provider,
|
|
131
|
+
model,
|
|
132
|
+
error=safe_msg,
|
|
133
|
+
is_rate_limit=is_rate_limit,
|
|
134
|
+
cooldown_seconds=cooldown_seconds,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def get_health(self, provider: str, model: str) -> ProviderHealthState:
|
|
138
|
+
"""Retrieve the health record for a provider/model."""
|
|
139
|
+
with self._lock:
|
|
140
|
+
k = self._key(provider, model)
|
|
141
|
+
state = self._states.get(k)
|
|
142
|
+
if not state:
|
|
143
|
+
p_low = str(provider or "").strip().lower()
|
|
144
|
+
is_local = p_low in ("ollama", "local", "vllm")
|
|
145
|
+
state = ProviderHealthState(
|
|
146
|
+
provider=provider,
|
|
147
|
+
model=model,
|
|
148
|
+
status="local" if is_local else "disconnected",
|
|
149
|
+
)
|
|
150
|
+
self._states[k] = state
|
|
151
|
+
# Synchronize breaker status
|
|
152
|
+
breaker_state = self._breaker.get_state(provider, model)
|
|
153
|
+
state.circuit_open = breaker_state in (CircuitBreakerState.OPEN,)
|
|
154
|
+
return state
|
|
155
|
+
|
|
156
|
+
def is_healthy(self, provider: str, model: str) -> bool:
|
|
157
|
+
"""Check if an endpoint is usable."""
|
|
158
|
+
if not self._breaker.can_attempt(provider, model):
|
|
159
|
+
return False
|
|
160
|
+
st = self.get_health(provider, model)
|
|
161
|
+
return st.status not in ("offline", "rate_limited") or not st.circuit_open
|
|
162
|
+
|
|
163
|
+
def all_states(self) -> List[ProviderHealthState]:
|
|
164
|
+
"""Return a snapshot list of all tracked health records."""
|
|
165
|
+
with self._lock:
|
|
166
|
+
return list(self._states.values())
|
|
167
|
+
|
|
168
|
+
def get_status_badge(self, provider: str, model: str) -> Tuple[str, str]:
|
|
169
|
+
"""Return (glyph, label) representation (e.g. ('🟢', 'Healthy'))."""
|
|
170
|
+
st = self.get_health(provider, model)
|
|
171
|
+
status = st.status
|
|
172
|
+
if status == "local":
|
|
173
|
+
return ("🟢", "Local")
|
|
174
|
+
elif status == "healthy":
|
|
175
|
+
return ("🟢", "Healthy")
|
|
176
|
+
elif status == "slow":
|
|
177
|
+
return ("🟡", "Slow")
|
|
178
|
+
elif status == "rate_limited":
|
|
179
|
+
return ("🟠", "Rate Limited")
|
|
180
|
+
elif status == "offline":
|
|
181
|
+
return ("🔴", "In Cooldown")
|
|
182
|
+
return ("⚫", "Disconnected")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
_GLOBAL_MONITOR: Optional[HealthMonitor] = None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def get_health_monitor() -> HealthMonitor:
|
|
189
|
+
global _GLOBAL_MONITOR
|
|
190
|
+
if _GLOBAL_MONITOR is None:
|
|
191
|
+
_GLOBAL_MONITOR = HealthMonitor()
|
|
192
|
+
return _GLOBAL_MONITOR
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CAT — Backup Provider & Resilience System: Native Ollama Local Adapter
|
|
3
|
+
Author: Kazi Zillani
|
|
4
|
+
|
|
5
|
+
First-class native integration for local Ollama fallback.
|
|
6
|
+
Requires:
|
|
7
|
+
- Zero API keys
|
|
8
|
+
- Zero cloud endpoints
|
|
9
|
+
- Automatic discovery of local models via /api/tags
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
_LOG = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
DEFAULT_OLLAMA_URL = "http://localhost:11434"
|
|
21
|
+
|
|
22
|
+
# Quality instruct model heuristics
|
|
23
|
+
INSTRUCT_PREFERENCES = [
|
|
24
|
+
"qwen2.5-coder", "qwen3", "qwen2.5", "llama3.3", "llama3.2",
|
|
25
|
+
"llama3.1", "deepseek-r1", "deepseek-coder", "codellama",
|
|
26
|
+
"mistral", "gemma2", "phi3", "phi4", "starcoder",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OllamaAdapter:
|
|
31
|
+
"""Zero-config discovery and management for local Ollama backup."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, base_url: str = DEFAULT_OLLAMA_URL):
|
|
34
|
+
self.base_url = (base_url or DEFAULT_OLLAMA_URL).rstrip("/")
|
|
35
|
+
self._cached_models: Optional[List[dict]] = None
|
|
36
|
+
self._last_checked = 0.0
|
|
37
|
+
|
|
38
|
+
def is_available(self, timeout: float = 1.5) -> bool:
|
|
39
|
+
"""Check if local Ollama daemon is reachable."""
|
|
40
|
+
try:
|
|
41
|
+
import requests
|
|
42
|
+
resp = requests.get(f"{self.base_url}/api/tags", timeout=timeout)
|
|
43
|
+
return resp.status_code == 200
|
|
44
|
+
except Exception:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
def list_installed_models(self, force_refresh: bool = False) -> List[dict]:
|
|
48
|
+
"""Fetch models currently pulled on the local machine."""
|
|
49
|
+
now = time.time()
|
|
50
|
+
if not force_refresh and self._cached_models is not None and (now - self._last_checked) < 30.0:
|
|
51
|
+
return self._cached_models
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
import requests
|
|
55
|
+
resp = requests.get(f"{self.base_url}/api/tags", timeout=2.5)
|
|
56
|
+
if resp.status_code == 200:
|
|
57
|
+
data = resp.json()
|
|
58
|
+
models = data.get("models", [])
|
|
59
|
+
self._cached_models = models
|
|
60
|
+
self._last_checked = now
|
|
61
|
+
return models
|
|
62
|
+
except Exception as e:
|
|
63
|
+
_LOG.debug("Ollama discovery failed: %s", e)
|
|
64
|
+
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
def get_model_names(self) -> List[str]:
|
|
68
|
+
return [str(m.get("name", "")).strip() for m in self.list_installed_models() if m.get("name")]
|
|
69
|
+
|
|
70
|
+
def select_best_model(self, coding: bool = False, reasoning: bool = False) -> Optional[str]:
|
|
71
|
+
"""Pick the best available instruct model installed locally."""
|
|
72
|
+
names = self.get_model_names()
|
|
73
|
+
if not names:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
# Filter out raw non-instruct base models
|
|
77
|
+
non_base = [
|
|
78
|
+
n for n in names
|
|
79
|
+
if not any(b in n.lower() for b in ("-base", "_base", "base-q", ":base"))
|
|
80
|
+
]
|
|
81
|
+
candidates = non_base if non_base else names
|
|
82
|
+
|
|
83
|
+
if coding:
|
|
84
|
+
for pref in ("coder", "code", "qwen", "deepseek"):
|
|
85
|
+
for m in candidates:
|
|
86
|
+
if pref in m.lower():
|
|
87
|
+
return m
|
|
88
|
+
|
|
89
|
+
if reasoning:
|
|
90
|
+
for m in candidates:
|
|
91
|
+
if "r1" in m.lower() or "reasoning" in m.lower():
|
|
92
|
+
return m
|
|
93
|
+
|
|
94
|
+
for pref in INSTRUCT_PREFERENCES:
|
|
95
|
+
for m in candidates:
|
|
96
|
+
if pref in m.lower():
|
|
97
|
+
return m
|
|
98
|
+
|
|
99
|
+
return candidates[0]
|
|
100
|
+
|
|
101
|
+
def build_backup_entry(self, model: Optional[str] = None) -> dict:
|
|
102
|
+
"""Create a standard backup configuration entry for Ollama."""
|
|
103
|
+
selected_model = model or self.select_best_model() or "llama3.3"
|
|
104
|
+
return {
|
|
105
|
+
"provider": "ollama",
|
|
106
|
+
"model": selected_model,
|
|
107
|
+
"api_key": "",
|
|
108
|
+
"base_url": self.base_url,
|
|
109
|
+
"api_style": "ollama",
|
|
110
|
+
"name": "Ollama (Local Backup)",
|
|
111
|
+
"enabled": True,
|
|
112
|
+
"status": "connected" if self.is_available() else "disconnected",
|
|
113
|
+
"last_used": 0,
|
|
114
|
+
"priority": 999, # Default to final local safety net
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_GLOBAL_OLLAMA: Optional[OllamaAdapter] = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_ollama_adapter() -> OllamaAdapter:
|
|
122
|
+
global _GLOBAL_OLLAMA
|
|
123
|
+
if _GLOBAL_OLLAMA is None:
|
|
124
|
+
_GLOBAL_OLLAMA = OllamaAdapter()
|
|
125
|
+
return _GLOBAL_OLLAMA
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CAT — Backup Provider & Resilience System: Unified AI Orchestrator
|
|
3
|
+
Author: Kazi Zillani
|
|
4
|
+
|
|
5
|
+
The central orchestrator coordinating AI provider access across CAT's entire
|
|
6
|
+
architecture: Chat, Coding Agent, IDE Agent, Tools, Browser Agent, and Live Preview.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Callable, Dict, Generator, List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from .agent_state import AgentTaskState
|
|
14
|
+
from .circuit_breaker import get_circuit_breaker
|
|
15
|
+
from .failover_engine import get_failover_engine
|
|
16
|
+
from .health_monitor import get_health_monitor, sanitize_error
|
|
17
|
+
from .ollama_adapter import get_ollama_adapter
|
|
18
|
+
from .types import CandidateTarget, CostPolicy, FailureType, SecurityLevel, TaskRequirements
|
|
19
|
+
|
|
20
|
+
_LOG = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AIOrchestrator:
|
|
24
|
+
"""Master AI Orchestration & Resilience Core for CAT."""
|
|
25
|
+
|
|
26
|
+
def __init__(self):
|
|
27
|
+
self.engine = get_failover_engine()
|
|
28
|
+
self.health_monitor = get_health_monitor()
|
|
29
|
+
self.circuit_breaker = get_circuit_breaker()
|
|
30
|
+
self.ollama = get_ollama_adapter()
|
|
31
|
+
self.security_level = SecurityLevel.BALANCED
|
|
32
|
+
self.cost_policy = CostPolicy.ANY
|
|
33
|
+
self._failover_callbacks: List[Callable[[str], None]] = []
|
|
34
|
+
|
|
35
|
+
def add_failover_listener(self, callback: Callable[[str], None]) -> None:
|
|
36
|
+
"""Register a callback to receive user-facing failover announcements."""
|
|
37
|
+
if callback not in self._failover_callbacks:
|
|
38
|
+
self._failover_callbacks.append(callback)
|
|
39
|
+
|
|
40
|
+
def remove_failover_listener(self, callback: Callable[[str], None]) -> None:
|
|
41
|
+
if callback in self._failover_callbacks:
|
|
42
|
+
self._failover_callbacks.remove(callback)
|
|
43
|
+
|
|
44
|
+
def notify_failover(self, message: str) -> None:
|
|
45
|
+
for cb in self._failover_callbacks:
|
|
46
|
+
try:
|
|
47
|
+
cb(message)
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def get_privacy_policy(self) -> str:
|
|
52
|
+
try:
|
|
53
|
+
from ..model_router import get_privacy_policy
|
|
54
|
+
return get_privacy_policy()
|
|
55
|
+
except Exception:
|
|
56
|
+
return "local_first"
|
|
57
|
+
|
|
58
|
+
def set_privacy_policy(self, policy: str) -> None:
|
|
59
|
+
try:
|
|
60
|
+
from ..model_router import set_privacy_policy
|
|
61
|
+
set_privacy_policy(policy)
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
def enable_emergency_offline_mode(self) -> Tuple[bool, str]:
|
|
66
|
+
"""Switch CAT to local-only emergency mode: zero cloud, Ollama primary."""
|
|
67
|
+
self.set_privacy_policy("never_cloud")
|
|
68
|
+
self.cost_policy = CostPolicy.LOCAL_ONLY
|
|
69
|
+
is_ollama_ready = self.ollama.is_available()
|
|
70
|
+
models = self.ollama.get_model_names() if is_ollama_ready else []
|
|
71
|
+
|
|
72
|
+
if is_ollama_ready and models:
|
|
73
|
+
msg = f"Offline Local Mode active. Local Ollama available ({len(models)} models: {', '.join(models[:3])})."
|
|
74
|
+
return True, msg
|
|
75
|
+
elif is_ollama_ready:
|
|
76
|
+
msg = "Offline Local Mode active. Local Ollama daemon running, but no models found (run `cat models pull <name>`)."
|
|
77
|
+
return True, msg
|
|
78
|
+
else:
|
|
79
|
+
msg = "Offline Local Mode active. Cloud disabled. Note: Local Ollama is not currently detected at localhost:11434."
|
|
80
|
+
return False, msg
|
|
81
|
+
|
|
82
|
+
def build_targets(
|
|
83
|
+
self,
|
|
84
|
+
primary_config: Optional[dict] = None,
|
|
85
|
+
requirements: Optional[TaskRequirements] = None,
|
|
86
|
+
) -> List[CandidateTarget]:
|
|
87
|
+
"""Resolve ordered list of capable, allowed candidate targets."""
|
|
88
|
+
try:
|
|
89
|
+
from .. import aicore
|
|
90
|
+
prim = dict(primary_config or aicore.load_config() or {})
|
|
91
|
+
except Exception:
|
|
92
|
+
prim = dict(primary_config or {})
|
|
93
|
+
|
|
94
|
+
return self.engine.build_failover_targets(
|
|
95
|
+
primary_config=prim,
|
|
96
|
+
requirements=requirements or TaskRequirements(),
|
|
97
|
+
privacy_policy=self.get_privacy_policy(),
|
|
98
|
+
cost_policy=self.cost_policy,
|
|
99
|
+
security_level=self.security_level,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def execute_query(
|
|
103
|
+
self,
|
|
104
|
+
prompt: str,
|
|
105
|
+
system_prompt: str = "",
|
|
106
|
+
history: Optional[List] = None,
|
|
107
|
+
attachments: Optional[List] = None,
|
|
108
|
+
requirements: Optional[TaskRequirements] = None,
|
|
109
|
+
config: Optional[dict] = None,
|
|
110
|
+
on_failover: Optional[Callable[[str], None]] = None,
|
|
111
|
+
size_class: str = "normal",
|
|
112
|
+
) -> str:
|
|
113
|
+
"""Execute chat/generation turn with resilient automatic failover."""
|
|
114
|
+
targets = self.build_targets(primary_config=config, requirements=requirements)
|
|
115
|
+
if not targets:
|
|
116
|
+
return "No eligible AI providers available for this task under current privacy and capability settings."
|
|
117
|
+
|
|
118
|
+
from ..aicore import (
|
|
119
|
+
_attempts_per_provider,
|
|
120
|
+
_backoff_sleep,
|
|
121
|
+
_mark_backup_success,
|
|
122
|
+
_query_ai_once,
|
|
123
|
+
_short_id,
|
|
124
|
+
log_request_event,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
last_result = ""
|
|
128
|
+
max_attempts = _attempts_per_provider()
|
|
129
|
+
fallback_used = False
|
|
130
|
+
|
|
131
|
+
for t_idx, target in enumerate(targets):
|
|
132
|
+
cfg = target.config
|
|
133
|
+
provider = target.provider
|
|
134
|
+
model = target.model
|
|
135
|
+
|
|
136
|
+
self.health_monitor.record_turn_start(provider, model)
|
|
137
|
+
t_start = time.time()
|
|
138
|
+
|
|
139
|
+
for attempt in range(max_attempts):
|
|
140
|
+
if attempt > 0:
|
|
141
|
+
log_request_event(
|
|
142
|
+
_short_id(), "retry_same_provider",
|
|
143
|
+
provider=provider, model=model,
|
|
144
|
+
size_class=size_class, retry_count=attempt
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
last_result = _query_ai_once(
|
|
148
|
+
prompt,
|
|
149
|
+
system_prompt=system_prompt,
|
|
150
|
+
history=history,
|
|
151
|
+
config=cfg,
|
|
152
|
+
attachments=attachments,
|
|
153
|
+
size_class=size_class,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if not self.engine.should_failover(last_result):
|
|
157
|
+
elapsed_ms = (time.time() - t_start) * 1000.0
|
|
158
|
+
self.health_monitor.record_success(provider, model, latency_ms=elapsed_ms)
|
|
159
|
+
if target.is_backup and target.entry:
|
|
160
|
+
_mark_backup_success(target.entry)
|
|
161
|
+
self.notify_failover(f"✓ Connected to backup ({provider}/{model}).")
|
|
162
|
+
if on_failover:
|
|
163
|
+
on_failover(f"✓ Connected to backup ({provider}/{model}).")
|
|
164
|
+
|
|
165
|
+
log_request_event(
|
|
166
|
+
_short_id(), "finish",
|
|
167
|
+
provider=provider, model=model,
|
|
168
|
+
size_class=size_class, chars=len(last_result),
|
|
169
|
+
fallback=fallback_used
|
|
170
|
+
)
|
|
171
|
+
return last_result
|
|
172
|
+
|
|
173
|
+
# Classify failure
|
|
174
|
+
ftype = self.engine.classify_failure(last_result)
|
|
175
|
+
is_rate_limit = ftype == FailureType.RATE_LIMIT
|
|
176
|
+
|
|
177
|
+
if attempt + 1 < max_attempts and self.engine.is_retryable(ftype):
|
|
178
|
+
log_request_event(
|
|
179
|
+
_short_id(), "attempt_failed_retryable",
|
|
180
|
+
provider=provider, detail=last_result[:120],
|
|
181
|
+
size_class=size_class
|
|
182
|
+
)
|
|
183
|
+
_backoff_sleep(attempt)
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
# Non-retryable or retries exhausted
|
|
187
|
+
self.health_monitor.record_failure(
|
|
188
|
+
provider, model, error_message=last_result, is_rate_limit=is_rate_limit
|
|
189
|
+
)
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
# Failover to next target
|
|
193
|
+
if t_idx + 1 < len(targets):
|
|
194
|
+
fallback_used = True
|
|
195
|
+
nxt = targets[t_idx + 1]
|
|
196
|
+
cur_desc = f"{provider} ({model})"
|
|
197
|
+
nxt_desc = f"{nxt.provider} ({nxt.model})"
|
|
198
|
+
msg = f"⚠ Provider issue with {cur_desc}. Switching to {'Model Fallback' if nxt.tier == 1 else 'Backup Provider'} ({nxt_desc})..."
|
|
199
|
+
self.notify_failover(msg)
|
|
200
|
+
if on_failover:
|
|
201
|
+
on_failover(msg)
|
|
202
|
+
|
|
203
|
+
return last_result or "All configured AI providers failed to respond."
|
|
204
|
+
|
|
205
|
+
def execute_stream(
|
|
206
|
+
self,
|
|
207
|
+
prompt: str,
|
|
208
|
+
system_prompt: str = "",
|
|
209
|
+
history: Optional[List] = None,
|
|
210
|
+
attachments: Optional[List] = None,
|
|
211
|
+
requirements: Optional[TaskRequirements] = None,
|
|
212
|
+
config: Optional[dict] = None,
|
|
213
|
+
on_failover: Optional[Callable[[str], None]] = None,
|
|
214
|
+
size_class: str = "normal",
|
|
215
|
+
) -> Generator[str, None, None]:
|
|
216
|
+
"""Stream response fragments with automatic failover if first chunk fails."""
|
|
217
|
+
targets = self.build_targets(primary_config=config, requirements=requirements)
|
|
218
|
+
if not targets:
|
|
219
|
+
yield "No eligible AI providers available for this task."
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
from ..aicore import (
|
|
223
|
+
_attempts_per_provider,
|
|
224
|
+
_backoff_sleep,
|
|
225
|
+
_mark_backup_success,
|
|
226
|
+
_peek_first,
|
|
227
|
+
_stream_ai_once,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
last_err = ""
|
|
231
|
+
max_attempts = _attempts_per_provider()
|
|
232
|
+
|
|
233
|
+
for t_idx, target in enumerate(targets):
|
|
234
|
+
cfg = target.config
|
|
235
|
+
provider = target.provider
|
|
236
|
+
model = target.model
|
|
237
|
+
|
|
238
|
+
self.health_monitor.record_turn_start(provider, model)
|
|
239
|
+
t_start = time.time()
|
|
240
|
+
|
|
241
|
+
for attempt in range(max_attempts):
|
|
242
|
+
gen = _stream_ai_once(
|
|
243
|
+
prompt,
|
|
244
|
+
system_prompt=system_prompt,
|
|
245
|
+
history=history,
|
|
246
|
+
config=cfg,
|
|
247
|
+
attachments=attachments,
|
|
248
|
+
size_class=size_class,
|
|
249
|
+
)
|
|
250
|
+
first, rest = _peek_first(gen)
|
|
251
|
+
|
|
252
|
+
if first is None:
|
|
253
|
+
last_err = "No output produced by AI provider."
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
if not self.engine.should_failover(first):
|
|
257
|
+
elapsed_ms = (time.time() - t_start) * 1000.0
|
|
258
|
+
self.health_monitor.record_success(provider, model, latency_ms=elapsed_ms)
|
|
259
|
+
if target.is_backup and target.entry:
|
|
260
|
+
_mark_backup_success(target.entry)
|
|
261
|
+
yield first
|
|
262
|
+
yield from rest
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
# First chunk indicates failure
|
|
266
|
+
last_err = first
|
|
267
|
+
ftype = self.engine.classify_failure(first)
|
|
268
|
+
is_rate_limit = ftype == FailureType.RATE_LIMIT
|
|
269
|
+
|
|
270
|
+
if attempt + 1 < max_attempts and self.engine.is_retryable(ftype):
|
|
271
|
+
_backoff_sleep(attempt)
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
self.health_monitor.record_failure(
|
|
275
|
+
provider, model, error_message=first, is_rate_limit=is_rate_limit
|
|
276
|
+
)
|
|
277
|
+
break
|
|
278
|
+
|
|
279
|
+
if t_idx + 1 < len(targets):
|
|
280
|
+
nxt = targets[t_idx + 1]
|
|
281
|
+
msg = f"⚠ Switching provider from {provider}/{model} to {nxt.provider}/{nxt.model}..."
|
|
282
|
+
self.notify_failover(msg)
|
|
283
|
+
if on_failover:
|
|
284
|
+
on_failover(msg)
|
|
285
|
+
|
|
286
|
+
yield last_err or "All configured AI providers failed."
|
|
287
|
+
|
|
288
|
+
def test_provider(self, config: dict) -> Tuple[bool, str, List[str]]:
|
|
289
|
+
"""Verify connectivity, model availability, and response latency."""
|
|
290
|
+
from ..providers import provider_manager as pm
|
|
291
|
+
start = time.time()
|
|
292
|
+
ok, msg, models = pm.connect_provider(config)
|
|
293
|
+
elapsed_ms = (time.time() - start) * 1000.0
|
|
294
|
+
|
|
295
|
+
prov = config.get("provider", "?")
|
|
296
|
+
model = config.get("model", "")
|
|
297
|
+
if ok:
|
|
298
|
+
self.health_monitor.record_success(prov, model, latency_ms=elapsed_ms)
|
|
299
|
+
else:
|
|
300
|
+
self.health_monitor.record_failure(prov, model, error_message=msg)
|
|
301
|
+
|
|
302
|
+
return ok, sanitize_error(msg), models
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
_GLOBAL_ORCHESTRATOR: Optional[AIOrchestrator] = None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def get_orchestrator() -> AIOrchestrator:
|
|
309
|
+
global _GLOBAL_ORCHESTRATOR
|
|
310
|
+
if _GLOBAL_ORCHESTRATOR is None:
|
|
311
|
+
_GLOBAL_ORCHESTRATOR = AIOrchestrator()
|
|
312
|
+
return _GLOBAL_ORCHESTRATOR
|