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.
Files changed (325) hide show
  1. calc_terminal/__init__.py +14 -0
  2. calc_terminal/__main__.py +14 -0
  3. calc_terminal/activity.py +1334 -0
  4. calc_terminal/agent.py +3387 -0
  5. calc_terminal/agent_runtime.py +519 -0
  6. calc_terminal/ai_context.py +447 -0
  7. calc_terminal/ai_modes.py +752 -0
  8. calc_terminal/ai_personalization.py +286 -0
  9. calc_terminal/ai_preview_feedback.py +213 -0
  10. calc_terminal/aicore.py +2572 -0
  11. calc_terminal/anim.py +367 -0
  12. calc_terminal/app.py +3685 -0
  13. calc_terminal/art.py +639 -0
  14. calc_terminal/atomsim.py +368 -0
  15. calc_terminal/attachments.py +743 -0
  16. calc_terminal/benchmark_system.py +414 -0
  17. calc_terminal/browser/__init__.py +36 -0
  18. calc_terminal/browser/browser_state.py +346 -0
  19. calc_terminal/browser/devserver.py +176 -0
  20. calc_terminal/browser/engine.py +494 -0
  21. calc_terminal/browser/navigation.py +84 -0
  22. calc_terminal/browser/preview.py +429 -0
  23. calc_terminal/browser/preview_entry.py +95 -0
  24. calc_terminal/browser/project_detector.py +144 -0
  25. calc_terminal/browser/server.py +449 -0
  26. calc_terminal/browser/state.py +75 -0
  27. calc_terminal/browser/watcher.py +99 -0
  28. calc_terminal/browser_gui/__init__.py +1 -0
  29. calc_terminal/browser_gui/__main__.py +3 -0
  30. calc_terminal/browser_gui/launcher.py +173 -0
  31. calc_terminal/browser_gui/playwright_browser.py +117 -0
  32. calc_terminal/browser_gui/qt_browser.py +1501 -0
  33. calc_terminal/browser_gui/webview_browser.py +57 -0
  34. calc_terminal/capabilities/__init__.py +35 -0
  35. calc_terminal/capabilities/adapters/__init__.py +33 -0
  36. calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
  37. calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
  38. calc_terminal/capabilities/adapters/filesystem.py +206 -0
  39. calc_terminal/capabilities/adapters/git_adapter.py +202 -0
  40. calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
  41. calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
  42. calc_terminal/capabilities/adapters/platforms.py +200 -0
  43. calc_terminal/capabilities/adapters/python_exec.py +93 -0
  44. calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
  45. calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
  46. calc_terminal/capabilities/adapters/structural_bio.py +161 -0
  47. calc_terminal/capabilities/adapters/terminal.py +99 -0
  48. calc_terminal/capabilities/bus.py +178 -0
  49. calc_terminal/capabilities/discovery.py +207 -0
  50. calc_terminal/capabilities/schema.py +221 -0
  51. calc_terminal/cat.ico +0 -0
  52. calc_terminal/cat_browser.py +2018 -0
  53. calc_terminal/chat_store.py +703 -0
  54. calc_terminal/cli.py +1178 -0
  55. calc_terminal/code_editor.py +640 -0
  56. calc_terminal/collaboration.py +723 -0
  57. calc_terminal/commands_data.py +139 -0
  58. calc_terminal/compatibility_engine.py +352 -0
  59. calc_terminal/compute/__init__.py +31 -0
  60. calc_terminal/compute/fabric.py +350 -0
  61. calc_terminal/config.py +227 -0
  62. calc_terminal/core/__init__.py +41 -0
  63. calc_terminal/core/checkpoint.py +156 -0
  64. calc_terminal/core/input/__init__.py +45 -0
  65. calc_terminal/core/mode_registry.py +300 -0
  66. calc_terminal/core/project_graph.py +172 -0
  67. calc_terminal/core/recovery.py +129 -0
  68. calc_terminal/core/security_layer.py +112 -0
  69. calc_terminal/core/task_graph.py +202 -0
  70. calc_terminal/core/unified_runtime.py +184 -0
  71. calc_terminal/core/verification.py +257 -0
  72. calc_terminal/customization.py +1566 -0
  73. calc_terminal/derivations.py +153 -0
  74. calc_terminal/device_control.py +263 -0
  75. calc_terminal/diagnostics/__init__.py +27 -0
  76. calc_terminal/diagnostics/doctor_engine.py +382 -0
  77. calc_terminal/diagnostics/self_test.py +247 -0
  78. calc_terminal/doctor.py +519 -0
  79. calc_terminal/easter_eggs.py +274 -0
  80. calc_terminal/editor/__init__.py +1 -0
  81. calc_terminal/editor/actions.py +263 -0
  82. calc_terminal/editor/commands.py +160 -0
  83. calc_terminal/editor/shortcuts.py +226 -0
  84. calc_terminal/engine.py +259 -0
  85. calc_terminal/errors.py +120 -0
  86. calc_terminal/event_stream.py +146 -0
  87. calc_terminal/eventbus.py +133 -0
  88. calc_terminal/extensions.py +733 -0
  89. calc_terminal/fallback_cli.py +1321 -0
  90. calc_terminal/first_run.py +265 -0
  91. calc_terminal/fomoji_auth.py +1043 -0
  92. calc_terminal/formulas.py +82 -0
  93. calc_terminal/fs_cache.py +121 -0
  94. calc_terminal/fs_watcher.py +277 -0
  95. calc_terminal/game.py +193 -0
  96. calc_terminal/gen1.py +5 -0
  97. calc_terminal/generators.py +245 -0
  98. calc_terminal/gestures/__init__.py +42 -0
  99. calc_terminal/gestures/bindings.py +175 -0
  100. calc_terminal/gestures/manager.py +477 -0
  101. calc_terminal/goodbye.py +363 -0
  102. calc_terminal/gpu3d.py +290 -0
  103. calc_terminal/graphs.py +358 -0
  104. calc_terminal/hardware_analyzer.py +440 -0
  105. calc_terminal/host/__init__.py +30 -0
  106. calc_terminal/host/browser_manager.py +187 -0
  107. calc_terminal/host/desktop.py +1386 -0
  108. calc_terminal/host/launcher.py +395 -0
  109. calc_terminal/host/terminal.py +279 -0
  110. calc_terminal/identity.py +216 -0
  111. calc_terminal/input/__init__.py +54 -0
  112. calc_terminal/input/capabilities.py +258 -0
  113. calc_terminal/input/focus.py +87 -0
  114. calc_terminal/input/gestures.py +64 -0
  115. calc_terminal/input/pointer.py +114 -0
  116. calc_terminal/input/touch.py +345 -0
  117. calc_terminal/keys.py +84 -0
  118. calc_terminal/live_automation.py +165 -0
  119. calc_terminal/mathtext.py +433 -0
  120. calc_terminal/mcp.py +386 -0
  121. calc_terminal/memory.py +337 -0
  122. calc_terminal/memory_v2.py +479 -0
  123. calc_terminal/metrics.py +333 -0
  124. calc_terminal/mode_detection.py +146 -0
  125. calc_terminal/model.py +2431 -0
  126. calc_terminal/model_router.py +665 -0
  127. calc_terminal/models/__init__.py +0 -0
  128. calc_terminal/models/active_state.py +187 -0
  129. calc_terminal/models/dynamic_registry.py +584 -0
  130. calc_terminal/models/manager.py +781 -0
  131. calc_terminal/models/model_metadata.json +3526 -0
  132. calc_terminal/models/profiles.py +194 -0
  133. calc_terminal/models/registry.py +265 -0
  134. calc_terminal/models/schema.py +197 -0
  135. calc_terminal/models/validator.py +287 -0
  136. calc_terminal/models/verification_engine.py +368 -0
  137. calc_terminal/native_picker.py +215 -0
  138. calc_terminal/ollama_catalog.py +279 -0
  139. calc_terminal/ollama_download.py +233 -0
  140. calc_terminal/orchestrator.py +304 -0
  141. calc_terminal/package_research.py +322 -0
  142. calc_terminal/packages.py +1024 -0
  143. calc_terminal/pc_specs.py +116 -0
  144. calc_terminal/permissions.py +334 -0
  145. calc_terminal/pet.py +106 -0
  146. calc_terminal/pipeline.py +505 -0
  147. calc_terminal/platform/__init__.py +491 -0
  148. calc_terminal/platform/desktop.py +491 -0
  149. calc_terminal/platform/web.py +781 -0
  150. calc_terminal/preview/__init__.py +1 -0
  151. calc_terminal/preview/dev_server.py +303 -0
  152. calc_terminal/preview/diagnostics.py +131 -0
  153. calc_terminal/preview/live_reload.py +66 -0
  154. calc_terminal/preview/manager.py +129 -0
  155. calc_terminal/project_stats.py +209 -0
  156. calc_terminal/projects.py +328 -0
  157. calc_terminal/providers/__init__.py +0 -0
  158. calc_terminal/providers/adapters/__init__.py +80 -0
  159. calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
  160. calc_terminal/providers/adapters/base.py +106 -0
  161. calc_terminal/providers/adapters/chinese_adapters.py +420 -0
  162. calc_terminal/providers/adapters/gemini_adapter.py +101 -0
  163. calc_terminal/providers/adapters/ollama_adapter.py +83 -0
  164. calc_terminal/providers/adapters/openai_adapter.py +159 -0
  165. calc_terminal/providers/adapters/other_adapters.py +246 -0
  166. calc_terminal/providers/anthropic_provider.py +172 -0
  167. calc_terminal/providers/auto_update.py +416 -0
  168. calc_terminal/providers/base_provider.py +105 -0
  169. calc_terminal/providers/discovery_manager.py +207 -0
  170. calc_terminal/providers/gemini_provider.py +178 -0
  171. calc_terminal/providers/lifecycle.py +767 -0
  172. calc_terminal/providers/ollama_adapter.py +707 -0
  173. calc_terminal/providers/openai_provider.py +254 -0
  174. calc_terminal/providers/provider_manager.py +1827 -0
  175. calc_terminal/providers/providers.json +4075 -0
  176. calc_terminal/reactionsim.py +279 -0
  177. calc_terminal/registry.py +337 -0
  178. calc_terminal/report.py +162 -0
  179. calc_terminal/research/__init__.py +45 -0
  180. calc_terminal/research/artifact_intel.py +126 -0
  181. calc_terminal/research/data_lineage.py +123 -0
  182. calc_terminal/research/experiment_ledger.py +303 -0
  183. calc_terminal/research/reproducibility.py +131 -0
  184. calc_terminal/resilience/__init__.py +47 -0
  185. calc_terminal/resilience/agent_state.py +121 -0
  186. calc_terminal/resilience/capability_matcher.py +174 -0
  187. calc_terminal/resilience/circuit_breaker.py +158 -0
  188. calc_terminal/resilience/failover_engine.py +230 -0
  189. calc_terminal/resilience/health_monitor.py +192 -0
  190. calc_terminal/resilience/ollama_adapter.py +125 -0
  191. calc_terminal/resilience/orchestrator.py +312 -0
  192. calc_terminal/resilience/types.py +134 -0
  193. calc_terminal/sandbox.py +98 -0
  194. calc_terminal/scires.py +558 -0
  195. calc_terminal/security_scanner.py +126 -0
  196. calc_terminal/session.py +294 -0
  197. calc_terminal/sim3d.py +206 -0
  198. calc_terminal/solver.py +276 -0
  199. calc_terminal/sound.py +127 -0
  200. calc_terminal/task_reports.py +287 -0
  201. calc_terminal/terminal_host.py +201 -0
  202. calc_terminal/terminal_identity.py +411 -0
  203. calc_terminal/test_ai_mode_reliability.py +344 -0
  204. calc_terminal/test_browser.py +368 -0
  205. calc_terminal/test_code_editor_upgrade.py +485 -0
  206. calc_terminal/test_customization.py +1148 -0
  207. calc_terminal/test_customization_ui.py +612 -0
  208. calc_terminal/test_dynamic_registry.py +304 -0
  209. calc_terminal/test_extensions.py +436 -0
  210. calc_terminal/test_overhaul.py +557 -0
  211. calc_terminal/test_project_detect.py +255 -0
  212. calc_terminal/test_root_cause_fix.py +527 -0
  213. calc_terminal/test_stability.py +532 -0
  214. calc_terminal/test_terminal_identity.py +132 -0
  215. calc_terminal/test_v079_speed.py +460 -0
  216. calc_terminal/theme.py +1107 -0
  217. calc_terminal/timeline.py +139 -0
  218. calc_terminal/todos.py +246 -0
  219. calc_terminal/tool_call_normalizer.py +419 -0
  220. calc_terminal/tui.py +104 -0
  221. calc_terminal/ui/__init__.py +8 -0
  222. calc_terminal/ui/activity_panel.py +231 -0
  223. calc_terminal/ui/activity_stream_panel.py +238 -0
  224. calc_terminal/ui/animations.py +122 -0
  225. calc_terminal/ui/app.py +7271 -0
  226. calc_terminal/ui/attach_panel.py +597 -0
  227. calc_terminal/ui/attachments.py +424 -0
  228. calc_terminal/ui/backup_panel.py +810 -0
  229. calc_terminal/ui/browser_shell.py +887 -0
  230. calc_terminal/ui/cat_agent.py +357 -0
  231. calc_terminal/ui/chats_panel.py +899 -0
  232. calc_terminal/ui/command_palette.py +125 -0
  233. calc_terminal/ui/command_palette_modal.py +166 -0
  234. calc_terminal/ui/composer.py +1141 -0
  235. calc_terminal/ui/context_menu.py +197 -0
  236. calc_terminal/ui/conversation.py +1435 -0
  237. calc_terminal/ui/customization_panel.py +1229 -0
  238. calc_terminal/ui/dashboard.py +404 -0
  239. calc_terminal/ui/design_system.py +557 -0
  240. calc_terminal/ui/diff_panel.py +213 -0
  241. calc_terminal/ui/editor.py +2102 -0
  242. calc_terminal/ui/empty_state.py +302 -0
  243. calc_terminal/ui/events.py +487 -0
  244. calc_terminal/ui/extensions_panel.py +815 -0
  245. calc_terminal/ui/footer.py +166 -0
  246. calc_terminal/ui/gestures_panel.py +383 -0
  247. calc_terminal/ui/goodbye_screen.py +100 -0
  248. calc_terminal/ui/header.py +1034 -0
  249. calc_terminal/ui/help_panel.py +254 -0
  250. calc_terminal/ui/live_activities.py +914 -0
  251. calc_terminal/ui/mcp_panel.py +570 -0
  252. calc_terminal/ui/memory_center.py +524 -0
  253. calc_terminal/ui/mode_colors_panel.py +525 -0
  254. calc_terminal/ui/nav_screens.py +747 -0
  255. calc_terminal/ui/ollama_panel.py +536 -0
  256. calc_terminal/ui/palette.py +221 -0
  257. calc_terminal/ui/permission_panel.py +269 -0
  258. calc_terminal/ui/personalization_panel.py +517 -0
  259. calc_terminal/ui/personalize_center.py +1568 -0
  260. calc_terminal/ui/preview_panel.py +441 -0
  261. calc_terminal/ui/resizers.py +402 -0
  262. calc_terminal/ui/sidebar.py +1285 -0
  263. calc_terminal/ui/statusbar.py +168 -0
  264. calc_terminal/ui/theme_css.py +1396 -0
  265. calc_terminal/ui/thinking.py +226 -0
  266. calc_terminal/ui/timeline_panel.py +102 -0
  267. calc_terminal/ui/todo_panel.py +193 -0
  268. calc_terminal/ui/viewport.py +136 -0
  269. calc_terminal/ui/vision_panel.py +489 -0
  270. calc_terminal/ui/welcome_modal.py +343 -0
  271. calc_terminal/ui/widgets.py +160 -0
  272. calc_terminal/ui/workspace.py +831 -0
  273. calc_terminal/viewers/__init__.py +1 -0
  274. calc_terminal/viewers/document_viewer.py +252 -0
  275. calc_terminal/viewers/image_viewer.py +241 -0
  276. calc_terminal/viewers/pdf_viewer.py +203 -0
  277. calc_terminal/viewers/presentation_viewer.py +164 -0
  278. calc_terminal/viewers/registry.py +120 -0
  279. calc_terminal/viewers/spreadsheet_viewer.py +204 -0
  280. calc_terminal/vision/__init__.py +89 -0
  281. calc_terminal/vision/analysis.py +194 -0
  282. calc_terminal/vision/annotations.py +297 -0
  283. calc_terminal/vision/capture.py +171 -0
  284. calc_terminal/vision/context.py +231 -0
  285. calc_terminal/vision/correlation.py +169 -0
  286. calc_terminal/vision/cursor.py +258 -0
  287. calc_terminal/vision/events.py +66 -0
  288. calc_terminal/vision/frame_pipeline.py +259 -0
  289. calc_terminal/vision/priority.py +218 -0
  290. calc_terminal/vision/provider.py +180 -0
  291. calc_terminal/vision/safety.py +149 -0
  292. calc_terminal/vision/session.py +281 -0
  293. calc_terminal/vision/verify.py +162 -0
  294. calc_terminal/vision.py +514 -0
  295. calc_terminal/vscode_integration.py +113 -0
  296. calc_terminal/web/__init__.py +8 -0
  297. calc_terminal/web/cat_runtime.py +710 -0
  298. calc_terminal/web/server.py +2891 -0
  299. calc_terminal/web/static/css/app.css +3152 -0
  300. calc_terminal/web/static/icons/badge-72.png +0 -0
  301. calc_terminal/web/static/icons/cat.ico +0 -0
  302. calc_terminal/web/static/icons/icon-128.png +0 -0
  303. calc_terminal/web/static/icons/icon-144.png +0 -0
  304. calc_terminal/web/static/icons/icon-152.png +0 -0
  305. calc_terminal/web/static/icons/icon-192.png +0 -0
  306. calc_terminal/web/static/icons/icon-384.png +0 -0
  307. calc_terminal/web/static/icons/icon-512.png +0 -0
  308. calc_terminal/web/static/icons/icon-72.png +0 -0
  309. calc_terminal/web/static/icons/icon-96.png +0 -0
  310. calc_terminal/web/static/icons/icon.svg +34 -0
  311. calc_terminal/web/static/icons/new-project.png +0 -0
  312. calc_terminal/web/static/icons/open-project.png +0 -0
  313. calc_terminal/web/static/index.html +734 -0
  314. calc_terminal/web/static/js/app.js +2403 -0
  315. calc_terminal/web/static/manifest.json +88 -0
  316. calc_terminal/web/static/sw.js +230 -0
  317. calc_terminal/workflow_engine.py +769 -0
  318. calc_terminal/workspace.py +593 -0
  319. calc_terminal/workspace_index.py +385 -0
  320. cct_cli-0.7.9.0.dist-info/METADATA +210 -0
  321. cct_cli-0.7.9.0.dist-info/RECORD +325 -0
  322. cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
  323. cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
  324. cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
  325. cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1827 @@
1
+ """Provider manager — registry, config persistence, import/export.
2
+
3
+ Supports 200+ providers with dynamic model discovery, caching,
4
+ request cancellation, and pagination support.
5
+
6
+ v0.7.9.5: Added auto-update system for providers to automatically
7
+ fetch new models from provider APIs and central registry.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import threading
13
+ import time
14
+ from typing import Optional, Dict, List, Any
15
+
16
+ import requests
17
+
18
+ from .base_provider import BaseProvider
19
+ from .openai_provider import OpenAIProvider, CustomOpenAIProvider
20
+ from .anthropic_provider import AnthropicProvider
21
+ from .gemini_provider import GeminiProvider
22
+
23
+ # Auto-update system for providers
24
+ try:
25
+ from .auto_update import (
26
+ get_updater, start_auto_updates, stop_auto_updates,
27
+ force_update, get_latest_providers, add_custom_provider,
28
+ remove_custom_provider, discover_models as auto_discover_models
29
+ )
30
+ _HAS_AUTO_UPDATE = True
31
+ except ImportError:
32
+ _HAS_AUTO_UPDATE = False
33
+
34
+ import re as _re
35
+
36
+
37
+ def _fix_spacing(text):
38
+ """Repair concatenated text from LLMs that omit spaces between words.
39
+ Handles keyword concatenation, markdown fences, subscript digits, and heavy concatenation."""
40
+ if not text or not isinstance(text, str):
41
+ return text
42
+ # Common English word set for heavy concatenation repair
43
+ COMMON_WORDS = {
44
+ "i","am","still","in","learning","and","have","some","more","experience","so","it","would","be","nice","to","get","that","working","soon","if","you","are","interested","please","let","me","know","cheers","this","is","an","open","source","bot","which","can","do","the","following","show","all","registered","users","on","instance","listen","your","command","or","phrase","whatever","want","reply","with","text","may","use","simple","print","function","as","example","how","could","look","like","will","echo","back","future","plan","add","functionality","depends","input","greet","ask","for","list","statistics","about","usage","my","name","any","chatbot","think","good","practice","does","work","test","out","just","send","start","message","will","respond","something","can","be","anything","from","hello","simply","greeting","change","code","anytime","even","another","word","way","create","own","works","exactly","like","but","has","own","which","makes","unique","allow","add","their","names","remove","them","they","unregister","get","of","back","recommend","using","since","feature","pretty","however","also","existing","framework","allows","manage","app","keep","mind","have","implement","commands","yourself","already","implemented","for","by","default","want","while","other","suggested","optional","makes","much","interesting","leave","after","receiving","done","then","with","delete","allows","create","chatbots","used","different","situations","example","game","talk","something","really","care","send","photos","if","photo","not","from","replying","another","picture","uses","pillow","library","allows","pictures","from","internet","resize","before","sending","them","great","one","add","well","anonymous","chat","post","messages","private","channel","not","registered","kind","want","build","often","least","once","per","week","currently","doesn't","haven't","added","yet","easily","simply","modifying","extra","logic","there","file","receive","same","deleted","one","probably","going","take","time","figure","worth","fun","project","working","anytime","need","details","anything","else","support","voice","don't","since","only","text","doesn't","images","anyone","needs","able","audio","would","suggest","same","approach","described","above","photo","microphone","whatever","just","image","any","time","which","let","play","song","listening","realtime","have","fun","project","open","questions","regarding","don't","mind","learn","more","works","improve","feel","free","reach","help","a","the","is","it","you","we","are","my","your","our","their","be","been","being","have","has","had","do","did","done","will","would","could","should","may","might","must","shall","can","cannot","not","no","yes","hi","how","help","assistant","plan","computer","science","domain","books","videos","free","online","search","pip","install"
45
+ }
46
+ def _segment_heavy(s):
47
+ # Only attempt if very few spaces (heavily concatenated)
48
+ if s.count(" ") >= len(s) * 0.08: # at least 8% spaces = normal
49
+ return s
50
+ # Preserve URLs, code fences, brackets, backticks segments
51
+ # Use precise URL pattern that stops at ) ] * " ' < > and whitespace (fixes github.com)which... capture)
52
+ url_pat = r"https?://[^\s\)\]\*\"'<>]+"
53
+ urls = []
54
+ def _save_url(m):
55
+ urls.append(m.group(0))
56
+ return f"URLPLACEHOLDER{len(urls)-1}END"
57
+ tmp = _re.sub(url_pat, _save_url, s)
58
+ # Split on markdown/code markers but keep them
59
+ parts = _re.split(r"(\s+|```.*?```|`[^`]*`|\[.*?\]\(.*?\)|[*_#`\[\]()/])", tmp, flags=_re.DOTALL)
60
+ out = []
61
+ for p in parts:
62
+ if not p or p.startswith("URLPLACEHOLDER") or p.startswith("__URL") or p.startswith("```") or p.startswith("`") or p.startswith("["):
63
+ out.append(p)
64
+ continue
65
+ # If already has spaces or is punctuation, keep
66
+ if " " in p or len(p) < 12:
67
+ out.append(p)
68
+ continue
69
+ # Try to segment long concatenated word-run (e.g., Iamstillinlearning...)
70
+ low = p.lower()
71
+ # Quick check: if no known word inside, skip
72
+ if not any(w in low for w in ("iam","still","learning","have","experience")):
73
+ # General: try DP segmentation for any long lowercase run
74
+ if len(p) > 20 and p.isalpha() and p.count(" ") == 0:
75
+ # DP segmentation
76
+ n = len(low)
77
+ dp = [None]*(n+1)
78
+ dp[0] = []
79
+ for i in range(n):
80
+ if dp[i] is None:
81
+ continue
82
+ for j in range(i+1, min(n, i+12)+1): # words 1-12 chars (to handle 'do','of','to','I','a')
83
+ word = low[i:j]
84
+ if word in COMMON_WORDS:
85
+ if dp[j] is None or len(dp[i])+1 < len(dp[j]):
86
+ dp[j] = dp[i] + [p[i:j]]
87
+ if dp[n] is not None:
88
+ out.append(" ".join(dp[n]))
89
+ continue
90
+ out.append(p)
91
+ continue
92
+ # Heuristic for the specific bot description
93
+ # Greedy longest-match segmentation
94
+ res_words = []
95
+ i = 0
96
+ L = len(p)
97
+ while i < L:
98
+ # Skip non-alpha
99
+ if not p[i].isalpha():
100
+ res_words.append(p[i])
101
+ i += 1
102
+ continue
103
+ best = None
104
+ best_len = 0
105
+ # Try longest word starting at i
106
+ for l in range(min(12, L-i), 1, -1):
107
+ cand = low[i:i+l]
108
+ if cand in COMMON_WORDS:
109
+ # Prefer longer
110
+ if l > best_len:
111
+ best = p[i:i+l]
112
+ best_len = l
113
+ break
114
+ if best:
115
+ res_words.append(best)
116
+ i += best_len
117
+ else:
118
+ # Single char fallback (like I, a)
119
+ if low[i] in ("i","a"):
120
+ res_words.append(p[i])
121
+ i += 1
122
+ else:
123
+ # No match, take char as is and continue
124
+ res_words.append(p[i])
125
+ i += 1
126
+ # Re-join with spaces, but keep original casing where possible
127
+ # Fix "I" capitalization
128
+ segmented = " ".join(res_words)
129
+ segmented = _re.sub(r"\bi\s*\b", "I ", segmented)
130
+ segmented = _re.sub(r"\s+", " ", segmented)
131
+ out.append(segmented)
132
+ # Restore URLs
133
+ restored = "".join(out)
134
+ for idx, url in enumerate(urls):
135
+ restored = restored.replace(f"URLPLACEHOLDER{idx}END", url)
136
+ return restored
137
+
138
+ parts = _re.split(r"(```.*?```)", text, flags=_re.DOTALL)
139
+ out_parts = []
140
+ for part in parts:
141
+ is_code = part.startswith("```")
142
+ t = part
143
+ t = t.replace("\u2081", "1").replace("\u2082", "2").replace("\u2083", "3").replace("\u2084", "4")
144
+ t = t.replace("\u2085", "5").replace("\u2086", "6").replace("\u2087", "7").replace("\u2088", "8").replace("\u2089", "9").replace("\u2080", "0")
145
+ t = _re.sub(r"\bimport(?=[a-zA-Z_])", "import ", t)
146
+ t = _re.sub(r"\bfrom(?=[a-zA-Z_])", "from ", t)
147
+ t = _re.sub(r"\bclass(?=[a-zA-Z_])", "class ", t)
148
+ t = _re.sub(r"\bdef(?=[a-zA-Z_])", "def ", t)
149
+ t = _re.sub(r"\breturn(?=[a-zA-Z_])", "return ", t)
150
+ t = _re.sub(r"([a-z])from(?=[A-Z])", r"\1 from", t)
151
+ t = _re.sub(r"opensourcebot", "open source bot", t, flags=_re.IGNORECASE)
152
+ t = _re.sub(r"whichcandothefollowing", "which can do the following", t, flags=_re.IGNORECASE)
153
+ t = _re.sub(r"whichcandothefollowing", "which can do the following", t, flags=_re.IGNORECASE)
154
+ t = _re.sub(r"([a-z])import(?=[A-Z])", r"\1 import", t)
155
+ t = _re.sub(r"([A-Za-z0-9])```", r"\1\n\n```", t)
156
+ t = _re.sub(r"```(#[A-Za-z])", r"```\n\1", t) # only split heading fences, not language tags like ```python
157
+ t = _re.sub(r"([a-zA-Z0-9])`([a-zA-Z0-9])", r"\1` \2", t)
158
+ t = _re.sub(r"([a-z])([A-Z][a-z]+\.txt)", r"\1 \2", t)
159
+ if not is_code:
160
+ # Heavy concatenation repair for prose
161
+ if t.count(" ") < len(t) * 0.08 and len(t) > 20:
162
+ t = _segment_heavy(t)
163
+ t = _re.sub(r"([.!?:;])([A-Z])", r"\1 \2", t) # only before capital to avoid github.com
164
+ t = _re.sub(r"([a-z,;)])([A-Z])", r"\1 \2", t)
165
+ t = _re.sub(r"([a-z])(\()", r"\1 \2", t)
166
+ t = _re.sub(r"(\))([a-zA-Z])", r"\1 \2", t)
167
+ # Fix missing spaces after * bullets and brackets: *Thisisan -> * This is an
168
+ t = _re.sub(r"\*([A-Za-z])", r"* \1", t)
169
+ # Fix Thisisan -> This is an
170
+ t = _re.sub(r"\bThisisan\b", "This is an", t)
171
+ t = _re.sub(r"\bwhichcan\b", "which can", t)
172
+ t = _re.sub(r"\bShowall\b", "Show all", t)
173
+ t = _re.sub(r"\bListento\b", "Listen to", t)
174
+ out_parts.append(t)
175
+ rejoined = "".join(out_parts)
176
+ rejoined = _re.sub(r"\b(hi){3,}\b", "hi", rejoined, flags=_re.IGNORECASE)
177
+ rejoined = _re.sub(r"hihii+", "hi", rejoined, flags=_re.IGNORECASE)
178
+ noisy = rejoined.replace(" ", "").lower()
179
+ if "mynameisyourassistant" in noisy or "assistant_chatbot" in noisy.lower() or "assistant-chatbot" in noisy.lower():
180
+ if len(rejoined) < 600 and ("howcan" in noisy or "myname" in noisy):
181
+ return "Hi, I'm CAT Plan — your planning assistant inside Coding Agent Terminal by Kazi Zillani. How can I help you plan today?"
182
+ rejoined = _re.sub(r"```python\s*#?pip\s*install\s*assistant_chatbot[^\n]*```", "", rejoined, flags=_re.IGNORECASE)
183
+ rejoined = _re.sub(r"#?pip\s*install\s*assistant_chatbot[^\n]*", "", rejoined, flags=_re.IGNORECASE)
184
+ rejoined = _re.sub(r"My\s*name\s*is\s*your\s*assistant[^.!\n]*[.!]?", "I'm CAT Plan by Kazi Zillani.", rejoined, flags=_re.IGNORECASE)
185
+ rejoined = rejoined.replace("assistant_chatbot", "CAT").replace("Assistant-Chatbot", "CAT")
186
+ if "howcan" in noisy and "helpyou" in noisy:
187
+ rejoined = _re.sub(r"Hi,?\s*how\s*can\s*I\s*help\s*you\??", "Hi, how can I help you?", rejoined, flags=_re.IGNORECASE)
188
+ rejoined = _re.sub(r"How\s*can\s*I\s*helo\s*you\s*there\??", "How can I help you there?", rejoined, flags=_re.IGNORECASE)
189
+ rejoined = _re.sub(r"You\s*can\s*ask\s*me\s*about\s*anything\s*in\s*the\s*computer\s*science\s*domain.*?(?=```|\n\n|$)", "You can ask me about anything in the computer science domain and I will help you plan it.", rejoined, flags=_re.IGNORECASE | _re.DOTALL)
190
+ # Final cleanup: collapse multiple spaces, fix " Iam " -> " I am "
191
+ rejoined = _re.sub(r" {2,}", " ", rejoined)
192
+ rejoined = _re.sub(r"\bIam\b", "I am", rejoined)
193
+ rejoined = _re.sub(r"\bIhavesome\b", "I have some", rejoined)
194
+ return rejoined.strip()
195
+
196
+
197
+ # ── Debug logging (user app-data logs dir when installed; legacy
198
+ # repo-root startup.log as fallback for source checkouts) ──────────────
199
+ def _resolve_log_file() -> str:
200
+ try:
201
+ from calc_terminal.first_run import logs_dir as _logs_dir
202
+
203
+ import os as _os
204
+
205
+ _d = _logs_dir()
206
+ _os.makedirs(_d, exist_ok=True)
207
+ return _os.path.join(_d, "startup.log")
208
+ except Exception:
209
+ pass
210
+ return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
211
+ os.path.abspath(__file__)))), "startup.log")
212
+
213
+
214
+ _LOG_FILE = _resolve_log_file()
215
+
216
+
217
+ def _log(msg: str, exc_info: bool = False):
218
+ """Append a timestamped log line to startup.log."""
219
+ try:
220
+ ts = time.strftime("%H:%M:%S")
221
+ with open(_LOG_FILE, "a", encoding="utf-8") as f:
222
+ f.write(f"[{ts}] [ProviderManager] {msg}\n")
223
+ if exc_info:
224
+ import traceback
225
+ traceback.print_exc(file=f)
226
+ except Exception:
227
+ pass # best-effort logging
228
+
229
+
230
+ CONFIG_FILE = os.path.join(os.path.expanduser("~"), ".cct_ai_config.json")
231
+ PROVIDERS_FILE = os.path.join(os.path.expanduser("~"), ".cct_providers.json")
232
+ BACKUP_PROVIDERS_FILE = os.path.join(os.path.expanduser("~"), ".cct_backup_providers.json")
233
+
234
+ _lock = threading.Lock()
235
+
236
+ # Registry of built-in provider classes
237
+ _BUILTIN_PROVIDERS: dict[str, type[BaseProvider]] = {}
238
+
239
+ # Dynamically added/loaded provider instances
240
+ _PROVIDER_INSTANCES: dict[str, BaseProvider] = {}
241
+
242
+ # ── Model Cache ────────────────────────────────────────────────────────────
243
+ # Format: {provider_id: (models_list, timestamp)}
244
+ # The in-memory dict is a fast-path mirror of the on-disk cache under
245
+ # cache/models/ (see models/manager.py). Every write persists to disk so
246
+ # model lists survive restarts and never go stale between launches.
247
+ _model_cache: dict[str, tuple[list[str], float]] = {}
248
+ _CACHE_TTL = 300 # 5 minutes (in-memory mirror only)
249
+
250
+
251
+ def _from_models_manager(provider_id: str) -> Optional[list[str]]:
252
+ """Read the on-disk cache (cache/models/{provider_id}.json)."""
253
+ try:
254
+ from ..models import manager as _mgr
255
+ cached = _mgr.get_cached(provider_id)
256
+ if cached:
257
+ return list(cached[0])
258
+ except Exception:
259
+ pass
260
+ return None
261
+
262
+
263
+ def invalidate_model_cache(provider_id: str = None):
264
+ """Clear cached models. If provider_id is None, clears all.
265
+ Removes the on-disk cache too, so the next /model refresh re-fetches."""
266
+ with _lock:
267
+ if provider_id:
268
+ _model_cache.pop(provider_id, None)
269
+ else:
270
+ _model_cache.clear()
271
+ try:
272
+ from ..models import manager as _mgr
273
+ if provider_id:
274
+ _mgr.delete_cache(provider_id)
275
+ else:
276
+ import glob
277
+ for path in glob.glob(os.path.join(_mgr.CACHE_DIR, "*.json")):
278
+ try:
279
+ os.remove(path)
280
+ except Exception:
281
+ pass
282
+ except Exception:
283
+ pass
284
+
285
+
286
+ def get_cached_models(provider_id: str) -> Optional[list[str]]:
287
+ """Get cached models if still fresh. Returns None if stale or missing.
288
+ Falls back to the on-disk cache when the in-memory mirror is empty."""
289
+ with _lock:
290
+ entry = _model_cache.get(provider_id)
291
+ if entry is not None:
292
+ models, ts = entry
293
+ if time.time() - ts > _CACHE_TTL:
294
+ _model_cache.pop(provider_id, None)
295
+ else:
296
+ return list(models)
297
+ disk = _from_models_manager(provider_id)
298
+ if disk is not None:
299
+ with _lock:
300
+ _model_cache[provider_id] = (list(disk), time.time())
301
+ return list(disk)
302
+ return None
303
+
304
+
305
+ def set_cached_models(provider_id: str, models: list[str]):
306
+ """Store models in cache (memory + disk)."""
307
+ with _lock:
308
+ _model_cache[provider_id] = (list(models), time.time())
309
+ try:
310
+ from ..models import manager as _mgr
311
+ _mgr.write_cache(provider_id, list(models))
312
+ except Exception:
313
+ pass
314
+
315
+
316
+ # ── Request Cancellation ───────────────────────────────────────────────────
317
+ # Each in-flight fetch gets an Event. Setting it signals cancellation.
318
+ _pending_fetches: dict[str, threading.Event] = {}
319
+
320
+
321
+ def _cancel_token(provider_id: str) -> threading.Event:
322
+ with _lock:
323
+ old = _pending_fetches.pop(provider_id, None)
324
+ if old:
325
+ old.set() # signal cancellation to any previous request
326
+ ev = threading.Event()
327
+ _pending_fetches[provider_id] = ev
328
+ return ev
329
+
330
+
331
+ def _finish_fetch(provider_id: str):
332
+ with _lock:
333
+ _pending_fetches.pop(provider_id, None)
334
+
335
+
336
+ def cancel_fetch(provider_id: str):
337
+ """Cancel any in-flight model fetch for the given provider."""
338
+ with _lock:
339
+ ev = _pending_fetches.pop(provider_id, None)
340
+ if ev:
341
+ ev.set()
342
+
343
+
344
+ # ── Generic Fallback Model Fetch ───────────────────────────────────────────
345
+
346
+
347
+ def _mask_key(key: str) -> str:
348
+ """Mask API key for logging (show first 4 + last 4 chars)."""
349
+ if not key:
350
+ return ""
351
+ k = key.strip()
352
+ if len(k) <= 12:
353
+ return k[:4] + "****" + k[-4:] if len(k) > 8 else "****"
354
+ return k[:4] + "..." + k[-4:]
355
+
356
+
357
+ def _generic_fetch_models_openai(config: dict,
358
+ cancel_ev: threading.Event = None) -> list[str]:
359
+ """Generic model list fetch for any OpenAI-compatible /v1 endpoint.
360
+
361
+ Sends: GET {base_url}/models
362
+ Expects: {"data": [{"id": "model-name", ...}, ...]}
363
+ Handles cursor-based pagination (limit=100, after=last_id).
364
+ Returns sorted unique list of model IDs on success, [] on failure.
365
+ """
366
+ base_url = config.get("base_url", "").rstrip("/")
367
+ if not base_url:
368
+ _log("generic_openai: no base_url in config — returning []")
369
+ return []
370
+
371
+ api_key = config.get("api_key", "")
372
+ provider_id = config.get("provider", "?")
373
+ headers = {"Content-Type": "application/json"}
374
+ if api_key:
375
+ headers["Authorization"] = f"Bearer {api_key}"
376
+
377
+ url = f"{base_url}/models"
378
+ all_models = []
379
+ after = None
380
+ limit = 100
381
+
382
+ while True:
383
+ if cancel_ev and cancel_ev.is_set():
384
+ _log(f"generic_openai [{provider_id}]: cancelled mid-fetch "
385
+ f"({len(all_models)} so far)")
386
+ return all_models or []
387
+
388
+ params = {"limit": limit}
389
+ if after:
390
+ params["after"] = after
391
+
392
+ # Log the outgoing request (mask auth header)
393
+ safe_headers = dict(headers)
394
+ if "Authorization" in safe_headers:
395
+ safe_headers["Authorization"] = f"Bearer {_mask_key(api_key)}"
396
+ _log(f"generic_openai [{provider_id}]: GET {url} "
397
+ f"params={params} headers={safe_headers}")
398
+
399
+ try:
400
+ resp = requests.get(url, headers=headers,
401
+ params=params, timeout=10)
402
+ except requests.exceptions.ConnectionError:
403
+ _log(f"generic_openai [{provider_id}]: ConnectionError "
404
+ f"— cannot reach {base_url}")
405
+ return all_models or []
406
+ except requests.exceptions.Timeout:
407
+ _log(f"generic_openai [{provider_id}]: Timeout "
408
+ f"— {base_url}/models did not respond within 10s")
409
+ return all_models or []
410
+ except Exception as e:
411
+ _log(f"generic_openai [{provider_id}]: GET failed — {e}",
412
+ exc_info=True)
413
+ return all_models or []
414
+
415
+ # Log response summary
416
+ body_snippet = ""
417
+ try:
418
+ body_snippet = resp.text[:200]
419
+ except Exception:
420
+ pass
421
+ _log(f"generic_openai [{provider_id}]: HTTP {resp.status_code} "
422
+ f"({len(resp.content)} bytes) "
423
+ f"snippet={body_snippet}")
424
+
425
+ if resp.status_code == 401:
426
+ _log(f"generic_openai [{provider_id}]: HTTP 401 "
427
+ f"— invalid or missing API key")
428
+ return all_models or []
429
+ if resp.status_code == 403:
430
+ _log(f"generic_openai [{provider_id}]: HTTP 403 "
431
+ f"— access forbidden (key may lack permissions)")
432
+ return all_models or []
433
+ if resp.status_code >= 400:
434
+ _log(f"generic_openai [{provider_id}]: HTTP {resp.status_code} "
435
+ f"— unexpected error")
436
+ return all_models or []
437
+
438
+ # Parse response body
439
+ try:
440
+ data = resp.json()
441
+ except Exception as e:
442
+ _log(f"generic_openai [{provider_id}]: JSON parse failed — {e}",
443
+ exc_info=True)
444
+ return all_models or []
445
+
446
+ items = data.get("data", [])
447
+ if not isinstance(items, list):
448
+ _log(f"generic_openai [{provider_id}]: 'data' field is not a list "
449
+ f"(got {type(items).__name__}) — body keys: {list(data.keys())}")
450
+ return all_models or []
451
+
452
+ page_count = 0
453
+ for m in items:
454
+ if isinstance(m, dict) and m.get("id"):
455
+ all_models.append(str(m["id"]))
456
+ page_count += 1
457
+ _log(f"generic_openai [{provider_id}]: page got {page_count} models "
458
+ f"(total {len(all_models)})")
459
+
460
+ # Pagination: check has_more or use cursor from last item
461
+ if "has_more" in data:
462
+ if not data.get("has_more"):
463
+ break
464
+ if items:
465
+ after = items[-1].get("id")
466
+ if not after:
467
+ break
468
+ # Safety: don't loop forever
469
+ if len(all_models) > 5000:
470
+ _log(f"generic_openai [{provider_id}]: safety limit "
471
+ f"(5000 models) reached — stopping pagination")
472
+ break
473
+
474
+ result = sorted(set(all_models))
475
+ _log(f"generic_openai [{provider_id}]: done — {len(result)} unique models")
476
+ return result
477
+
478
+
479
+ def _generic_fetch_models_ollama(config: dict,
480
+ cancel_ev: threading.Event = None) -> list[str]:
481
+ """Fetch model list from a local Ollama instance.
482
+
483
+ Sends: GET {base_url}/api/tags
484
+ Expects: {"models": [{"name": "llama3.1:8b", ...}, ...]}
485
+ """
486
+ # Dual-truth fix: accept base_url OR ollama_url OR CCTConfig ollama_endpoint
487
+ base_url = (config.get("base_url") or config.get("ollama_url") or "").strip()
488
+ if not base_url:
489
+ try:
490
+ from .. import config as _cctcfg
491
+ base_url = _cctcfg.get_config().ollama_endpoint
492
+ except Exception:
493
+ pass
494
+ base_url = (base_url or "http://localhost:11434").rstrip("/")
495
+ # Handle OpenAI-compat /v1 suffix — strip for /api/tags
496
+ if base_url.endswith("/v1"):
497
+ base_url = base_url[:-3].rstrip("/")
498
+ provider_id = config.get("provider", "ollama")
499
+ url = f"{base_url}/api/tags"
500
+
501
+ def _catalog_fallback():
502
+ try:
503
+ from ..ollama_catalog import all_models as _all
504
+ return sorted({m["name"] for m in _all()})
505
+ except Exception:
506
+ return []
507
+
508
+ if cancel_ev and cancel_ev.is_set():
509
+ return _catalog_fallback() or []
510
+
511
+ _log(f"generic_ollama [{provider_id}]: GET {url}")
512
+
513
+ try:
514
+ resp = requests.get(url, timeout=12)
515
+ except requests.exceptions.ConnectionError:
516
+ _log(f"generic_ollama [{provider_id}]: ConnectionError "
517
+ f"— is Ollama running at {base_url}? — returning catalog")
518
+ return _catalog_fallback() or []
519
+ except requests.exceptions.Timeout:
520
+ _log(f"generic_ollama [{provider_id}]: Timeout "
521
+ f"— {url} did not respond within 12s — returning catalog")
522
+ return _catalog_fallback() or []
523
+ except Exception as e:
524
+ _log(f"generic_ollama [{provider_id}]: GET failed — {e}",
525
+ exc_info=True)
526
+ return _catalog_fallback() or []
527
+
528
+ _log(f"generic_ollama [{provider_id}]: HTTP {resp.status_code} "
529
+ f"({len(resp.content)} bytes)")
530
+
531
+ if resp.status_code >= 400:
532
+ _log(f"generic_ollama [{provider_id}]: HTTP {resp.status_code} — returning catalog")
533
+ return _catalog_fallback() or []
534
+
535
+ try:
536
+ data = resp.json()
537
+ except Exception as e:
538
+ _log(f"generic_ollama [{provider_id}]: JSON parse failed — {e}",
539
+ exc_info=True)
540
+ return _catalog_fallback() or []
541
+
542
+ raw_models = data.get("models", [])
543
+ if not isinstance(raw_models, list):
544
+ _log(f"generic_ollama [{provider_id}]: 'models' field is not a list "
545
+ f"(got {type(raw_models).__name__}) — returning catalog")
546
+ return _catalog_fallback() or []
547
+
548
+ models = []
549
+ for m in raw_models:
550
+ name = m.get("name") if isinstance(m, dict) else None
551
+ if name:
552
+ models.append(str(name))
553
+
554
+ result = sorted(set(models))
555
+ # Merge with curated 250+ catalog so ModelScreen shows all ollama models in model.py
556
+ try:
557
+ from ..ollama_catalog import all_models as _ollama_all
558
+ catalog_names = [m["name"] for m in _ollama_all()]
559
+ result = sorted(set(result) | set(catalog_names))
560
+ _log(f"generic_ollama [{provider_id}]: merged catalog — {len(result)} total")
561
+ except Exception:
562
+ pass
563
+ _log(f"generic_ollama [{provider_id}]: done — {len(result)} models")
564
+ return result
565
+
566
+
567
+ def add_custom_ollama_model(model_name: str, base_url: str = None) -> bool:
568
+ """Add a custom Ollama model to the configuration.
569
+
570
+ This allows users to add models from their local Ollama instance
571
+ that may not be in the default list. The model will be validated
572
+ by checking if it exists in the local Ollama instance.
573
+
574
+ Args:
575
+ model_name: The name of the model to add (e.g., "my-custom-model:latest")
576
+ base_url: Optional custom Ollama base URL (defaults to http://localhost:11434)
577
+
578
+ Returns:
579
+ True if the model was added successfully, False otherwise
580
+ """
581
+ import json
582
+ import os
583
+
584
+ base_url = base_url or "http://localhost:11434"
585
+ config_file = os.path.join(os.path.expanduser("~"), ".cct_ai_config.json")
586
+
587
+ try:
588
+ # First, verify the model exists in the local Ollama instance
589
+ import requests
590
+ resp = requests.get(f"{base_url}/api/tags", timeout=5)
591
+ if resp.status_code != 200:
592
+ _log(f"add_custom_ollama_model: Failed to connect to Ollama at {base_url}")
593
+ return False
594
+
595
+ data = resp.json()
596
+ available_models = [m.get("name", "") for m in data.get("models", [])]
597
+
598
+ if model_name not in available_models:
599
+ _log(f"add_custom_ollama_model: Model '{model_name}' not found in Ollama. "
600
+ f"Available models: {available_models[:10]}...")
601
+ return False
602
+
603
+ # Load existing config
604
+ config = {}
605
+ if os.path.exists(config_file):
606
+ with open(config_file, "r", encoding="utf-8") as f:
607
+ config = json.load(f)
608
+
609
+ # Add the custom model to the Ollama provider config
610
+ if "ollama" not in config:
611
+ config["ollama"] = {}
612
+
613
+ if "custom_models" not in config["ollama"]:
614
+ config["ollama"]["custom_models"] = []
615
+
616
+ if model_name not in config["ollama"]["custom_models"]:
617
+ config["ollama"]["custom_models"].append(model_name)
618
+
619
+ # Save the updated config
620
+ with open(config_file, "w", encoding="utf-8") as f:
621
+ json.dump(config, f, indent=2, ensure_ascii=False)
622
+
623
+ _log(f"add_custom_ollama_model: Successfully added '{model_name}'")
624
+ return True
625
+
626
+ except Exception as e:
627
+ _log(f"add_custom_ollama_model: Error adding model — {e}", exc_info=True)
628
+ return False
629
+
630
+
631
+ def remove_custom_ollama_model(model_name: str) -> bool:
632
+ """Remove a custom Ollama model from the configuration.
633
+
634
+ Args:
635
+ model_name: The name of the model to remove
636
+
637
+ Returns:
638
+ True if the model was removed successfully, False otherwise
639
+ """
640
+ import json
641
+ import os
642
+
643
+ config_file = os.path.join(os.path.expanduser("~"), ".cct_ai_config.json")
644
+
645
+ try:
646
+ # Load existing config
647
+ if not os.path.exists(config_file):
648
+ return False
649
+
650
+ with open(config_file, "r", encoding="utf-8") as f:
651
+ config = json.load(f)
652
+
653
+ # Remove the custom model from Ollama provider config
654
+ if "ollama" in config and "custom_models" in config["ollama"]:
655
+ if model_name in config["ollama"]["custom_models"]:
656
+ config["ollama"]["custom_models"].remove(model_name)
657
+
658
+ # Save the updated config
659
+ with open(config_file, "w", encoding="utf-8") as f:
660
+ json.dump(config, f, indent=2, ensure_ascii=False)
661
+
662
+ _log(f"remove_custom_ollama_model: Successfully removed '{model_name}'")
663
+ return True
664
+
665
+ return False
666
+
667
+ except Exception as e:
668
+ _log(f"remove_custom_ollama_model: Error removing model — {e}", exc_info=True)
669
+ return False
670
+
671
+
672
+ def get_custom_ollama_models() -> list[str]:
673
+ """Get the list of custom Ollama models added by the user.
674
+
675
+ Returns:
676
+ List of custom model names
677
+ """
678
+ import json
679
+ import os
680
+
681
+ config_file = os.path.join(os.path.expanduser("~"), ".cct_ai_config.json")
682
+
683
+ try:
684
+ if not os.path.exists(config_file):
685
+ return []
686
+
687
+ with open(config_file, "r", encoding="utf-8") as f:
688
+ config = json.load(f)
689
+
690
+ return config.get("ollama", {}).get("custom_models", [])
691
+
692
+ except Exception:
693
+ return []
694
+
695
+
696
+ def refresh_ollama_models(base_url: str = None) -> list[str]:
697
+ """Refresh the list of available Ollama models from the local instance.
698
+
699
+ This function queries the local Ollama instance and returns all
700
+ available models, including any custom models the user has added.
701
+
702
+ Args:
703
+ base_url: Optional custom Ollama base URL (defaults to http://localhost:11434)
704
+
705
+ Returns:
706
+ List of available model names
707
+ """
708
+ base_url = base_url or "http://localhost:11434"
709
+
710
+ try:
711
+ # Get models from Ollama instance
712
+ ollama_models = _generic_fetch_models_ollama({"base_url": base_url})
713
+
714
+ # Get custom models from config
715
+ custom_models = get_custom_ollama_models()
716
+
717
+ # Merge and deduplicate
718
+ all_models = sorted(set(ollama_models + custom_models))
719
+
720
+ _log(f"refresh_ollama_models: Found {len(all_models)} models "
721
+ f"({len(ollama_models)} from Ollama, {len(custom_models)} custom)")
722
+
723
+ return all_models
724
+
725
+ except Exception as e:
726
+ _log(f"refresh_ollama_models: Error refreshing models — {e}", exc_info=True)
727
+ return get_custom_ollama_models() # Return custom models as fallback
728
+
729
+ def discover_models(provider_id: str, config: dict = None) -> list[str]:
730
+ """Discover all models for a provider using live API fetch with caching.
731
+
732
+ Tries registered provider class first. Falls back to generic HTTP
733
+ fetch for unknown providers (OpenAI-compatible or Ollama).
734
+ Returns cached models if fresh, otherwise fetches live.
735
+ Handles cancellation, pagination, and error recovery.
736
+ """
737
+ cached = get_cached_models(provider_id)
738
+ if cached is not None:
739
+ _log(f"discover_models [{provider_id}]: returning {len(cached)} cached models")
740
+ return cached
741
+
742
+ # Try unified dynamic ProviderDiscoveryManager first
743
+ try:
744
+ from .discovery_manager import get_discovery_manager
745
+ dm = get_discovery_manager()
746
+ discov_list = dm.discover_provider(provider_id, config=config)
747
+ if discov_list:
748
+ model_ids = [m.model_id for m in discov_list]
749
+ _log(f"discover_models [{provider_id}]: DiscoveryManager returned {len(model_ids)} models")
750
+ set_cached_models(provider_id, model_ids)
751
+ return model_ids
752
+ except Exception as e:
753
+ _log(f"discover_models [{provider_id}]: DiscoveryManager fallback: {e}")
754
+
755
+ cfg = config or {}
756
+ cfg["provider"] = provider_id
757
+ cls = get_provider_class(provider_id)
758
+
759
+ cancel_ev = _cancel_token(provider_id)
760
+ try:
761
+ if cls:
762
+ _log(f"discover_models [{provider_id}]: using registered class "
763
+ f"{cls.__name__}")
764
+ inst = cls(cfg)
765
+ if hasattr(inst, "fetch_models_paginated"):
766
+ models = inst.fetch_models_paginated(cancel_ev)
767
+ else:
768
+ if cancel_ev.is_set():
769
+ return []
770
+ models = inst.fetch_models()
771
+ else:
772
+ # No registered provider class — try generic HTTP fetch
773
+ api_style = cfg.get("api_style", "openai")
774
+ _log(f"discover_models [{provider_id}]: no registered class, "
775
+ f"api_style={api_style!r} — trying generic fetch")
776
+ if api_style == "openai":
777
+ models = _generic_fetch_models_openai(cfg, cancel_ev)
778
+ elif api_style == "ollama":
779
+ models = _generic_fetch_models_ollama(cfg, cancel_ev)
780
+ else:
781
+ _log(f"discover_models [{provider_id}]: unsupported api_style "
782
+ f"{api_style!r} — no model fetch available")
783
+ models = []
784
+
785
+ if cancel_ev.is_set():
786
+ _log(f"discover_models [{provider_id}]: cancelled after fetch")
787
+ return []
788
+ if models:
789
+ _log(f"discover_models [{provider_id}]: caching {len(models)} models")
790
+ set_cached_models(provider_id, models)
791
+ else:
792
+ _log(f"discover_models [{provider_id}]: fetch returned 0 models")
793
+ return models or []
794
+ except Exception:
795
+ _log(f"discover_models [{provider_id}]: unhandled exception",
796
+ exc_info=True)
797
+ return []
798
+ finally:
799
+ _finish_fetch(provider_id)
800
+
801
+
802
+ def discover_all_models(providers: list[dict], timeout: int = 8) -> dict[str, list[str]]:
803
+ """Discover models for multiple providers in parallel.
804
+
805
+ Returns {provider_id: models_list}. Failed providers get empty list.
806
+ """
807
+ results = {}
808
+ threads = []
809
+
810
+ def _fetch(p):
811
+ pid = p.get("id")
812
+ config = {"base_url": p.get("url", ""), "api_style": p.get("api_style", "openai")}
813
+ results[pid] = discover_models(pid, config)
814
+
815
+ for p in providers:
816
+ t = threading.Thread(target=_fetch, args=(p,), daemon=True)
817
+ t.start()
818
+ threads.append(t)
819
+
820
+ for t in threads:
821
+ t.join(timeout=timeout)
822
+
823
+ return results
824
+
825
+
826
+ # ── Dynamic Provider List ──────────────────────────────────────────────────
827
+
828
+ def build_provider_list() -> list[dict]:
829
+ """Build the full provider list with dynamically discovered models.
830
+
831
+ Returns the built-in SDK providers as a dict keyed by provider id.
832
+ Use enrich_providers() to merge into a full static list like ALL_PROVIDERS.
833
+ """
834
+ providers = list_builtin_providers()
835
+ for p in providers:
836
+ pid = p.get("id")
837
+ cached = get_cached_models(pid)
838
+ if cached:
839
+ p["models"] = cached
840
+ p["models_count"] = len(cached)
841
+ return providers
842
+
843
+
844
+ def enrich_providers(static_providers: list[dict]) -> list[dict]:
845
+ """Merge cached/live model data into a static provider list.
846
+
847
+ Accepts the full ALL_PROVIDERS list (100+ providers) and enriches each
848
+ entry with live model data from the cache. Never removes or reorders
849
+ providers — only updates the 'models' and 'models_count' fields.
850
+ Preserves alphabetical ordering and all metadata.
851
+ """
852
+ enriched = []
853
+ for p in static_providers:
854
+ entry = dict(p)
855
+ pid = entry.get("id")
856
+ cached = get_cached_models(pid)
857
+ if cached:
858
+ entry["models"] = cached
859
+ entry["models_count"] = len(cached)
860
+ else:
861
+ entry["models"] = list(entry.get("models", []))
862
+ enriched.append(entry)
863
+ return enriched
864
+
865
+
866
+ def register_provider(cls: type[BaseProvider]):
867
+ _BUILTIN_PROVIDERS[cls.ID] = cls
868
+
869
+
870
+ def register_builtins():
871
+ register_provider(OpenAIProvider)
872
+ register_provider(CustomOpenAIProvider)
873
+ register_provider(AnthropicProvider)
874
+ register_provider(GeminiProvider)
875
+
876
+
877
+ def get_provider_class(provider_id: str) -> Optional[type[BaseProvider]]:
878
+ return _BUILTIN_PROVIDERS.get(provider_id)
879
+
880
+
881
+ def list_builtin_providers() -> list[dict]:
882
+ """Return info dict for every registered built-in provider."""
883
+ out = []
884
+ for pid, cls in _BUILTIN_PROVIDERS.items():
885
+ d = cls.to_provider_dict()
886
+ d["builtin"] = True
887
+ out.append(d)
888
+ return sorted(out, key=lambda x: x["name"].lower())
889
+
890
+
891
+ # ---------------------------------------------------------------------------
892
+ # Provider config persistence
893
+
894
+ def get_env_api_key(provider_id: str) -> str:
895
+ """Check standard environment variables for provider API keys."""
896
+ if not provider_id:
897
+ return ""
898
+ pid = str(provider_id).lower().replace("-", "_")
899
+ candidates = [
900
+ f"{pid.upper()}_API_KEY",
901
+ f"{pid.upper()}_KEY",
902
+ f"{pid.upper()}_TOKEN",
903
+ ]
904
+ if pid == "gemini":
905
+ candidates.extend(["GOOGLE_API_KEY", "GEMINI_API_KEY"])
906
+ elif pid in ("google", "google_genai"):
907
+ candidates.extend(["GEMINI_API_KEY", "GOOGLE_API_KEY"])
908
+ elif pid == "nvidia":
909
+ candidates.extend(["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "NGC_API_KEY"])
910
+ elif pid in ("openai", "custom"):
911
+ candidates.extend(["OPENAI_API_KEY", "AI_API_KEY", "CAT_API_KEY"])
912
+ elif pid == "groq":
913
+ candidates.extend(["GROQ_API_KEY"])
914
+ elif pid == "openrouter":
915
+ candidates.extend(["OPENROUTER_API_KEY"])
916
+ elif pid == "anthropic":
917
+ candidates.extend(["ANTHROPIC_API_KEY"])
918
+ elif pid == "deepseek":
919
+ candidates.extend(["DEEPSEEK_API_KEY"])
920
+ elif pid == "mistral":
921
+ candidates.extend(["MISTRAL_API_KEY"])
922
+ elif pid == "cohere":
923
+ candidates.extend(["COHERE_API_KEY"])
924
+ elif pid == "together":
925
+ candidates.extend(["TOGETHER_API_KEY", "TOGETHERAI_API_KEY"])
926
+ elif pid == "fireworks":
927
+ candidates.extend(["FIREWORKS_API_KEY"])
928
+
929
+ for c in candidates:
930
+ val = os.environ.get(c, "").strip()
931
+ if val:
932
+ return val
933
+ # Generic fallbacks
934
+ for g in ("AI_API_KEY", "CAT_API_KEY"):
935
+ val = os.environ.get(g, "").strip()
936
+ if val:
937
+ return val
938
+ return ""
939
+
940
+
941
+ def load_config() -> dict:
942
+ cfg = {}
943
+ if os.path.exists(CONFIG_FILE):
944
+ try:
945
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
946
+ cfg = json.load(f)
947
+ except Exception:
948
+ cfg = {}
949
+ if not cfg:
950
+ cfg = {"provider": None, "api_key": None,
951
+ "model": _default_model_id(), "ollama_url": "http://localhost:11434"}
952
+
953
+ # Auto-resolve API key from environment if not set
954
+ prov = cfg.get("provider")
955
+ if prov and not (cfg.get("api_key") or "").strip():
956
+ env_key = get_env_api_key(prov)
957
+ if env_key:
958
+ cfg["api_key"] = env_key
959
+ if prov == "nvidia" and cfg.get("api_style") != "openai":
960
+ cfg["api_style"] = "openai"
961
+ return cfg
962
+
963
+
964
+ def _default_model_id() -> str:
965
+ """Data-driven default model: providers.json 'default_model' for the
966
+ default provider (openai); hardcoded value as last resort."""
967
+ try:
968
+ from ..models import manager as _mgr
969
+ return _mgr.default_model_for("openai", "gpt-4o")
970
+ except Exception:
971
+ return "gpt-4o"
972
+
973
+
974
+ def save_config(config: dict):
975
+ if config.get("provider") == "nvidia":
976
+ config["api_style"] = "openai"
977
+ with open(CONFIG_FILE, "w") as f:
978
+ json.dump(config, f)
979
+ try:
980
+ from .. import config as _cctconfig
981
+ cfg = _cctconfig.get_config()
982
+ if config.get("provider"):
983
+ cfg.default_ai_provider = config["provider"]
984
+ if config.get("model"):
985
+ cfg.default_model = config["model"]
986
+ # Sync ollama endpoint both ways (dual-truth fix)
987
+ ollama_url = config.get("ollama_url") or config.get("base_url") if config.get("provider") == "ollama" else None
988
+ if ollama_url:
989
+ try:
990
+ cfg.ollama_endpoint = ollama_url.rstrip("/")
991
+ except Exception:
992
+ pass
993
+ elif config.get("ollama_url"):
994
+ try:
995
+ cfg.ollama_endpoint = config["ollama_url"].rstrip("/")
996
+ except Exception:
997
+ pass
998
+ _cctconfig.save_config(cfg)
999
+ except Exception:
1000
+ pass
1001
+
1002
+
1003
+ def resolve_config(config: dict = None) -> dict:
1004
+ """Return config with sensible defaults filled in."""
1005
+ cfg = config or load_config()
1006
+ provider_id = cfg.get("provider")
1007
+ cls = get_provider_class(provider_id) if provider_id else None
1008
+ if cls:
1009
+ if not cfg.get("model"):
1010
+ cfg["model"] = cls.default_model()
1011
+ if not cfg.get("base_url") and not cfg.get("api_url"):
1012
+ cfg["base_url"] = cls._default_url() if hasattr(cls, "_default_url") else ""
1013
+ return cfg
1014
+
1015
+
1016
+ def get_provider(config: dict = None) -> Optional[BaseProvider]:
1017
+ """Get a provider instance for the given or current config."""
1018
+ cfg = config or load_config()
1019
+ provider_id = cfg.get("provider")
1020
+ if not provider_id:
1021
+ return None
1022
+ cls = get_provider_class(provider_id)
1023
+ if not cls:
1024
+ return None
1025
+ return cls(cfg)
1026
+
1027
+
1028
+ # ---------------------------------------------------------------------------
1029
+ # Multi-provider storage (for the provider manager UI)
1030
+
1031
+ def load_all_providers() -> list[dict]:
1032
+ """Load user-saved providers from disk."""
1033
+ if os.path.exists(PROVIDERS_FILE):
1034
+ try:
1035
+ with open(PROVIDERS_FILE, "r") as f:
1036
+ return json.load(f)
1037
+ except Exception:
1038
+ pass
1039
+ return []
1040
+
1041
+
1042
+ def save_all_providers(providers: list[dict]):
1043
+ with open(PROVIDERS_FILE, "w") as f:
1044
+ json.dump(providers, f, indent=2)
1045
+
1046
+
1047
+ def add_provider(entry: dict):
1048
+ providers = load_all_providers()
1049
+ providers.append(entry)
1050
+ save_all_providers(providers)
1051
+
1052
+
1053
+ def delete_provider(provider_id: str):
1054
+ providers = load_all_providers()
1055
+ providers = [p for p in providers if p.get("id") != provider_id]
1056
+ save_all_providers(providers)
1057
+
1058
+
1059
+ def update_provider(provider_id: str, updates: dict):
1060
+ providers = load_all_providers()
1061
+ for p in providers:
1062
+ if p.get("id") == provider_id:
1063
+ p.update(updates)
1064
+ break
1065
+ save_all_providers(providers)
1066
+
1067
+
1068
+ def import_providers_json(path: str) -> tuple[int, str]:
1069
+ """Import providers from a JSON file. Returns (count, message)."""
1070
+ try:
1071
+ with open(path, "r") as f:
1072
+ data = json.load(f)
1073
+ if isinstance(data, dict):
1074
+ data = [data]
1075
+ if not isinstance(data, list):
1076
+ return 0, "Invalid format: expected a list of providers"
1077
+ existing = load_all_providers()
1078
+ existing_ids = {p.get("id") for p in existing}
1079
+ added = 0
1080
+ for entry in data:
1081
+ if entry.get("id") and entry["id"] not in existing_ids:
1082
+ existing.append(entry)
1083
+ existing_ids.add(entry["id"])
1084
+ added += 1
1085
+ save_all_providers(existing)
1086
+ return added, f"Imported {added} provider(s)"
1087
+ except Exception as e:
1088
+ return 0, f"Import failed: {e}"
1089
+
1090
+
1091
+ def verify_provider_count(static_providers: list[dict]) -> int:
1092
+ """Regression check: ensure provider count never decreases unexpectedly.
1093
+ Returns the count. Raises ValueError if count is suspiciously low (<20)."""
1094
+ count = len(static_providers)
1095
+ if count < 20:
1096
+ raise ValueError(
1097
+ f"Provider count dropped to {count} — "
1098
+ "this should never happen. ALL_PROVIDERS is the authoritative list."
1099
+ )
1100
+ return count
1101
+
1102
+
1103
+ def export_providers_json(path: str) -> tuple[bool, str]:
1104
+ """Export all user providers to a JSON file."""
1105
+ try:
1106
+ providers = load_all_providers()
1107
+ with open(path, "w") as f:
1108
+ json.dump(providers, f, indent=2)
1109
+ return True, f"Exported {len(providers)} provider(s) to {path}"
1110
+ except Exception as e:
1111
+ return False, f"Export failed: {e}"
1112
+
1113
+
1114
+ def mask_key(key: str) -> str:
1115
+ """Mask an API key for display."""
1116
+ if not key or len(key) < 8:
1117
+ return "****"
1118
+ return key[:3] + "*" * (len(key) - 6) + key[-3:]
1119
+
1120
+
1121
+ # ---------------------------------------------------------------------------
1122
+ # Backup providers (v0.7.8 BONUS 1) — automatic failover chain.
1123
+ # A prioritized, unlimited list of fallback provider configs. When the
1124
+ # primary provider exhausts quota / times out / is rate limited or
1125
+ # offline, aicore.query_ai/stream_ai fall back to the next enabled
1126
+ # entry in priority order without the conversation noticing (session
1127
+ # memory/context live in the UI, not on the provider). File is kept
1128
+ # separate from ~/.cct_ai_config.json so save_config() can't clobber it.
1129
+ #
1130
+ # v0.7.9.5: Ollama is now a first-class backup provider option. When
1131
+ # configured as a backup, it provides a reliable local fallback that
1132
+ # works offline and has no rate limits or quota issues.
1133
+
1134
+ DEFAULT_STATUS = {
1135
+ "provider": "", "model": "", "api_key": "", "base_url": "",
1136
+ "api_style": "openai", "name": "", "enabled": True,
1137
+ "status": "disconnected", "last_used": 0, "priority": 0,
1138
+ "latency_ms": None, "fallback_models": [], "cost_tier": "free",
1139
+ "capabilities": []
1140
+ }
1141
+
1142
+ # Default backup providers list - Ollama is included as a recommended
1143
+ # local fallback that always works when online providers fail.
1144
+ DEFAULT_BACKUP_PROVIDERS = [
1145
+ {
1146
+ "provider": "ollama",
1147
+ "model": "llama3.3",
1148
+ "api_key": "",
1149
+ "base_url": "http://localhost:11434",
1150
+ "api_style": "ollama",
1151
+ "name": "Ollama (Local)",
1152
+ "enabled": True,
1153
+ "status": "disconnected",
1154
+ "last_used": 0,
1155
+ "priority": 0
1156
+ },
1157
+ {
1158
+ "provider": "groq",
1159
+ "model": "llama-3.3-70b-versatile",
1160
+ "api_key": "",
1161
+ "base_url": "https://api.groq.com/openai/v1",
1162
+ "api_style": "openai",
1163
+ "name": "Groq (Fast)",
1164
+ "enabled": True,
1165
+ "status": "disconnected",
1166
+ "last_used": 0,
1167
+ "priority": 1
1168
+ },
1169
+ {
1170
+ "provider": "openrouter",
1171
+ "model": "openai/gpt-4o-mini",
1172
+ "api_key": "",
1173
+ "base_url": "https://openrouter.ai/api/v1",
1174
+ "api_style": "openai",
1175
+ "name": "OpenRouter (Multi)",
1176
+ "enabled": True,
1177
+ "status": "disconnected",
1178
+ "last_used": 0,
1179
+ "priority": 2
1180
+ }
1181
+ ]
1182
+
1183
+
1184
+ def load_backup_providers() -> list[dict]:
1185
+ """Load the ordered backup-provider list from disk. Never raises.
1186
+
1187
+ v0.7.9.5: If no backup providers are configured, returns the default
1188
+ list which includes Ollama as a local fallback. This ensures there's
1189
+ always a working backup chain, even for new installations.
1190
+ """
1191
+ try:
1192
+ if os.path.exists(BACKUP_PROVIDERS_FILE):
1193
+ with open(BACKUP_PROVIDERS_FILE, "r", encoding="utf-8") as f:
1194
+ data = json.load(f)
1195
+ if isinstance(data, list) and len(data) > 0:
1196
+ out = []
1197
+ for i, entry in enumerate(data):
1198
+ if not isinstance(entry, dict):
1199
+ continue
1200
+ rec = dict(DEFAULT_STATUS)
1201
+ rec.update({k: v for k, v in entry.items() if k in DEFAULT_STATUS})
1202
+ rec["priority"] = i
1203
+ out.append(rec)
1204
+ return out
1205
+ except Exception:
1206
+ pass
1207
+
1208
+ # v0.7.9.5: Return default backup providers (includes Ollama)
1209
+ # This ensures a working failover chain even for new installations
1210
+ _log("No backup providers configured, using defaults (includes Ollama)")
1211
+ return [dict(entry) for entry in DEFAULT_BACKUP_PROVIDERS]
1212
+
1213
+
1214
+ def save_backup_providers(providers: list[dict]) -> bool:
1215
+ """Persist the ordered backup-provider list. Returns True on success."""
1216
+ try:
1217
+ clean = []
1218
+ for i, entry in enumerate(providers or []):
1219
+ rec = {k: v for k, v in entry.items() if k in DEFAULT_STATUS}
1220
+ rec["priority"] = i
1221
+ clean.append(rec)
1222
+ with open(BACKUP_PROVIDERS_FILE, "w", encoding="utf-8") as f:
1223
+ json.dump(clean, f, indent=2)
1224
+ return True
1225
+ except Exception:
1226
+ return False
1227
+
1228
+
1229
+ def add_backup_provider(entry: dict) -> bool:
1230
+ """Add a new provider configuration to the dynamic backup pool."""
1231
+ if not isinstance(entry, dict):
1232
+ return False
1233
+ try:
1234
+ providers = load_backup_providers()
1235
+ prov_id = str(entry.get("provider", "")).strip().lower()
1236
+ model = str(entry.get("model", "")).strip()
1237
+ if not prov_id or not model:
1238
+ return False
1239
+
1240
+ # Update if existing, or append
1241
+ existing = next((p for p in providers if p.get("provider", "").lower() == prov_id and p.get("model") == model), None)
1242
+ if existing:
1243
+ existing.update({k: v for k, v in entry.items() if k in DEFAULT_STATUS})
1244
+ else:
1245
+ rec = dict(DEFAULT_STATUS)
1246
+ rec.update({k: v for k, v in entry.items() if k in DEFAULT_STATUS})
1247
+ rec["priority"] = len(providers)
1248
+ providers.append(rec)
1249
+
1250
+ return save_backup_providers(providers)
1251
+ except Exception:
1252
+ return False
1253
+
1254
+
1255
+ def remove_backup_provider(identifier: Any) -> bool:
1256
+ """Remove a backup provider by 0-based index or provider_id."""
1257
+ try:
1258
+ providers = load_backup_providers()
1259
+ if isinstance(identifier, int) and 0 <= identifier < len(providers):
1260
+ providers.pop(identifier)
1261
+ return save_backup_providers(providers)
1262
+
1263
+ target = str(identifier).strip().lower()
1264
+ if target.isdigit():
1265
+ idx = int(target)
1266
+ if 0 <= idx < len(providers):
1267
+ providers.pop(idx)
1268
+ return save_backup_providers(providers)
1269
+
1270
+ filtered = [p for p in providers if p.get("provider", "").lower() != target]
1271
+ if len(filtered) != len(providers):
1272
+ return save_backup_providers(filtered)
1273
+ return False
1274
+ except Exception:
1275
+ return False
1276
+
1277
+
1278
+ def reorder_backup_provider(from_index: int, to_index: int) -> bool:
1279
+ """Move a backup provider from one priority position to another."""
1280
+ try:
1281
+ providers = load_backup_providers()
1282
+ if not (0 <= from_index < len(providers) and 0 <= to_index < len(providers)):
1283
+ return False
1284
+ item = providers.pop(from_index)
1285
+ providers.insert(to_index, item)
1286
+ return save_backup_providers(providers)
1287
+ except Exception:
1288
+ return False
1289
+
1290
+
1291
+ def set_backup_provider_enabled(identifier: Any, enabled: bool) -> bool:
1292
+ """Toggle the enabled status of a backup provider."""
1293
+ try:
1294
+ providers = load_backup_providers()
1295
+ if isinstance(identifier, int) and 0 <= identifier < len(providers):
1296
+ providers[identifier]["enabled"] = bool(enabled)
1297
+ return save_backup_providers(providers)
1298
+
1299
+ target = str(identifier).strip().lower()
1300
+ if target.isdigit():
1301
+ idx = int(target)
1302
+ if 0 <= idx < len(providers):
1303
+ providers[idx]["enabled"] = bool(enabled)
1304
+ return save_backup_providers(providers)
1305
+
1306
+ matched = False
1307
+ for p in providers:
1308
+ if p.get("provider", "").lower() == target:
1309
+ p["enabled"] = bool(enabled)
1310
+ matched = True
1311
+ if matched:
1312
+ return save_backup_providers(providers)
1313
+ return False
1314
+ except Exception:
1315
+ return False
1316
+
1317
+
1318
+ def test_provider_connectivity(config: dict) -> tuple[bool, str, list[str]]:
1319
+ """Test connectivity, model listing, and latency without leaking API keys."""
1320
+ start = time.time()
1321
+ ok, msg, models = connect_provider(config)
1322
+ lat = int((time.time() - start) * 1000)
1323
+
1324
+ # Sanitize message of any keys
1325
+ from ..resilience.health_monitor import sanitize_error, get_health_monitor
1326
+ clean_msg = sanitize_error(msg)
1327
+
1328
+ prov = config.get("provider", "")
1329
+ model = config.get("model", "")
1330
+ mon = get_health_monitor()
1331
+ if ok:
1332
+ mon.record_success(prov, model, latency_ms=lat)
1333
+ # Also update status in saved backup provider record
1334
+ try:
1335
+ providers = load_backup_providers()
1336
+ for p in providers:
1337
+ if p.get("provider") == prov and p.get("model") == model:
1338
+ p["status"] = "connected"
1339
+ p["latency_ms"] = lat
1340
+ p["last_used"] = int(time.time())
1341
+ save_backup_providers(providers)
1342
+ except Exception:
1343
+ pass
1344
+ else:
1345
+ mon.record_failure(prov, model, error_message=clean_msg)
1346
+
1347
+ return ok, clean_msg, models
1348
+
1349
+
1350
+ def backup_configs() -> list[dict]:
1351
+ """The live request-config shapes for every enabled backup, in
1352
+ priority order — exactly what aicore feeds into _resolve_provider.
1353
+
1354
+ v0.7.9.5: Improved Ollama handling for backup failover. Ollama
1355
+ providers are automatically detected and configured with the
1356
+ correct base_url and api_style for reliable local inference.
1357
+ """
1358
+ out = []
1359
+ for entry in load_backup_providers():
1360
+ if not entry.get("enabled"):
1361
+ continue
1362
+ prov_id = entry.get("provider", "")
1363
+ key = entry.get("api_key", "")
1364
+ # Resolve API key from environment if missing in saved backup config
1365
+ if not (key or "").strip() and prov_id:
1366
+ env_k = get_env_api_key(prov_id)
1367
+ if env_k:
1368
+ key = env_k
1369
+
1370
+ cfg = {
1371
+ "provider": prov_id,
1372
+ "model": entry.get("model", ""),
1373
+ "api_key": key,
1374
+ "api_style": entry.get("api_style", "openai"),
1375
+ }
1376
+ # v0.7.10: Always resolve base_url, even if empty in entry
1377
+ base_url = entry.get("base_url", "")
1378
+ if not base_url:
1379
+ # Try to get base_url from providers.json
1380
+ provider_id = cfg.get("provider", "")
1381
+ if provider_id:
1382
+ try:
1383
+ from ..models.manager import load_providers
1384
+ for p in load_providers():
1385
+ if p.get("id") == provider_id:
1386
+ base_url = p.get("api_endpoint", "")
1387
+ if base_url:
1388
+ break
1389
+ except Exception:
1390
+ pass
1391
+ # Fallback to built-in provider URLs
1392
+ if not base_url:
1393
+ _builtin_urls = {
1394
+ "openai": "https://api.openai.com/v1",
1395
+ "anthropic": "https://api.anthropic.com",
1396
+ "groq": "https://api.groq.com/openai/v1",
1397
+ "openrouter": "https://openrouter.ai/api/v1",
1398
+ "ollama": "http://localhost:11434",
1399
+ "deepseek": "https://api.deepseek.com/v1",
1400
+ "mistral": "https://api.mistral.ai/v1",
1401
+ "cohere": "https://api.cohere.com/v1",
1402
+ "fireworks": "https://api.fireworks.ai/inference/v1",
1403
+ "together": "https://api.together.xyz/v1",
1404
+ "nvidia": "https://integrate.api.nvidia.com/v1",
1405
+ }
1406
+ base_url = _builtin_urls.get(provider_id, "")
1407
+ if base_url:
1408
+ cfg["base_url"] = base_url
1409
+
1410
+ # v0.7.9.5: Auto-configure Ollama settings for backup providers
1411
+ if cfg.get("provider") == "ollama":
1412
+ cfg["api_style"] = "ollama"
1413
+ cfg["needs_key"] = False
1414
+ # Ensure Ollama base_url is always set
1415
+ if not cfg.get("base_url"):
1416
+ cfg["base_url"] = "http://localhost:11434"
1417
+
1418
+ if cfg.get("provider") and cfg.get("model"):
1419
+ out.append((cfg, entry))
1420
+ # Auto-heal: if no enabled backups but there are disabled ones with valid keys, enable the most recent one
1421
+ if not out:
1422
+ try:
1423
+ all_entries = load_backup_providers()
1424
+ disabled_with_key = [
1425
+ e for e in all_entries
1426
+ if not e.get("enabled") and ((e.get("api_key") or "").strip() or get_env_api_key(e.get("provider", ""))) and e.get("provider") and e.get("model")
1427
+ ]
1428
+ if disabled_with_key:
1429
+ # Enable the one with most recent last_used or first
1430
+ disabled_with_key.sort(key=lambda x: x.get("last_used", 0), reverse=True)
1431
+ to_enable = disabled_with_key[0]
1432
+ # Persist enabled state
1433
+ for rec in all_entries:
1434
+ if rec.get("provider") == to_enable.get("provider") and rec.get("model") == to_enable.get("model"):
1435
+ rec["enabled"] = True
1436
+ rec["status"] = "disconnected"
1437
+ break
1438
+ save_backup_providers(all_entries)
1439
+ _log(f"Auto-enabled disabled backup {to_enable.get('provider')}/{to_enable.get('model')} — was disabled but had valid key")
1440
+ # Re-build out with newly enabled
1441
+ return backup_configs()
1442
+ except Exception:
1443
+ pass
1444
+ return out
1445
+
1446
+
1447
+ def mark_backup_used(entry: dict):
1448
+ """Record 'last used' + 'connected' on a backup entry and persist it."""
1449
+ if not isinstance(entry, dict):
1450
+ return
1451
+ try:
1452
+ import time as _t
1453
+ providers = load_backup_providers()
1454
+ changed = False
1455
+ for rec in providers:
1456
+ if (rec.get("provider") == entry.get("provider")
1457
+ and rec.get("model") == entry.get("model")):
1458
+ rec["last_used"] = int(_t.time())
1459
+ rec["status"] = "connected"
1460
+ changed = True
1461
+ if changed:
1462
+ save_backup_providers(providers)
1463
+ except Exception:
1464
+ pass
1465
+
1466
+
1467
+ # ---------------------------------------------------------------------------
1468
+ # Fetch models
1469
+
1470
+ def fetch_models_for(config: dict) -> list[str]:
1471
+ """Fetch live models for a given config. Uses cache if available.
1472
+ Falls back to generic HTTP fetch for unknown providers.
1473
+ Never raises."""
1474
+ provider_id = config.get("provider")
1475
+ if not provider_id:
1476
+ return []
1477
+ cached = get_cached_models(provider_id)
1478
+ if cached is not None:
1479
+ return cached
1480
+ cls = get_provider_class(provider_id)
1481
+ if cls:
1482
+ try:
1483
+ inst = cls(config)
1484
+ models = inst.fetch_models()
1485
+ if models:
1486
+ set_cached_models(provider_id, models)
1487
+ return models or []
1488
+ except Exception:
1489
+ pass
1490
+ # Generic fallback for providers without a registered class
1491
+ api_style = config.get("api_style", "openai")
1492
+ _log(f"fetch_models_for [{provider_id}]: no class found, "
1493
+ f"trying generic {api_style} fetch")
1494
+ if api_style == "openai":
1495
+ models = _generic_fetch_models_openai(config)
1496
+ elif api_style == "ollama":
1497
+ models = _generic_fetch_models_ollama(config)
1498
+ else:
1499
+ models = []
1500
+ if models:
1501
+ set_cached_models(provider_id, models)
1502
+ return models or []
1503
+
1504
+
1505
+ def fetch_models_for_provider_id(provider_id: str,
1506
+ api_key: str = "",
1507
+ base_url: str = "") -> list[str]:
1508
+ """Helper to build config and fetch models."""
1509
+ config = {"provider": provider_id, "api_key": api_key, "base_url": base_url}
1510
+ return fetch_models_for(config)
1511
+
1512
+
1513
+ def connect_provider(config: dict) -> tuple[bool, str, list[str]]:
1514
+ """Test connection for a provider config. Caches models on success.
1515
+ Falls back to generic HTTP fetch for unknown providers."""
1516
+ provider_id = config.get("provider")
1517
+ cls = get_provider_class(provider_id) if provider_id else None
1518
+ if cls:
1519
+ try:
1520
+ inst = cls(config)
1521
+ ok, msg, models = inst.connect()
1522
+ if ok and models:
1523
+ set_cached_models(provider_id, models)
1524
+ return ok, msg, models
1525
+ except Exception as e:
1526
+ return False, f"Error: {e}", []
1527
+ # Generic fallback: use model fetch as connectivity test
1528
+ _log(f"connect_provider [{provider_id}]: no class found, "
1529
+ f"trying generic fetch as connectivity test")
1530
+ models = fetch_models_for(config)
1531
+ if models:
1532
+ return True, f"Connected \u2014 {len(models)} model(s) visible", models
1533
+ return False, f"Unknown provider: {provider_id}", []
1534
+
1535
+
1536
+ # ---------------------------------------------------------------------------
1537
+ # Chat / Stream helpers with lifecycle-aware model validation
1538
+
1539
+ def chat(prompt: str, system_prompt: str = "",
1540
+ history: list = None, config: dict = None) -> str:
1541
+ """Send a chat message with lifecycle-aware model validation.
1542
+
1543
+ Validates the model before sending, auto-resolves aliases,
1544
+ and recovers from deprecation errors automatically.
1545
+ """
1546
+ try:
1547
+ from .lifecycle import validate_and_chat
1548
+ result = validate_and_chat(
1549
+ config or {}, prompt, system_prompt=system_prompt, history=history)
1550
+ return _fix_spacing(result)
1551
+ except ImportError:
1552
+ pass
1553
+ inst = get_provider(config)
1554
+ if not inst:
1555
+ return "AI not configured. Run setup first."
1556
+ return _fix_spacing(inst.chat(prompt, system_prompt=system_prompt, history=history))
1557
+
1558
+
1559
+ def stream(prompt: str, system_prompt: str = "",
1560
+ history: list = None, config: dict = None):
1561
+ """Stream a chat response with lifecycle-aware model validation."""
1562
+ try:
1563
+ from .lifecycle import validate_and_stream
1564
+ yield from validate_and_stream(
1565
+ config or {}, prompt, system_prompt=system_prompt, history=history)
1566
+ return
1567
+ except ImportError:
1568
+ pass
1569
+ inst = get_provider(config)
1570
+ if not inst:
1571
+ yield "AI not configured. Run setup first."
1572
+ return
1573
+ yield from inst.stream(prompt, system_prompt=system_prompt, history=history)
1574
+
1575
+
1576
+ # ── Lifecycle convenience re-exports ──────────────────────────────────────
1577
+
1578
+ def ensure_fresh_models(provider_id: str, config: dict = None,
1579
+ force: bool = False) -> list[str]:
1580
+ """Ensure models are fresh for a provider — refresh if stale."""
1581
+ try:
1582
+ from .lifecycle import ensure_fresh_models as _efm
1583
+ return _efm(provider_id, config, force=force)
1584
+ except ImportError:
1585
+ _log(f"ensure_fresh_models [{provider_id}]: lifecycle not available")
1586
+ return []
1587
+
1588
+
1589
+ def check_provider_health(provider_id: str,
1590
+ config: dict = None) -> dict:
1591
+ """Get health status for a provider."""
1592
+ try:
1593
+ from .lifecycle import check_health
1594
+ h = check_health(provider_id, config)
1595
+ return {
1596
+ "provider_id": h.provider_id,
1597
+ "online": h.online,
1598
+ "api_reachable": h.api_reachable,
1599
+ "authenticated": h.authenticated,
1600
+ "model_list_available": h.model_list_available,
1601
+ "chat_supported": h.chat_supported,
1602
+ "latency_ms": h.latency_ms,
1603
+ "last_checked": h.last_checked,
1604
+ "error": h.error,
1605
+ }
1606
+ except ImportError:
1607
+ return {"provider_id": provider_id, "online": False,
1608
+ "error": "lifecycle module not available"}
1609
+
1610
+
1611
+ def validate_and_resolve_model(provider_id: str, model: str) -> dict:
1612
+ """Validate a model and resolve any aliases.
1613
+
1614
+ Returns {"valid": bool, "model": resolved_model, "message": str}.
1615
+ """
1616
+ try:
1617
+ from .lifecycle import pre_request_check
1618
+ result = pre_request_check(provider_id, model)
1619
+ return {
1620
+ "valid": result.valid,
1621
+ "model": result.replacement or result.model,
1622
+ "message": result.message,
1623
+ }
1624
+ except ImportError:
1625
+ return {"valid": True, "model": model, "message": ""}
1626
+
1627
+
1628
+ # ── Dynamic model management (v0.7.4+) ────────────────────────────────────
1629
+ # These delegate to models/manager.py, the data-driven core: live API
1630
+ # fetch -> disk cache -> built-in fallback, with source tracking.
1631
+
1632
+ def get_models_with_source(provider_id: str, config: dict = None,
1633
+ force: bool = False) -> tuple[list[str], str]:
1634
+ """Resolve models for a provider. Returns (models, source) where
1635
+ source is 'live' | 'cached' | 'default'. Merges the saved config
1636
+ (api_key/base_url) so live fetches actually authenticate."""
1637
+ try:
1638
+ from ..models import manager as _mgr
1639
+ cfg = dict(config or {})
1640
+ saved = load_config()
1641
+ if saved.get("provider") == provider_id:
1642
+ cfg.setdefault("api_key", saved.get("api_key", ""))
1643
+ cfg.setdefault("base_url", saved.get("base_url") or saved.get("api_url", ""))
1644
+ if not cfg.get("base_url") and not cfg.get("api_url"):
1645
+ info = _mgr.get_provider(provider_id) or {}
1646
+ cfg["base_url"] = info.get("api_endpoint", "")
1647
+ return _mgr.get_models(provider_id, cfg, force_refresh=force)
1648
+ except Exception as e:
1649
+ _log(f"get_models_with_source [{provider_id}]: {e}", exc_info=True)
1650
+ return [], "default"
1651
+
1652
+
1653
+ def refresh_provider_models(provider_id: str, config: dict = None
1654
+ ) -> tuple[list[str], str]:
1655
+ """Force-refresh the model list for a provider from its official API."""
1656
+ return get_models_with_source(provider_id, config, force=True)
1657
+
1658
+
1659
+ def ensure_provider_models_fresh(provider_id: str, config: dict = None
1660
+ ) -> tuple[list[str], str]:
1661
+ """Refresh if the cache is stale; otherwise reuse the cached list.
1662
+ Called automatically when the model picker opens."""
1663
+ try:
1664
+ from ..models import manager as _mgr
1665
+ cfg = dict(config or {})
1666
+ saved = load_config()
1667
+ if saved.get("provider") == provider_id:
1668
+ cfg.setdefault("api_key", saved.get("api_key", ""))
1669
+ cfg.setdefault("base_url", saved.get("base_url") or saved.get("api_url", ""))
1670
+ if not cfg.get("base_url") and not cfg.get("api_url"):
1671
+ info = _mgr.get_provider(provider_id) or {}
1672
+ cfg["base_url"] = info.get("api_endpoint", "")
1673
+ return _mgr.ensure_fresh(provider_id, cfg)
1674
+ except Exception as e:
1675
+ _log(f"ensure_provider_models_fresh [{provider_id}]: {e}", exc_info=True)
1676
+ return [], "default"
1677
+
1678
+
1679
+ def get_model_source(provider_id: str) -> str:
1680
+ """'live' | 'cached' | 'default' for the provider's last resolution."""
1681
+ try:
1682
+ from ..models import manager as _mgr
1683
+ return _mgr.get_model_source(provider_id)
1684
+ except Exception:
1685
+ return "default"
1686
+
1687
+
1688
+ def get_last_model_refresh(provider_id: str) -> str:
1689
+ """ISO timestamp of the last successful model fetch ('' if none)."""
1690
+ try:
1691
+ from ..models import manager as _mgr
1692
+ return _mgr.get_last_refresh(provider_id)
1693
+ except Exception:
1694
+ return ""
1695
+
1696
+
1697
+ def get_provider_status(provider_id: str, model_id: str = "") -> dict:
1698
+ """Full status dict for /status — provider, model source, last refresh,
1699
+ capability flags, model count. Never raises."""
1700
+ try:
1701
+ from ..models import manager as _mgr
1702
+ return _mgr.get_status(provider_id, model_id)
1703
+ except Exception:
1704
+ return {"provider_id": provider_id, "model_source": "default"}
1705
+
1706
+
1707
+ def search_provider_models(provider_id: str, query: str,
1708
+ filter_id: str = "all") -> list[dict]:
1709
+ """Search models by name/family/capability/provider, with a category
1710
+ filter (all/free/paid/latest/reasoning/coding/vision/fast/
1711
+ long_context/image/speech/embedding/experimental)."""
1712
+ try:
1713
+ from ..models import manager as _mgr
1714
+ return _mgr.search(provider_id, query, filter_id)
1715
+ except Exception:
1716
+ return []
1717
+
1718
+
1719
+ def provider_capability_badges(provider_id: str) -> list[str]:
1720
+ """Capability badges for a provider row ([FREE] [PAID] [VISION] ...)."""
1721
+ try:
1722
+ from ..models import manager as _mgr
1723
+ return _mgr.provider_badges(provider_id)
1724
+ except Exception:
1725
+ return []
1726
+
1727
+
1728
+ # ── Auto-Update System ─────────────────────────────────────────────────────
1729
+
1730
+ def start_provider_auto_updates():
1731
+ """Start the background auto-update system for providers.
1732
+
1733
+ This will periodically check for new models from provider APIs
1734
+ and update the local cache. Call this on app startup.
1735
+ """
1736
+ if _HAS_AUTO_UPDATE:
1737
+ start_auto_updates()
1738
+ _log("Started provider auto-update system")
1739
+ else:
1740
+ _log("Auto-update system not available")
1741
+
1742
+
1743
+ def stop_provider_auto_updates():
1744
+ """Stop the background auto-update system."""
1745
+ if _HAS_AUTO_UPDATE:
1746
+ stop_auto_updates()
1747
+ _log("Stopped provider auto-update system")
1748
+
1749
+
1750
+ def force_provider_update() -> bool:
1751
+ """Force an immediate update of all providers.
1752
+
1753
+ Returns:
1754
+ True if updates were applied, False otherwise
1755
+ """
1756
+ if _HAS_AUTO_UPDATE:
1757
+ return force_update()
1758
+ return False
1759
+
1760
+
1761
+ def get_auto_updated_providers() -> Dict:
1762
+ """Get the latest provider data from auto-update system.
1763
+
1764
+ Returns:
1765
+ Dictionary with providers data
1766
+ """
1767
+ if _HAS_AUTO_UPDATE:
1768
+ return get_latest_providers()
1769
+ return {"version": 1, "providers": []}
1770
+
1771
+
1772
+ def add_custom_provider_to_config(provider: Dict) -> bool:
1773
+ """Add a custom provider to the local configuration.
1774
+
1775
+ Args:
1776
+ provider: Provider configuration dictionary
1777
+
1778
+ Returns:
1779
+ True if added successfully, False otherwise
1780
+ """
1781
+ if _HAS_AUTO_UPDATE:
1782
+ return add_custom_provider(provider)
1783
+ return False
1784
+
1785
+
1786
+ def remove_custom_provider_from_config(provider_id: str) -> bool:
1787
+ """Remove a custom provider from the local configuration.
1788
+
1789
+ Args:
1790
+ provider_id: ID of the provider to remove
1791
+
1792
+ Returns:
1793
+ True if removed successfully, False otherwise
1794
+ """
1795
+ if _HAS_AUTO_UPDATE:
1796
+ return remove_custom_provider(provider_id)
1797
+ return False
1798
+
1799
+
1800
+ def discover_provider_models_auto(provider_id: str, base_url: str,
1801
+ api_style: str = "openai") -> List[str]:
1802
+ """Discover available models from a provider's API.
1803
+
1804
+ Args:
1805
+ provider_id: Provider identifier
1806
+ base_url: API base URL
1807
+ api_style: API style (openai, anthropic, gemini, ollama)
1808
+
1809
+ Returns:
1810
+ List of available model names
1811
+ """
1812
+ try:
1813
+ from .discovery_manager import get_discovery_manager
1814
+ dm = get_discovery_manager()
1815
+ discov_list = dm.discover_provider(provider_id, config={"base_url": base_url, "api_style": api_style})
1816
+ if discov_list:
1817
+ return [m.model_id for m in discov_list]
1818
+ except Exception:
1819
+ pass
1820
+
1821
+ if _HAS_AUTO_UPDATE:
1822
+ return auto_discover_models(provider_id, base_url, api_style)
1823
+ return []
1824
+
1825
+
1826
+ # Initialize on import
1827
+ register_builtins()