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,117 @@
1
+ """GPU/VRAM detection."""
2
+
3
+ import subprocess
4
+ from typing import Any
5
+
6
+ # Process-wide cache: GPU topology does not change during a run, and the probe
7
+ # (subprocess + driver query) is comparatively expensive. Memoizing also dedupes
8
+ # the historically-double probe (runtime bootstrap + hardware detector).
9
+ _GPU_CACHE: dict[str, Any] | None = None
10
+
11
+ # Subprocess probes must never block startup if a GPU driver is wedged.
12
+ _PROBE_TIMEOUT = 3.0
13
+
14
+
15
+ class GPUDetector:
16
+ """Detects GPU capabilities and VRAM."""
17
+
18
+ def detect(self) -> dict[str, Any]:
19
+ """Detect GPU information (memoized for the process lifetime)."""
20
+ global _GPU_CACHE
21
+ if _GPU_CACHE is not None:
22
+ return dict(_GPU_CACHE)
23
+ info = {
24
+ "has_gpu": False,
25
+ "gpu_type": None,
26
+ "vram_total_gb": None,
27
+ "vram_free_gb": None,
28
+ "cuda_available": False,
29
+ }
30
+
31
+ # Try NVIDIA -> AMD (ROCm) -> Apple Silicon (Metal), first hit wins.
32
+ for probe in (self._detect_nvidia, self._detect_amd, self._detect_metal):
33
+ result = probe()
34
+ if result:
35
+ info.update(result)
36
+ break
37
+
38
+ _GPU_CACHE = dict(info)
39
+ return info
40
+
41
+ def _detect_nvidia(self) -> dict[str, Any] | None:
42
+ """Detect NVIDIA GPU via nvidia-smi."""
43
+ try:
44
+ result = subprocess.run(
45
+ [
46
+ "nvidia-smi",
47
+ "--query-gpu=name,memory.total,memory.free",
48
+ "--format=csv,noheader",
49
+ ],
50
+ capture_output=True,
51
+ text=True,
52
+ check=True,
53
+ timeout=_PROBE_TIMEOUT,
54
+ )
55
+
56
+ lines = result.stdout.strip().split("\n")
57
+ if not lines:
58
+ return None
59
+
60
+ parts = lines[0].split(",")
61
+ gpu_name = parts[0].strip()
62
+ memory_total = parts[1].strip().replace(" MiB", "")
63
+ memory_free = parts[2].strip().replace(" MiB", "")
64
+
65
+ return {
66
+ "has_gpu": True,
67
+ "gpu_type": "nvidia",
68
+ "gpu_name": gpu_name,
69
+ "vram_total_gb": float(memory_total) / 1024,
70
+ "vram_free_gb": float(memory_free) / 1024,
71
+ "cuda_available": True,
72
+ }
73
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
74
+ return None
75
+
76
+ def _detect_amd(self) -> dict[str, Any] | None:
77
+ """Detect AMD GPU via rocm-smi."""
78
+ try:
79
+ subprocess.run(
80
+ ["rocm-smi", "--showmeminfo", "vram"],
81
+ capture_output=True,
82
+ text=True,
83
+ check=True,
84
+ timeout=_PROBE_TIMEOUT,
85
+ )
86
+
87
+ # Parse rocm-smi output (simplified)
88
+ return {
89
+ "has_gpu": True,
90
+ "gpu_type": "amd",
91
+ "cuda_available": False,
92
+ }
93
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
94
+ return None
95
+
96
+ def _detect_metal(self) -> dict[str, Any] | None:
97
+ """Detect Apple Silicon GPU via Metal."""
98
+ try:
99
+ import platform
100
+
101
+ if platform.machine() != "arm64":
102
+ return None
103
+
104
+ # Apple Silicon has unified memory
105
+ import psutil
106
+
107
+ total_memory = psutil.virtual_memory().total / (1024**3) # GB
108
+
109
+ return {
110
+ "has_gpu": True,
111
+ "gpu_type": "apple_silicon",
112
+ "vram_total_gb": total_memory,
113
+ "vram_free_gb": total_memory * 0.8, # Assume 80% available
114
+ "cuda_available": False,
115
+ }
116
+ except Exception:
117
+ return None
@@ -0,0 +1,21 @@
1
+ """Groq model discovery — returns GROQ_MODELS when a key is configured."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from velune.core.types.model import ModelDescriptor
6
+ from velune.providers import keystore
7
+
8
+
9
+ class GroqDiscovery:
10
+ """Returns the hardcoded Groq model list when a key is configured."""
11
+
12
+ provider_id = "groq"
13
+
14
+ async def discover(self) -> list[ModelDescriptor]:
15
+ # Call through the module (not a from-imported name) so the check
16
+ # always reflects the current keystore state and stays patchable.
17
+ if not keystore.has_key("groq"):
18
+ return []
19
+ from velune.providers.adapters.groq import GROQ_MODELS
20
+
21
+ return GROQ_MODELS
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from velune.core.types.model import CapabilityLevel, ModelCapabilityProfile, ModelDescriptor
6
+
7
+
8
+ class HuggingFaceDiscovery:
9
+ """Discovers models from HuggingFace cache."""
10
+
11
+ def __init__(self):
12
+ self.provider_id = "huggingface"
13
+ self.cache_path = Path.home() / ".cache" / "huggingface" / "hub"
14
+
15
+ async def discover(self) -> list[ModelDescriptor]:
16
+ """Discover models from HuggingFace cache."""
17
+ models = []
18
+
19
+ if not self.cache_path.exists():
20
+ return models
21
+
22
+ # Look for model directories
23
+ for model_dir in self.cache_path.iterdir():
24
+ if not model_dir.is_dir():
25
+ continue
26
+
27
+ descriptor = self._parse_model_dir(model_dir)
28
+ if descriptor:
29
+ models.append(descriptor)
30
+
31
+ return models
32
+
33
+ def _parse_model_dir(self, model_dir: Path) -> ModelDescriptor:
34
+ """Parse model directory into descriptor."""
35
+ model_id = model_dir.name
36
+
37
+ # Basic capability classification
38
+ capabilities = self._classify_capabilities(model_id)
39
+
40
+ return ModelDescriptor(
41
+ model_id=model_id,
42
+ provider_id=self.provider_id,
43
+ display_name=model_id,
44
+ context_length=4096,
45
+ capabilities=capabilities,
46
+ quantization=None,
47
+ vram_required_gb=None,
48
+ parameter_count_b=None,
49
+ speed_tier="medium",
50
+ cost_per_1k_tokens=None,
51
+ tags=["local", "huggingface"],
52
+ metadata={"cache_path": str(model_dir)},
53
+ )
54
+
55
+ def _classify_capabilities(self, model_id: str) -> ModelCapabilityProfile:
56
+ """Classify capabilities from model ID."""
57
+ model_lower = model_id.lower()
58
+
59
+ profile = ModelCapabilityProfile()
60
+
61
+ if any(name in model_lower for name in ["coder", "code"]):
62
+ profile.coding = CapabilityLevel.INTERMEDIATE
63
+
64
+ profile.reasoning = CapabilityLevel.BASIC
65
+ profile.instruction_following = CapabilityLevel.BASIC
66
+
67
+ return profile
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ import httpx
6
+
7
+ from velune.core.types.model import CapabilityLevel, ModelCapabilityProfile, ModelDescriptor
8
+
9
+ logger = logging.getLogger("velune.providers.discovery.lmstudio")
10
+
11
+
12
+ class LMStudioDiscovery:
13
+ """Discovers models from LM Studio."""
14
+
15
+ def __init__(self):
16
+ self.provider_id = "lmstudio"
17
+ self.base_url = "http://localhost:1234"
18
+
19
+ @classmethod
20
+ async def is_running(cls) -> bool:
21
+ """Return True if the LM Studio server is reachable."""
22
+ try:
23
+ async with httpx.AsyncClient(timeout=2.0) as client:
24
+ r = await client.head("http://localhost:1234")
25
+ return r.status_code < 500
26
+ except Exception:
27
+ return False
28
+
29
+ async def discover(self) -> list[ModelDescriptor]:
30
+ """Discover models from LM Studio."""
31
+ models = []
32
+
33
+ try:
34
+ async with httpx.AsyncClient(timeout=10.0) as client:
35
+ response = await client.get(f"{self.base_url}/v1/models")
36
+ response.raise_for_status()
37
+ data = response.json()
38
+
39
+ for model in data.get("data", []):
40
+ descriptor = self._parse_model(model)
41
+ if descriptor:
42
+ models.append(descriptor)
43
+ except Exception as e:
44
+ logger.debug("LM Studio discovery failed: %s", e)
45
+
46
+ return models
47
+
48
+ def _parse_model(self, model_data: dict) -> ModelDescriptor:
49
+ model_id = model_data["id"]
50
+ capabilities = self._classify_capabilities(model_id)
51
+
52
+ return ModelDescriptor(
53
+ model_id=model_id,
54
+ provider_id=self.provider_id,
55
+ display_name=model_id,
56
+ context_length=4096,
57
+ capabilities=capabilities,
58
+ quantization=None,
59
+ vram_required_gb=None,
60
+ parameter_count_b=None,
61
+ speed_tier="medium",
62
+ cost_per_1k_tokens=None,
63
+ tags=["local", "lmstudio"],
64
+ metadata={"raw": model_data},
65
+ )
66
+
67
+ def _classify_capabilities(self, model_id: str) -> ModelCapabilityProfile:
68
+ model_lower = model_id.lower()
69
+ profile = ModelCapabilityProfile()
70
+
71
+ if any(name in model_lower for name in ["coder", "code"]):
72
+ profile.coding = CapabilityLevel.INTERMEDIATE
73
+ else:
74
+ profile.coding = CapabilityLevel.BASIC
75
+
76
+ profile.reasoning = CapabilityLevel.BASIC
77
+ profile.instruction_following = CapabilityLevel.INTERMEDIATE
78
+ profile.summarization = CapabilityLevel.BASIC
79
+
80
+ return profile
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+
6
+ import httpx
7
+
8
+ from velune.core.types.model import CapabilityLevel, ModelCapabilityProfile, ModelDescriptor
9
+ from velune.providers.discovery.gpu import GPUDetector
10
+
11
+ logger = logging.getLogger("velune.providers.discovery.ollama")
12
+
13
+
14
+ class OllamaDiscovery:
15
+ """Discovers models from Ollama."""
16
+
17
+ def __init__(self):
18
+ self.provider_id = "ollama"
19
+ self.base_url = "http://localhost:11434"
20
+ self.gpu_detector = GPUDetector()
21
+
22
+ @classmethod
23
+ async def is_running(cls) -> bool:
24
+ """Return True if the Ollama daemon is reachable."""
25
+ try:
26
+ async with httpx.AsyncClient(timeout=2.0) as client:
27
+ r = await client.head("http://localhost:11434")
28
+ return r.status_code < 500
29
+ except Exception:
30
+ return False
31
+
32
+ async def _get_model_details(self, model_name: str) -> dict:
33
+ try:
34
+ async with httpx.AsyncClient(timeout=5.0) as client:
35
+ r = await client.post(f"{self.base_url}/api/show", json={"name": model_name})
36
+ data = r.json()
37
+ modelfile = data.get("modelfile", "")
38
+ for line in modelfile.splitlines():
39
+ if line.strip().upper().startswith("PARAMETER NUM_CTX"):
40
+ try:
41
+ return {"num_ctx": int(line.split()[-1])}
42
+ except ValueError:
43
+ pass
44
+ except Exception:
45
+ pass
46
+ return {}
47
+
48
+ async def discover(self) -> list[ModelDescriptor]:
49
+ """Discover models from Ollama."""
50
+ models = []
51
+
52
+ try:
53
+ async with httpx.AsyncClient(timeout=10.0) as client:
54
+ response = await client.get(f"{self.base_url}/api/tags")
55
+ response.raise_for_status()
56
+ data = response.json()
57
+
58
+ gpu_info = self.gpu_detector.detect()
59
+
60
+ tasks = [self._get_model_details(model["name"]) for model in data.get("models", [])]
61
+ details_list = await asyncio.gather(*tasks, return_exceptions=True)
62
+
63
+ for model, details in zip(data.get("models", []), details_list, strict=False):
64
+ if isinstance(details, dict) and "num_ctx" in details:
65
+ if "details" not in model or not isinstance(model["details"], dict):
66
+ model["details"] = {}
67
+ model["details"]["num_ctx"] = details["num_ctx"]
68
+
69
+ descriptor = self._parse_model(model, gpu_info)
70
+ if descriptor:
71
+ models.append(descriptor)
72
+ except Exception as e:
73
+ logger.debug("Ollama discovery failed: %s", e)
74
+
75
+ return models
76
+
77
+ def _parse_model(self, model_data: dict, gpu_info: dict) -> ModelDescriptor:
78
+ model_id = model_data["name"]
79
+ details = model_data.get("details", {})
80
+ quantization = self._extract_quantization(model_id)
81
+ vram_gb = self._estimate_vram(details, quantization)
82
+ capabilities = self._classify_capabilities(model_id)
83
+
84
+ return ModelDescriptor(
85
+ model_id=model_id,
86
+ provider_id=self.provider_id,
87
+ display_name=model_id,
88
+ context_length=details.get("num_ctx", 4096),
89
+ capabilities=capabilities,
90
+ is_local=True,
91
+ quantization=quantization,
92
+ vram_required_gb=vram_gb,
93
+ parameter_count_b=details.get("parameter_count"),
94
+ speed_tier="medium",
95
+ cost_per_1k_tokens=None,
96
+ tags=["local", "ollama"],
97
+ metadata={"details": details},
98
+ )
99
+
100
+ def _extract_quantization(self, model_id: str) -> str | None:
101
+ quant_map = {
102
+ "q4_k_m": "Q4_K_M",
103
+ "q4_0": "Q4_0",
104
+ "q5_k_m": "Q5_K_M",
105
+ "q5_0": "Q5_0",
106
+ "q8_0": "Q8_0",
107
+ "fp16": "FP16",
108
+ "f16": "F16",
109
+ }
110
+ model_lower = model_id.lower()
111
+ for key, value in quant_map.items():
112
+ if key in model_lower:
113
+ return value
114
+ return None
115
+
116
+ def _estimate_vram(self, details: dict, quantization: str | None) -> float | None:
117
+ param_count = details.get("parameter_count", 0)
118
+ if not param_count:
119
+ return None
120
+ vram_per_param_gb = {
121
+ None: 2.0,
122
+ "FP16": 2.0,
123
+ "F16": 2.0,
124
+ "Q8_0": 1.0,
125
+ "Q5_K_M": 0.7,
126
+ "Q5_0": 0.7,
127
+ "Q4_K_M": 0.5,
128
+ "Q4_0": 0.5,
129
+ }
130
+ vram_per_param = vram_per_param_gb.get(quantization, 0.5)
131
+ return (param_count * vram_per_param) / 1e9
132
+
133
+ def _classify_capabilities(self, model_id: str) -> ModelCapabilityProfile:
134
+ model_lower = model_id.lower()
135
+ profile = ModelCapabilityProfile()
136
+
137
+ if any(name in model_lower for name in ["coder", "code", "deepseek-coder", "qwen-coder"]):
138
+ profile.coding = CapabilityLevel.ADVANCED
139
+ elif any(name in model_lower for name in ["llama", "mistral", "qwen"]):
140
+ profile.coding = CapabilityLevel.INTERMEDIATE
141
+
142
+ if any(name in model_lower for name in ["r1", "reason", "deepseek-r1"]):
143
+ profile.reasoning = CapabilityLevel.ADVANCED
144
+ elif any(name in model_lower for name in ["qwq", "qwen"]):
145
+ profile.reasoning = CapabilityLevel.INTERMEDIATE
146
+
147
+ if profile.reasoning >= CapabilityLevel.INTERMEDIATE:
148
+ profile.planning = CapabilityLevel.INTERMEDIATE
149
+
150
+ if any(name in model_lower for name in ["llama", "mistral"]):
151
+ profile.summarization = CapabilityLevel.INTERMEDIATE
152
+
153
+ if "instruct" in model_lower or "chat" in model_lower:
154
+ profile.instruction_following = CapabilityLevel.INTERMEDIATE
155
+
156
+ if any(name in model_lower for name in ["long", "32k", "128k"]):
157
+ profile.long_context = CapabilityLevel.INTERMEDIATE
158
+
159
+ if profile.instruction_following >= CapabilityLevel.INTERMEDIATE:
160
+ profile.tool_use = CapabilityLevel.INTERMEDIATE
161
+
162
+ return profile
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+ from velune.core.types.model import CapabilityLevel, ModelCapabilityProfile, ModelDescriptor
6
+ from velune.providers.keystore import get_key
7
+
8
+
9
+ class OpenAIDiscovery:
10
+ """Discovers models from OpenAI."""
11
+
12
+ def __init__(self):
13
+ self.provider_id = "openai"
14
+ self.api_key = get_key("openai")
15
+ self.base_url = "https://api.openai.com/v1"
16
+
17
+ async def discover(self) -> list[ModelDescriptor]:
18
+ """Discover models from OpenAI."""
19
+ if not self.api_key:
20
+ return []
21
+
22
+ models = []
23
+
24
+ try:
25
+ headers = {"Authorization": f"Bearer {self.api_key}"}
26
+ async with httpx.AsyncClient(timeout=30.0) as client:
27
+ response = await client.get(
28
+ f"{self.base_url}/models",
29
+ headers=headers,
30
+ )
31
+ response.raise_for_status()
32
+ data = response.json()
33
+
34
+ for model in data.get("data", []):
35
+ if "gpt" in model["id"].lower():
36
+ descriptor = self._parse_model(model)
37
+ if descriptor:
38
+ models.append(descriptor)
39
+ except Exception:
40
+ pass
41
+
42
+ return models
43
+
44
+ def _parse_model(self, model_data: dict) -> ModelDescriptor:
45
+ """Parse model data into descriptor."""
46
+ model_id = model_data["id"]
47
+
48
+ capabilities = self._classify_capabilities(model_id)
49
+
50
+ # Determine context length and cost
51
+ if "gpt-4" in model_id:
52
+ context_length = 128000
53
+ cost_per_1k = 0.03
54
+ elif "gpt-3.5" in model_id:
55
+ context_length = 16385
56
+ cost_per_1k = 0.002
57
+ else:
58
+ context_length = 4096
59
+ cost_per_1k = 0.001
60
+
61
+ return ModelDescriptor(
62
+ model_id=model_id,
63
+ provider_id=self.provider_id,
64
+ display_name=model_id,
65
+ context_length=context_length,
66
+ capabilities=capabilities,
67
+ quantization=None,
68
+ vram_required_gb=None,
69
+ parameter_count_b=None,
70
+ speed_tier="fast",
71
+ cost_per_1k_tokens=cost_per_1k,
72
+ tags=["cloud", "openai"],
73
+ metadata={"raw": model_data},
74
+ )
75
+
76
+ def _classify_capabilities(self, model_id: str) -> ModelCapabilityProfile:
77
+ """Classify capabilities for OpenAI models."""
78
+ profile = ModelCapabilityProfile()
79
+
80
+ if "gpt-4" in model_id:
81
+ profile.coding = CapabilityLevel.ADVANCED
82
+ profile.reasoning = CapabilityLevel.EXPERT
83
+ profile.planning = CapabilityLevel.EXPERT
84
+ profile.summarization = CapabilityLevel.ADVANCED
85
+ profile.instruction_following = CapabilityLevel.EXPERT
86
+ profile.tool_use = CapabilityLevel.EXPERT
87
+ profile.long_context = CapabilityLevel.ADVANCED
88
+ elif "gpt-3.5" in model_id:
89
+ profile.coding = CapabilityLevel.INTERMEDIATE
90
+ profile.reasoning = CapabilityLevel.INTERMEDIATE
91
+ profile.planning = CapabilityLevel.INTERMEDIATE
92
+ profile.summarization = CapabilityLevel.INTERMEDIATE
93
+ profile.instruction_following = CapabilityLevel.INTERMEDIATE
94
+ profile.tool_use = CapabilityLevel.INTERMEDIATE
95
+
96
+ return profile
@@ -0,0 +1,113 @@
1
+ """OpenRouter model discovery with 1-hour local cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+
11
+ from velune.core.types.model import CapabilityLevel, ModelCapabilityProfile, ModelDescriptor
12
+ from velune.providers.keystore import get_key
13
+
14
+ _CACHE_TTL = 3600.0 # seconds
15
+
16
+
17
+ def _cache_path() -> Path:
18
+ """Resolve the cache file location, preferring the project .velune dir."""
19
+ project = Path.cwd() / ".velune"
20
+ if project.exists():
21
+ return project / "openrouter_models_cache.json"
22
+ home = Path.home() / ".velune"
23
+ home.mkdir(parents=True, exist_ok=True)
24
+ return home / "openrouter_models_cache.json"
25
+
26
+
27
+ class OpenRouterDiscovery:
28
+ """Fetches the full OpenRouter model catalogue with a 1-hour disk cache."""
29
+
30
+ provider_id = "openrouter"
31
+
32
+ async def discover(self) -> list[ModelDescriptor]:
33
+ if not get_key("openrouter"):
34
+ return []
35
+
36
+ cached = self._load_cache()
37
+ if cached is not None:
38
+ return cached
39
+
40
+ models = await self._fetch()
41
+ self._save_cache(models)
42
+ return models
43
+
44
+ def _load_cache(self) -> list[ModelDescriptor] | None:
45
+ path = _cache_path()
46
+ if not path.exists():
47
+ return None
48
+ try:
49
+ data = json.loads(path.read_text(encoding="utf-8"))
50
+ if time.time() - data.get("cached_at", 0) > _CACHE_TTL:
51
+ return None
52
+ return [self._raw_to_descriptor(m) for m in data.get("models", [])]
53
+ except Exception:
54
+ return None
55
+
56
+ def _save_cache(self, models: list[ModelDescriptor]) -> None:
57
+ path = _cache_path()
58
+ try:
59
+ raw = [
60
+ {
61
+ "id": m.model_id,
62
+ "name": m.display_name,
63
+ "context_length": m.context_length,
64
+ "cost_per_1k_tokens": m.cost_per_1k_tokens,
65
+ }
66
+ for m in models
67
+ ]
68
+ path.write_text(
69
+ json.dumps({"cached_at": time.time(), "models": raw}, indent=2),
70
+ encoding="utf-8",
71
+ )
72
+ except Exception:
73
+ pass
74
+
75
+ async def _fetch(self) -> list[ModelDescriptor]:
76
+ api_key = get_key("openrouter")
77
+ try:
78
+ headers = {
79
+ "Authorization": f"Bearer {api_key}",
80
+ "HTTP-Referer": "Velune CLI",
81
+ "X-Title": "Velune CLI",
82
+ }
83
+ async with httpx.AsyncClient(timeout=30.0) as client:
84
+ resp = await client.get(
85
+ "https://openrouter.ai/api/v1/models",
86
+ headers=headers,
87
+ )
88
+ resp.raise_for_status()
89
+ data = resp.json()
90
+ return [self._raw_to_descriptor(m) for m in data.get("data", [])]
91
+ except Exception:
92
+ return []
93
+
94
+ def _raw_to_descriptor(self, raw: dict) -> ModelDescriptor:
95
+ model_id = raw.get("id", "unknown")
96
+ context = raw.get("context_length") or 4096
97
+ pricing = raw.get("pricing", {})
98
+ cost = float(pricing.get("prompt", 0) or 0) * 1000
99
+ return ModelDescriptor(
100
+ model_id=model_id,
101
+ provider_id="openrouter",
102
+ display_name=raw.get("name") or model_id,
103
+ context_length=context,
104
+ capabilities=ModelCapabilityProfile(
105
+ coding=CapabilityLevel.INTERMEDIATE,
106
+ reasoning=CapabilityLevel.INTERMEDIATE,
107
+ instruction_following=CapabilityLevel.ADVANCED,
108
+ ),
109
+ speed_tier="medium",
110
+ cost_per_1k_tokens=cost if cost > 0 else None,
111
+ tags=["cloud", "openrouter"],
112
+ metadata={},
113
+ )