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,781 @@
1
+ """Dynamic model manager — the future-proof model management core.
2
+
3
+ Priority order for any provider:
4
+
5
+ 1. Fetch models from the provider's official API (live).
6
+ 2. If fetching fails, use the on-disk cache (cache/models/).
7
+ 3. If no cache exists, use the provider's built-in fallback list
8
+ (providers.json -> fallback_models).
9
+
10
+ Every successful live fetch is written to the disk cache with a
11
+ timestamp. Opening /model triggers an automatic background refresh when
12
+ the cache is older than the configured interval, so newly released
13
+ models show up without an application update.
14
+
15
+ Everything is data-driven: provider metadata lives in
16
+ providers/providers.json and curated model metadata lives in
17
+ models/model_metadata.json — no model names are hardcoded in Python
18
+ source code.
19
+ """
20
+
21
+ import json
22
+ import os
23
+ import re
24
+ import threading
25
+ import time
26
+ from datetime import datetime, timedelta, timezone
27
+ from typing import Optional
28
+
29
+ # ── Paths ──────────────────────────────────────────────────────────────────
30
+ # _PKG_DIR points inside the installed package (site-packages or source
31
+ # checkout) and is correct for READ-ONLY bundled data (providers.json,
32
+ # model_metadata.json). The writable model cache MUST live in the user's
33
+ # app-data dir — never inside site-packages (read-only when installed) and
34
+ # never tied to the git checkout path. first_run.data_dir() is the single
35
+ # source of truth (CCT_DATA_DIR override + OS conventions); _LEGACY_CACHE_DIR
36
+ # preserves reads from older source-checkout runs.
37
+ _ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
38
+ _PKG_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # calc_terminal/
39
+ PROVIDERS_FILE = os.path.join(_PKG_DIR, "providers", "providers.json")
40
+ MODEL_METADATA_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
41
+ "model_metadata.json")
42
+
43
+
44
+ def _user_cache_dir() -> str:
45
+ try:
46
+ from ..first_run import cache_dir as _app_cache
47
+
48
+ return os.path.join(_app_cache(), "models")
49
+ except Exception:
50
+ pass
51
+ base = os.environ.get("CCT_DATA_DIR", "").strip()
52
+ if base:
53
+ return os.path.join(os.path.abspath(base), "cache", "models")
54
+ return os.path.join(os.path.expanduser("~"), ".cct", "cache", "models")
55
+
56
+
57
+ CACHE_DIR = _user_cache_dir()
58
+ _LEGACY_CACHE_DIR = os.path.join(_ROOT, "cache", "models")
59
+
60
+ # ── Sources ────────────────────────────────────────────────────────────────
61
+ SOURCE_LIVE = "live" # freshly fetched from the provider's API
62
+ SOURCE_CACHED = "cached" # read from the local disk cache
63
+ SOURCE_DEFAULT = "default" # built-in fallback list (providers.json)
64
+
65
+ # ── Category filters (UI + /model search) ──────────────────────────────────
66
+ CATEGORIES = [
67
+ "all", "free", "paid", "latest", "reasoning", "coding", "vision",
68
+ "fast", "long_context", "image", "speech", "embedding", "experimental",
69
+ ]
70
+
71
+ _lock = threading.Lock()
72
+ _providers: Optional[list[dict]] = None
73
+ _metadata: Optional[dict] = None
74
+ _metadata_index: Optional[dict] = None
75
+ _sources: dict[str, str] = {} # provider_id -> last source used
76
+ _last_refresh: dict[str, str] = {} # provider_id -> ISO timestamp
77
+
78
+ # ── Loading (JSON = single source of truth) ────────────────────────────────
79
+
80
+ def _default_cache_ttl_hours() -> float:
81
+ """Configured cache interval in hours (default 24)."""
82
+ try:
83
+ from .. import config as _cfg
84
+ return float(_cfg.load_config().get("model_cache_ttl_hours", 24.0) or 24.0)
85
+ except Exception:
86
+ return 24.0
87
+
88
+
89
+ def load_providers() -> list[dict]:
90
+ """Load provider metadata from providers.json. Never raises — an
91
+ unreadable file degrades to an empty list."""
92
+ global _providers
93
+ if _providers is not None:
94
+ return _providers
95
+ try:
96
+ with open(PROVIDERS_FILE, "r", encoding="utf-8") as f:
97
+ data = json.load(f)
98
+ _providers = list(data.get("providers", []))
99
+ except Exception:
100
+ _providers = []
101
+ return _providers
102
+
103
+
104
+ def reload_providers() -> list[dict]:
105
+ """Force reload of providers.json (used by import/export flows)."""
106
+ global _providers
107
+ _providers = None
108
+ return load_providers()
109
+
110
+
111
+ def get_provider(provider_id: str) -> Optional[dict]:
112
+ """Return provider metadata dict or None."""
113
+ for p in load_providers():
114
+ if p.get("id") == provider_id:
115
+ return p
116
+ return None
117
+
118
+
119
+ def default_model_for(provider_id: str, fallback: str = "") -> str:
120
+ """Data-driven default model for a provider: providers.json
121
+ 'default_model' field, else first fallback model, else `fallback`."""
122
+ try:
123
+ provider = get_provider(provider_id)
124
+ if provider:
125
+ dflt = str(provider.get("default_model") or "").strip()
126
+ if dflt:
127
+ return dflt
128
+ fb = [str(m) for m in provider.get("fallback_models", []) if str(m).strip()]
129
+ if fb:
130
+ return fb[0]
131
+ except Exception:
132
+ pass
133
+ return fallback
134
+
135
+
136
+ def list_providers() -> list[dict]:
137
+ return sorted(load_providers(), key=lambda p: p.get("name", "").lower())
138
+
139
+
140
+ def _load_metadata() -> dict:
141
+ """{model_id: metadata_dict} merged from model_metadata.json."""
142
+ global _metadata
143
+ if _metadata is not None:
144
+ return _metadata
145
+ out = {}
146
+ try:
147
+ with open(MODEL_METADATA_FILE, "r", encoding="utf-8") as f:
148
+ data = json.load(f)
149
+ for m in data.get("models", []):
150
+ if m.get("id"):
151
+ out[str(m["id"])] = m
152
+ except Exception:
153
+ pass
154
+ _metadata = out
155
+ return out
156
+
157
+
158
+ def get_model_meta(model_id: str, provider_id: str = "") -> Optional[dict]:
159
+ """Curated metadata for a model. Falls back to matching any provider
160
+ if provider_id is unknown."""
161
+ try:
162
+ from .dynamic_registry import get_registry
163
+ reg = get_registry()
164
+ m = reg.get_model(model_id, provider_id)
165
+ if m:
166
+ return {
167
+ "id": m.model_id,
168
+ "provider": m.provider,
169
+ "display_name": m.display_name,
170
+ "family": m.family,
171
+ "context_length": m.context_window,
172
+ "reasoning": m.has_capability("reasoning"),
173
+ "coding": m.has_capability("coding"),
174
+ "vision": m.has_capability("vision"),
175
+ "embedding": m.has_capability("embedding"),
176
+ "tools": m.has_capability("tools"),
177
+ "streaming": m.has_capability("streaming"),
178
+ "free": m.availability in ("free_api", "free_tier") or m.pricing.get("input_price_per_1m", 0.0) == 0.0,
179
+ "paid": m.pricing.get("input_price_per_1m", 0.0) > 0.0,
180
+ "deprecated": m.status == "deprecated",
181
+ "preview": m.status in ("preview", "beta"),
182
+ "input_price_per_1m": m.pricing.get("input_price_per_1m", 0.0),
183
+ "output_price_per_1m": m.pricing.get("output_price_per_1m", 0.0),
184
+ "reasoning_levels": m.reasoning_levels,
185
+ "recommendation_badges": m.recommendation_badges,
186
+ }
187
+ except Exception:
188
+ pass
189
+ meta = _load_metadata()
190
+ entry = meta.get(model_id)
191
+ if entry and (not provider_id or entry.get("provider") == provider_id):
192
+ return entry
193
+ if provider_id and entry and entry.get("provider") != provider_id:
194
+ return None
195
+ return entry
196
+
197
+
198
+ # ── Disk cache (cache/models/{provider_id}.json) ───────────────────────────
199
+
200
+ def get_cache_path(provider_id: str) -> str:
201
+ safe = re.sub(r"[^A-Za-z0-9_.-]", "_", str(provider_id))
202
+ return os.path.join(CACHE_DIR, f"{safe}.json")
203
+
204
+
205
+ def get_cached(provider_id: str) -> Optional[tuple[list[str], str]]:
206
+ """(models, fetched_at_iso) from disk, or None.
207
+
208
+ Reads the user app-data cache first, then the legacy source-checkout
209
+ location (so caches written before the pip-installable layout still
210
+ work). Writes always go to the user location (see write_cache)."""
211
+ candidates = [get_cache_path(provider_id)]
212
+ try:
213
+ safe = re.sub(r"[^A-Za-z0-9_.-]", "_", str(provider_id))
214
+ legacy = os.path.join(_LEGACY_CACHE_DIR, f"{safe}.json")
215
+ if legacy not in candidates:
216
+ candidates.append(legacy)
217
+ except Exception:
218
+ pass
219
+ for path in candidates:
220
+ try:
221
+ with open(path, "r", encoding="utf-8") as f:
222
+ data = json.load(f)
223
+ models = data.get("models")
224
+ if not isinstance(models, list) or not models:
225
+ continue
226
+ return [str(m) for m in models], str(data.get("fetched_at", ""))
227
+ except Exception:
228
+ continue
229
+ return None
230
+
231
+
232
+ def write_cache(provider_id: str, models: list[str]):
233
+ """Persist a fetched model list with a timestamp. Never raises."""
234
+ os.makedirs(CACHE_DIR, exist_ok=True)
235
+ payload = {
236
+ "provider": provider_id,
237
+ "fetched_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
238
+ "models": sorted(set(models)),
239
+ }
240
+ try:
241
+ tmp = get_cache_path(provider_id) + ".tmp"
242
+ with open(tmp, "w", encoding="utf-8") as f:
243
+ json.dump(payload, f, indent=2)
244
+ os.replace(tmp, get_cache_path(provider_id))
245
+ except Exception:
246
+ pass
247
+
248
+
249
+ def delete_cache(provider_id: str):
250
+ try:
251
+ path = get_cache_path(provider_id)
252
+ if os.path.exists(path):
253
+ os.remove(path)
254
+ except Exception:
255
+ pass
256
+
257
+
258
+ def cache_age_hours(provider_id: str) -> Optional[float]:
259
+ """How old the cached list is, in hours. None if no cache."""
260
+ entry = get_cached(provider_id)
261
+ if not entry:
262
+ return None
263
+ try:
264
+ fetched = datetime.fromisoformat(entry[1])
265
+ if fetched.tzinfo is None:
266
+ fetched = fetched.replace(tzinfo=timezone.utc)
267
+ return max(0.0, (datetime.now(timezone.utc) - fetched).total_seconds() / 3600.0)
268
+ except Exception:
269
+ return None
270
+
271
+
272
+ def cache_is_fresh(provider_id: str) -> bool:
273
+ age = cache_age_hours(provider_id)
274
+ if age is None:
275
+ return False
276
+ return age <= _default_cache_ttl_hours()
277
+
278
+
279
+ # ── Live fetching ──────────────────────────────────────────────────────────
280
+
281
+ def _fetch_openai_compatible(config: dict, timeout: int = 12) -> list[str]:
282
+ """GET {base_url}/models → {"data": [{"id": ...}]}."""
283
+ import requests
284
+ base_url = (config.get("base_url") or config.get("api_url") or "").rstrip("/")
285
+ if not base_url:
286
+ return []
287
+ headers = {"Content-Type": "application/json"}
288
+ if config.get("api_key"):
289
+ headers["Authorization"] = f"Bearer {config['api_key']}"
290
+ resp = requests.get(f"{base_url}/models", headers=headers, timeout=timeout)
291
+ if resp.status_code >= 400:
292
+ return []
293
+ data = resp.json()
294
+ items = data.get("data", [])
295
+ return sorted({str(m.get("id")) for m in items if isinstance(m, dict) and m.get("id")})
296
+
297
+
298
+ def _fetch_gemini(config: dict, timeout: int = 12) -> list[str]:
299
+ """GET {base}/models?key=... → {"models": [{"name": "models/..."}]}."""
300
+ import requests
301
+ base_url = (config.get("base_url") or "").rstrip("/")
302
+ if not base_url:
303
+ return []
304
+ api_key = config.get("api_key", "")
305
+ resp = requests.get(f"{base_url}/models", params={"key": api_key}, timeout=timeout)
306
+ if resp.status_code >= 400:
307
+ return []
308
+ out = []
309
+ for m in resp.json().get("models", []):
310
+ methods = m.get("supportedGenerationMethods") or []
311
+ if not methods or "generateContent" in methods:
312
+ name = str(m.get("name", ""))
313
+ if "/" in name:
314
+ name = name.split("/")[-1]
315
+ if name:
316
+ out.append(name)
317
+ return sorted(set(out))
318
+
319
+
320
+ def _fetch_anthropic(config: dict, timeout: int = 12) -> list[str]:
321
+ """GET {base}/models → {"data": [{"id": ...}]} (Anthropic models API)."""
322
+ import requests
323
+ base_url = (config.get("base_url") or "").rstrip("/")
324
+ if not base_url:
325
+ return []
326
+ headers = {"Content-Type": "application/json"}
327
+ if config.get("api_key"):
328
+ headers["x-api-key"] = config["api_key"]
329
+ headers["anthropic-version"] = "2023-06-01"
330
+ resp = requests.get(f"{base_url}/models", headers=headers, timeout=timeout)
331
+ if resp.status_code >= 400:
332
+ return []
333
+ data = resp.json()
334
+ items = data.get("data", [])
335
+ return sorted({str(m.get("id")) for m in items if isinstance(m, dict) and m.get("id")})
336
+
337
+
338
+ def _fetch_ollama(config: dict, timeout: int = 8) -> list[str]:
339
+ """GET {base}/api/tags → {"models": [{"name": ...}]}."""
340
+ import requests
341
+ base_url = (config.get("base_url") or config.get("ollama_url") or
342
+ "http://localhost:11434").rstrip("/")
343
+ if base_url.endswith("/v1"):
344
+ return _fetch_openai_compatible(config, timeout)
345
+ resp = requests.get(f"{base_url}/api/tags", timeout=timeout)
346
+ if resp.status_code >= 400:
347
+ return []
348
+ models = []
349
+ for m in resp.json().get("models", []):
350
+ name = m.get("name") if isinstance(m, dict) else None
351
+ if name:
352
+ models.append(str(name))
353
+ return sorted(set(models))
354
+
355
+
356
+ def fetch_live(provider_id: str, config: dict = None) -> list[str]:
357
+ """Try the official models API for a provider. Prefers registered
358
+ provider SDK classes, falls back to a generic fetch by api_style.
359
+ Returns [] on any failure (callers then fall back to cache/default).
360
+ """
361
+ cfg = dict(config or {})
362
+ cfg.setdefault("provider", provider_id)
363
+ provider = get_provider(provider_id) or {}
364
+ cfg.setdefault("base_url", provider.get("api_endpoint", ""))
365
+ cfg.setdefault("api_style", provider.get("api_style", "openai"))
366
+ api_style = cfg.get("api_style", "openai")
367
+
368
+ # Only gate on metadata when the provider is actually known — custom
369
+ # providers (arbitrary base_url/api_style from user config) always try.
370
+ if provider and not provider.get("supports_model_listing", False):
371
+ return []
372
+
373
+ try:
374
+ from ..providers.provider_manager import get_provider_class
375
+ cls = get_provider_class(provider_id)
376
+ if cls is not None:
377
+ inst = cls(cfg)
378
+ if hasattr(inst, "fetch_models"):
379
+ models = inst.fetch_models()
380
+ if models:
381
+ return sorted(set(str(m) for m in models))
382
+ return []
383
+ except Exception:
384
+ pass # fall through to generic fetch
385
+
386
+ try:
387
+ if api_style == "openai":
388
+ return _fetch_openai_compatible(cfg)
389
+ if api_style == "gemini":
390
+ return _fetch_gemini(cfg)
391
+ if api_style == "anthropic":
392
+ return _fetch_anthropic(cfg)
393
+ if api_style == "ollama":
394
+ return _fetch_ollama(cfg)
395
+ except Exception:
396
+ return []
397
+ return []
398
+
399
+
400
+ # ── Core resolution (the priority chain) ───────────────────────────────────
401
+
402
+ def _fallback_models(provider_id: str) -> list[str]:
403
+ provider = get_provider(provider_id) or {}
404
+ fallback = [str(m) for m in provider.get("fallback_models", [])]
405
+ if not fallback:
406
+ fallback = ["default"]
407
+ return fallback
408
+
409
+
410
+ def get_models(provider_id: str, config: dict = None,
411
+ force_refresh: bool = False) -> tuple[list[str], str]:
412
+ """Resolve the model list for a provider. Uses ProviderDiscoveryManager
413
+ and DynamicModelRegistry with safe fallback to cache/default."""
414
+ with _lock:
415
+ if force_refresh:
416
+ try:
417
+ from ..providers.discovery_manager import get_discovery_manager
418
+ dm = get_discovery_manager()
419
+ discovered = dm.discover_provider(provider_id, config)
420
+ if discovered:
421
+ models = [m.model_id for m in discovered]
422
+ write_cache(provider_id, models)
423
+ _sources[provider_id] = SOURCE_LIVE
424
+ _last_refresh[provider_id] = datetime.now(timezone.utc).isoformat(timespec="seconds")
425
+ return models, SOURCE_LIVE
426
+ except Exception:
427
+ pass
428
+ models = fetch_live(provider_id, config)
429
+ if models:
430
+ write_cache(provider_id, models)
431
+ _sources[provider_id] = SOURCE_LIVE
432
+ _last_refresh[provider_id] = datetime.now(timezone.utc).isoformat(timespec="seconds")
433
+ return models, SOURCE_LIVE
434
+ # live failed — fall through to cache then defaults
435
+
436
+ cached = get_cached(provider_id)
437
+ if cached:
438
+ models, fetched_at = cached
439
+ _sources[provider_id] = SOURCE_CACHED
440
+ _last_refresh[provider_id] = fetched_at
441
+ return list(models), SOURCE_CACHED
442
+
443
+ try:
444
+ from .dynamic_registry import get_registry
445
+ reg = get_registry()
446
+ reg_models = [m.model_id for m in reg.list_models(provider=provider_id)]
447
+ if reg_models:
448
+ return reg_models, SOURCE_CACHED
449
+ except Exception:
450
+ pass
451
+
452
+ try:
453
+ from ..providers.discovery_manager import get_discovery_manager
454
+ dm = get_discovery_manager()
455
+ discovered = dm.discover_provider(provider_id, config)
456
+ if discovered:
457
+ models = [m.model_id for m in discovered]
458
+ write_cache(provider_id, models)
459
+ _sources[provider_id] = SOURCE_LIVE
460
+ _last_refresh[provider_id] = datetime.now(timezone.utc).isoformat(timespec="seconds")
461
+ return models, SOURCE_LIVE
462
+ except Exception:
463
+ pass
464
+
465
+ models = fetch_live(provider_id, config)
466
+ if models:
467
+ write_cache(provider_id, models)
468
+ _sources[provider_id] = SOURCE_LIVE
469
+ _last_refresh[provider_id] = datetime.now(timezone.utc).isoformat(timespec="seconds")
470
+ return models, SOURCE_LIVE
471
+
472
+ fallback = _fallback_models(provider_id)
473
+ _sources[provider_id] = SOURCE_DEFAULT
474
+ _last_refresh[provider_id] = ""
475
+ return fallback, SOURCE_DEFAULT
476
+
477
+
478
+ def refresh_models(provider_id: str, config: dict = None) -> tuple[list[str], str]:
479
+ """Force a fresh fetch from the provider's API. Returns
480
+ (models, source) — falls back to cache/default on failure."""
481
+ return get_models(provider_id, config, force_refresh=True)
482
+
483
+
484
+ def ensure_fresh(provider_id: str, config: dict = None) -> tuple[list[str], str]:
485
+ """Refresh when the cache is stale, otherwise keep the cache.
486
+ Called automatically whenever the model picker opens."""
487
+ if cache_is_fresh(provider_id) and get_cached(provider_id):
488
+ cached = get_cached(provider_id)
489
+ _sources[provider_id] = SOURCE_CACHED
490
+ _last_refresh[provider_id] = cached[1]
491
+ return list(cached[0]), SOURCE_CACHED
492
+ return refresh_models(provider_id, config)
493
+
494
+
495
+ def get_model_source(provider_id: str) -> str:
496
+ """'live' | 'cached' | 'default' for the last resolution."""
497
+ return _sources.get(provider_id, SOURCE_DEFAULT)
498
+
499
+
500
+ def get_last_refresh(provider_id: str) -> str:
501
+ return _last_refresh.get(provider_id, "")
502
+
503
+
504
+ # ── New-model detection ────────────────────────────────────────────────────
505
+ # A model is "new" when it appeared in a live fetch but is not part of the
506
+ # built-in fallback list — so brand-new releases get a NEW badge without any
507
+ # application update.
508
+
509
+ def new_models(provider_id: str) -> set[str]:
510
+ provider = get_provider(provider_id) or {}
511
+ fallback = {str(m) for m in provider.get("fallback_models", [])}
512
+ cached = get_cached(provider_id)
513
+ if not cached:
514
+ return set()
515
+ live_set = set(cached[0])
516
+ new = live_set - fallback
517
+ for m in live_set:
518
+ lower = m.lower()
519
+ if any(k in lower for k in ("latest", "-exp", "exp-", "-preview", "preview-")):
520
+ new.add(m)
521
+ return new
522
+
523
+
524
+ # ── Enrichment / badges / filtering ────────────────────────────────────────
525
+
526
+ _FAST_HINTS = ("flash", "mini", "nano", "turbo", "lite", "small", "instant",
527
+ "haiku", "-8b", "-7b", "8b-", "7b-", "speed", "fast", "fx")
528
+ _LONG_CTX_HINTS = ("128k", "200k", "1m", "1000000", "1048576", "2097152", "long", "extended")
529
+
530
+
531
+ def _pricing_tier(meta: Optional[dict], model_id: str) -> str:
532
+ if meta:
533
+ if meta.get("free"):
534
+ return "free"
535
+ if meta.get("paid"):
536
+ return "paid"
537
+ lower = model_id.lower()
538
+ if any(k in lower for k in ("free", "open-", "gemma", "llama-4-scout")):
539
+ return "free"
540
+ return "unknown"
541
+
542
+
543
+ def _status(meta: Optional[dict]) -> str:
544
+ if not meta:
545
+ return "stable"
546
+ if meta.get("deprecated"):
547
+ return "deprecated"
548
+ if meta.get("preview"):
549
+ return "preview"
550
+ return "stable"
551
+
552
+
553
+ def _capabilities(meta: Optional[dict], model_id: str) -> list[str]:
554
+ if meta:
555
+ caps = []
556
+ for key, label in (("reasoning", "reasoning"), ("vision", "vision"),
557
+ ("coding", "coding"), ("embedding", "embeddings"),
558
+ ("image_generation", "image_gen"), ("speech", "audio"),
559
+ ("tools", "tool_use"), ("streaming", "streaming"),
560
+ ("chat", "chat")):
561
+ if meta.get(key):
562
+ caps.append(label)
563
+ if caps:
564
+ return caps
565
+ caps = []
566
+ lower = model_id.lower()
567
+ if "vision" in lower or "4o" in lower or "flash" in lower:
568
+ caps.append("vision")
569
+ if "reason" in lower or "thinking" in lower or "o1" in lower or "o3" in lower or "r1" in lower:
570
+ caps.append("reasoning")
571
+ if "embed" in lower:
572
+ caps.append("embeddings")
573
+ if "whisper" in lower or "tts" in lower:
574
+ caps.append("audio")
575
+ return caps
576
+
577
+
578
+ def build_badges(provider_id: str, model_id: str, meta: Optional[dict] = None,
579
+ new_set: Optional[set[str]] = None) -> list[str]:
580
+ """FREE/PAID + NEW/LATEST/RECOMMENDED/BETA/PREVIEW/DEPRECATED badges."""
581
+ badges = []
582
+ tier = _pricing_tier(meta, model_id)
583
+ badges.append("[FREE]" if tier == "free" else "[PAID]" if tier == "paid" else "[?]")
584
+
585
+ new_set = new_set if new_set is not None else set()
586
+ if model_id in new_set:
587
+ badges.append("[NEW]")
588
+ if meta and meta.get("recommended"):
589
+ badges.append("[LATEST]")
590
+ if meta and meta.get("preview"):
591
+ badges.append("[BETA]")
592
+ status = _status(meta)
593
+ if status == "preview" and not (meta and meta.get("preview") and model_id in new_set):
594
+ pass
595
+ if status == "deprecated":
596
+ badges.append("[DEPRECATED]")
597
+ if status == "preview":
598
+ badges.append("[PREVIEW]")
599
+ return badges
600
+
601
+
602
+ def enrich(provider_id: str, model_ids: list[str],
603
+ with_new: bool = True) -> list[dict]:
604
+ """Merge dynamic model IDs with curated metadata. Returns sorted list:
605
+ {"id", "display_name", "family", "category", "capabilities",
606
+ "context_length", "free", "paid", "pricing_tier", "status",
607
+ "recommended", "preview", "deprecated", "badges", "is_new"}
608
+ """
609
+ new_set = new_models(provider_id) if with_new else set()
610
+ result = []
611
+ for mid in model_ids:
612
+ meta = get_model_meta(mid, provider_id)
613
+ entry = {
614
+ "id": mid,
615
+ "display_name": (meta or {}).get("display_name") or mid,
616
+ "provider": provider_id,
617
+ "family": (meta or {}).get("family", ""),
618
+ "category": (meta or {}).get("category", "general"),
619
+ "capabilities": _capabilities(meta, mid),
620
+ "context_length": int((meta or {}).get("context_length", 0) or 0),
621
+ "free": _pricing_tier(meta, mid) == "free",
622
+ "paid": _pricing_tier(meta, mid) == "paid",
623
+ "pricing_tier": _pricing_tier(meta, mid),
624
+ "status": _status(meta),
625
+ "recommended": bool((meta or {}).get("recommended", False)),
626
+ "preview": bool((meta or {}).get("preview", False)),
627
+ "deprecated": bool((meta or {}).get("deprecated", False)),
628
+ "release_date": (meta or {}).get("release_date"),
629
+ "input_price_per_1m": float((meta or {}).get("input_price_per_1m", 0.0) or 0.0),
630
+ "output_price_per_1m": float((meta or {}).get("output_price_per_1m", 0.0) or 0.0),
631
+ "is_new": mid in new_set,
632
+ "badges": [],
633
+ }
634
+ entry["badges"] = build_badges(provider_id, mid, meta, new_set)
635
+ result.append(entry)
636
+ result.sort(key=lambda e: e["id"].lower())
637
+ return result
638
+
639
+
640
+ def _is_latest(entry: dict, new_set: set[str]) -> bool:
641
+ if entry["id"] in new_set:
642
+ return True
643
+ if entry.get("recommended"):
644
+ return True
645
+ if entry.get("status") in ("preview", "experimental"):
646
+ return True
647
+ return False
648
+
649
+
650
+ def _is_fast(entry: dict) -> bool:
651
+ lower = entry["id"].lower()
652
+ return any(h in lower for h in _FAST_HINTS)
653
+
654
+
655
+ def _is_long_context(entry: dict) -> bool:
656
+ if entry.get("context_length", 0) >= 100000:
657
+ return True
658
+ lower = entry["id"].lower()
659
+ return any(h in lower for h in _LONG_CTX_HINTS)
660
+
661
+
662
+ def apply_filter(entries: list[dict], filter_id: str,
663
+ new_set: Optional[set[str]] = None) -> list[dict]:
664
+ """Filter enriched entries by category id (see CATEGORIES)."""
665
+ if not filter_id or filter_id == "all":
666
+ return list(entries)
667
+ new_set = new_set if new_set is not None else set()
668
+ out = []
669
+ for e in entries:
670
+ if filter_id == "free" and e["free"]:
671
+ out.append(e)
672
+ elif filter_id == "paid" and e["paid"]:
673
+ out.append(e)
674
+ elif filter_id == "latest" and _is_latest(e, new_set):
675
+ out.append(e)
676
+ elif filter_id == "reasoning" and "reasoning" in e["capabilities"]:
677
+ out.append(e)
678
+ elif filter_id == "coding" and "coding" in e["capabilities"]:
679
+ out.append(e)
680
+ elif filter_id == "vision" and "vision" in e["capabilities"]:
681
+ out.append(e)
682
+ elif filter_id == "fast" and _is_fast(e):
683
+ out.append(e)
684
+ elif filter_id == "long_context" and _is_long_context(e):
685
+ out.append(e)
686
+ elif filter_id == "image" and "image_gen" in e["capabilities"]:
687
+ out.append(e)
688
+ elif filter_id == "speech" and "audio" in e["capabilities"]:
689
+ out.append(e)
690
+ elif filter_id == "embedding" and "embeddings" in e["capabilities"]:
691
+ out.append(e)
692
+ elif filter_id == "experimental" and e.get("status") in ("preview", "experimental"):
693
+ out.append(e)
694
+ return out
695
+
696
+
697
+ def search(provider_id: str, query: str, filter_id: str = "all",
698
+ model_ids: Optional[list[str]] = None) -> list[dict]:
699
+ """Search models by name, family, capability, or provider name.
700
+ Optionally apply a category filter. Returns enriched entries."""
701
+ q = (query or "").strip().lower()
702
+ if model_ids is None:
703
+ models, _ = get_models(provider_id)
704
+ else:
705
+ models = model_ids
706
+ entries = enrich(provider_id, models)
707
+ if q:
708
+ provider = get_provider(provider_id) or {}
709
+ provider_name = (provider.get("name") or "").lower()
710
+ out = []
711
+ for e in entries:
712
+ hay = " ".join([
713
+ e["id"].lower(),
714
+ e.get("family", "").lower(),
715
+ e.get("category", "").lower(),
716
+ " ".join(e["capabilities"]),
717
+ provider_name,
718
+ e.get("display_name", "").lower(),
719
+ ])
720
+ if q in hay:
721
+ out.append(e)
722
+ entries = out
723
+ return apply_filter(entries, filter_id)
724
+
725
+
726
+ # ── Status ─────────────────────────────────────────────────────────────────
727
+
728
+ def get_status(provider_id: str, model_id: str = "") -> dict:
729
+ """Everything /status needs:
730
+ provider, country, current model, model source, last refresh,
731
+ cache age, model count, provider capability flags."""
732
+ provider = get_provider(provider_id) or {"id": provider_id}
733
+ source = get_model_source(provider_id)
734
+ last_refresh = get_last_refresh(provider_id)
735
+ if not last_refresh:
736
+ cached = get_cached(provider_id)
737
+ if cached:
738
+ last_refresh = cached[1]
739
+ source = SOURCE_CACHED
740
+ models, _ = get_models(provider_id)
741
+ age = cache_age_hours(provider_id)
742
+ return {
743
+ "provider_id": provider_id,
744
+ "provider_name": provider.get("name", provider_id),
745
+ "country": provider.get("country", ""),
746
+ "current_model": model_id or "",
747
+ "model_source": source,
748
+ "last_refresh": last_refresh,
749
+ "cache_age_hours": age,
750
+ "model_count": len(models),
751
+ "supports_model_listing": bool(provider.get("supports_model_listing", False)),
752
+ "supports_streaming": bool(provider.get("supports_streaming", False)),
753
+ "supports_vision": bool(provider.get("supports_vision", False)),
754
+ "supports_embeddings": bool(provider.get("supports_embeddings", False)),
755
+ "supports_audio": bool(provider.get("supports_audio", False)),
756
+ "supports_reasoning": bool(provider.get("supports_reasoning", False)),
757
+ "openai_compatible": bool(provider.get("openai_compatible", False)),
758
+ "free_models_available": bool(provider.get("free_models_available", False)),
759
+ "paid_models_available": bool(provider.get("paid_models_available", False)),
760
+ }
761
+
762
+
763
+ def provider_badges(provider_id: str) -> list[str]:
764
+ """Capability badges for a provider row in the picker UI."""
765
+ provider = get_provider(provider_id) or {}
766
+ badges = []
767
+ if provider.get("supports_vision"):
768
+ badges.append("[VISION]")
769
+ if provider.get("supports_embeddings"):
770
+ badges.append("[EMBED]")
771
+ if provider.get("supports_audio"):
772
+ badges.append("[AUDIO]")
773
+ if provider.get("supports_reasoning"):
774
+ badges.append("[REASONING]")
775
+ if provider.get("free_models_available"):
776
+ badges.append("[FREE]")
777
+ if provider.get("paid_models_available"):
778
+ badges.append("[PAID]")
779
+ if not provider.get("needs_key", True):
780
+ badges.append("[NO KEY]")
781
+ return badges