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,665 @@
1
+ """
2
+ CAT v0.7.9.0 — model_router.py: the Smart Model Router + Request Classifier.
3
+
4
+ CAT should not blindly send every request to the same model through the
5
+ same heavyweight path. This module is the brain that sits between the
6
+ user's message and the pipeline:
7
+
8
+ USER MESSAGE
9
+ ↓
10
+ classify() — task types (pure regex/heuristics, no LLM call:
11
+ ↓ classification itself must cost microseconds,
12
+ │ not seconds)
13
+ route()
14
+ ├── fast_path — simple chat: skip memory/tools/multi-agent/vision
15
+ ├── agent_path — real tool loop (coding / agentic execution)
16
+ ├── vision_path — image(s) attached → vision-capable model chosen
17
+ └── multi_ai_path — genuinely complex tasks only
18
+
19
+ The router also owns the MODEL CAPABILITY REGISTRY: every configured
20
+ provider/model (primary + backups) is described by ModelCapabilities
21
+ (text/vision/tools/streaming/reasoning/context_window/latency/cost/
22
+ reliability) so routing decisions never have to be discovered "after a
23
+ request fails".
24
+
25
+ Everything here is heuristic and local: no network calls on the routing
26
+ path (availability probing would add latency — the opposite of the
27
+ goal). Availability/failover stays with aicore's existing backup chain;
28
+ the router just picks the BEST CAPABLE candidate order.
29
+ """
30
+
31
+ if __name__ == "__main__":
32
+ print("This is a library file and is not meant to be run directly.")
33
+ import sys
34
+ sys.exit(1)
35
+
36
+ import re
37
+ import time
38
+ from dataclasses import dataclass, field
39
+ from typing import Dict, List, Optional, Set, Tuple
40
+
41
+ # ----------------------------------------------------------------- types --
42
+
43
+ TASK_TYPES = (
44
+ "simple_chat", "coding", "debugging", "research", "mathematics",
45
+ "long_context", "vision", "document_analysis", "multimodal",
46
+ "planning", "agentic_execution", "multi_agent",
47
+ )
48
+
49
+ # Pipeline paths the router can choose between.
50
+ PATH_FAST = "fast"
51
+ PATH_STREAM = "stream" # plain streaming chat (default)
52
+ PATH_AGENT = "agent" # tool-calling loop
53
+ PATH_VISION = "vision" # image analysis
54
+ PATH_MULTI_AI = "multi_ai" # orchestrated team
55
+
56
+ # Privacy & routing policies (CAT Master Architecture)
57
+ POLICY_LOCAL_FIRST = "local_first"
58
+ POLICY_CLOUD_FIRST = "cloud_first"
59
+ POLICY_BEST_AVAILABLE = "best_available"
60
+ POLICY_NEVER_CLOUD = "never_cloud"
61
+ POLICIES = (POLICY_LOCAL_FIRST, POLICY_CLOUD_FIRST, POLICY_BEST_AVAILABLE, POLICY_NEVER_CLOUD)
62
+
63
+ # v0.7.9.5 complexity classes (spec #4): one coarse label per request so
64
+ # the execution path AND the timeout budget both key off the same
65
+ # classification — simple prompt → simple path, complex prompt → complex
66
+ # path. Pure-local, microseconds.
67
+ CLASS_TRIVIAL = "TRIVIAL" # "hi", "2+2", "thanks" → direct answer
68
+ CLASS_SIMPLE = "SIMPLE" # "what is recursion?" → one efficient call
69
+ CLASS_NORMAL = "NORMAL" # coding/debugging questions → model + tools
70
+ CLASS_COMPLEX = "COMPLEX" # long context / docs / vision analysis
71
+ CLASS_AGENT = "AGENT" # workspace/build tasks → agent workflow
72
+ CLASS_RESEARCH = "RESEARCH" # web research → retrieval-heavy path
73
+
74
+
75
+ @dataclass
76
+ class RouteDecision:
77
+ """What the router decided for one user message."""
78
+ path: str = PATH_STREAM
79
+ task_types: Set[str] = field(default_factory=set)
80
+ reason: str = ""
81
+ # When images are attached and the PRIMARY provider/model cannot see,
82
+ # this carries a vision-capable backup config to use instead.
83
+ vision_config: Optional[dict] = None
84
+ use_tools: bool = False
85
+ fast: bool = False
86
+ use_multi_agent: bool = False
87
+ # TRIVIAL / SIMPLE / NORMAL / COMPLEX / AGENT / RESEARCH
88
+ size_class: str = CLASS_NORMAL
89
+ privacy_policy: str = POLICY_LOCAL_FIRST
90
+ candidate_chain: List[dict] = field(default_factory=list)
91
+
92
+
93
+ @dataclass
94
+ class ModelCapabilities:
95
+ """Requirement #8: explicit per-model capability record. The router
96
+ consults this BEFORE sending a request; capabilities are never
97
+ discovered only after a failure."""
98
+ provider: str = ""
99
+ model: str = ""
100
+ api_style: str = "openai"
101
+ text: bool = True
102
+ vision: bool = False
103
+ tools: bool = True
104
+ streaming: bool = True
105
+ reasoning: bool = False
106
+ context_window: int = 32000
107
+ latency_score: float = 0.5 # 0 slow .. 1 instant (heuristic prior)
108
+ cost_score: float = 0.5 # 0 cheap .. 1 expensive (heuristic prior)
109
+ reliability: float = 0.7 # heuristic prior; nudged by observed fails
110
+ is_backup: bool = False
111
+
112
+ def score_for(self, needed_vision: bool = False,
113
+ needed_reasoning: bool = False,
114
+ needed_long_ctx: bool = False,
115
+ wants_fast: bool = False) -> float:
116
+ """Higher is better fit for the stated requirements."""
117
+ if needed_vision and not self.vision:
118
+ return -1.0
119
+ if needed_long_ctx and self.context_window < 60000:
120
+ return -1.0
121
+ s = 1.0
122
+ if needed_vision:
123
+ s += 0.5
124
+ if needed_reasoning and self.reasoning:
125
+ s += 0.4
126
+ if wants_fast:
127
+ s += self.latency_score * 0.6
128
+ s -= self.cost_score * 0.15
129
+ else:
130
+ s += self.reliability * 0.2
131
+ if needed_reasoning:
132
+ s += min(self.context_window, 200000) / 500000.0
133
+ return s
134
+
135
+ def to_dict(self) -> dict:
136
+ return {
137
+ "provider": self.provider,
138
+ "model": self.model,
139
+ "api_style": self.api_style,
140
+ "text": self.text,
141
+ "vision": self.vision,
142
+ "tools": self.tools,
143
+ "streaming": self.streaming,
144
+ "reasoning": self.reasoning,
145
+ "context_window": self.context_window,
146
+ "latency_score": self.latency_score,
147
+ "cost_score": self.cost_score,
148
+ "reliability": self.reliability,
149
+ "is_backup": self.is_backup,
150
+ }
151
+
152
+
153
+ # ------------------------------------------------------- classification --
154
+
155
+ _RE_GREETING = re.compile(
156
+ r"^\s*(hi|hello|hey|yo|sup|good (morning|evening|afternoon)|thanks|thank you|"
157
+ r"ok(ay)?|cool|nice|great|bye)\b[\s!.?]*$", re.I)
158
+
159
+ _RE_SIMPLE_Q = re.compile(
160
+ r"\b(what is|what's|who is|when is|where is|define|explain|meaning of|"
161
+ r"how do you spell|translate)\b.{0,120}\?", re.I)
162
+
163
+ _RE_ARITH = re.compile(r"^\s*[\d\s().+\-*/^%]+$")
164
+ # "what is 2+2", "12*7" etc. — tiny arithmetic, no tools needed.
165
+ _RE_TINY_MATH = re.compile(
166
+ r"\b(?:what(?:'s| is)\s+)?\d+[\s]*[+\-*/x×÷^%][\s]*\d+", re.I)
167
+
168
+ _RE_CODING = re.compile(
169
+ r"\b(code|coding|program|script|function|class |refactor|implement|"
170
+ r"build (me |us )?(a|an|the)? ?(website|web ?app|app|site|page|dashboard|game|api|server|cli|bot)|"
171
+ r"create (a|an|the)? ?(website|web ?app|app|site|page|component|file|module|package)|"
172
+ r"react|vue|angular|django|flask|fastapi|node|express|html|css|javascript|typescript|python|"
173
+ r"java\b|c\+\+|c#|rust|golang|sql query|database schema|unit test|pytest|npm|pip install)\b",
174
+ re.I)
175
+
176
+ _RE_DEBUGGING = re.compile(
177
+ r"\b(debug|bug|error|exception|traceback|stack trace|crash|not working|"
178
+ r"doesn'?t work|fails?|fix this|why is this (broken|failing)|"
179
+ r"undefined|null pointer|segfault|lint)\b", re.I)
180
+
181
+ _RE_RESEARCH = re.compile(
182
+ r"\b(research|latest|recent|news|current|202\d|compare|vs\.?|versus|"
183
+ r"search (the web|online)|look up|sources?|cite|state of the art|"
184
+ r"best (library|framework|tool)|documentation)\b", re.I)
185
+
186
+ _RE_MATH = re.compile(
187
+ r"\b(solve|equation|integral|derivative|limit|matrix|probability|"
188
+ r"calculus|algebra|geometry|trigonometry|prove|theorem|"
189
+ r"first[- ]order|second[- ]order|kinetics|mole|stoichiometr|nernst|"
190
+ r"half[- ]life|arrhenius|ph\b|equilibrium)\b", re.I)
191
+
192
+ _RE_PLANNING = re.compile(
193
+ r"\b(plan|roadmap|strategy|milestones?|architecture design|design (a|an) "
194
+ r"(system|architecture|approach)|step[- ]by[- ]step plan|brainstorm|proposal)\b",
195
+ re.I)
196
+
197
+ _RE_MULTI_AGENT = re.compile(
198
+ r"\b(review (this|the) (whole |entire )?(project|codebase|architecture)|"
199
+ r"(refactor|improve|overhaul).{0,40}(project|codebase|architecture)|"
200
+ r"across (the )?(whole |entire )?(project|codebase)|multi[- ]agent|"
201
+ r"use (multiple|several|3) (agents|ais|models)|team (up|of agents))\b",
202
+ re.I)
203
+
204
+ _RE_AGENTIC = re.compile(
205
+ r"\b(create|write|make|generate|build|install|delete|move|rename|organize|run|execute)"
206
+ r"\b[^?.!]{0,60}\b(file|folder|directory|script|package|command|test)s?\b", re.I)
207
+ _RE_FILE_OP = re.compile(
208
+ r"\b(read|open|list|summarize) (this |the |that )?(file|folder|directory|workspace)\b", re.I)
209
+
210
+ _RE_LONG_CTX = re.compile(
211
+ r"\b(entire (document|codebase|book|pdf)|whole (document|file|report)|"
212
+ r"large document|long document|all \d+ (pages|files)|summarize .{0,30}(document|chapter|paper))\b",
213
+ re.I)
214
+
215
+ _RE_DOC_ANALYSIS = re.compile(
216
+ r"\b(analyz|extract|parse|ocr|read) .{0,40}(image|screenshot|scan|document|receipt|invoice|table|chart|diagram)\b",
217
+ re.I)
218
+
219
+ _RE_EXTRACT_ALL = re.compile(
220
+ r"\b(extract (and explain |all )?(everything|all|important|information|text|data)|"
221
+ r"detailed (analysis|extraction)|full(y)? (analyz|extract)|transcribe)\b", re.I)
222
+
223
+
224
+ def classify(prompt: str, attachments: Optional[List] = None,
225
+ mode: Optional[str] = None) -> Set[str]:
226
+ """Pure-local task classification. Returns a set of TASK_TYPES.
227
+ Costs microseconds — regex only, deliberately NO LLM call here."""
228
+ text = (prompt or "").strip()
229
+ types: Set[str] = set()
230
+ low = text.lower()
231
+
232
+ has_image = any(getattr(a, "kind", "") == "image" or
233
+ str(getattr(a, "extension", "")).lower() in
234
+ (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp",
235
+ ".tif", ".tiff")
236
+ for a in (attachments or []))
237
+ has_any_attachment = bool(attachments)
238
+
239
+ if len(text) <= 140 and (_RE_GREETING.match(text) or _RE_SIMPLE_Q.match(text)
240
+ or _RE_ARITH.match(text)):
241
+ types.add("simple_chat")
242
+
243
+ if _RE_TINY_MATH.search(low) and len(text) < 80:
244
+ types.add("simple_chat")
245
+ types.add("mathematics")
246
+
247
+ if _RE_CODING.search(low):
248
+ types.update(("coding",))
249
+ if _RE_DEBUGGING.search(low):
250
+ types.add("debugging")
251
+ if _RE_RESEARCH.search(low):
252
+ types.add("research")
253
+ if _RE_MATH.search(low):
254
+ types.add("mathematics")
255
+ if _RE_PLANNING.search(low):
256
+ types.add("planning")
257
+ if _RE_LONG_CTX.search(low) or (has_any_attachment and len(text) > 400):
258
+ types.add("long_context")
259
+ if has_image:
260
+ types.update(("vision", "multimodal"))
261
+ if _RE_DOC_ANALYSIS.search(low) or _RE_EXTRACT_ALL.search(low):
262
+ types.add("document_analysis")
263
+ elif has_any_attachment:
264
+ types.add("document_analysis")
265
+ if _RE_AGENTIC.search(low) or _RE_FILE_OP.search(low):
266
+ types.add("agentic_execution")
267
+ if _RE_MULTI_AGENT.search(low):
268
+ types.add("multi_agent")
269
+ if mode in ("agent", "build"):
270
+ types.add("agentic_execution")
271
+ if mode == "debugger":
272
+ types.add("debugging")
273
+ if mode == "research":
274
+ types.add("research")
275
+ if mode == "plan":
276
+ types.add("planning")
277
+ if not types:
278
+ types.add("simple_chat" if len(text) < 60 else "research")
279
+ return types
280
+
281
+
282
+ def complexity_score(types: Set[str], prompt: str) -> float:
283
+ """0..1 rough complexity estimate used for dynamic multi-AI team sizing
284
+ and the fast/heavy path decision."""
285
+ score = 0.15
286
+ heavy = {"coding": 0.25, "agentic_execution": 0.25, "multi_agent": 0.35,
287
+ "long_context": 0.2, "vision": 0.15, "document_analysis": 0.1,
288
+ "planning": 0.1, "debugging": 0.15}
289
+ for t in types:
290
+ score += heavy.get(t, 0.05)
291
+ words = len((prompt or "").split())
292
+ if words > 60:
293
+ score += 0.1
294
+ if words > 150:
295
+ score += 0.1
296
+ return min(1.0, score)
297
+
298
+
299
+ def complexity_class(types: Set[str], prompt: str,
300
+ mode: Optional[str] = None) -> str:
301
+ """Map the classification to one coarse execution class (spec #4).
302
+ Drives both the pipeline path AND the timeout size class."""
303
+ p = (prompt or "").strip()
304
+ # Explicit agent/build intent or agentic work always wins.
305
+ if "multi_agent" in types or "agentic_execution" in types \
306
+ or "planning" in types or mode in ("agent", "build"):
307
+ return CLASS_AGENT
308
+ if "research" in types:
309
+ return CLASS_RESEARCH
310
+ if types & {"vision", "document_analysis", "long_context", "multimodal"}:
311
+ return CLASS_COMPLEX
312
+ if len(p) <= 25 and (types <= {"simple_chat"} or not types):
313
+ return CLASS_TRIVIAL
314
+ if types <= {"simple_chat", "mathematics"} and len(p) < 120:
315
+ return CLASS_SIMPLE
316
+ if types & {"coding", "debugging", "mathematics"}:
317
+ return CLASS_NORMAL
318
+ return CLASS_NORMAL
319
+
320
+
321
+ # Timeout budget per class (aicore.request_timeouts consumes the same
322
+ # labels): trivial prompts fail fast instead of hanging on '...'.
323
+ _CLASS_TO_SIZE = {
324
+ CLASS_TRIVIAL: "simple",
325
+ CLASS_SIMPLE: "simple",
326
+ CLASS_NORMAL: "normal",
327
+ CLASS_COMPLEX: "normal",
328
+ CLASS_AGENT: "large",
329
+ CLASS_RESEARCH: "normal",
330
+ }
331
+
332
+
333
+ def timeout_size_class(complexity_cls: str) -> str:
334
+ return _CLASS_TO_SIZE.get(complexity_cls, "normal")
335
+
336
+
337
+ # ---------------------------------------------------- capability registry --
338
+
339
+ _VISION_STYLES = ("openai", "anthropic", "gemini")
340
+
341
+ _NON_VISION_HINTS = ("gpt-3.5", "llama", "mixtral", "mistral", "deepseek",
342
+ "phi-3", "phi3", "qwen", "command", "gemma", "granite",
343
+ "codex", "o1-mini", "o3-mini")
344
+ _VISION_HINTS = ("gpt-4o", "gpt-4.1", "gpt-5", "o3", "o4", "claude-3",
345
+ "claude-4", "gemini-1.5", "gemini-2.0", "gemini-2.5",
346
+ "gemini-3", "qwen2.5-vl", "llava", "llama-3.2-vision",
347
+ "pixtral", "vision")
348
+ _REASONING_HINTS = ("o1", "o3", "o4", "r1", "thinking", "reason", "qwq",
349
+ "claude-3.7", "claude-4", "gpt-5", "deepseek-r")
350
+ _FAST_HINTS = ("mini", "flash", "lite", "small", "turbo", "instant", "haiku",
351
+ "nano", "8b", "7b")
352
+ _EXPENSIVE_HINTS = ("opus", "gpt-5", "o3", "ultra", "claude-4")
353
+ _BIGCTX_HINTS = ("gemini", "claude-3", "claude-4", "gpt-4.1", "gpt-5")
354
+
355
+
356
+ _registry_cache: Optional[Tuple[float, List[ModelCapabilities]]] = None
357
+ _REGISTRY_TTL = 30.0 # seconds — config rarely changes mid-session
358
+
359
+
360
+ def invalidate_cache() -> None:
361
+ """Invalidate cached available configs so router immediately picks up
362
+ new primary or backup model choices without waiting for TTL."""
363
+ global _registry_cache
364
+ _registry_cache = None
365
+
366
+
367
+ def capabilities_for(provider: str, model: str = "") -> ModelCapabilities:
368
+ """Public helper to derive capabilities for a given provider and model."""
369
+ return _capabilities_for({"provider": provider, "model": model})
370
+
371
+
372
+ def _capabilities_for(config: dict, is_backup: bool = False) -> ModelCapabilities:
373
+ """Derive ModelCapabilities from a provider config dict. Pure name/
374
+ style heuristics plus curated context-window metadata — no network."""
375
+ provider = str(config.get("provider", "")).lower()
376
+ model = str(config.get("model", "")).lower()
377
+ api_style = str(config.get("api_style", "") or "").lower()
378
+ try:
379
+ from . import aicore as _a
380
+ info = _a.PROVIDERS.get(provider)
381
+ api_style = api_style or ((info["api_style"] if info else "openai") or "openai")
382
+ except Exception:
383
+ api_style = api_style or "openai"
384
+
385
+ caps = ModelCapabilities(provider=provider, model=model,
386
+ api_style=api_style, is_backup=is_backup)
387
+ caps.vision = (api_style in _VISION_STYLES
388
+ and not any(h in model for h in _NON_VISION_HINTS)
389
+ and any(h in model for h in _VISION_HINTS))
390
+ caps.tools = api_style != "unsupported"
391
+ caps.streaming = True
392
+ caps.reasoning = any(h in model for h in _REASONING_HINTS)
393
+ caps.latency_score = 0.85 if any(h in model for h in _FAST_HINTS) else 0.45
394
+ if provider == "ollama":
395
+ caps.latency_score = max(0.1, caps.latency_score - 0.25)
396
+ caps.cost_score = 0.0
397
+ if provider == "groq":
398
+ caps.latency_score = min(1.0, caps.latency_score + 0.2)
399
+ caps.cost_score = 0.8 if any(h in model for h in _EXPENSIVE_HINTS) else \
400
+ (0.3 if any(h in model for h in _FAST_HINTS) else 0.5)
401
+ try:
402
+ from . import aicore as _a
403
+ caps.context_window = int(_a.context_window_for(model) or 32000)
404
+ except Exception:
405
+ caps.context_window = 200000 if any(h in model for h in _BIGCTX_HINTS) else 32000
406
+ return caps
407
+
408
+
409
+ def available_configs(force_refresh: bool = False) -> List[ModelCapabilities]:
410
+ """Every configured model (primary first, then enabled backups), with
411
+ capabilities. Cached briefly — building it must stay off the hot path."""
412
+ global _registry_cache
413
+ now = time.time()
414
+ if not force_refresh and _registry_cache is not None:
415
+ ts, items = _registry_cache
416
+ if now - ts < _REGISTRY_TTL:
417
+ return items
418
+ out: List[ModelCapabilities] = []
419
+ try:
420
+ from . import aicore as _a
421
+ cfg = _a.load_config() or {}
422
+ if cfg.get("provider"):
423
+ out.append(_capabilities_for(cfg, is_backup=False))
424
+ try:
425
+ from .providers.provider_manager import backup_configs
426
+ for bcfg, _entry in backup_configs():
427
+ out.append(_capabilities_for(bcfg, is_backup=True))
428
+ except Exception:
429
+ pass
430
+ except Exception:
431
+ pass
432
+ _registry_cache = (now, out)
433
+ return out
434
+
435
+
436
+ def find_vision_capable(preferred_config: Optional[dict] = None
437
+ ) -> Tuple[Optional[dict], Optional[ModelCapabilities]]:
438
+ """Requirement #19: when the active model can't see an image, find a
439
+ configured model that CAN. Returns (config_dict_or_None, caps_or_None).
440
+ Never raises; (None, None) means honestly 'no vision-capable model is
441
+ configured'."""
442
+ configs = available_configs()
443
+ if preferred_config:
444
+ pref_caps = _capabilities_for(preferred_config)
445
+ if pref_caps.vision:
446
+ return preferred_config, pref_caps
447
+ for caps in configs:
448
+ if caps.vision:
449
+ try:
450
+ if caps.is_backup:
451
+ from .providers.provider_manager import backup_configs
452
+ for bcfg, _e in backup_configs():
453
+ if str(bcfg.get("provider", "")).lower() == caps.provider \
454
+ and str(bcfg.get("model", "")).lower() == caps.model:
455
+ return bcfg, caps
456
+ else:
457
+ from . import aicore as _a
458
+ return _a.load_config(), caps
459
+ except Exception:
460
+ continue
461
+ return None, None
462
+
463
+
464
+ def get_privacy_policy() -> str:
465
+ """Read the active privacy and routing policy from central config."""
466
+ try:
467
+ from . import config as _cfg
468
+ c = _cfg.get_config()
469
+ p = getattr(c, "privacy_policy", POLICY_LOCAL_FIRST)
470
+ if p in POLICIES:
471
+ return p
472
+ except Exception:
473
+ pass
474
+ return POLICY_LOCAL_FIRST
475
+
476
+
477
+ def set_privacy_policy(policy: str) -> None:
478
+ """Update and persist the active privacy and routing policy."""
479
+ if policy not in POLICIES:
480
+ return
481
+ try:
482
+ from . import config as _cfg
483
+ c = _cfg.get_config()
484
+ c.privacy_policy = policy
485
+ _cfg.save_config(c)
486
+ invalidate_cache()
487
+ except Exception:
488
+ pass
489
+
490
+
491
+ def build_candidate_chain(task_types: Set[str], prompt: str = "", policy: Optional[str] = None) -> List[dict]:
492
+ """Return ordered candidate model configs adhering to the selected privacy policy.
493
+
494
+ - never_cloud: Strict privacy — filters out all non-local providers.
495
+ - local_first: Prioritizes local models; cloud models act as secondary fallback.
496
+ - cloud_first: Prioritizes high-end cloud providers; local models act as offline fallback.
497
+ - best_available: Scores candidates purely on capability and benchmark fit.
498
+ """
499
+ pol = policy or get_privacy_policy()
500
+ caps_list = available_configs()
501
+
502
+ needed_vision = "vision" in task_types
503
+ needed_reasoning = bool({"reasoning", "mathematics", "debugging"} & task_types)
504
+ needed_long_ctx = "long_context" in task_types
505
+ wants_fast = "simple_chat" in task_types
506
+
507
+ local_providers = {"ollama", "local", "vllm"}
508
+
509
+ scored_candidates = []
510
+ for c in caps_list:
511
+ is_local = c.provider.lower() in local_providers
512
+ if pol == POLICY_NEVER_CLOUD and not is_local:
513
+ continue
514
+
515
+ base_score = c.score_for(
516
+ needed_vision=needed_vision,
517
+ needed_reasoning=needed_reasoning,
518
+ needed_long_ctx=needed_long_ctx,
519
+ wants_fast=wants_fast,
520
+ )
521
+ if base_score < 0:
522
+ continue
523
+
524
+ priority = 0.0
525
+ if pol == POLICY_LOCAL_FIRST:
526
+ priority = 10.0 if is_local else 0.0
527
+ elif pol == POLICY_CLOUD_FIRST:
528
+ priority = 10.0 if not is_local else 0.0
529
+ elif pol == POLICY_BEST_AVAILABLE:
530
+ priority = 5.0 * (1.0 if not is_local else 0.8)
531
+
532
+ final_score = priority + base_score
533
+ scored_candidates.append((final_score, c))
534
+
535
+ scored_candidates.sort(key=lambda x: x[0], reverse=True)
536
+ return [c.to_dict() for _, c in scored_candidates]
537
+
538
+
539
+ # ----------------------------------------------------------------- route --
540
+
541
+ def route(prompt: str, attachments: Optional[List] = None,
542
+ mode: Optional[str] = None,
543
+ workflow_hint: Optional[str] = None) -> RouteDecision:
544
+ """The one function the UI/pipeline calls per user message. Returns a
545
+ RouteDecision choosing the pipeline path + best capable model config
546
+ (for vision rerouting). Deliberately cheap: pure local heuristics."""
547
+ t0 = time.perf_counter()
548
+ types = classify(prompt, attachments, mode)
549
+ pol = get_privacy_policy()
550
+ dec = RouteDecision(task_types=types, privacy_policy=pol)
551
+ dec.candidate_chain = build_candidate_chain(types, prompt, pol)
552
+ has_image = "vision" in types
553
+ complexity = complexity_score(types, prompt)
554
+ dec.size_class = timeout_size_class(complexity_class(types, prompt, mode))
555
+
556
+ # ---- vision path -----------------------------------------------------
557
+ if has_image:
558
+ dec.path = PATH_VISION
559
+ dec.use_tools = bool({"agentic_execution"} & types) and mode in ("agent", "build")
560
+ dec.reason = "image attached → vision-capable model required"
561
+ dec.size_class = "large"
562
+ vision_cfg, vcaps = find_vision_capable()
563
+ dec.vision_config = vision_cfg
564
+ if vision_cfg is None:
565
+ dec.reason = ("image attached but no vision-capable model is "
566
+ "configured — will report clearly")
567
+ _stamp(dec, t0)
568
+ return dec
569
+
570
+ # ---- fast path (requirement #9) --------------------------------------
571
+ tiny = len(prompt) < 220 and types <= {"simple_chat", "mathematics", "research"}
572
+ greetingish = bool({"simple_chat"} & types) and complexity <= 0.2
573
+ # v0.7.9.5: a genuinely trivial prompt takes the fast path regardless
574
+ # of complexity scoring — "hi" must never trigger planning.
575
+ trivial = len((prompt or "").strip()) <= 25 and \
576
+ types <= {"simple_chat", "mathematics"}
577
+ if (tiny and (greetingish or mode in (None, "notebook"))) or \
578
+ (trivial and mode in (None, "notebook")):
579
+ dec.path = PATH_FAST
580
+ dec.fast = True
581
+ dec.size_class = "simple"
582
+ dec.reason = ("trivial prompt → fast path (no memory scan, no agents,"
583
+ " no tools)" if trivial else
584
+ "simple question → fast path (no memory scan, no agents,"
585
+ " no tools)")
586
+ _stamp(dec, t0)
587
+ return dec
588
+
589
+ # ---- multi-AI only when it actually helps (requirement #33) ----------
590
+ wants_multi = "multi_agent" in types or (
591
+ mode == "agent" and complexity >= 0.75 and
592
+ ({"coding", "agentic_execution", "long_context"} & types))
593
+ if wants_multi and complexity >= 0.6:
594
+ dec.path = PATH_MULTI_AI
595
+ dec.use_multi_agent = True
596
+ dec.size_class = "large"
597
+ dec.reason = f"complex multi-part task (complexity {complexity:.2f}) → orchestrated team"
598
+ _stamp(dec, t0)
599
+ return dec
600
+
601
+ # ---- agent/tool path --------------------------------------------------
602
+ if mode in ("agent", "build"):
603
+ dec.path = PATH_AGENT
604
+ dec.use_tools = True
605
+ dec.size_class = "large"
606
+ dec.reason = f"{mode} mode → tool-executing agent loop"
607
+ _stamp(dec, t0)
608
+ return dec
609
+ if "agentic_execution" in types and mode not in ("notebook",):
610
+ dec.path = PATH_AGENT
611
+ dec.use_tools = True
612
+ dec.size_class = "large"
613
+ dec.reason = "task requires real file/tool operations → agent loop"
614
+ _stamp(dec, t0)
615
+ return dec
616
+ if workflow_hint in ("install", "pipeline"):
617
+ dec.path = PATH_AGENT
618
+ dec.use_tools = True
619
+ dec.size_class = "large"
620
+ dec.reason = f"workflow '{workflow_hint}' → agent/pipeline execution"
621
+ _stamp(dec, t0)
622
+ return dec
623
+
624
+ # ---- default: plain streaming -----------------------------------------
625
+ bits = sorted(types)
626
+ dec.path = PATH_STREAM
627
+ dec.fast = complexity <= 0.2
628
+ dec.reason = "streaming chat (" + ", ".join(bits) + ")" if bits else "streaming chat"
629
+ _stamp(dec, t0)
630
+ return dec
631
+
632
+
633
+ def _stamp(dec: RouteDecision, t0: float) -> None:
634
+ try:
635
+ from . import metrics
636
+ m = metrics.current()
637
+ if m is not None:
638
+ m.mark_stage("routing", t0)
639
+ m.task_types = sorted(dec.task_types)
640
+ m.route_path = dec.path
641
+ except Exception:
642
+ pass
643
+
644
+
645
+ def describe_decision(dec: RouteDecision) -> str:
646
+ """One human-readable line for the UI activity feed — real decision,
647
+ really made."""
648
+ tt = ", ".join(sorted(dec.task_types)) or "general"
649
+ pol_tag = f" [{dec.privacy_policy.upper()}]" if hasattr(dec, "privacy_policy") and dec.privacy_policy else ""
650
+ icon = "[Router]"
651
+ try:
652
+ # Check if terminal/stream supports unicode compass
653
+ import sys
654
+ enc = getattr(sys.stdout, "encoding", "") or "utf-8"
655
+ if "utf" in enc.lower():
656
+ icon = "\U0001f9ed Router:"
657
+ else:
658
+ icon = "[Router]"
659
+ except Exception:
660
+ icon = "[Router]"
661
+ line = f"{icon} {dec.path.upper()}{pol_tag} ({tt})"
662
+ if dec.vision_config is not None:
663
+ line += (f" → {dec.vision_config.get('provider', '?')}/"
664
+ f"{dec.vision_config.get('model', '?')}")
665
+ return line
File without changes