loomcast 0.1.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.
- loomcast/__init__.py +130 -0
- loomcast/adapters/__init__.py +13 -0
- loomcast/adapters/cache/__init__.py +3 -0
- loomcast/adapters/cache/store.py +56 -0
- loomcast/adapters/catalog/__init__.py +4 -0
- loomcast/adapters/catalog/dynamic.py +154 -0
- loomcast/adapters/catalog/static.py +68 -0
- loomcast/adapters/downloader/__init__.py +13 -0
- loomcast/adapters/downloader/progress.py +112 -0
- loomcast/adapters/runtimes/__init__.py +9 -0
- loomcast/adapters/runtimes/_openai_compat.py +33 -0
- loomcast/adapters/runtimes/llamacpp.py +73 -0
- loomcast/adapters/runtimes/lmstudio.py +71 -0
- loomcast/adapters/runtimes/ollama.py +117 -0
- loomcast/cli/__init__.py +3 -0
- loomcast/cli/main.py +276 -0
- loomcast/common/__init__.py +30 -0
- loomcast/common/config.py +93 -0
- loomcast/common/exceptions.py +35 -0
- loomcast/common/logging.py +21 -0
- loomcast/common/paths.py +31 -0
- loomcast/core/__init__.py +5 -0
- loomcast/core/manager.py +166 -0
- loomcast/core/verifier.py +68 -0
- loomcast/domain/__init__.py +8 -0
- loomcast/domain/entities.py +45 -0
- loomcast/ports/__init__.py +9 -0
- loomcast/ports/cache.py +24 -0
- loomcast/ports/catalog.py +21 -0
- loomcast/ports/runtime.py +32 -0
- loomcast-0.1.0.dist-info/METADATA +112 -0
- loomcast-0.1.0.dist-info/RECORD +36 -0
- loomcast-0.1.0.dist-info/WHEEL +5 -0
- loomcast-0.1.0.dist-info/entry_points.txt +2 -0
- loomcast-0.1.0.dist-info/licenses/LICENSE +20 -0
- loomcast-0.1.0.dist-info/top_level.txt +1 -0
loomcast/__init__.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
2
|
+
|
|
3
|
+
from loomcast.adapters.downloader.progress import (
|
|
4
|
+
NullProgressReporter,
|
|
5
|
+
ProgressReporter,
|
|
6
|
+
RichProgressReporter,
|
|
7
|
+
)
|
|
8
|
+
from loomcast.common.config import LoomcastConfig
|
|
9
|
+
from loomcast.common.exceptions import (
|
|
10
|
+
CacheError,
|
|
11
|
+
ConfigurationError,
|
|
12
|
+
LoomcastError,
|
|
13
|
+
ModelNotFoundError,
|
|
14
|
+
RuntimeExecutionError,
|
|
15
|
+
RuntimeUnavailableError,
|
|
16
|
+
)
|
|
17
|
+
from loomcast.adapters.runtimes import LlamaCppRuntime
|
|
18
|
+
from loomcast.core.manager import LoomcastManager
|
|
19
|
+
from loomcast.domain.entities import CatalogEntry, ChatMessage, Model, RuntimeType, VerificationResult
|
|
20
|
+
|
|
21
|
+
_default_manager: Optional[LoomcastManager] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_manager(
|
|
25
|
+
config: Optional[LoomcastConfig] = None,
|
|
26
|
+
refresh: bool = False,
|
|
27
|
+
) -> LoomcastManager:
|
|
28
|
+
"""Get or create global default LoomcastManager instance."""
|
|
29
|
+
global _default_manager
|
|
30
|
+
if _default_manager is None or refresh:
|
|
31
|
+
_default_manager = LoomcastManager(config=config)
|
|
32
|
+
return _default_manager
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def search(query: str) -> List[CatalogEntry]:
|
|
36
|
+
"""Search model catalog by query string."""
|
|
37
|
+
return get_manager().search(query)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def recommend(task: str) -> List[CatalogEntry]:
|
|
41
|
+
"""Recommend model catalog entries matching a task or tag."""
|
|
42
|
+
return get_manager().recommend(task)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def install_category(
|
|
46
|
+
category: str,
|
|
47
|
+
runtime_type: Optional[Union[RuntimeType, str]] = None,
|
|
48
|
+
max_concurrent: int = 3,
|
|
49
|
+
progress: Optional[ProgressReporter] = None,
|
|
50
|
+
) -> Tuple[List[str], List[str]]:
|
|
51
|
+
"""Batch install all models in a category."""
|
|
52
|
+
reporter = progress if progress is not None else NullProgressReporter()
|
|
53
|
+
return get_manager().install_category(
|
|
54
|
+
category,
|
|
55
|
+
runtime_type=runtime_type,
|
|
56
|
+
max_concurrent=max_concurrent,
|
|
57
|
+
progress=reporter,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def verify(
|
|
62
|
+
model: str,
|
|
63
|
+
runtime_type: Optional[Union[RuntimeType, str]] = None,
|
|
64
|
+
) -> VerificationResult:
|
|
65
|
+
"""Verify installed model integrity."""
|
|
66
|
+
return get_manager().verify(model, runtime_type=runtime_type)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def chat(
|
|
70
|
+
model: str,
|
|
71
|
+
messages: List[Dict[str, Any]],
|
|
72
|
+
runtime_type: Optional[Union[RuntimeType, str]] = None,
|
|
73
|
+
) -> str:
|
|
74
|
+
"""Send chat messages to a local LLM model."""
|
|
75
|
+
return get_manager().chat(model, messages, runtime_type=runtime_type)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def install(
|
|
79
|
+
model: str,
|
|
80
|
+
runtime_type: Optional[Union[RuntimeType, str]] = None,
|
|
81
|
+
progress: Optional[ProgressReporter] = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Install a model into the local runtime."""
|
|
84
|
+
reporter = progress if progress is not None else NullProgressReporter()
|
|
85
|
+
get_manager().install(model, runtime_type=runtime_type, progress=reporter)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def list_installed(
|
|
89
|
+
runtime_type: Optional[Union[RuntimeType, str]] = None,
|
|
90
|
+
) -> List[str]:
|
|
91
|
+
"""List installed models in the local runtime."""
|
|
92
|
+
return get_manager().list_installed(runtime_type=runtime_type)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def is_model_installed(
|
|
96
|
+
model: str,
|
|
97
|
+
runtime_type: Optional[Union[RuntimeType, str]] = None,
|
|
98
|
+
) -> bool:
|
|
99
|
+
"""Check if a model is installed in the local runtime."""
|
|
100
|
+
return get_manager().is_model_installed(model, runtime_type=runtime_type)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
__all__ = [
|
|
104
|
+
"LoomcastManager",
|
|
105
|
+
"get_manager",
|
|
106
|
+
"search",
|
|
107
|
+
"recommend",
|
|
108
|
+
"install_category",
|
|
109
|
+
"verify",
|
|
110
|
+
"chat",
|
|
111
|
+
"install",
|
|
112
|
+
"list_installed",
|
|
113
|
+
"is_model_installed",
|
|
114
|
+
"ProgressReporter",
|
|
115
|
+
"RichProgressReporter",
|
|
116
|
+
"NullProgressReporter",
|
|
117
|
+
"LlamaCppRuntime",
|
|
118
|
+
"LoomcastConfig",
|
|
119
|
+
"LoomcastError",
|
|
120
|
+
"RuntimeUnavailableError",
|
|
121
|
+
"ModelNotFoundError",
|
|
122
|
+
"ConfigurationError",
|
|
123
|
+
"RuntimeExecutionError",
|
|
124
|
+
"CacheError",
|
|
125
|
+
"RuntimeType",
|
|
126
|
+
"Model",
|
|
127
|
+
"CatalogEntry",
|
|
128
|
+
"ChatMessage",
|
|
129
|
+
"VerificationResult",
|
|
130
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from loomcast.adapters.cache.store import FileSystemCache
|
|
2
|
+
from loomcast.adapters.catalog.dynamic import DynamicCatalog
|
|
3
|
+
from loomcast.adapters.catalog.static import StaticCatalog
|
|
4
|
+
from loomcast.adapters.runtimes.lmstudio import LMStudioRuntime
|
|
5
|
+
from loomcast.adapters.runtimes.ollama import OllamaRuntime
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"OllamaRuntime",
|
|
9
|
+
"LMStudioRuntime",
|
|
10
|
+
"FileSystemCache",
|
|
11
|
+
"StaticCatalog",
|
|
12
|
+
"DynamicCatalog",
|
|
13
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional, Union
|
|
4
|
+
|
|
5
|
+
from loomcast.common.exceptions import CacheError
|
|
6
|
+
from loomcast.common.paths import get_cache_dir
|
|
7
|
+
from loomcast.ports.cache import CachePort
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FileSystemCache(CachePort):
|
|
11
|
+
"""File system implementation of CachePort."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, cache_dir: Optional[Union[str, Path]] = None):
|
|
14
|
+
if cache_dir is None:
|
|
15
|
+
self.cache_dir = get_cache_dir()
|
|
16
|
+
else:
|
|
17
|
+
self.cache_dir = Path(cache_dir)
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
except OSError as e:
|
|
22
|
+
raise CacheError(f"Failed to create cache directory '{self.cache_dir}': {e}") from e
|
|
23
|
+
|
|
24
|
+
def _get_path(self, key: str) -> Path:
|
|
25
|
+
return self.cache_dir / key
|
|
26
|
+
|
|
27
|
+
def get(self, key: str) -> Optional[bytes]:
|
|
28
|
+
"""Retrieve cached bytes by key, returning None if non-existent."""
|
|
29
|
+
path = self._get_path(key)
|
|
30
|
+
if not path.is_file():
|
|
31
|
+
return None
|
|
32
|
+
try:
|
|
33
|
+
return path.read_bytes()
|
|
34
|
+
except OSError as e:
|
|
35
|
+
raise CacheError(f"Failed to read cache entry '{key}': {e}") from e
|
|
36
|
+
|
|
37
|
+
def put(self, key: str, value: bytes) -> None:
|
|
38
|
+
"""Store bytes into cache under key."""
|
|
39
|
+
path = self._get_path(key)
|
|
40
|
+
try:
|
|
41
|
+
path.write_bytes(value)
|
|
42
|
+
except OSError as e:
|
|
43
|
+
raise CacheError(f"Failed to write cache entry '{key}': {e}") from e
|
|
44
|
+
|
|
45
|
+
def exists(self, key: str) -> bool:
|
|
46
|
+
"""Check if key exists in cache without reading contents."""
|
|
47
|
+
path = self._get_path(key)
|
|
48
|
+
try:
|
|
49
|
+
return path.is_file()
|
|
50
|
+
except OSError as e:
|
|
51
|
+
raise CacheError(f"Failed to check existence for cache entry '{key}': {e}") from e
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def compute_key(data: bytes) -> str:
|
|
55
|
+
"""Compute hexadecimal SHA-256 hash of content."""
|
|
56
|
+
return hashlib.sha256(data).hexdigest()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import time
|
|
4
|
+
from typing import List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from loomcast.adapters.cache.store import FileSystemCache
|
|
9
|
+
from loomcast.adapters.catalog.static import StaticCatalog
|
|
10
|
+
from loomcast.common.logging import logger
|
|
11
|
+
from loomcast.domain.entities import CatalogEntry, RuntimeType
|
|
12
|
+
from loomcast.ports.cache import CachePort
|
|
13
|
+
from loomcast.ports.catalog import CatalogPort
|
|
14
|
+
|
|
15
|
+
CACHE_SNAPSHOT_KEY = "catalog_dynamic_snapshot"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_library_html(html: str) -> List[CatalogEntry]:
|
|
19
|
+
"""Parse HTML from ollama.com/library into a list of CatalogEntry items."""
|
|
20
|
+
entries: List[CatalogEntry] = []
|
|
21
|
+
seen_names = set()
|
|
22
|
+
|
|
23
|
+
# Pattern matches <a href="/library/<model_name>">...</a>
|
|
24
|
+
card_pattern = re.compile(
|
|
25
|
+
r'<a\s+[^>]*href=["\']/library/([a-zA-Z0-9._-]+)["\'][^>]*>(.*?)</a>',
|
|
26
|
+
re.DOTALL | re.IGNORECASE,
|
|
27
|
+
)
|
|
28
|
+
desc_pattern = re.compile(r'<p[^>]*>(.*?)</p>', re.DOTALL | re.IGNORECASE)
|
|
29
|
+
tag_pattern = re.compile(r'<span[^>]*>(.*?)</span>', re.DOTALL | re.IGNORECASE)
|
|
30
|
+
strip_tags_pattern = re.compile(r'<[^>]+>')
|
|
31
|
+
|
|
32
|
+
for match in card_pattern.finditer(html):
|
|
33
|
+
name = match.group(1).lower()
|
|
34
|
+
if name in seen_names:
|
|
35
|
+
continue
|
|
36
|
+
seen_names.add(name)
|
|
37
|
+
|
|
38
|
+
card_content = match.group(2)
|
|
39
|
+
|
|
40
|
+
# Extract description from <p>
|
|
41
|
+
desc_match = desc_pattern.search(card_content)
|
|
42
|
+
description = None
|
|
43
|
+
if desc_match:
|
|
44
|
+
clean_desc = strip_tags_pattern.sub("", desc_match.group(1)).strip()
|
|
45
|
+
if clean_desc:
|
|
46
|
+
description = clean_desc
|
|
47
|
+
|
|
48
|
+
# Extract tags from <span>
|
|
49
|
+
tags = []
|
|
50
|
+
for t_match in tag_pattern.finditer(card_content):
|
|
51
|
+
clean_tag = strip_tags_pattern.sub("", t_match.group(1)).strip()
|
|
52
|
+
if clean_tag and len(clean_tag) < 30:
|
|
53
|
+
tags.append(clean_tag)
|
|
54
|
+
|
|
55
|
+
if not tags:
|
|
56
|
+
tags = ["ollama"]
|
|
57
|
+
|
|
58
|
+
entries.append(
|
|
59
|
+
CatalogEntry(
|
|
60
|
+
name=name,
|
|
61
|
+
description=description,
|
|
62
|
+
publisher="ollama",
|
|
63
|
+
tags=tags,
|
|
64
|
+
default_runtime=RuntimeType.OLLAMA,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return entries
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class DynamicCatalog(CatalogPort):
|
|
72
|
+
"""Dynamic CatalogPort implementation with TTL caching and static fallback."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
cache: Optional[CachePort] = None,
|
|
77
|
+
fallback_catalog: Optional[CatalogPort] = None,
|
|
78
|
+
ttl_hours: int = 24,
|
|
79
|
+
):
|
|
80
|
+
self.cache: CachePort = cache if cache is not None else FileSystemCache()
|
|
81
|
+
self.fallback_catalog: CatalogPort = (
|
|
82
|
+
fallback_catalog if fallback_catalog is not None else StaticCatalog()
|
|
83
|
+
)
|
|
84
|
+
self.ttl_hours = ttl_hours
|
|
85
|
+
|
|
86
|
+
def _get_entries(self) -> Tuple[Optional[List[CatalogEntry]], bool]:
|
|
87
|
+
"""Fetch entries from cache or network, or indicate fallback required."""
|
|
88
|
+
# 1. Try cache
|
|
89
|
+
raw_cache = self.cache.get(CACHE_SNAPSHOT_KEY)
|
|
90
|
+
if raw_cache:
|
|
91
|
+
try:
|
|
92
|
+
data = json.loads(raw_cache.decode("utf-8"))
|
|
93
|
+
timestamp = data.get("timestamp", 0)
|
|
94
|
+
if (time.time() - timestamp) < (self.ttl_hours * 3600):
|
|
95
|
+
entries_data = data.get("entries", [])
|
|
96
|
+
entries = [CatalogEntry.model_validate(item) for item in entries_data]
|
|
97
|
+
if entries:
|
|
98
|
+
return entries, False
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logger.warning(f"Failed to read dynamic catalog cache snapshot: {e}")
|
|
101
|
+
|
|
102
|
+
# 2. Try scraping Ollama library
|
|
103
|
+
try:
|
|
104
|
+
response = httpx.get("https://ollama.com/library", timeout=10.0)
|
|
105
|
+
if response.status_code != 200:
|
|
106
|
+
raise ValueError(f"HTTP error status code {response.status_code}")
|
|
107
|
+
entries = _parse_library_html(response.text)
|
|
108
|
+
|
|
109
|
+
if not entries:
|
|
110
|
+
raise ValueError("No catalog entries parsed from HTML response")
|
|
111
|
+
|
|
112
|
+
# Save snapshot to cache
|
|
113
|
+
cache_payload = {
|
|
114
|
+
"timestamp": time.time(),
|
|
115
|
+
"entries": [e.model_dump() for e in entries],
|
|
116
|
+
}
|
|
117
|
+
self.cache.put(CACHE_SNAPSHOT_KEY, json.dumps(cache_payload).encode("utf-8"))
|
|
118
|
+
return entries, False
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.warning(
|
|
121
|
+
f"Failed to fetch dynamic catalog from Ollama library ({e}). "
|
|
122
|
+
"Falling back to static catalog."
|
|
123
|
+
)
|
|
124
|
+
return None, True
|
|
125
|
+
|
|
126
|
+
def search(self, query: str) -> List[CatalogEntry]:
|
|
127
|
+
"""Search catalog for entries matching query, falling back to static catalog if needed."""
|
|
128
|
+
entries, is_fallback = self._get_entries()
|
|
129
|
+
if is_fallback or entries is None:
|
|
130
|
+
return self.fallback_catalog.search(query)
|
|
131
|
+
|
|
132
|
+
query_lower = query.lower()
|
|
133
|
+
results: List[CatalogEntry] = []
|
|
134
|
+
for entry in entries:
|
|
135
|
+
name_match = query_lower in entry.name.lower()
|
|
136
|
+
desc_match = bool(entry.description and query_lower in entry.description.lower())
|
|
137
|
+
tag_match = any(query_lower in tag.lower() for tag in entry.tags)
|
|
138
|
+
|
|
139
|
+
if name_match or desc_match or tag_match:
|
|
140
|
+
results.append(entry)
|
|
141
|
+
|
|
142
|
+
return results
|
|
143
|
+
|
|
144
|
+
def get_entry(self, name: str) -> Optional[CatalogEntry]:
|
|
145
|
+
"""Retrieve catalog entry by name, falling back to static catalog if needed."""
|
|
146
|
+
entries, is_fallback = self._get_entries()
|
|
147
|
+
if is_fallback or entries is None:
|
|
148
|
+
return self.fallback_catalog.get_entry(name)
|
|
149
|
+
|
|
150
|
+
name_lower = name.lower()
|
|
151
|
+
for entry in entries:
|
|
152
|
+
if entry.name.lower() == name_lower:
|
|
153
|
+
return entry
|
|
154
|
+
return None
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List, Optional, Union
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
|
|
6
|
+
from loomcast.common.exceptions import LoomcastError
|
|
7
|
+
from loomcast.domain.entities import CatalogEntry
|
|
8
|
+
from loomcast.ports.catalog import CatalogPort
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StaticCatalog(CatalogPort):
|
|
12
|
+
"""CatalogPort implementation loading entries from an embedded JSON file."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, catalog_path: Optional[Union[str, Path]] = None):
|
|
15
|
+
if catalog_path is None:
|
|
16
|
+
self.catalog_path = Path(__file__).parent / "data" / "catalog.json"
|
|
17
|
+
else:
|
|
18
|
+
self.catalog_path = Path(catalog_path)
|
|
19
|
+
|
|
20
|
+
self._entries: List[CatalogEntry] = self._load_catalog()
|
|
21
|
+
|
|
22
|
+
def _load_catalog(self) -> List[CatalogEntry]:
|
|
23
|
+
if not self.catalog_path.is_file():
|
|
24
|
+
raise LoomcastError(f"Catalog file not found at '{self.catalog_path}'.")
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
with open(self.catalog_path, "r", encoding="utf-8") as f:
|
|
28
|
+
data = json.load(f)
|
|
29
|
+
except Exception as e:
|
|
30
|
+
raise LoomcastError(f"Failed to read catalog JSON file '{self.catalog_path}': {e}") from e
|
|
31
|
+
|
|
32
|
+
if not isinstance(data, list):
|
|
33
|
+
raise LoomcastError(f"Catalog JSON file '{self.catalog_path}' must contain a list of entries.")
|
|
34
|
+
|
|
35
|
+
entries: List[CatalogEntry] = []
|
|
36
|
+
for idx, item in enumerate(data):
|
|
37
|
+
try:
|
|
38
|
+
entry = CatalogEntry.model_validate(item)
|
|
39
|
+
entries.append(entry)
|
|
40
|
+
except ValidationError as ve:
|
|
41
|
+
raise LoomcastError(
|
|
42
|
+
f"Invalid CatalogEntry format at index {idx} in '{self.catalog_path}': {ve}"
|
|
43
|
+
) from ve
|
|
44
|
+
|
|
45
|
+
return entries
|
|
46
|
+
|
|
47
|
+
def search(self, query: str) -> List[CatalogEntry]:
|
|
48
|
+
"""Search catalog entries matching query in name, description, or tags (case-insensitive)."""
|
|
49
|
+
query_lower = query.lower()
|
|
50
|
+
results: List[CatalogEntry] = []
|
|
51
|
+
|
|
52
|
+
for entry in self._entries:
|
|
53
|
+
name_match = query_lower in entry.name.lower()
|
|
54
|
+
desc_match = bool(entry.description and query_lower in entry.description.lower())
|
|
55
|
+
tag_match = any(query_lower in tag.lower() for tag in entry.tags)
|
|
56
|
+
|
|
57
|
+
if name_match or desc_match or tag_match:
|
|
58
|
+
results.append(entry)
|
|
59
|
+
|
|
60
|
+
return results
|
|
61
|
+
|
|
62
|
+
def get_entry(self, name: str) -> Optional[CatalogEntry]:
|
|
63
|
+
"""Retrieve exact catalog entry by name (case-insensitive comparison)."""
|
|
64
|
+
name_lower = name.lower()
|
|
65
|
+
for entry in self._entries:
|
|
66
|
+
if entry.name.lower() == name_lower:
|
|
67
|
+
return entry
|
|
68
|
+
return None
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from loomcast.adapters.downloader.progress import (
|
|
2
|
+
NullProgressReporter,
|
|
3
|
+
ProgressReporter,
|
|
4
|
+
RichProgressReporter,
|
|
5
|
+
SubProgressReporter,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ProgressReporter",
|
|
10
|
+
"RichProgressReporter",
|
|
11
|
+
"NullProgressReporter",
|
|
12
|
+
"SubProgressReporter",
|
|
13
|
+
]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from typing import Any, Dict, Optional
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.progress import BarColumn, DownloadColumn, Progress, TaskID, TextColumn, TransferSpeedColumn
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ProgressReporter:
|
|
7
|
+
"""Base callback object for model download progress tracking."""
|
|
8
|
+
|
|
9
|
+
def on_progress(self, status: str, completed: Optional[int] = None, total: Optional[int] = None) -> None:
|
|
10
|
+
"""Receive a progress update callback.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
status: Status description text (e.g. 'downloading sha256:...', 'verifying').
|
|
14
|
+
completed: Total bytes completed so far, if available.
|
|
15
|
+
total: Total expected bytes, if available.
|
|
16
|
+
"""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
def create_sub_reporter(self, prefix: str) -> "ProgressReporter":
|
|
20
|
+
"""Create a child progress reporter for concurrent task isolation."""
|
|
21
|
+
return self
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NullProgressReporter(ProgressReporter):
|
|
25
|
+
"""Silent progress reporter that ignores all progress events."""
|
|
26
|
+
|
|
27
|
+
def on_progress(self, status: str, completed: Optional[int] = None, total: Optional[int] = None) -> None:
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SubProgressReporter(ProgressReporter):
|
|
32
|
+
"""Child progress reporter bound to a dedicated task in a parent RichProgressReporter."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, parent: "RichProgressReporter", prefix: str):
|
|
35
|
+
self.parent = parent
|
|
36
|
+
self.prefix = prefix
|
|
37
|
+
self._task_id: Optional[TaskID] = None
|
|
38
|
+
|
|
39
|
+
def on_progress(self, status: str, completed: Optional[int] = None, total: Optional[int] = None) -> None:
|
|
40
|
+
self.parent.start()
|
|
41
|
+
desc = f"[{self.prefix}] {status}" if self.prefix else status
|
|
42
|
+
if self._task_id is None:
|
|
43
|
+
self._task_id = self.parent._progress.add_task(
|
|
44
|
+
description=desc,
|
|
45
|
+
total=total,
|
|
46
|
+
completed=completed or 0,
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
kwargs: Dict[str, Any] = {"description": desc}
|
|
50
|
+
if total is not None:
|
|
51
|
+
kwargs["total"] = total
|
|
52
|
+
if completed is not None:
|
|
53
|
+
kwargs["completed"] = completed
|
|
54
|
+
self.parent._progress.update(self._task_id, **kwargs)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RichProgressReporter(ProgressReporter):
|
|
58
|
+
"""Terminal visual progress reporter powered by rich.progress."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, console: Optional[Console] = None):
|
|
61
|
+
self.console = console
|
|
62
|
+
self._progress: Optional[Progress] = None
|
|
63
|
+
self._task_id: Optional[TaskID] = None
|
|
64
|
+
|
|
65
|
+
def start(self) -> None:
|
|
66
|
+
"""Start the Rich Progress display if not already running."""
|
|
67
|
+
if self._progress is None:
|
|
68
|
+
self._progress = Progress(
|
|
69
|
+
TextColumn("[progress.description]{task.description}"),
|
|
70
|
+
BarColumn(),
|
|
71
|
+
DownloadColumn(),
|
|
72
|
+
TransferSpeedColumn(),
|
|
73
|
+
console=self.console,
|
|
74
|
+
)
|
|
75
|
+
self._progress.start()
|
|
76
|
+
|
|
77
|
+
def stop(self) -> None:
|
|
78
|
+
"""Stop and clean up the Rich Progress display."""
|
|
79
|
+
if self._progress is not None:
|
|
80
|
+
self._progress.stop()
|
|
81
|
+
self._progress = None
|
|
82
|
+
self._task_id = None
|
|
83
|
+
|
|
84
|
+
def create_sub_reporter(self, prefix: str) -> ProgressReporter:
|
|
85
|
+
"""Create a child progress reporter dedicated to a specific task prefix."""
|
|
86
|
+
return SubProgressReporter(self, prefix=prefix)
|
|
87
|
+
|
|
88
|
+
def __enter__(self) -> "RichProgressReporter":
|
|
89
|
+
self.start()
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
93
|
+
self.stop()
|
|
94
|
+
|
|
95
|
+
def on_progress(self, status: str, completed: Optional[int] = None, total: Optional[int] = None) -> None:
|
|
96
|
+
"""Update progress bar display with status and byte metrics."""
|
|
97
|
+
if self._progress is None:
|
|
98
|
+
self.start()
|
|
99
|
+
|
|
100
|
+
if self._task_id is None:
|
|
101
|
+
self._task_id = self._progress.add_task(
|
|
102
|
+
description=status,
|
|
103
|
+
total=total,
|
|
104
|
+
completed=completed or 0,
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
kwargs: Dict[str, Any] = {"description": status}
|
|
108
|
+
if total is not None:
|
|
109
|
+
kwargs["total"] = total
|
|
110
|
+
if completed is not None:
|
|
111
|
+
kwargs["completed"] = completed
|
|
112
|
+
self._progress.update(self._task_id, **kwargs)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from loomcast.adapters.runtimes.llamacpp import LlamaCppRuntime
|
|
2
|
+
from loomcast.adapters.runtimes.lmstudio import LMStudioRuntime
|
|
3
|
+
from loomcast.adapters.runtimes.ollama import OllamaRuntime
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"OllamaRuntime",
|
|
7
|
+
"LMStudioRuntime",
|
|
8
|
+
"LlamaCppRuntime",
|
|
9
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from typing import Any, Dict, List
|
|
2
|
+
import httpx
|
|
3
|
+
|
|
4
|
+
from loomcast.common.exceptions import RuntimeExecutionError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def send_openai_compat_chat(
|
|
8
|
+
base_url: str,
|
|
9
|
+
model: str,
|
|
10
|
+
messages: List[Dict[str, Any]],
|
|
11
|
+
timeout: float,
|
|
12
|
+
runtime_name: str = "OpenAI-compatible",
|
|
13
|
+
) -> str:
|
|
14
|
+
"""Send a chat completion request to an OpenAI-compatible /v1/chat/completions endpoint."""
|
|
15
|
+
try:
|
|
16
|
+
payload = {
|
|
17
|
+
"model": model,
|
|
18
|
+
"messages": messages,
|
|
19
|
+
"stream": False,
|
|
20
|
+
}
|
|
21
|
+
response = httpx.post(
|
|
22
|
+
f"{base_url.rstrip('/')}/v1/chat/completions",
|
|
23
|
+
json=payload,
|
|
24
|
+
timeout=timeout,
|
|
25
|
+
)
|
|
26
|
+
response.raise_for_status()
|
|
27
|
+
data = response.json()
|
|
28
|
+
choices = data.get("choices", [])
|
|
29
|
+
if not choices:
|
|
30
|
+
raise RuntimeExecutionError(f"Empty choices returned from {runtime_name} chat API.")
|
|
31
|
+
return choices[0].get("message", {}).get("content", "")
|
|
32
|
+
except httpx.HTTPError as e:
|
|
33
|
+
raise RuntimeExecutionError(f"{runtime_name} chat execution failed for model '{model}': {e}") from e
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
import httpx
|
|
3
|
+
|
|
4
|
+
from loomcast.adapters.downloader.progress import ProgressReporter
|
|
5
|
+
from loomcast.adapters.runtimes._openai_compat import send_openai_compat_chat
|
|
6
|
+
from loomcast.common.exceptions import RuntimeExecutionError, RuntimeUnavailableError
|
|
7
|
+
from loomcast.ports.runtime import RuntimePort
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LlamaCppRuntime(RuntimePort):
|
|
11
|
+
"""Adapter for local llama.cpp server (llama-server) running on port 8080 (default)."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, base_url: str = "http://localhost:8080", timeout: float = 30.0):
|
|
14
|
+
self.base_url = base_url.rstrip("/")
|
|
15
|
+
self.timeout = timeout
|
|
16
|
+
|
|
17
|
+
def is_available(self) -> bool:
|
|
18
|
+
"""Check if llama-server is running via GET /health."""
|
|
19
|
+
try:
|
|
20
|
+
response = httpx.get(f"{self.base_url}/health", timeout=2.0)
|
|
21
|
+
return response.status_code == 200
|
|
22
|
+
except httpx.HTTPError:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
def list_installed(self) -> List[str]:
|
|
26
|
+
"""List model currently loaded in llama-server.
|
|
27
|
+
|
|
28
|
+
Note:
|
|
29
|
+
llama-server typically serves a single model specified at server launch.
|
|
30
|
+
This queries GET /v1/models to retrieve the active model identifier.
|
|
31
|
+
"""
|
|
32
|
+
if not self.is_available():
|
|
33
|
+
raise RuntimeUnavailableError("llamacpp", self.base_url)
|
|
34
|
+
try:
|
|
35
|
+
response = httpx.get(f"{self.base_url}/v1/models", timeout=self.timeout)
|
|
36
|
+
response.raise_for_status()
|
|
37
|
+
data = response.json()
|
|
38
|
+
models = data.get("data", [])
|
|
39
|
+
return [m["id"] for m in models if "id" in m]
|
|
40
|
+
except httpx.HTTPError as e:
|
|
41
|
+
raise RuntimeExecutionError(f"Failed to list llama.cpp models: {e}") from e
|
|
42
|
+
|
|
43
|
+
def is_model_installed(self, model: str) -> bool:
|
|
44
|
+
"""Check if a model is currently loaded in llama-server."""
|
|
45
|
+
installed = self.list_installed()
|
|
46
|
+
return model in installed
|
|
47
|
+
|
|
48
|
+
def install(self, model: str, progress: Optional[ProgressReporter] = None) -> None:
|
|
49
|
+
"""llama.cpp model installation directive.
|
|
50
|
+
|
|
51
|
+
Note:
|
|
52
|
+
llama.cpp does not support remote model downloading via HTTP API.
|
|
53
|
+
Models must be loaded by launching llama-server with command-line flags.
|
|
54
|
+
"""
|
|
55
|
+
if not self.is_available():
|
|
56
|
+
raise RuntimeUnavailableError("llamacpp", self.base_url)
|
|
57
|
+
if not self.is_model_installed(model):
|
|
58
|
+
raise RuntimeExecutionError(
|
|
59
|
+
f"Model '{model}' is not currently loaded in llama.cpp. "
|
|
60
|
+
"Please launch llama-server with your desired model specified via command-line flags."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def chat(self, model: str, messages: List[Dict[str, Any]]) -> str:
|
|
64
|
+
"""Send chat completion request to llama-server OpenAI-compatible endpoint."""
|
|
65
|
+
if not self.is_available():
|
|
66
|
+
raise RuntimeUnavailableError("llamacpp", self.base_url)
|
|
67
|
+
return send_openai_compat_chat(
|
|
68
|
+
base_url=self.base_url,
|
|
69
|
+
model=model,
|
|
70
|
+
messages=messages,
|
|
71
|
+
timeout=self.timeout,
|
|
72
|
+
runtime_name="llama.cpp",
|
|
73
|
+
)
|