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.
- velune/__init__.py +5 -0
- velune/__main__.py +6 -0
- velune/cli/__init__.py +5 -0
- velune/cli/app.py +208 -0
- velune/cli/autocomplete.py +80 -0
- velune/cli/banner.py +60 -0
- velune/cli/commands/__init__.py +32 -0
- velune/cli/commands/ask.py +175 -0
- velune/cli/commands/base.py +16 -0
- velune/cli/commands/chat.py +228 -0
- velune/cli/commands/config.py +224 -0
- velune/cli/commands/daemon.py +88 -0
- velune/cli/commands/doctor.py +721 -0
- velune/cli/commands/init.py +170 -0
- velune/cli/commands/mcp.py +82 -0
- velune/cli/commands/memory.py +293 -0
- velune/cli/commands/models.py +683 -0
- velune/cli/commands/preflight.py +95 -0
- velune/cli/commands/run.py +270 -0
- velune/cli/commands/setup.py +184 -0
- velune/cli/commands/workspace.py +249 -0
- velune/cli/context.py +36 -0
- velune/cli/councilmodel_ui.py +199 -0
- velune/cli/display/council_view.py +254 -0
- velune/cli/display/memory_view.py +126 -0
- velune/cli/display/panels.py +35 -0
- velune/cli/display/progress.py +25 -0
- velune/cli/display/themes.py +25 -0
- velune/cli/main.py +15 -0
- velune/cli/model_selector.py +51 -0
- velune/cli/modes.py +86 -0
- velune/cli/pull_ui.py +123 -0
- velune/cli/registry.py +80 -0
- velune/cli/rendering/__init__.py +5 -0
- velune/cli/rendering/error_panel.py +79 -0
- velune/cli/rendering/markdown.py +63 -0
- velune/cli/repl.py +1855 -0
- velune/cli/session_manager.py +71 -0
- velune/cli/slash_commands.py +37 -0
- velune/cli/theme.py +8 -0
- velune/cognition/__init__.py +23 -0
- velune/cognition/agents/__init__.py +7 -0
- velune/cognition/agents/coder.py +209 -0
- velune/cognition/agents/planner.py +156 -0
- velune/cognition/agents/reviewer.py +195 -0
- velune/cognition/arbitrator.py +220 -0
- velune/cognition/architecture.py +415 -0
- velune/cognition/budget.py +65 -0
- velune/cognition/council/__init__.py +47 -0
- velune/cognition/council/base.py +217 -0
- velune/cognition/council/challenger.py +74 -0
- velune/cognition/council/coder.py +79 -0
- velune/cognition/council/critic_agent.py +43 -0
- velune/cognition/council/critic_configs.py +111 -0
- velune/cognition/council/critics.py +41 -0
- velune/cognition/council/debate.py +46 -0
- velune/cognition/council/factory.py +140 -0
- velune/cognition/council/messages.py +56 -0
- velune/cognition/council/planner.py +124 -0
- velune/cognition/council/reviewer.py +74 -0
- velune/cognition/council/synthesizer.py +67 -0
- velune/cognition/council/tiers.py +188 -0
- velune/cognition/council_orchestrator.py +282 -0
- velune/cognition/firewall.py +354 -0
- velune/cognition/module.py +46 -0
- velune/cognition/orchestrator.py +1205 -0
- velune/cognition/personality.py +238 -0
- velune/cognition/state.py +104 -0
- velune/cognition/style_resolver.py +64 -0
- velune/cognition/verification.py +205 -0
- velune/context/__init__.py +28 -0
- velune/context/assembler.py +240 -0
- velune/context/budget.py +97 -0
- velune/context/extractive.py +95 -0
- velune/context/prompt_adaptation.py +480 -0
- velune/context/sections.py +99 -0
- velune/context/token_counter.py +134 -0
- velune/context/utilization.py +33 -0
- velune/context/window.py +63 -0
- velune/core/__init__.py +89 -0
- velune/core/background.py +5 -0
- velune/core/config/__init__.py +37 -0
- velune/core/errors/__init__.py +90 -0
- velune/core/errors/catalog.py +188 -0
- velune/core/errors/execution.py +31 -0
- velune/core/errors/memory.py +25 -0
- velune/core/errors/orchestration.py +31 -0
- velune/core/errors/provider.py +37 -0
- velune/core/event_loop.py +35 -0
- velune/core/logging.py +83 -0
- velune/core/paths.py +165 -0
- velune/core/runtime.py +113 -0
- velune/core/startup_profiler.py +56 -0
- velune/core/task_registry.py +117 -0
- velune/core/trace.py +83 -0
- velune/core/types/__init__.py +48 -0
- velune/core/types/agent.py +53 -0
- velune/core/types/context.py +42 -0
- velune/core/types/inference.py +38 -0
- velune/core/types/memory.py +42 -0
- velune/core/types/model.py +70 -0
- velune/core/types/provider.py +62 -0
- velune/core/types/repository.py +38 -0
- velune/core/types/task.py +61 -0
- velune/core/types/workspace.py +28 -0
- velune/daemon/client.py +13 -0
- velune/daemon/server.py +127 -0
- velune/daemon/transport.py +179 -0
- velune/events.py +204 -0
- velune/execution/__init__.py +22 -0
- velune/execution/benchmarker.py +315 -0
- velune/execution/cancellation.py +53 -0
- velune/execution/checkpointer.py +130 -0
- velune/execution/command_spec.py +165 -0
- velune/execution/diff_preview.py +197 -0
- velune/execution/executor.py +181 -0
- velune/execution/module.py +18 -0
- velune/execution/multi_diff.py +67 -0
- velune/execution/path_guard.py +74 -0
- velune/execution/planner.py +91 -0
- velune/execution/rollback.py +89 -0
- velune/execution/sandbox.py +268 -0
- velune/execution/validator.py +115 -0
- velune/hardware/__init__.py +1 -0
- velune/hardware/detector.py +192 -0
- velune/kernel/__init__.py +55 -0
- velune/kernel/bootstrap.py +125 -0
- velune/kernel/config.py +426 -0
- velune/kernel/entrypoint.py +78 -0
- velune/kernel/health.py +54 -0
- velune/kernel/lifecycle.py +143 -0
- velune/kernel/module.py +17 -0
- velune/kernel/modules.py +23 -0
- velune/kernel/registry.py +96 -0
- velune/kernel/schemas.py +28 -0
- velune/main.py +9 -0
- velune/mcp/__init__.py +9 -0
- velune/mcp/client.py +115 -0
- velune/mcp/config.py +19 -0
- velune/mcp/server.py +624 -0
- velune/memory/__init__.py +32 -0
- velune/memory/compaction.py +506 -0
- velune/memory/embedding_pipeline.py +241 -0
- velune/memory/lifecycle.py +680 -0
- velune/memory/module.py +218 -0
- velune/memory/prioritizer.py +67 -0
- velune/memory/storage/episodic_schema.sql +53 -0
- velune/memory/storage/lancedb_store.py +282 -0
- velune/memory/storage/sqlite_manager.py +369 -0
- velune/memory/storage/sqlite_pool.py +149 -0
- velune/memory/tiers/episodic.py +588 -0
- velune/memory/tiers/graph.py +378 -0
- velune/memory/tiers/lineage.py +416 -0
- velune/memory/tiers/semantic.py +475 -0
- velune/memory/tiers/working.py +168 -0
- velune/memory/vitality.py +132 -0
- velune/models/__init__.py +15 -0
- velune/models/family.py +76 -0
- velune/models/module.py +20 -0
- velune/models/probes.py +192 -0
- velune/models/profile_cache.py +84 -0
- velune/models/profiler.py +108 -0
- velune/models/registry.py +251 -0
- velune/models/scorer.py +233 -0
- velune/models/specializations.py +205 -0
- velune/orchestration/__init__.py +19 -0
- velune/orchestration/engine.py +239 -0
- velune/orchestration/module.py +15 -0
- velune/orchestration/role_assignments.py +82 -0
- velune/orchestration/schemas.py +98 -0
- velune/plugins/__init__.py +20 -0
- velune/plugins/hooks.py +50 -0
- velune/plugins/loader.py +161 -0
- velune/plugins/registry.py +56 -0
- velune/plugins/schemas.py +21 -0
- velune/providers/__init__.py +23 -0
- velune/providers/adapters/anthropic.py +257 -0
- velune/providers/adapters/fireworks.py +115 -0
- velune/providers/adapters/google.py +234 -0
- velune/providers/adapters/groq.py +151 -0
- velune/providers/adapters/huggingface.py +210 -0
- velune/providers/adapters/llamacpp.py +208 -0
- velune/providers/adapters/lmstudio.py +175 -0
- velune/providers/adapters/ollama.py +233 -0
- velune/providers/adapters/openai.py +213 -0
- velune/providers/adapters/openrouter.py +81 -0
- velune/providers/adapters/together.py +134 -0
- velune/providers/adapters/xai.py +60 -0
- velune/providers/base.py +86 -0
- velune/providers/benchmarker.py +138 -0
- velune/providers/discovery/__init__.py +33 -0
- velune/providers/discovery/anthropic.py +79 -0
- velune/providers/discovery/benchmarks.py +44 -0
- velune/providers/discovery/classifier.py +69 -0
- velune/providers/discovery/fireworks.py +95 -0
- velune/providers/discovery/gguf.py +88 -0
- velune/providers/discovery/google.py +95 -0
- velune/providers/discovery/gpu.py +117 -0
- velune/providers/discovery/groq.py +21 -0
- velune/providers/discovery/huggingface.py +67 -0
- velune/providers/discovery/lmstudio.py +80 -0
- velune/providers/discovery/ollama.py +162 -0
- velune/providers/discovery/openai.py +96 -0
- velune/providers/discovery/openrouter.py +113 -0
- velune/providers/discovery/scanner.py +115 -0
- velune/providers/discovery/together.py +114 -0
- velune/providers/discovery/xai.py +57 -0
- velune/providers/health.py +67 -0
- velune/providers/health_monitor.py +169 -0
- velune/providers/keystore.py +142 -0
- velune/providers/local_paths.py +49 -0
- velune/providers/local_resolver.py +229 -0
- velune/providers/module.py +51 -0
- velune/providers/ollama_manager.py +193 -0
- velune/providers/registry.py +220 -0
- velune/providers/router.py +255 -0
- velune/providers/task_classifier.py +288 -0
- velune/py.typed +0 -0
- velune/repository/__init__.py +33 -0
- velune/repository/analyzer.py +127 -0
- velune/repository/ast_parser.py +822 -0
- velune/repository/blast_radius.py +298 -0
- velune/repository/boundary_classifier.py +295 -0
- velune/repository/cognition.py +316 -0
- velune/repository/grapher.py +179 -0
- velune/repository/import_graph.py +263 -0
- velune/repository/incremental_indexer.py +275 -0
- velune/repository/index_state.py +96 -0
- velune/repository/indexer.py +243 -0
- velune/repository/module.py +17 -0
- velune/repository/parser.py +474 -0
- velune/repository/project_type.py +300 -0
- velune/repository/rename_journal.py +287 -0
- velune/repository/scanner.py +193 -0
- velune/repository/schemas.py +102 -0
- velune/repository/symbol_registry.py +365 -0
- velune/repository/tracker.py +252 -0
- velune/retrieval/__init__.py +27 -0
- velune/retrieval/cache.py +110 -0
- velune/retrieval/fast_path.py +391 -0
- velune/retrieval/graph.py +124 -0
- velune/retrieval/hybrid.py +271 -0
- velune/retrieval/keyword.py +131 -0
- velune/retrieval/module.py +26 -0
- velune/retrieval/pipeline.py +303 -0
- velune/retrieval/reranker.py +102 -0
- velune/retrieval/schemas.py +59 -0
- velune/retrieval/slow_path.py +364 -0
- velune/retrieval/vector.py +203 -0
- velune/telemetry/__init__.py +59 -0
- velune/telemetry/cognition.py +267 -0
- velune/telemetry/cost_estimator.py +92 -0
- velune/telemetry/debug.py +304 -0
- velune/telemetry/doctor.py +244 -0
- velune/telemetry/logging.py +286 -0
- velune/telemetry/spans.py +277 -0
- velune/telemetry/token_tracker.py +140 -0
- velune/telemetry/usage_tracker.py +340 -0
- velune/tools/__init__.py +41 -0
- velune/tools/base/registry.py +87 -0
- velune/tools/base/tool.py +63 -0
- velune/tools/code/navigate.py +116 -0
- velune/tools/code/search.py +123 -0
- velune/tools/filesystem/read.py +75 -0
- velune/tools/filesystem/search.py +136 -0
- velune/tools/filesystem/write.py +163 -0
- velune/tools/git/history.py +177 -0
- velune/tools/git/operations.py +122 -0
- velune/tools/git/state.py +121 -0
- velune/tools/module.py +81 -0
- velune/tools/terminal/execute.py +72 -0
- velune/tools/terminal/history.py +47 -0
- velune/tools/web/fetch.py +55 -0
- velune/tools/web/validator.py +122 -0
- velune_cli-0.9.0.dist-info/METADATA +518 -0
- velune_cli-0.9.0.dist-info/RECORD +279 -0
- velune_cli-0.9.0.dist-info/WHEEL +4 -0
- velune_cli-0.9.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|