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,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
|