velune-cli 0.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 (279) hide show
  1. velune/__init__.py +5 -0
  2. velune/__main__.py +6 -0
  3. velune/cli/__init__.py +5 -0
  4. velune/cli/app.py +208 -0
  5. velune/cli/autocomplete.py +80 -0
  6. velune/cli/banner.py +60 -0
  7. velune/cli/commands/__init__.py +32 -0
  8. velune/cli/commands/ask.py +175 -0
  9. velune/cli/commands/base.py +16 -0
  10. velune/cli/commands/chat.py +228 -0
  11. velune/cli/commands/config.py +224 -0
  12. velune/cli/commands/daemon.py +88 -0
  13. velune/cli/commands/doctor.py +721 -0
  14. velune/cli/commands/init.py +170 -0
  15. velune/cli/commands/mcp.py +82 -0
  16. velune/cli/commands/memory.py +293 -0
  17. velune/cli/commands/models.py +683 -0
  18. velune/cli/commands/preflight.py +95 -0
  19. velune/cli/commands/run.py +270 -0
  20. velune/cli/commands/setup.py +184 -0
  21. velune/cli/commands/workspace.py +249 -0
  22. velune/cli/context.py +36 -0
  23. velune/cli/councilmodel_ui.py +199 -0
  24. velune/cli/display/council_view.py +254 -0
  25. velune/cli/display/memory_view.py +126 -0
  26. velune/cli/display/panels.py +35 -0
  27. velune/cli/display/progress.py +25 -0
  28. velune/cli/display/themes.py +25 -0
  29. velune/cli/main.py +15 -0
  30. velune/cli/model_selector.py +51 -0
  31. velune/cli/modes.py +86 -0
  32. velune/cli/pull_ui.py +123 -0
  33. velune/cli/registry.py +80 -0
  34. velune/cli/rendering/__init__.py +5 -0
  35. velune/cli/rendering/error_panel.py +79 -0
  36. velune/cli/rendering/markdown.py +63 -0
  37. velune/cli/repl.py +1855 -0
  38. velune/cli/session_manager.py +71 -0
  39. velune/cli/slash_commands.py +37 -0
  40. velune/cli/theme.py +8 -0
  41. velune/cognition/__init__.py +23 -0
  42. velune/cognition/agents/__init__.py +7 -0
  43. velune/cognition/agents/coder.py +209 -0
  44. velune/cognition/agents/planner.py +156 -0
  45. velune/cognition/agents/reviewer.py +195 -0
  46. velune/cognition/arbitrator.py +220 -0
  47. velune/cognition/architecture.py +415 -0
  48. velune/cognition/budget.py +65 -0
  49. velune/cognition/council/__init__.py +47 -0
  50. velune/cognition/council/base.py +217 -0
  51. velune/cognition/council/challenger.py +74 -0
  52. velune/cognition/council/coder.py +79 -0
  53. velune/cognition/council/critic_agent.py +43 -0
  54. velune/cognition/council/critic_configs.py +111 -0
  55. velune/cognition/council/critics.py +41 -0
  56. velune/cognition/council/debate.py +46 -0
  57. velune/cognition/council/factory.py +140 -0
  58. velune/cognition/council/messages.py +56 -0
  59. velune/cognition/council/planner.py +124 -0
  60. velune/cognition/council/reviewer.py +74 -0
  61. velune/cognition/council/synthesizer.py +67 -0
  62. velune/cognition/council/tiers.py +188 -0
  63. velune/cognition/council_orchestrator.py +282 -0
  64. velune/cognition/firewall.py +354 -0
  65. velune/cognition/module.py +46 -0
  66. velune/cognition/orchestrator.py +1205 -0
  67. velune/cognition/personality.py +238 -0
  68. velune/cognition/state.py +104 -0
  69. velune/cognition/style_resolver.py +64 -0
  70. velune/cognition/verification.py +205 -0
  71. velune/context/__init__.py +28 -0
  72. velune/context/assembler.py +240 -0
  73. velune/context/budget.py +97 -0
  74. velune/context/extractive.py +95 -0
  75. velune/context/prompt_adaptation.py +480 -0
  76. velune/context/sections.py +99 -0
  77. velune/context/token_counter.py +134 -0
  78. velune/context/utilization.py +33 -0
  79. velune/context/window.py +63 -0
  80. velune/core/__init__.py +89 -0
  81. velune/core/background.py +5 -0
  82. velune/core/config/__init__.py +37 -0
  83. velune/core/errors/__init__.py +90 -0
  84. velune/core/errors/catalog.py +188 -0
  85. velune/core/errors/execution.py +31 -0
  86. velune/core/errors/memory.py +25 -0
  87. velune/core/errors/orchestration.py +31 -0
  88. velune/core/errors/provider.py +37 -0
  89. velune/core/event_loop.py +35 -0
  90. velune/core/logging.py +83 -0
  91. velune/core/paths.py +165 -0
  92. velune/core/runtime.py +113 -0
  93. velune/core/startup_profiler.py +56 -0
  94. velune/core/task_registry.py +117 -0
  95. velune/core/trace.py +83 -0
  96. velune/core/types/__init__.py +48 -0
  97. velune/core/types/agent.py +53 -0
  98. velune/core/types/context.py +42 -0
  99. velune/core/types/inference.py +38 -0
  100. velune/core/types/memory.py +42 -0
  101. velune/core/types/model.py +70 -0
  102. velune/core/types/provider.py +62 -0
  103. velune/core/types/repository.py +38 -0
  104. velune/core/types/task.py +61 -0
  105. velune/core/types/workspace.py +28 -0
  106. velune/daemon/client.py +13 -0
  107. velune/daemon/server.py +127 -0
  108. velune/daemon/transport.py +179 -0
  109. velune/events.py +204 -0
  110. velune/execution/__init__.py +22 -0
  111. velune/execution/benchmarker.py +315 -0
  112. velune/execution/cancellation.py +53 -0
  113. velune/execution/checkpointer.py +130 -0
  114. velune/execution/command_spec.py +165 -0
  115. velune/execution/diff_preview.py +197 -0
  116. velune/execution/executor.py +181 -0
  117. velune/execution/module.py +18 -0
  118. velune/execution/multi_diff.py +67 -0
  119. velune/execution/path_guard.py +74 -0
  120. velune/execution/planner.py +91 -0
  121. velune/execution/rollback.py +89 -0
  122. velune/execution/sandbox.py +268 -0
  123. velune/execution/validator.py +115 -0
  124. velune/hardware/__init__.py +1 -0
  125. velune/hardware/detector.py +192 -0
  126. velune/kernel/__init__.py +55 -0
  127. velune/kernel/bootstrap.py +125 -0
  128. velune/kernel/config.py +426 -0
  129. velune/kernel/entrypoint.py +78 -0
  130. velune/kernel/health.py +54 -0
  131. velune/kernel/lifecycle.py +143 -0
  132. velune/kernel/module.py +17 -0
  133. velune/kernel/modules.py +23 -0
  134. velune/kernel/registry.py +96 -0
  135. velune/kernel/schemas.py +28 -0
  136. velune/main.py +9 -0
  137. velune/mcp/__init__.py +9 -0
  138. velune/mcp/client.py +115 -0
  139. velune/mcp/config.py +19 -0
  140. velune/mcp/server.py +624 -0
  141. velune/memory/__init__.py +32 -0
  142. velune/memory/compaction.py +506 -0
  143. velune/memory/embedding_pipeline.py +241 -0
  144. velune/memory/lifecycle.py +680 -0
  145. velune/memory/module.py +218 -0
  146. velune/memory/prioritizer.py +67 -0
  147. velune/memory/storage/episodic_schema.sql +53 -0
  148. velune/memory/storage/lancedb_store.py +282 -0
  149. velune/memory/storage/sqlite_manager.py +369 -0
  150. velune/memory/storage/sqlite_pool.py +149 -0
  151. velune/memory/tiers/episodic.py +588 -0
  152. velune/memory/tiers/graph.py +378 -0
  153. velune/memory/tiers/lineage.py +416 -0
  154. velune/memory/tiers/semantic.py +475 -0
  155. velune/memory/tiers/working.py +168 -0
  156. velune/memory/vitality.py +132 -0
  157. velune/models/__init__.py +15 -0
  158. velune/models/family.py +76 -0
  159. velune/models/module.py +20 -0
  160. velune/models/probes.py +192 -0
  161. velune/models/profile_cache.py +84 -0
  162. velune/models/profiler.py +108 -0
  163. velune/models/registry.py +251 -0
  164. velune/models/scorer.py +233 -0
  165. velune/models/specializations.py +205 -0
  166. velune/orchestration/__init__.py +19 -0
  167. velune/orchestration/engine.py +239 -0
  168. velune/orchestration/module.py +15 -0
  169. velune/orchestration/role_assignments.py +82 -0
  170. velune/orchestration/schemas.py +98 -0
  171. velune/plugins/__init__.py +20 -0
  172. velune/plugins/hooks.py +50 -0
  173. velune/plugins/loader.py +161 -0
  174. velune/plugins/registry.py +56 -0
  175. velune/plugins/schemas.py +21 -0
  176. velune/providers/__init__.py +23 -0
  177. velune/providers/adapters/anthropic.py +257 -0
  178. velune/providers/adapters/fireworks.py +115 -0
  179. velune/providers/adapters/google.py +234 -0
  180. velune/providers/adapters/groq.py +151 -0
  181. velune/providers/adapters/huggingface.py +210 -0
  182. velune/providers/adapters/llamacpp.py +208 -0
  183. velune/providers/adapters/lmstudio.py +175 -0
  184. velune/providers/adapters/ollama.py +233 -0
  185. velune/providers/adapters/openai.py +213 -0
  186. velune/providers/adapters/openrouter.py +81 -0
  187. velune/providers/adapters/together.py +134 -0
  188. velune/providers/adapters/xai.py +60 -0
  189. velune/providers/base.py +86 -0
  190. velune/providers/benchmarker.py +138 -0
  191. velune/providers/discovery/__init__.py +33 -0
  192. velune/providers/discovery/anthropic.py +79 -0
  193. velune/providers/discovery/benchmarks.py +44 -0
  194. velune/providers/discovery/classifier.py +69 -0
  195. velune/providers/discovery/fireworks.py +95 -0
  196. velune/providers/discovery/gguf.py +88 -0
  197. velune/providers/discovery/google.py +95 -0
  198. velune/providers/discovery/gpu.py +117 -0
  199. velune/providers/discovery/groq.py +21 -0
  200. velune/providers/discovery/huggingface.py +67 -0
  201. velune/providers/discovery/lmstudio.py +80 -0
  202. velune/providers/discovery/ollama.py +162 -0
  203. velune/providers/discovery/openai.py +96 -0
  204. velune/providers/discovery/openrouter.py +113 -0
  205. velune/providers/discovery/scanner.py +115 -0
  206. velune/providers/discovery/together.py +114 -0
  207. velune/providers/discovery/xai.py +57 -0
  208. velune/providers/health.py +67 -0
  209. velune/providers/health_monitor.py +169 -0
  210. velune/providers/keystore.py +142 -0
  211. velune/providers/local_paths.py +49 -0
  212. velune/providers/local_resolver.py +229 -0
  213. velune/providers/module.py +51 -0
  214. velune/providers/ollama_manager.py +193 -0
  215. velune/providers/registry.py +220 -0
  216. velune/providers/router.py +255 -0
  217. velune/providers/task_classifier.py +288 -0
  218. velune/py.typed +0 -0
  219. velune/repository/__init__.py +33 -0
  220. velune/repository/analyzer.py +127 -0
  221. velune/repository/ast_parser.py +822 -0
  222. velune/repository/blast_radius.py +298 -0
  223. velune/repository/boundary_classifier.py +295 -0
  224. velune/repository/cognition.py +316 -0
  225. velune/repository/grapher.py +179 -0
  226. velune/repository/import_graph.py +263 -0
  227. velune/repository/incremental_indexer.py +275 -0
  228. velune/repository/index_state.py +96 -0
  229. velune/repository/indexer.py +243 -0
  230. velune/repository/module.py +17 -0
  231. velune/repository/parser.py +474 -0
  232. velune/repository/project_type.py +300 -0
  233. velune/repository/rename_journal.py +287 -0
  234. velune/repository/scanner.py +193 -0
  235. velune/repository/schemas.py +102 -0
  236. velune/repository/symbol_registry.py +365 -0
  237. velune/repository/tracker.py +252 -0
  238. velune/retrieval/__init__.py +27 -0
  239. velune/retrieval/cache.py +110 -0
  240. velune/retrieval/fast_path.py +391 -0
  241. velune/retrieval/graph.py +124 -0
  242. velune/retrieval/hybrid.py +271 -0
  243. velune/retrieval/keyword.py +131 -0
  244. velune/retrieval/module.py +26 -0
  245. velune/retrieval/pipeline.py +303 -0
  246. velune/retrieval/reranker.py +102 -0
  247. velune/retrieval/schemas.py +59 -0
  248. velune/retrieval/slow_path.py +364 -0
  249. velune/retrieval/vector.py +203 -0
  250. velune/telemetry/__init__.py +59 -0
  251. velune/telemetry/cognition.py +267 -0
  252. velune/telemetry/cost_estimator.py +92 -0
  253. velune/telemetry/debug.py +304 -0
  254. velune/telemetry/doctor.py +244 -0
  255. velune/telemetry/logging.py +286 -0
  256. velune/telemetry/spans.py +277 -0
  257. velune/telemetry/token_tracker.py +140 -0
  258. velune/telemetry/usage_tracker.py +340 -0
  259. velune/tools/__init__.py +41 -0
  260. velune/tools/base/registry.py +87 -0
  261. velune/tools/base/tool.py +63 -0
  262. velune/tools/code/navigate.py +116 -0
  263. velune/tools/code/search.py +123 -0
  264. velune/tools/filesystem/read.py +75 -0
  265. velune/tools/filesystem/search.py +136 -0
  266. velune/tools/filesystem/write.py +163 -0
  267. velune/tools/git/history.py +177 -0
  268. velune/tools/git/operations.py +122 -0
  269. velune/tools/git/state.py +121 -0
  270. velune/tools/module.py +81 -0
  271. velune/tools/terminal/execute.py +72 -0
  272. velune/tools/terminal/history.py +47 -0
  273. velune/tools/web/fetch.py +55 -0
  274. velune/tools/web/validator.py +122 -0
  275. velune_cli-0.9.0.dist-info/METADATA +518 -0
  276. velune_cli-0.9.0.dist-info/RECORD +279 -0
  277. velune_cli-0.9.0.dist-info/WHEEL +4 -0
  278. velune_cli-0.9.0.dist-info/entry_points.txt +2 -0
  279. velune_cli-0.9.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,115 @@
1
+ """Unified model discovery coordinator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+
8
+ from velune.core.types.model import ModelDescriptor
9
+ from velune.providers.discovery.anthropic import AnthropicDiscovery
10
+ from velune.providers.discovery.fireworks import FireworksDiscovery
11
+ from velune.providers.discovery.gguf import GGUFDiscovery
12
+ from velune.providers.discovery.google import GoogleDiscovery
13
+ from velune.providers.discovery.groq import GroqDiscovery
14
+ from velune.providers.discovery.huggingface import HuggingFaceDiscovery
15
+ from velune.providers.discovery.lmstudio import LMStudioDiscovery
16
+ from velune.providers.discovery.ollama import OllamaDiscovery
17
+ from velune.providers.discovery.openai import OpenAIDiscovery
18
+ from velune.providers.discovery.openrouter import OpenRouterDiscovery
19
+ from velune.providers.discovery.together import TogetherDiscovery
20
+ from velune.providers.discovery.xai import XAIDiscovery
21
+
22
+ logger = logging.getLogger("velune.providers.discovery.scanner")
23
+
24
+ # Providers that run locally and need no API key
25
+ _LOCAL_PROVIDERS: frozenset[str] = frozenset({"ollama", "lmstudio", "gguf", "llamacpp"})
26
+
27
+
28
+ class ModelDiscoveryScanner:
29
+ """Coordinates model discovery across all configured providers.
30
+
31
+ Cloud discoverers are only invoked when a key is available for that
32
+ provider. Local discoverers always run; Ollama and LM Studio are
33
+ additionally gated on their daemon being reachable.
34
+ """
35
+
36
+ def __init__(self) -> None:
37
+ self.discoverers = [
38
+ OllamaDiscovery(),
39
+ LMStudioDiscovery(),
40
+ GGUFDiscovery(),
41
+ HuggingFaceDiscovery(),
42
+ OpenAIDiscovery(),
43
+ AnthropicDiscovery(),
44
+ XAIDiscovery(),
45
+ GoogleDiscovery(),
46
+ GroqDiscovery(),
47
+ OpenRouterDiscovery(),
48
+ TogetherDiscovery(),
49
+ FireworksDiscovery(),
50
+ ]
51
+
52
+ def _should_run(self, discoverer) -> bool:
53
+ """Return True if this discoverer should be queried."""
54
+ if discoverer.provider_id in _LOCAL_PROVIDERS:
55
+ return True
56
+ from velune.providers.keystore import has_key
57
+
58
+ return has_key(discoverer.provider_id)
59
+
60
+ async def _collect(self, discoverer) -> list[ModelDescriptor]:
61
+ """Run a single discoverer, returning [] on any failure."""
62
+ try:
63
+ return await discoverer.discover()
64
+ except Exception as e:
65
+ logger.debug("Discovery failed for %s: %s", discoverer.provider_id, e)
66
+ return []
67
+
68
+ async def scan_all(self) -> list[ModelDescriptor]:
69
+ """Scan all providers for models in parallel."""
70
+ # Gate server-dependent local providers on reachability
71
+ ollama_ok, lmstudio_ok = await asyncio.gather(
72
+ OllamaDiscovery.is_running(),
73
+ LMStudioDiscovery.is_running(),
74
+ )
75
+
76
+ tasks = []
77
+ for d in self.discoverers:
78
+ if d.provider_id == "ollama" and not ollama_ok:
79
+ continue
80
+ if d.provider_id == "lmstudio" and not lmstudio_ok:
81
+ continue
82
+ if self._should_run(d):
83
+ tasks.append(self._collect(d))
84
+
85
+ results = await asyncio.gather(*tasks, return_exceptions=True)
86
+
87
+ all_models: list[ModelDescriptor] = []
88
+ for result in results:
89
+ if isinstance(result, list):
90
+ all_models.extend(result)
91
+
92
+ # Summary log
93
+ counts: dict[str, int] = {"gguf": 0, "ollama": 0, "lmstudio": 0, "cloud": 0}
94
+ for m in all_models:
95
+ if m.provider_id in counts:
96
+ counts[m.provider_id] += 1
97
+ elif m.provider_id not in _LOCAL_PROVIDERS:
98
+ counts["cloud"] += 1
99
+
100
+ logger.info(
101
+ "Local: %d GGUF, %d Ollama, %d LM Studio | Cloud: %d models",
102
+ counts["gguf"],
103
+ counts["ollama"],
104
+ counts["lmstudio"],
105
+ counts["cloud"],
106
+ )
107
+
108
+ return all_models
109
+
110
+ async def scan_provider(self, provider_id: str) -> list[ModelDescriptor]:
111
+ """Scan a specific provider by ID."""
112
+ for discoverer in self.discoverers:
113
+ if discoverer.provider_id == provider_id:
114
+ return await self._collect(discoverer)
115
+ return []
@@ -0,0 +1,114 @@
1
+ """Together.AI model discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from velune.core.types.model import CapabilityLevel, ModelCapabilityProfile, ModelDescriptor
6
+ from velune.providers.keystore import get_key
7
+
8
+
9
+ class TogetherDiscovery:
10
+ """Returns the hardcoded Together.AI model list when a key is configured."""
11
+
12
+ provider_id = "together"
13
+
14
+ async def discover(self) -> list[ModelDescriptor]:
15
+ if not get_key("together"):
16
+ return []
17
+
18
+ return [
19
+ ModelDescriptor(
20
+ model_id="meta-llama/Llama-3.3-70B-Instruct-Turbo",
21
+ display_name="Llama 3.3 70B Instruct Turbo",
22
+ provider_id="together",
23
+ context_length=131072,
24
+ capabilities=ModelCapabilityProfile(
25
+ coding=CapabilityLevel.ADVANCED,
26
+ reasoning=CapabilityLevel.ADVANCED,
27
+ planning=CapabilityLevel.ADVANCED,
28
+ summarization=CapabilityLevel.EXPERT,
29
+ instruction_following=CapabilityLevel.EXPERT,
30
+ tool_use=CapabilityLevel.ADVANCED,
31
+ long_context=CapabilityLevel.ADVANCED,
32
+ ),
33
+ speed_tier="fast",
34
+ cost_per_1k_tokens=0.00088,
35
+ tags=["cloud", "together", "llama", "turbo"],
36
+ metadata={},
37
+ ),
38
+ ModelDescriptor(
39
+ model_id="meta-llama/Llama-3.2-11B-Vision-Instruct-Turbo",
40
+ display_name="Llama 3.2 11B Vision Instruct",
41
+ provider_id="together",
42
+ context_length=131072,
43
+ capabilities=ModelCapabilityProfile(
44
+ coding=CapabilityLevel.INTERMEDIATE,
45
+ reasoning=CapabilityLevel.INTERMEDIATE,
46
+ planning=CapabilityLevel.INTERMEDIATE,
47
+ summarization=CapabilityLevel.ADVANCED,
48
+ instruction_following=CapabilityLevel.ADVANCED,
49
+ tool_use=CapabilityLevel.INTERMEDIATE,
50
+ long_context=CapabilityLevel.INTERMEDIATE,
51
+ ),
52
+ speed_tier="fast",
53
+ cost_per_1k_tokens=0.00018,
54
+ tags=["cloud", "together", "llama", "vision", "cheap"],
55
+ metadata={},
56
+ ),
57
+ ModelDescriptor(
58
+ model_id="Qwen/Qwen2.5-Coder-32B-Instruct",
59
+ display_name="Qwen 2.5 Coder 32B Instruct",
60
+ provider_id="together",
61
+ context_length=131072,
62
+ capabilities=ModelCapabilityProfile(
63
+ coding=CapabilityLevel.EXPERT,
64
+ reasoning=CapabilityLevel.ADVANCED,
65
+ planning=CapabilityLevel.ADVANCED,
66
+ summarization=CapabilityLevel.ADVANCED,
67
+ instruction_following=CapabilityLevel.EXPERT,
68
+ tool_use=CapabilityLevel.ADVANCED,
69
+ long_context=CapabilityLevel.ADVANCED,
70
+ ),
71
+ speed_tier="medium",
72
+ cost_per_1k_tokens=0.0008,
73
+ tags=["cloud", "together", "qwen", "coding"],
74
+ metadata={},
75
+ ),
76
+ ModelDescriptor(
77
+ model_id="deepseek-ai/DeepSeek-R1",
78
+ display_name="DeepSeek R1",
79
+ provider_id="together",
80
+ context_length=163840,
81
+ capabilities=ModelCapabilityProfile(
82
+ coding=CapabilityLevel.EXPERT,
83
+ reasoning=CapabilityLevel.EXPERT,
84
+ planning=CapabilityLevel.EXPERT,
85
+ summarization=CapabilityLevel.ADVANCED,
86
+ instruction_following=CapabilityLevel.EXPERT,
87
+ tool_use=CapabilityLevel.ADVANCED,
88
+ long_context=CapabilityLevel.ADVANCED,
89
+ ),
90
+ speed_tier="slow",
91
+ cost_per_1k_tokens=0.003,
92
+ tags=["cloud", "together", "deepseek", "reasoning"],
93
+ metadata={},
94
+ ),
95
+ ModelDescriptor(
96
+ model_id="mistralai/Mistral-7B-Instruct-v0.3",
97
+ display_name="Mistral 7B Instruct v0.3",
98
+ provider_id="together",
99
+ context_length=32768,
100
+ capabilities=ModelCapabilityProfile(
101
+ coding=CapabilityLevel.INTERMEDIATE,
102
+ reasoning=CapabilityLevel.INTERMEDIATE,
103
+ planning=CapabilityLevel.INTERMEDIATE,
104
+ summarization=CapabilityLevel.ADVANCED,
105
+ instruction_following=CapabilityLevel.ADVANCED,
106
+ tool_use=CapabilityLevel.INTERMEDIATE,
107
+ long_context=CapabilityLevel.BASIC,
108
+ ),
109
+ speed_tier="fast",
110
+ cost_per_1k_tokens=0.0002,
111
+ tags=["cloud", "together", "mistral", "cheap"],
112
+ metadata={},
113
+ ),
114
+ ]
@@ -0,0 +1,57 @@
1
+ """xAI (Grok) model discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from velune.core.types.model import CapabilityLevel, ModelCapabilityProfile, ModelDescriptor
6
+ from velune.providers.keystore import get_key
7
+
8
+
9
+ class XAIDiscovery:
10
+ """Returns the hardcoded xAI Grok model list when a key is configured."""
11
+
12
+ provider_id = "xai"
13
+
14
+ async def discover(self) -> list[ModelDescriptor]:
15
+ if not get_key("xai"):
16
+ return []
17
+
18
+ return [
19
+ ModelDescriptor(
20
+ model_id="grok-2",
21
+ provider_id="xai",
22
+ display_name="Grok 2",
23
+ context_length=131072,
24
+ capabilities=ModelCapabilityProfile(
25
+ coding=CapabilityLevel.ADVANCED,
26
+ reasoning=CapabilityLevel.ADVANCED,
27
+ planning=CapabilityLevel.ADVANCED,
28
+ summarization=CapabilityLevel.ADVANCED,
29
+ instruction_following=CapabilityLevel.EXPERT,
30
+ tool_use=CapabilityLevel.ADVANCED,
31
+ long_context=CapabilityLevel.ADVANCED,
32
+ ),
33
+ speed_tier="medium",
34
+ cost_per_1k_tokens=0.005,
35
+ tags=["cloud", "xai"],
36
+ metadata={},
37
+ ),
38
+ ModelDescriptor(
39
+ model_id="grok-2-mini",
40
+ provider_id="xai",
41
+ display_name="Grok 2 Mini",
42
+ context_length=131072,
43
+ capabilities=ModelCapabilityProfile(
44
+ coding=CapabilityLevel.INTERMEDIATE,
45
+ reasoning=CapabilityLevel.INTERMEDIATE,
46
+ planning=CapabilityLevel.INTERMEDIATE,
47
+ summarization=CapabilityLevel.ADVANCED,
48
+ instruction_following=CapabilityLevel.ADVANCED,
49
+ tool_use=CapabilityLevel.INTERMEDIATE,
50
+ long_context=CapabilityLevel.ADVANCED,
51
+ ),
52
+ speed_tier="fast",
53
+ cost_per_1k_tokens=0.0005,
54
+ tags=["cloud", "xai", "mini"],
55
+ metadata={},
56
+ ),
57
+ ]
@@ -0,0 +1,67 @@
1
+ """Internet connectivity detection with TTL-based caching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import socket
7
+ import time
8
+
9
+ logger = logging.getLogger("velune.providers.health")
10
+
11
+ _DEFAULT_TTL = 30.0 # seconds
12
+ _CHECK_HOST = "1.1.1.1"
13
+ _CHECK_PORT = 80
14
+ _CHECK_TIMEOUT = 2.0 # seconds
15
+
16
+
17
+ class InternetConnectivityChecker:
18
+ """Checks internet connectivity via a TCP probe to 1.1.1.1:80.
19
+
20
+ Results are cached for *ttl* seconds to avoid hammering the network on
21
+ every router decision.
22
+ """
23
+
24
+ def __init__(self, ttl: float = _DEFAULT_TTL) -> None:
25
+ self._ttl = ttl
26
+ self._cached: bool | None = None
27
+ self._cached_at: float = 0.0
28
+
29
+ def _probe(self) -> bool:
30
+ """Attempt a TCP connection. Returns True if reachable within timeout."""
31
+ try:
32
+ with socket.create_connection((_CHECK_HOST, _CHECK_PORT), timeout=_CHECK_TIMEOUT):
33
+ return True
34
+ except OSError:
35
+ return False
36
+
37
+ @property
38
+ def is_online(self) -> bool:
39
+ """True if the internet is reachable; cached for *ttl* seconds."""
40
+ now = time.monotonic()
41
+ if self._cached is None or (now - self._cached_at) >= self._ttl:
42
+ self._cached = self._probe()
43
+ self._cached_at = now
44
+ if not self._cached:
45
+ logger.debug("Internet connectivity check: OFFLINE")
46
+ return self._cached
47
+
48
+ def invalidate(self) -> None:
49
+ """Force the next call to is_online to re-probe."""
50
+ self._cached = None
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Module-level singleton — shared across the process
55
+ # ---------------------------------------------------------------------------
56
+
57
+ _checker = InternetConnectivityChecker()
58
+
59
+
60
+ def is_online() -> bool:
61
+ """Return True if the internet appears reachable (cached 30 s)."""
62
+ return _checker.is_online
63
+
64
+
65
+ def get_checker() -> InternetConnectivityChecker:
66
+ """Return the shared InternetConnectivityChecker instance."""
67
+ return _checker
@@ -0,0 +1,169 @@
1
+ """Provider health monitoring with real-time capability tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from collections import defaultdict, deque
9
+
10
+ from velune.core.types.provider import CapabilityManifest, ProviderHealth
11
+ from velune.providers.base import ModelProvider
12
+ from velune.providers.registry import ProviderRegistry
13
+
14
+ logger = logging.getLogger("velune.providers.health_monitor")
15
+
16
+
17
+ class ProviderHealthMonitor:
18
+ """Continuously polls registered providers and maintains real-time capability manifests."""
19
+
20
+ def __init__(self, registry: ProviderRegistry) -> None:
21
+ self._registry = registry
22
+ self._manifests: dict[str, CapabilityManifest] = {}
23
+ self._latency_windows: dict[str, deque[int]] = defaultdict(lambda: deque(maxlen=5))
24
+ self._health_history: dict[str, deque[ProviderHealth]] = defaultdict(
25
+ lambda: deque(maxlen=3)
26
+ )
27
+ self._polling_task: asyncio.Task | None = None
28
+ self._poll_interval = 30.0 # seconds
29
+ self._health_check_timeout = 2.0 # seconds
30
+ self._running = False
31
+
32
+ async def start(self) -> None:
33
+ """Start the background polling task."""
34
+ if self._running:
35
+ logger.warning("ProviderHealthMonitor already running")
36
+ return
37
+
38
+ self._running = True
39
+ self._polling_task = asyncio.create_task(self._polling_loop())
40
+ logger.info("ProviderHealthMonitor started")
41
+
42
+ async def stop(self) -> None:
43
+ """Stop the background polling task."""
44
+ self._running = False
45
+ if self._polling_task:
46
+ self._polling_task.cancel()
47
+ try:
48
+ await self._polling_task
49
+ except asyncio.CancelledError:
50
+ pass
51
+ logger.info("ProviderHealthMonitor stopped")
52
+
53
+ def get_manifest(self, provider_id: str) -> CapabilityManifest | None:
54
+ """Get the latest manifest for a provider."""
55
+ return self._manifests.get(provider_id)
56
+
57
+ def get_all_manifests(self) -> dict[str, CapabilityManifest]:
58
+ """Get all provider manifests."""
59
+ return self._manifests.copy()
60
+
61
+ def record_latency(self, provider_id: str, latency_ms: int) -> None:
62
+ """Record a call latency for rolling average calculation."""
63
+ self._latency_windows[provider_id].append(latency_ms)
64
+ if manifest := self._manifests.get(provider_id):
65
+ avg_latency = int(
66
+ sum(self._latency_windows[provider_id]) / len(self._latency_windows[provider_id])
67
+ )
68
+ manifest.estimated_latency_ms = avg_latency
69
+
70
+ async def _polling_loop(self) -> None:
71
+ """Background task that polls all providers every 30 seconds."""
72
+ # Standard provider IDs that may be registered
73
+ standard_providers = [
74
+ "ollama",
75
+ "openai",
76
+ "anthropic",
77
+ "google",
78
+ "groq",
79
+ "xai",
80
+ "openrouter",
81
+ "together",
82
+ "fireworks",
83
+ "huggingface",
84
+ "lmstudio",
85
+ "llamacpp",
86
+ ]
87
+
88
+ while self._running:
89
+ try:
90
+ # Get all registered provider IDs
91
+ providers_to_check = []
92
+ for provider_id in standard_providers:
93
+ if provider := self._registry.get(provider_id):
94
+ providers_to_check.append((provider_id, provider))
95
+
96
+ # Poll all providers in parallel
97
+ tasks = [
98
+ self._health_check_provider(provider_id, provider)
99
+ for provider_id, provider in providers_to_check
100
+ ]
101
+ await asyncio.gather(*tasks, return_exceptions=True)
102
+
103
+ except Exception as e:
104
+ logger.error(f"Error in health monitoring loop: {e}")
105
+
106
+ # Sleep before next poll
107
+ try:
108
+ await asyncio.sleep(self._poll_interval)
109
+ except asyncio.CancelledError:
110
+ break
111
+
112
+ async def _health_check_provider(self, provider_id: str, provider: ModelProvider) -> None:
113
+ """Check health of a single provider and update manifest."""
114
+ try:
115
+ # Call health_check with timeout
116
+ health = await asyncio.wait_for(
117
+ provider.health_check(), timeout=self._health_check_timeout
118
+ )
119
+ except TimeoutError:
120
+ health = ProviderHealth.DEGRADED
121
+ logger.debug(f"Provider {provider_id} health check timed out")
122
+ except Exception as e:
123
+ health = ProviderHealth.UNAVAILABLE
124
+ logger.debug(f"Provider {provider_id} health check failed: {e}")
125
+
126
+ try:
127
+ # Get list of available models
128
+ available_models = await asyncio.wait_for(
129
+ provider.list_models(), timeout=self._health_check_timeout
130
+ )
131
+ except (TimeoutError, Exception):
132
+ available_models = []
133
+
134
+ # Get provider capabilities
135
+ capabilities = provider.get_capabilities()
136
+
137
+ # Track health history for consecutive unavailability detection
138
+ self._health_history[provider_id].append(health)
139
+
140
+ # Create or update manifest
141
+ manifest = CapabilityManifest(
142
+ provider_id=provider_id,
143
+ health=health,
144
+ available_models=available_models,
145
+ rate_limit_remaining=None, # Would be populated from response headers
146
+ rate_limit_reset_at=None,
147
+ estimated_latency_ms=int(
148
+ sum(self._latency_windows[provider_id]) / len(self._latency_windows[provider_id])
149
+ )
150
+ if self._latency_windows[provider_id]
151
+ else 0,
152
+ supports_streaming=capabilities.supports_streaming,
153
+ supports_tools=capabilities.supports_function_calling,
154
+ is_online=True, # Connected check would be done here
155
+ refreshed_at=time.time(),
156
+ )
157
+
158
+ # Detect status changes
159
+ old_manifest = self._manifests.get(provider_id)
160
+ if old_manifest and old_manifest.health != health:
161
+ logger.info(f"Provider {provider_id} health changed: {old_manifest.health} → {health}")
162
+
163
+ # Check for 3 consecutive unavailable polls
164
+ if len(self._health_history[provider_id]) >= 3:
165
+ recent = list(self._health_history[provider_id])
166
+ if all(h == ProviderHealth.UNAVAILABLE for h in recent[-3:]):
167
+ logger.warning(f"Provider {provider_id} unavailable for 3 consecutive polls")
168
+
169
+ self._manifests[provider_id] = manifest
@@ -0,0 +1,142 @@
1
+ """BYOK key storage backed by the OS keyring with env-var fallback.
2
+
3
+ Performance notes
4
+ -----------------
5
+ On Windows the OS keyring is the Windows Credential Manager (DPAPI). A single
6
+ ``keyring.get_password`` call can block for *seconds* the first time it is hit
7
+ after a cold boot or screen unlock, and Velune historically issued one such
8
+ call per provider (9+) sequentially during startup — tens of seconds of pure
9
+ blocking. This module now:
10
+
11
+ * imports ``keyring`` lazily (env-only users never pay the import cost);
12
+ * checks the free, instant environment variables before ever touching the
13
+ keyring;
14
+ * parallelizes the remaining keyring probes across a thread pool.
15
+
16
+ We deliberately do *not* cache resolved keys process-wide: a stale cache would
17
+ mask a key the user just saved (or deleted) mid-session, and correctness here
18
+ matters more than shaving a second off repeated lookups.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ from concurrent.futures import ThreadPoolExecutor, as_completed
25
+
26
+ _SERVICE = "velune/{}"
27
+ _USERNAME = "api_key"
28
+
29
+ PROVIDER_ENV_VARS: dict[str, str] = {
30
+ "anthropic": "ANTHROPIC_API_KEY",
31
+ "openai": "OPENAI_API_KEY",
32
+ "xai": "XAI_API_KEY",
33
+ "google": "GOOGLE_API_KEY",
34
+ "groq": "GROQ_API_KEY",
35
+ "openrouter": "OPENROUTER_API_KEY",
36
+ "huggingface": "HF_TOKEN",
37
+ "together": "TOGETHER_API_KEY",
38
+ "fireworks": "FIREWORKS_API_KEY",
39
+ }
40
+
41
+ _ENV_VARS = PROVIDER_ENV_VARS
42
+
43
+
44
+ def _keyring_get(provider_id: str) -> str | None:
45
+ """Raw, uncached OS-keyring lookup. Lazily imports keyring."""
46
+ try:
47
+ import keyring
48
+
49
+ return keyring.get_password(_SERVICE.format(provider_id), _USERNAME)
50
+ except Exception:
51
+ return None
52
+
53
+
54
+ def _resolve_uncached(provider_id: str) -> str | None:
55
+ """Resolve a key without consulting the cache (keyring then env)."""
56
+ key = _keyring_get(provider_id)
57
+ if key:
58
+ return key
59
+ env_var = _ENV_VARS.get(provider_id)
60
+ if env_var:
61
+ return os.getenv(env_var) or None
62
+ return None
63
+
64
+
65
+ def save_key(provider_id: str, api_key: str) -> None:
66
+ """Persist *api_key* for *provider_id* in the OS keyring."""
67
+ import keyring
68
+
69
+ keyring.set_password(_SERVICE.format(provider_id), _USERNAME, api_key)
70
+
71
+
72
+ def get_key(provider_id: str) -> str | None:
73
+ """Return the API key for *provider_id* (OS keyring first, then env var)."""
74
+ return _resolve_uncached(provider_id)
75
+
76
+
77
+ def delete_key(provider_id: str) -> None:
78
+ """Remove the stored key for *provider_id* from the OS keyring."""
79
+ import keyring
80
+ import keyring.errors
81
+
82
+ try:
83
+ keyring.delete_password(_SERVICE.format(provider_id), _USERNAME)
84
+ except keyring.errors.PasswordDeleteError:
85
+ pass
86
+
87
+
88
+ def has_key(provider_id: str) -> bool:
89
+ """Return True if a key is available for *provider_id*."""
90
+ return get_key(provider_id) is not None
91
+
92
+
93
+ def is_ollama_live(timeout: float = 1.0) -> bool:
94
+ """Return True if a local Ollama server is reachable.
95
+
96
+ Kept deliberately short (1s) — the startup path should not block multiple
97
+ seconds probing an optional local service.
98
+ """
99
+ try:
100
+ import httpx
101
+
102
+ r = httpx.get("http://localhost:11434/api/tags", timeout=timeout)
103
+ return r.status_code == 200
104
+ except Exception:
105
+ return False
106
+
107
+
108
+ def list_configured_providers(include_ollama: bool = True) -> list[str]:
109
+ """Return provider IDs that have a usable key, resolved in parallel.
110
+
111
+ Environment variables are checked first (free, instant). Only providers
112
+ not already satisfied by an env var incur a (parallelized, cached) keyring
113
+ probe. Ollama is local and keyless — it counts as configured when its
114
+ server is reachable.
115
+ """
116
+ configured: set[str] = set()
117
+ needs_keyring: list[str] = []
118
+
119
+ for pid, env_var in _ENV_VARS.items():
120
+ if os.getenv(env_var):
121
+ configured.add(pid)
122
+ else:
123
+ needs_keyring.append(pid)
124
+
125
+ if needs_keyring:
126
+ with ThreadPoolExecutor(max_workers=min(8, len(needs_keyring))) as pool:
127
+ futures = {pool.submit(_keyring_get, pid): pid for pid in needs_keyring}
128
+ for fut in as_completed(futures):
129
+ pid = futures[fut]
130
+ try:
131
+ key = fut.result()
132
+ except Exception:
133
+ key = None
134
+ if key:
135
+ configured.add(pid)
136
+
137
+ # Preserve a stable, declaration-order result.
138
+ ordered = [pid for pid in _ENV_VARS if pid in configured]
139
+
140
+ if include_ollama and is_ollama_live():
141
+ ordered.insert(0, "ollama")
142
+ return ordered