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,767 @@
|
|
|
1
|
+
"""Model lifecycle management — validation, deprecation handling,
|
|
2
|
+
alias resolution, background refresh, and health monitoring.
|
|
3
|
+
|
|
4
|
+
Architecture:
|
|
5
|
+
┌─────────────────────────┐
|
|
6
|
+
│ Application/Chat │
|
|
7
|
+
│ pre_request_check() │
|
|
8
|
+
│ validate_and_chat() │
|
|
9
|
+
└────────┬────────────────┘
|
|
10
|
+
│ validate_model()
|
|
11
|
+
▼
|
|
12
|
+
┌─────────────────────────┐
|
|
13
|
+
│ ModelLifecycleManager │
|
|
14
|
+
│ ─ cache check │
|
|
15
|
+
│ ─ deprecation check │
|
|
16
|
+
│ ─ alias resolution │
|
|
17
|
+
│ ─ refresh on stale │
|
|
18
|
+
└────────┬────────────────┘
|
|
19
|
+
│ fetch_models()
|
|
20
|
+
▼
|
|
21
|
+
┌─────────────────────────┐
|
|
22
|
+
│ Provider Adapter │
|
|
23
|
+
│ (generic fallback or │
|
|
24
|
+
│ registered class) │
|
|
25
|
+
└─────────────────────────┘
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
from typing import Optional
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── Logging ────────────────────────────────────────────────────────────────
|
|
37
|
+
# User app-data logs dir first (pip-installed site-packages is read-only);
|
|
38
|
+
# legacy repo-root startup.log as fallback. Best-effort only.
|
|
39
|
+
|
|
40
|
+
def _resolve_log_file() -> str:
|
|
41
|
+
try:
|
|
42
|
+
from calc_terminal.first_run import logs_dir as _logs_dir
|
|
43
|
+
|
|
44
|
+
import os as _os
|
|
45
|
+
|
|
46
|
+
_d = _logs_dir()
|
|
47
|
+
_os.makedirs(_d, exist_ok=True)
|
|
48
|
+
return _os.path.join(_d, "startup.log")
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
|
|
52
|
+
os.path.abspath(__file__)))), "startup.log")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_LOG_FILE = _resolve_log_file()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _log(msg: str, exc_info: bool = False):
|
|
59
|
+
try:
|
|
60
|
+
ts = time.strftime("%H:%M:%S")
|
|
61
|
+
with open(_LOG_FILE, "a", encoding="utf-8") as f:
|
|
62
|
+
f.write(f"[{ts}] [Lifecycle] {msg}\n")
|
|
63
|
+
if exc_info:
|
|
64
|
+
import traceback
|
|
65
|
+
traceback.print_exc(file=f)
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── Result Types ───────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class ValidationResult:
|
|
74
|
+
"""Result of a model validation check."""
|
|
75
|
+
valid: bool
|
|
76
|
+
model: str
|
|
77
|
+
message: str = ""
|
|
78
|
+
replacement: Optional[str] = None
|
|
79
|
+
retry_after_refresh: bool = False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class RecoveryResult:
|
|
84
|
+
"""Result of a deprecation recovery attempt."""
|
|
85
|
+
recovered: bool
|
|
86
|
+
model: str
|
|
87
|
+
message: str
|
|
88
|
+
new_model: Optional[str] = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class ProviderCapabilities:
|
|
93
|
+
"""Capability flags for a provider adapter."""
|
|
94
|
+
supports_model_listing: bool = True
|
|
95
|
+
supports_streaming: bool = True
|
|
96
|
+
supports_tools: bool = False
|
|
97
|
+
supports_reasoning: bool = False
|
|
98
|
+
supports_vision: bool = False
|
|
99
|
+
supports_audio: bool = False
|
|
100
|
+
supports_images: bool = False
|
|
101
|
+
supports_embeddings: bool = False
|
|
102
|
+
supports_reranking: bool = False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class ProviderHealth:
|
|
107
|
+
"""Health snapshot for a provider."""
|
|
108
|
+
provider_id: str = ""
|
|
109
|
+
online: bool = False
|
|
110
|
+
api_reachable: bool = False
|
|
111
|
+
authenticated: bool = False
|
|
112
|
+
model_list_available: bool = False
|
|
113
|
+
chat_supported: bool = False
|
|
114
|
+
latency_ms: float = 0.0
|
|
115
|
+
last_checked: float = 0.0
|
|
116
|
+
error: str = ""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ── Model Alias / Replacement Map ──────────────────────────────────────────
|
|
120
|
+
# Known model renames/replacements published by providers.
|
|
121
|
+
# Format: (provider_id, old_model) -> new_model
|
|
122
|
+
# Data-driven (v0.7.4+): the authoritative alias list lives in
|
|
123
|
+
# models/model_metadata.json ("aliases"); the dict below is the fallback
|
|
124
|
+
# used only when the JSON store is unreadable.
|
|
125
|
+
|
|
126
|
+
_MODEL_ALIASES: dict[tuple[str, str], str] = {
|
|
127
|
+
# OpenAI
|
|
128
|
+
("openai", "gpt-4"): "gpt-4o",
|
|
129
|
+
("openai", "gpt-4-turbo-preview"): "gpt-4-turbo",
|
|
130
|
+
("openai", "gpt-3.5-turbo-0125"): "gpt-4o-mini",
|
|
131
|
+
|
|
132
|
+
# Anthropic
|
|
133
|
+
("anthropic", "claude-3-opus-20240229"): "claude-opus-4-20250514",
|
|
134
|
+
("anthropic", "claude-3-sonnet-20240229"): "claude-sonnet-4-20250514",
|
|
135
|
+
("anthropic", "claude-3-haiku-20240307"): "claude-3-5-haiku-20241022",
|
|
136
|
+
("anthropic", "claude-instant-1"): "claude-3-haiku-20240307",
|
|
137
|
+
("anthropic", "claude-2.1"): "claude-3-5-sonnet-20241022",
|
|
138
|
+
("anthropic", "claude-2.0"): "claude-3-5-sonnet-20241022",
|
|
139
|
+
("anthropic", "claude-1"): "claude-3-5-sonnet-20241022",
|
|
140
|
+
("anthropic", "claude-1.3"): "claude-3-5-sonnet-20241022",
|
|
141
|
+
("anthropic", "claude-1.2"): "claude-3-5-sonnet-20241022",
|
|
142
|
+
("anthropic", "claude-1.1"): "claude-3-5-sonnet-20241022",
|
|
143
|
+
|
|
144
|
+
# DeepSeek
|
|
145
|
+
("deepseek", "deepseek-chat"): "deepseek-chat",
|
|
146
|
+
("deepseek", "deepseek-v2"): "deepseek-chat",
|
|
147
|
+
|
|
148
|
+
# Mistral
|
|
149
|
+
("mistral", "mistral-tiny"): "open-mistral-nemo",
|
|
150
|
+
("mistral", "mistral-small-latest"): "mistral-small-2402",
|
|
151
|
+
("mistral", "mistral-medium-latest"): "mistral-large-2407",
|
|
152
|
+
|
|
153
|
+
# Google
|
|
154
|
+
("gemini", "gemini-pro"): "gemini-1.5-pro",
|
|
155
|
+
("gemini", "gemini-pro-vision"): "gemini-1.5-pro",
|
|
156
|
+
("gemini", "gemini-ultra"): "gemini-2.0-flash",
|
|
157
|
+
|
|
158
|
+
# xAI
|
|
159
|
+
("xai", "grok-1"): "grok-2-1212",
|
|
160
|
+
|
|
161
|
+
# Perplexity
|
|
162
|
+
("perplexity", "pplx-70b-online"): "sonar-pro",
|
|
163
|
+
("perplexity", "pplx-7b-online"): "sonar",
|
|
164
|
+
|
|
165
|
+
# Cohere
|
|
166
|
+
("cohere", "command-xlarge"): "command",
|
|
167
|
+
("cohere", "command-xlarge-beta"): "command",
|
|
168
|
+
("cohere", "command-medium"): "command-r",
|
|
169
|
+
("cohere", "command-medium-beta"): "command-r",
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _load_aliases() -> dict[tuple[str, str], str]:
|
|
174
|
+
"""Authoritative alias map from model_metadata.json; falls back to the
|
|
175
|
+
embedded dict if the JSON store is missing or unreadable."""
|
|
176
|
+
try:
|
|
177
|
+
from ..models import manager as _mgr
|
|
178
|
+
with open(_mgr.MODEL_METADATA_FILE, "r", encoding="utf-8") as f:
|
|
179
|
+
import json as _json
|
|
180
|
+
data = _json.load(f)
|
|
181
|
+
out = {}
|
|
182
|
+
for a in data.get("aliases", []):
|
|
183
|
+
if a.get("provider") and a.get("from") and a.get("to"):
|
|
184
|
+
out[(str(a["provider"]), str(a["from"]))] = str(a["to"])
|
|
185
|
+
if out:
|
|
186
|
+
return out
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
return dict(_MODEL_ALIASES)
|
|
190
|
+
|
|
191
|
+
# Patterns that indicate a model-not-found error in the response
|
|
192
|
+
_MODEL_NOT_FOUND_PATTERNS = [
|
|
193
|
+
"model.*not found",
|
|
194
|
+
"model.*unavailable",
|
|
195
|
+
"model.*deprecated",
|
|
196
|
+
"model.*decommissioned",
|
|
197
|
+
"model.*removed",
|
|
198
|
+
"model.*retired",
|
|
199
|
+
"model.*no longer",
|
|
200
|
+
"model.*unknown",
|
|
201
|
+
"model.*invalid",
|
|
202
|
+
"model.*unsupported",
|
|
203
|
+
"unknown model",
|
|
204
|
+
"not found",
|
|
205
|
+
"not a valid model",
|
|
206
|
+
"does not exist",
|
|
207
|
+
"has been decommissioned",
|
|
208
|
+
"has been deprecated",
|
|
209
|
+
"has been removed",
|
|
210
|
+
"is not supported",
|
|
211
|
+
"is not available",
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
# ── Refresh Tracking ───────────────────────────────────────────────────────
|
|
215
|
+
# {provider_id: {"last_refresh": timestamp, "refresh_count": int, "daily": bool}}
|
|
216
|
+
|
|
217
|
+
_refresh_tracker: dict[str, dict] = {}
|
|
218
|
+
_refresh_lock = threading.Lock()
|
|
219
|
+
_REFRESH_COOLDOWN = 300 # 5 minutes minimum between refreshes
|
|
220
|
+
_DAILY_REFRESH_SECONDS = 86400 # 24 hours
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _get_refresh_state(provider_id: str) -> dict:
|
|
224
|
+
with _refresh_lock:
|
|
225
|
+
if provider_id not in _refresh_tracker:
|
|
226
|
+
_refresh_tracker[provider_id] = {
|
|
227
|
+
"last_refresh": 0.0,
|
|
228
|
+
"refresh_count": 0,
|
|
229
|
+
"daily_refreshed": False,
|
|
230
|
+
}
|
|
231
|
+
return _refresh_tracker[provider_id]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _update_refresh_time(provider_id: str):
|
|
235
|
+
with _refresh_lock:
|
|
236
|
+
state = _refresh_tracker.get(provider_id, {})
|
|
237
|
+
state["last_refresh"] = time.time()
|
|
238
|
+
state["refresh_count"] = state.get("refresh_count", 0) + 1
|
|
239
|
+
_refresh_tracker[provider_id] = state
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def should_refresh(provider_id: str, force: bool = False) -> bool:
|
|
243
|
+
"""Check whether a model refresh is needed for this provider.
|
|
244
|
+
|
|
245
|
+
Returns True when:
|
|
246
|
+
- force=True
|
|
247
|
+
- never refreshed before
|
|
248
|
+
- last refresh older than cooldown (for on-error immediate refresh)
|
|
249
|
+
- last refresh older than 24h (daily refresh)
|
|
250
|
+
"""
|
|
251
|
+
if force:
|
|
252
|
+
return True
|
|
253
|
+
state = _get_refresh_state(provider_id)
|
|
254
|
+
now = time.time()
|
|
255
|
+
last = state.get("last_refresh", 0.0)
|
|
256
|
+
if last == 0.0:
|
|
257
|
+
return True # never refreshed
|
|
258
|
+
if now - last > _DAILY_REFRESH_SECONDS:
|
|
259
|
+
return True # stale daily refresh
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ── Model Lifecycle Manager ────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
class ModelLifecycleManager:
|
|
266
|
+
"""Central manager for model lifecycle operations.
|
|
267
|
+
|
|
268
|
+
Provides model validation, deprecation detection, alias resolution,
|
|
269
|
+
background refresh coordination, and health monitoring.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
def __init__(self):
|
|
273
|
+
self._deprecated: dict[str, dict[str, str]] = {}
|
|
274
|
+
# {provider_id: {bad_model: replacement_model_or_none}}
|
|
275
|
+
|
|
276
|
+
# ── Validation ─────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
def validate_model(self, provider_id: str, model: str) -> ValidationResult:
|
|
279
|
+
"""Check whether a model is valid, current, and available.
|
|
280
|
+
|
|
281
|
+
Returns ValidationResult with:
|
|
282
|
+
- valid=True if model is good to use
|
|
283
|
+
- replacement set if an alias or upgrade path exists
|
|
284
|
+
- retry_after_refresh=True if models are stale
|
|
285
|
+
"""
|
|
286
|
+
if not model:
|
|
287
|
+
return ValidationResult(
|
|
288
|
+
valid=False, model=model,
|
|
289
|
+
message="No model selected")
|
|
290
|
+
|
|
291
|
+
# 1. Check if model is actively tracked as deprecated
|
|
292
|
+
dep_map = self._deprecated.get(provider_id, {})
|
|
293
|
+
if model in dep_map:
|
|
294
|
+
replacement = dep_map[model]
|
|
295
|
+
msg = (f"Model \"{model}\" has been deprecated"
|
|
296
|
+
+ (f" \u2014 use \"{replacement}\" instead" if replacement else ""))
|
|
297
|
+
_log(f"validate_model [{provider_id}] DEPRECATED: {model}"
|
|
298
|
+
+ (f" -> {replacement}" if replacement else ""))
|
|
299
|
+
return ValidationResult(
|
|
300
|
+
valid=False, model=model, message=msg,
|
|
301
|
+
replacement=replacement, retry_after_refresh=False)
|
|
302
|
+
|
|
303
|
+
# 2. Check alias map (data-driven: model_metadata.json)
|
|
304
|
+
alias_key = (provider_id, model)
|
|
305
|
+
alias_map = _load_aliases()
|
|
306
|
+
if alias_key in alias_map:
|
|
307
|
+
replacement = alias_map[alias_key]
|
|
308
|
+
_log(f"validate_model [{provider_id}] ALIAS: {model} -> {replacement}")
|
|
309
|
+
return ValidationResult(
|
|
310
|
+
valid=True, model=replacement,
|
|
311
|
+
message=f"Model \"{model}\" aliased to \"{replacement}\"",
|
|
312
|
+
replacement=replacement, retry_after_refresh=False)
|
|
313
|
+
|
|
314
|
+
# 3. Check cache freshness
|
|
315
|
+
try:
|
|
316
|
+
from .provider_manager import get_cached_models
|
|
317
|
+
cached = get_cached_models(provider_id)
|
|
318
|
+
if cached is not None:
|
|
319
|
+
if model in cached:
|
|
320
|
+
return ValidationResult(valid=True, model=model)
|
|
321
|
+
# Model not in cached list — might be new or removed
|
|
322
|
+
# Try refreshing if cache is old
|
|
323
|
+
state = _get_refresh_state(provider_id)
|
|
324
|
+
if time.time() - state.get("last_refresh", 0) > _REFRESH_COOLDOWN:
|
|
325
|
+
return ValidationResult(
|
|
326
|
+
valid=False, model=model,
|
|
327
|
+
message="Model not found in current registry",
|
|
328
|
+
retry_after_refresh=True)
|
|
329
|
+
except Exception:
|
|
330
|
+
pass
|
|
331
|
+
|
|
332
|
+
# 4. No cache — model is unknown but could still work
|
|
333
|
+
return ValidationResult(valid=True, model=model)
|
|
334
|
+
|
|
335
|
+
def resolve_alias(self, provider_id: str, model: str) -> str:
|
|
336
|
+
"""Resolve model alias to its current name. Returns current model."""
|
|
337
|
+
alias_key = (provider_id, model)
|
|
338
|
+
return _load_aliases().get(alias_key, model)
|
|
339
|
+
|
|
340
|
+
# ── Deprecation Handling ───────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
def is_model_not_found_error(self, status_code: int, response_text: str) -> bool:
|
|
343
|
+
"""Heuristic check if an error response indicates a model lifecycle issue."""
|
|
344
|
+
if status_code in (400, 404, 410):
|
|
345
|
+
lower = response_text.lower()
|
|
346
|
+
for pattern in _MODEL_NOT_FOUND_PATTERNS:
|
|
347
|
+
if re.search(pattern, lower):
|
|
348
|
+
return True
|
|
349
|
+
return False
|
|
350
|
+
|
|
351
|
+
def handle_deprecation_error(
|
|
352
|
+
self, provider_id: str, model: str,
|
|
353
|
+
status_code: int, response_text: str
|
|
354
|
+
) -> RecoveryResult:
|
|
355
|
+
"""Handle a model-not-found error with automatic recovery.
|
|
356
|
+
|
|
357
|
+
Steps:
|
|
358
|
+
1. Refresh model list from provider
|
|
359
|
+
2. If model reappears, keep it
|
|
360
|
+
3. If model is gone, check alias map
|
|
361
|
+
4. If alias exists, switch to alias
|
|
362
|
+
5. If same-family model exists, suggest it
|
|
363
|
+
6. Mark model as deprecated for future checks
|
|
364
|
+
"""
|
|
365
|
+
_log(f"handle_deprecation [{provider_id}] model={model} "
|
|
366
|
+
f"HTTP {status_code}: {response_text[:200]}")
|
|
367
|
+
|
|
368
|
+
# Step 1: Refresh models
|
|
369
|
+
try:
|
|
370
|
+
from .provider_manager import discover_models, get_cached_models
|
|
371
|
+
config = {"provider": provider_id, "base_url": "", "api_style": "openai"}
|
|
372
|
+
refreshed = discover_models(provider_id, config)
|
|
373
|
+
_log(f"handle_deprecation [{provider_id}] refreshed: {len(refreshed)} models")
|
|
374
|
+
except Exception as e:
|
|
375
|
+
_log(f"handle_deprecation [{provider_id}] refresh failed: {e}")
|
|
376
|
+
refreshed = []
|
|
377
|
+
|
|
378
|
+
# Step 2: Check if model reappeared
|
|
379
|
+
if model in refreshed:
|
|
380
|
+
_log(f"handle_deprecation [{provider_id}] {model} still available after refresh")
|
|
381
|
+
return RecoveryResult(
|
|
382
|
+
recovered=True, model=model,
|
|
383
|
+
message=f"Model \"{model}\" confirmed available")
|
|
384
|
+
|
|
385
|
+
# Step 3: Check alias
|
|
386
|
+
replacement = self.resolve_alias(provider_id, model)
|
|
387
|
+
if replacement != model:
|
|
388
|
+
_log(f"handle_deprecation [{provider_id}] alias: {model} -> {replacement}")
|
|
389
|
+
self._mark_deprecated(provider_id, model, replacement)
|
|
390
|
+
return RecoveryResult(
|
|
391
|
+
recovered=True, model=replacement,
|
|
392
|
+
message=f"Model \"{model}\" aliased to \"{replacement}\"",
|
|
393
|
+
new_model=replacement)
|
|
394
|
+
|
|
395
|
+
# Step 4: Find same-family replacement from refreshed list
|
|
396
|
+
from calc_terminal.models.registry import get_family
|
|
397
|
+
family = get_family(model, provider_id)
|
|
398
|
+
if family and refreshed:
|
|
399
|
+
same_family = sorted([m for m in refreshed
|
|
400
|
+
if get_family(m, provider_id) == family])
|
|
401
|
+
if same_family:
|
|
402
|
+
replacement = same_family[-1] # pick newest in family
|
|
403
|
+
_log(f"handle_deprecation [{provider_id}] family match: "
|
|
404
|
+
f"{family} -> {replacement}")
|
|
405
|
+
self._mark_deprecated(provider_id, model, replacement)
|
|
406
|
+
return RecoveryResult(
|
|
407
|
+
recovered=True, model=replacement,
|
|
408
|
+
message=f"Switched to \"{replacement}\" (same family: {family})",
|
|
409
|
+
new_model=replacement)
|
|
410
|
+
|
|
411
|
+
# Step 5: Try provider default
|
|
412
|
+
try:
|
|
413
|
+
from .provider_manager import get_provider_class
|
|
414
|
+
cls = get_provider_class(provider_id)
|
|
415
|
+
replacement = cls.default_model() if cls else ""
|
|
416
|
+
if cls and replacement:
|
|
417
|
+
_log(f"handle_deprecation [{provider_id}] using default: {replacement}")
|
|
418
|
+
self._mark_deprecated(provider_id, model, replacement)
|
|
419
|
+
return RecoveryResult(
|
|
420
|
+
recovered=True, model=replacement,
|
|
421
|
+
message=f"Fell back to default model \"{replacement}\"",
|
|
422
|
+
new_model=replacement)
|
|
423
|
+
except Exception:
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
# Step 6: Pick first available model
|
|
427
|
+
if refreshed:
|
|
428
|
+
replacement = sorted(refreshed)[0]
|
|
429
|
+
_log(f"handle_deprecation [{provider_id}] first available: {replacement}")
|
|
430
|
+
self._mark_deprecated(provider_id, model, replacement)
|
|
431
|
+
return RecoveryResult(
|
|
432
|
+
recovered=True, model=replacement,
|
|
433
|
+
message=f"Using first available model \"{replacement}\"",
|
|
434
|
+
new_model=replacement)
|
|
435
|
+
|
|
436
|
+
# Step 7: No recovery possible
|
|
437
|
+
_log(f"handle_deprecation [{provider_id}] no recovery possible")
|
|
438
|
+
return RecoveryResult(
|
|
439
|
+
recovered=False, model=model,
|
|
440
|
+
message=f"Model \"{model}\" is no longer available and no replacement found")
|
|
441
|
+
|
|
442
|
+
def _mark_deprecated(self, provider_id: str, model: str, replacement: Optional[str] = None):
|
|
443
|
+
"""Track a deprecated model so future validate_model calls catch it fast."""
|
|
444
|
+
if provider_id not in self._deprecated:
|
|
445
|
+
self._deprecated[provider_id] = {}
|
|
446
|
+
self._deprecated[provider_id][model] = replacement
|
|
447
|
+
_log(f"deprecated [{provider_id}] tracked: {model}"
|
|
448
|
+
+ (f" -> {replacement}" if replacement else ""))
|
|
449
|
+
|
|
450
|
+
def get_deprecated(self, provider_id: str) -> dict[str, Optional[str]]:
|
|
451
|
+
"""Get known deprecated models for a provider."""
|
|
452
|
+
return dict(self._deprecated.get(provider_id, {}))
|
|
453
|
+
|
|
454
|
+
# ── Pre-Request Check ─────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
def pre_request_check(self, provider_id: str, model: str) -> ValidationResult:
|
|
457
|
+
"""Complete pre-request validation with auto-recovery.
|
|
458
|
+
|
|
459
|
+
Called before every chat/stream request.
|
|
460
|
+
Validates the model, resolves aliases, triggers refresh if stale.
|
|
461
|
+
"""
|
|
462
|
+
result = self.validate_model(provider_id, model)
|
|
463
|
+
if result.valid:
|
|
464
|
+
return result
|
|
465
|
+
|
|
466
|
+
# If stale cache, refresh and re-validate
|
|
467
|
+
if result.retry_after_refresh:
|
|
468
|
+
_log(f"pre_request_check [{provider_id}] refreshing stale models")
|
|
469
|
+
try:
|
|
470
|
+
from .provider_manager import discover_models, set_cached_models
|
|
471
|
+
config = {"provider": provider_id, "base_url": "", "api_style": "openai"}
|
|
472
|
+
fresh = discover_models(provider_id, config)
|
|
473
|
+
if fresh and model in fresh:
|
|
474
|
+
_update_refresh_time(provider_id)
|
|
475
|
+
return ValidationResult(valid=True, model=model,
|
|
476
|
+
message="Model confirmed after refresh")
|
|
477
|
+
if fresh and len(fresh) > 0:
|
|
478
|
+
# Model still not found — try deprecation recovery
|
|
479
|
+
recovery = self.handle_deprecation_error(
|
|
480
|
+
provider_id, model, 400,
|
|
481
|
+
"Model not found in refreshed registry")
|
|
482
|
+
if recovery.recovered and recovery.new_model:
|
|
483
|
+
_update_refresh_time(provider_id)
|
|
484
|
+
return ValidationResult(
|
|
485
|
+
valid=True, model=recovery.new_model,
|
|
486
|
+
message=recovery.message,
|
|
487
|
+
replacement=recovery.new_model)
|
|
488
|
+
except Exception as e:
|
|
489
|
+
_log(f"pre_request_check [{provider_id}] error during refresh: {e}")
|
|
490
|
+
|
|
491
|
+
return result
|
|
492
|
+
|
|
493
|
+
# ── Chat/Stream Wrappers ───────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
def validate_and_chat(
|
|
496
|
+
self, config: dict,
|
|
497
|
+
prompt: str, system_prompt: str = "",
|
|
498
|
+
history: Optional[list] = None
|
|
499
|
+
) -> str:
|
|
500
|
+
"""Wrapper around provider chat() that validates model first.
|
|
501
|
+
|
|
502
|
+
If the model is deprecated/aliased, resolves it automatically.
|
|
503
|
+
If the request fails with model-not-found, attempts recovery.
|
|
504
|
+
"""
|
|
505
|
+
provider_id = config.get("provider", "")
|
|
506
|
+
model = config.get("model", "")
|
|
507
|
+
|
|
508
|
+
# Phase 1: Pre-request validation
|
|
509
|
+
validation = self.pre_request_check(provider_id, model)
|
|
510
|
+
if not validation.valid:
|
|
511
|
+
return (f"[Model Error] {validation.message}")
|
|
512
|
+
|
|
513
|
+
# Use resolved model
|
|
514
|
+
resolved = validation.replacement or validation.model
|
|
515
|
+
if resolved != model:
|
|
516
|
+
config["model"] = resolved
|
|
517
|
+
_log(f"validate_and_chat [{provider_id}] model resolved: {model} -> {resolved}")
|
|
518
|
+
|
|
519
|
+
# Phase 2: Send request
|
|
520
|
+
try:
|
|
521
|
+
from .provider_manager import get_provider
|
|
522
|
+
inst = get_provider(config)
|
|
523
|
+
if not inst:
|
|
524
|
+
return "AI not configured."
|
|
525
|
+
response = inst.chat(prompt, system_prompt, history)
|
|
526
|
+
return response
|
|
527
|
+
except Exception as e:
|
|
528
|
+
error_text = str(e)
|
|
529
|
+
_log(f"validate_and_chat [{provider_id}] chat failed: {error_text}",
|
|
530
|
+
exc_info=True)
|
|
531
|
+
|
|
532
|
+
# Phase 3: Check for model-not-found in exception
|
|
533
|
+
if self.is_model_not_found_error(0, error_text):
|
|
534
|
+
recovery = self.handle_deprecation_error(
|
|
535
|
+
provider_id, resolved, 0, error_text)
|
|
536
|
+
if recovery.recovered and recovery.new_model:
|
|
537
|
+
config["model"] = recovery.new_model
|
|
538
|
+
_log(f"validate_and_chat [{provider_id}] retry with {recovery.new_model}")
|
|
539
|
+
try:
|
|
540
|
+
inst = get_provider(config)
|
|
541
|
+
if inst:
|
|
542
|
+
return (f"[Auto-recovered] {recovery.message}\n\n"
|
|
543
|
+
+ inst.chat(prompt, system_prompt, history))
|
|
544
|
+
except Exception:
|
|
545
|
+
pass
|
|
546
|
+
return (f"[Model Error] {recovery.message}")
|
|
547
|
+
|
|
548
|
+
return f"Error: {error_text}"
|
|
549
|
+
|
|
550
|
+
def validate_and_stream(
|
|
551
|
+
self, config: dict,
|
|
552
|
+
prompt: str, system_prompt: str = "",
|
|
553
|
+
history: Optional[list] = None
|
|
554
|
+
):
|
|
555
|
+
"""Wrapper around provider stream() that validates model first.
|
|
556
|
+
|
|
557
|
+
Same lifecycle logic as validate_and_chat but yields text fragments.
|
|
558
|
+
"""
|
|
559
|
+
provider_id = config.get("provider", "")
|
|
560
|
+
model = config.get("model", "")
|
|
561
|
+
|
|
562
|
+
validation = self.pre_request_check(provider_id, model)
|
|
563
|
+
if not validation.valid:
|
|
564
|
+
yield f"[Model Error] {validation.message}"
|
|
565
|
+
return
|
|
566
|
+
|
|
567
|
+
resolved = validation.replacement or validation.model
|
|
568
|
+
if resolved != model:
|
|
569
|
+
config["model"] = resolved
|
|
570
|
+
_log(f"validate_and_stream [{provider_id}] model resolved: {model} -> {resolved}")
|
|
571
|
+
|
|
572
|
+
try:
|
|
573
|
+
from .provider_manager import get_provider
|
|
574
|
+
inst = get_provider(config)
|
|
575
|
+
if not inst:
|
|
576
|
+
yield "AI not configured."
|
|
577
|
+
return
|
|
578
|
+
yield from inst.stream(prompt, system_prompt, history)
|
|
579
|
+
except Exception as e:
|
|
580
|
+
error_text = str(e)
|
|
581
|
+
_log(f"validate_and_stream [{provider_id}] stream failed: {error_text}",
|
|
582
|
+
exc_info=True)
|
|
583
|
+
|
|
584
|
+
if self.is_model_not_found_error(0, error_text):
|
|
585
|
+
recovery = self.handle_deprecation_error(
|
|
586
|
+
provider_id, resolved, 0, error_text)
|
|
587
|
+
if recovery.recovered and recovery.new_model:
|
|
588
|
+
config["model"] = recovery.new_model
|
|
589
|
+
yield f"\n[Auto-recovered] {recovery.message}\n"
|
|
590
|
+
try:
|
|
591
|
+
inst = get_provider(config)
|
|
592
|
+
if inst:
|
|
593
|
+
yield from inst.stream(prompt, system_prompt, history)
|
|
594
|
+
return
|
|
595
|
+
except Exception:
|
|
596
|
+
pass
|
|
597
|
+
yield f"\n[Model Error] {recovery.message}"
|
|
598
|
+
else:
|
|
599
|
+
yield f"Error: {error_text}"
|
|
600
|
+
|
|
601
|
+
# ── Health Monitoring ─────────────────────────────────────────────
|
|
602
|
+
|
|
603
|
+
def check_health(self, provider_id: str,
|
|
604
|
+
config: Optional[dict] = None) -> ProviderHealth:
|
|
605
|
+
"""Check provider health: reachability, auth, model listing, chat.
|
|
606
|
+
|
|
607
|
+
Returns a ProviderHealth dataclass with detailed status.
|
|
608
|
+
"""
|
|
609
|
+
health = ProviderHealth(provider_id=provider_id, last_checked=time.time())
|
|
610
|
+
|
|
611
|
+
if not config:
|
|
612
|
+
try:
|
|
613
|
+
from .provider_manager import load_config
|
|
614
|
+
cfg = load_config()
|
|
615
|
+
config = cfg
|
|
616
|
+
except Exception:
|
|
617
|
+
health.error = "No config available"
|
|
618
|
+
return health
|
|
619
|
+
|
|
620
|
+
cfg = dict(config) if config else {}
|
|
621
|
+
cfg["provider"] = provider_id
|
|
622
|
+
start = time.time()
|
|
623
|
+
|
|
624
|
+
try:
|
|
625
|
+
from .provider_manager import get_provider, get_provider_class
|
|
626
|
+
from .provider_manager import get_cached_models, discover_models
|
|
627
|
+
|
|
628
|
+
cls = get_provider_class(provider_id)
|
|
629
|
+
has_class = cls is not None
|
|
630
|
+
|
|
631
|
+
# Try to instantiate provider
|
|
632
|
+
inst = get_provider(cfg)
|
|
633
|
+
if inst:
|
|
634
|
+
health.api_reachable = True
|
|
635
|
+
health.chat_supported = True
|
|
636
|
+
|
|
637
|
+
# Auth check via model fetch
|
|
638
|
+
try:
|
|
639
|
+
models = inst.fetch_models()
|
|
640
|
+
if models:
|
|
641
|
+
health.authenticated = True
|
|
642
|
+
health.model_list_available = True
|
|
643
|
+
else:
|
|
644
|
+
health.authenticated = False
|
|
645
|
+
health.error = "Model list empty (auth may be missing)"
|
|
646
|
+
except Exception as auth_e:
|
|
647
|
+
health.error = str(auth_e)
|
|
648
|
+
health.authenticated = False
|
|
649
|
+
|
|
650
|
+
elif has_class:
|
|
651
|
+
health.error = "Provider class found but instantiation failed"
|
|
652
|
+
else:
|
|
653
|
+
# Generic provider — try HTTP reachability
|
|
654
|
+
base_url = cfg.get("base_url", "")
|
|
655
|
+
if base_url:
|
|
656
|
+
import requests
|
|
657
|
+
try:
|
|
658
|
+
resp = requests.get(f"{base_url.rstrip('/')}/models",
|
|
659
|
+
timeout=5)
|
|
660
|
+
health.api_reachable = resp.status_code < 500
|
|
661
|
+
health.authenticated = resp.status_code not in (401, 403)
|
|
662
|
+
health.model_list_available = resp.status_code == 200
|
|
663
|
+
except Exception as e:
|
|
664
|
+
health.error = str(e)
|
|
665
|
+
health.api_reachable = False
|
|
666
|
+
|
|
667
|
+
health.online = health.api_reachable
|
|
668
|
+
health.latency_ms = round((time.time() - start) * 1000)
|
|
669
|
+
|
|
670
|
+
except Exception as e:
|
|
671
|
+
health.error = str(e)
|
|
672
|
+
_log(f"check_health [{provider_id}] failed: {e}", exc_info=True)
|
|
673
|
+
|
|
674
|
+
# Update refresh tracker
|
|
675
|
+
_update_refresh_time(provider_id)
|
|
676
|
+
return health
|
|
677
|
+
|
|
678
|
+
# ── Background Refresh ────────────────────────────────────────────
|
|
679
|
+
|
|
680
|
+
def ensure_fresh_models(self, provider_id: str,
|
|
681
|
+
config: Optional[dict] = None,
|
|
682
|
+
force: bool = False) -> list[str]:
|
|
683
|
+
"""Ensure models are fresh — refresh if needed.
|
|
684
|
+
|
|
685
|
+
Called on provider creation, first use, or manually.
|
|
686
|
+
"""
|
|
687
|
+
if not should_refresh(provider_id, force=force):
|
|
688
|
+
try:
|
|
689
|
+
from .provider_manager import get_cached_models
|
|
690
|
+
cached = get_cached_models(provider_id)
|
|
691
|
+
if cached is not None:
|
|
692
|
+
return cached
|
|
693
|
+
except Exception:
|
|
694
|
+
pass
|
|
695
|
+
|
|
696
|
+
cfg = config or {"provider": provider_id, "base_url": "", "api_style": "openai"}
|
|
697
|
+
cfg["provider"] = provider_id
|
|
698
|
+
|
|
699
|
+
try:
|
|
700
|
+
from .provider_manager import discover_models
|
|
701
|
+
models = discover_models(provider_id, cfg)
|
|
702
|
+
_update_refresh_time(provider_id)
|
|
703
|
+
_log(f"ensure_fresh_models [{provider_id}]: {len(models)} models "
|
|
704
|
+
f"({'cached' if not force else 'forced'})")
|
|
705
|
+
return models or []
|
|
706
|
+
except Exception as e:
|
|
707
|
+
_log(f"ensure_fresh_models [{provider_id}] failed: {e}")
|
|
708
|
+
return []
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# ── Singleton ──────────────────────────────────────────────────────────────
|
|
712
|
+
|
|
713
|
+
_instance: Optional[ModelLifecycleManager] = None
|
|
714
|
+
_lock = threading.Lock()
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def get_lifecycle() -> ModelLifecycleManager:
|
|
718
|
+
"""Get the shared ModelLifecycleManager singleton."""
|
|
719
|
+
global _instance
|
|
720
|
+
if _instance is None:
|
|
721
|
+
with _lock:
|
|
722
|
+
if _instance is None:
|
|
723
|
+
_instance = ModelLifecycleManager()
|
|
724
|
+
return _instance
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
# ── Convenience Functions ──────────────────────────────────────────────────
|
|
728
|
+
|
|
729
|
+
def validate_model(provider_id: str, model: str) -> ValidationResult:
|
|
730
|
+
return get_lifecycle().validate_model(provider_id, model)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def pre_request_check(provider_id: str, model: str) -> ValidationResult:
|
|
734
|
+
return get_lifecycle().pre_request_check(provider_id, model)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def resolve_alias(provider_id: str, model: str) -> str:
|
|
738
|
+
return get_lifecycle().resolve_alias(provider_id, model)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def handle_deprecation_error(provider_id: str, model: str,
|
|
742
|
+
status_code: int, response_text: str) -> RecoveryResult:
|
|
743
|
+
return get_lifecycle().handle_deprecation_error(
|
|
744
|
+
provider_id, model, status_code, response_text)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def validate_and_chat(config: dict, prompt: str, system_prompt: str = "",
|
|
748
|
+
history: Optional[list] = None) -> str:
|
|
749
|
+
return get_lifecycle().validate_and_chat(config, prompt, system_prompt, history)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def validate_and_stream(config: dict, prompt: str, system_prompt: str = "",
|
|
753
|
+
history: Optional[list] = None):
|
|
754
|
+
return get_lifecycle().validate_and_stream(config, prompt, system_prompt, history)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def check_health(provider_id: str, config: Optional[dict] = None) -> ProviderHealth:
|
|
758
|
+
return get_lifecycle().check_health(provider_id, config)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def ensure_fresh_models(provider_id: str, config: Optional[dict] = None,
|
|
762
|
+
force: bool = False) -> list[str]:
|
|
763
|
+
return get_lifecycle().ensure_fresh_models(provider_id, config, force=force)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def get_deprecated_models(provider_id: str) -> dict[str, Optional[str]]:
|
|
767
|
+
return get_lifecycle().get_deprecated(provider_id)
|