hf2ollama-python-cli-tool 1.0.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.
- hf2ollama/__init__.py +3 -0
- hf2ollama/cli.py +329 -0
- hf2ollama/core/__init__.py +1 -0
- hf2ollama/core/config.py +73 -0
- hf2ollama/core/ollama.py +137 -0
- hf2ollama/hf/__init__.py +1 -0
- hf2ollama/hf/alt_download.py +78 -0
- hf2ollama/hf/api.py +197 -0
- hf2ollama/hf/download.py +121 -0
- hf2ollama/modelfile/__init__.py +1 -0
- hf2ollama/modelfile/generator.py +90 -0
- hf2ollama/modelfile/templates.py +134 -0
- hf2ollama/network/__init__.py +1 -0
- hf2ollama/network/detect.py +58 -0
- hf2ollama/network/ssl_fix.py +76 -0
- hf2ollama/scripts/HfDownload.ps1 +221 -0
- hf2ollama/utils/__init__.py +1 -0
- hf2ollama/utils/vram.py +65 -0
- hf2ollama_python_cli_tool-1.0.0.data/data/hf2ollama/scripts/HfDownload.ps1 +221 -0
- hf2ollama_python_cli_tool-1.0.0.dist-info/METADATA +200 -0
- hf2ollama_python_cli_tool-1.0.0.dist-info/RECORD +24 -0
- hf2ollama_python_cli_tool-1.0.0.dist-info/WHEEL +4 -0
- hf2ollama_python_cli_tool-1.0.0.dist-info/entry_points.txt +2 -0
- hf2ollama_python_cli_tool-1.0.0.dist-info/licenses/LICENSE +21 -0
hf2ollama/__init__.py
ADDED
hf2ollama/cli.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Click-based CLI for hf2ollama."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from hf2ollama import __version__
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group()
|
|
15
|
+
@click.version_option(__version__, prog_name="hf2ollama")
|
|
16
|
+
def main() -> None:
|
|
17
|
+
"""Search, download, and import HuggingFace GGUF models into Ollama."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@main.command()
|
|
21
|
+
@click.argument("repo")
|
|
22
|
+
@click.option(
|
|
23
|
+
"--quant", "-q", default=None, help="Quantization (Q2_K, Q3_K_M, Q4_K_M, Q5_K_M, Q6_K, Q8_0)."
|
|
24
|
+
)
|
|
25
|
+
@click.option(
|
|
26
|
+
"--name", "-n", default=None, help="Ollama model name. Auto-generated if not specified."
|
|
27
|
+
)
|
|
28
|
+
@click.option("--keep-gguf", is_flag=True, help="Keep the GGUF file after import.")
|
|
29
|
+
@click.option(
|
|
30
|
+
"--alt-download", is_flag=True, help="Use alternative PowerShell download (Windows only)."
|
|
31
|
+
)
|
|
32
|
+
@click.option("--ssl-fix", is_flag=True, help="Use OS certificate store for SSL verification.")
|
|
33
|
+
def pull(
|
|
34
|
+
repo: str,
|
|
35
|
+
quant: str | None,
|
|
36
|
+
name: str | None,
|
|
37
|
+
keep_gguf: bool,
|
|
38
|
+
alt_download: bool,
|
|
39
|
+
ssl_fix: bool,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Download a GGUF model and import into Ollama.
|
|
42
|
+
|
|
43
|
+
REPO is a HuggingFace repo ID (e.g., bartowski/Qwen2.5-Coder-7B-Instruct-GGUF).
|
|
44
|
+
"""
|
|
45
|
+
from hf2ollama.core.config import ensure_download_dir, load_config
|
|
46
|
+
from hf2ollama.core.ollama import create_model, find_ollama
|
|
47
|
+
from hf2ollama.hf.api import find_gguf_by_quant, get_download_url, list_gguf_files
|
|
48
|
+
from hf2ollama.hf.download import download_file, validate_file_size
|
|
49
|
+
from hf2ollama.modelfile.generator import (
|
|
50
|
+
detect_family,
|
|
51
|
+
generate_modelfile,
|
|
52
|
+
sanitize_model_name,
|
|
53
|
+
)
|
|
54
|
+
from hf2ollama.network.ssl_fix import apply_ssl_fix
|
|
55
|
+
|
|
56
|
+
config = load_config()
|
|
57
|
+
resolved_quant = quant or config.default_quant
|
|
58
|
+
|
|
59
|
+
if ssl_fix:
|
|
60
|
+
console.print("[dim]Configuring SSL certificates...[/dim]")
|
|
61
|
+
apply_ssl_fix(proxy=config.proxy)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
find_ollama()
|
|
65
|
+
except FileNotFoundError as e:
|
|
66
|
+
console.print(f"[red]{e}[/red]")
|
|
67
|
+
raise SystemExit(1)
|
|
68
|
+
|
|
69
|
+
console.print(f"[bold]Searching for {resolved_quant} in {repo}...[/bold]")
|
|
70
|
+
|
|
71
|
+
import httpx
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
client = httpx.Client(timeout=15.0)
|
|
75
|
+
gguf = find_gguf_by_quant(repo, resolved_quant, client=client)
|
|
76
|
+
|
|
77
|
+
if gguf is None:
|
|
78
|
+
console.print(f"[yellow]No {resolved_quant} file found. Available files:[/yellow]")
|
|
79
|
+
files = list_gguf_files(repo, client=client)
|
|
80
|
+
for f in files:
|
|
81
|
+
console.print(f" {f.filename} ({f.size_display})")
|
|
82
|
+
client.close()
|
|
83
|
+
raise SystemExit(1)
|
|
84
|
+
|
|
85
|
+
console.print(f"[green]Found:[/green] {gguf.filename} ({gguf.size_display})")
|
|
86
|
+
|
|
87
|
+
dl_dir = ensure_download_dir(config)
|
|
88
|
+
dest = dl_dir / gguf.filename
|
|
89
|
+
url = get_download_url(repo, gguf.filename)
|
|
90
|
+
|
|
91
|
+
if alt_download:
|
|
92
|
+
from hf2ollama.hf.alt_download import alt_download as _alt_dl
|
|
93
|
+
|
|
94
|
+
console.print("[bold yellow]Using alternative download method...[/bold yellow]")
|
|
95
|
+
dest = _alt_dl(repo, gguf.filename, str(dl_dir))
|
|
96
|
+
else:
|
|
97
|
+
console.print("[dim]Downloading...[/dim]")
|
|
98
|
+
dest = download_file(url, str(dest), client=client)
|
|
99
|
+
|
|
100
|
+
client.close()
|
|
101
|
+
|
|
102
|
+
if not validate_file_size(str(dest), gguf.size_bytes):
|
|
103
|
+
console.print("[red]File size mismatch - download may be incomplete.[/red]")
|
|
104
|
+
raise SystemExit(1)
|
|
105
|
+
|
|
106
|
+
except httpx.ConnectError as e:
|
|
107
|
+
err = str(e).lower()
|
|
108
|
+
if "ssl" in err or "certificate" in err:
|
|
109
|
+
console.print("[red]SSL certificate error.[/red]")
|
|
110
|
+
console.print("Try: [bold]hf2ollama pull --ssl-fix <repo>[/bold]")
|
|
111
|
+
else:
|
|
112
|
+
console.print(f"[red]Connection error: {e}[/red]")
|
|
113
|
+
raise SystemExit(1)
|
|
114
|
+
|
|
115
|
+
model_name = name or sanitize_model_name(gguf.filename)
|
|
116
|
+
family = detect_family(gguf.filename)
|
|
117
|
+
console.print(f"[dim]Detected family: {family}[/dim]")
|
|
118
|
+
|
|
119
|
+
modelfile_content = generate_modelfile(str(dest), family=family)
|
|
120
|
+
modelfile_path = dest.parent / "Modelfile"
|
|
121
|
+
modelfile_path.write_text(modelfile_content, encoding="utf-8")
|
|
122
|
+
|
|
123
|
+
console.print(f"[bold]Creating Ollama model: {model_name}[/bold]")
|
|
124
|
+
try:
|
|
125
|
+
output = create_model(model_name, str(modelfile_path))
|
|
126
|
+
console.print(f"[green]{output}[/green]")
|
|
127
|
+
except RuntimeError as e:
|
|
128
|
+
console.print(f"[red]{e}[/red]")
|
|
129
|
+
raise SystemExit(1)
|
|
130
|
+
|
|
131
|
+
modelfile_path.unlink(missing_ok=True)
|
|
132
|
+
if not keep_gguf:
|
|
133
|
+
dest.unlink(missing_ok=True)
|
|
134
|
+
console.print("[dim]Cleaned up GGUF file.[/dim]")
|
|
135
|
+
|
|
136
|
+
console.print(f"\n[bold green]Done![/bold green] Run: [bold]ollama run {model_name}[/bold]")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@main.command()
|
|
140
|
+
@click.argument("query")
|
|
141
|
+
@click.option("--limit", "-l", default=15, help="Max results.")
|
|
142
|
+
@click.option(
|
|
143
|
+
"--sort", "-s", default="downloads", type=click.Choice(["downloads", "likes", "lastModified"])
|
|
144
|
+
)
|
|
145
|
+
@click.option("--ssl-fix", is_flag=True, help="Use OS certificate store.")
|
|
146
|
+
def search(query: str, limit: int, sort: str, ssl_fix: bool) -> None:
|
|
147
|
+
"""Search HuggingFace for GGUF model repos."""
|
|
148
|
+
from hf2ollama.hf.api import search_gguf_repos
|
|
149
|
+
from hf2ollama.network.ssl_fix import apply_ssl_fix
|
|
150
|
+
|
|
151
|
+
if ssl_fix:
|
|
152
|
+
apply_ssl_fix()
|
|
153
|
+
|
|
154
|
+
results = search_gguf_repos(query, limit=limit, sort=sort)
|
|
155
|
+
|
|
156
|
+
if not results:
|
|
157
|
+
console.print("[yellow]No GGUF repos found.[/yellow]")
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
table = Table(title=f"GGUF repos matching '{query}'")
|
|
161
|
+
table.add_column("Repository", style="bold")
|
|
162
|
+
table.add_column("Downloads", justify="right")
|
|
163
|
+
table.add_column("Likes", justify="right")
|
|
164
|
+
|
|
165
|
+
for r in results:
|
|
166
|
+
table.add_row(r.repo_id, f"{r.downloads:,}", str(r.likes))
|
|
167
|
+
|
|
168
|
+
console.print(table)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@main.command("list-files")
|
|
172
|
+
@click.argument("repo")
|
|
173
|
+
@click.option("--ssl-fix", is_flag=True, help="Use OS certificate store.")
|
|
174
|
+
def list_files(repo: str, ssl_fix: bool) -> None:
|
|
175
|
+
"""List GGUF files in a HuggingFace repo."""
|
|
176
|
+
from hf2ollama.hf.api import list_gguf_files
|
|
177
|
+
from hf2ollama.network.ssl_fix import apply_ssl_fix
|
|
178
|
+
|
|
179
|
+
if ssl_fix:
|
|
180
|
+
apply_ssl_fix()
|
|
181
|
+
|
|
182
|
+
files = list_gguf_files(repo)
|
|
183
|
+
|
|
184
|
+
if not files:
|
|
185
|
+
console.print(f"[yellow]No GGUF files found in {repo}.[/yellow]")
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
table = Table(title=f"GGUF files in {repo}")
|
|
189
|
+
table.add_column("File", style="bold")
|
|
190
|
+
table.add_column("Size", justify="right")
|
|
191
|
+
|
|
192
|
+
for f in files:
|
|
193
|
+
table.add_row(f.filename, f.size_display)
|
|
194
|
+
|
|
195
|
+
console.print(table)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@main.command("list")
|
|
199
|
+
def list_models() -> None:
|
|
200
|
+
"""List locally installed Ollama models."""
|
|
201
|
+
from hf2ollama.core.ollama import list_models as _list
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
models = _list()
|
|
205
|
+
except FileNotFoundError as e:
|
|
206
|
+
console.print(f"[red]{e}[/red]")
|
|
207
|
+
raise SystemExit(1)
|
|
208
|
+
|
|
209
|
+
if not models:
|
|
210
|
+
console.print("[yellow]No Ollama models installed.[/yellow]")
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
table = Table(title="Ollama Models")
|
|
214
|
+
table.add_column("Name", style="bold")
|
|
215
|
+
table.add_column("Size", justify="right")
|
|
216
|
+
table.add_column("Modified")
|
|
217
|
+
|
|
218
|
+
for m in models:
|
|
219
|
+
table.add_row(m.name, m.size, m.modified)
|
|
220
|
+
|
|
221
|
+
console.print(table)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@main.command()
|
|
225
|
+
@click.argument("model_name")
|
|
226
|
+
def remove(model_name: str) -> None:
|
|
227
|
+
"""Remove an Ollama model."""
|
|
228
|
+
from hf2ollama.core.ollama import remove_model
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
remove_model(model_name)
|
|
232
|
+
console.print(f"[green]Removed: {model_name}[/green]")
|
|
233
|
+
except RuntimeError as e:
|
|
234
|
+
console.print(f"[red]{e}[/red]")
|
|
235
|
+
raise SystemExit(1)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@main.command()
|
|
239
|
+
@click.argument("model_name")
|
|
240
|
+
def info(model_name: str) -> None:
|
|
241
|
+
"""Show details about an Ollama model."""
|
|
242
|
+
from hf2ollama.core.ollama import show_model
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
output = show_model(model_name)
|
|
246
|
+
console.print(output)
|
|
247
|
+
except RuntimeError as e:
|
|
248
|
+
console.print(f"[red]{e}[/red]")
|
|
249
|
+
raise SystemExit(1)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@main.command()
|
|
253
|
+
@click.option(
|
|
254
|
+
"--vram", type=float, default=None, help="Available VRAM in GB. Auto-detected if not specified."
|
|
255
|
+
)
|
|
256
|
+
@click.option(
|
|
257
|
+
"--params", "-p", type=float, required=True,
|
|
258
|
+
help="Model parameter count in billions (e.g., 7 for 7B).",
|
|
259
|
+
)
|
|
260
|
+
def recommend(vram: float | None, params: float) -> None:
|
|
261
|
+
"""Recommend quantization based on VRAM and model size."""
|
|
262
|
+
from hf2ollama.utils.vram import detect_vram, recommend_quant
|
|
263
|
+
|
|
264
|
+
if vram is None:
|
|
265
|
+
vram_info = detect_vram()
|
|
266
|
+
if vram_info:
|
|
267
|
+
vram = vram_info.available_gb
|
|
268
|
+
console.print(f"[dim]Detected: {vram_info.gpu_name} ({vram:.1f} GB)[/dim]")
|
|
269
|
+
else:
|
|
270
|
+
console.print("[yellow]Could not detect GPU. Specify --vram manually.[/yellow]")
|
|
271
|
+
raise SystemExit(1)
|
|
272
|
+
|
|
273
|
+
fits = recommend_quant(params, vram)
|
|
274
|
+
|
|
275
|
+
if not fits:
|
|
276
|
+
console.print(
|
|
277
|
+
f"[red]No quantization of a {params}B model fits in {vram:.1f} GB VRAM.[/red]"
|
|
278
|
+
)
|
|
279
|
+
console.print(f"[dim]Smallest (Q2_K) needs ~{params * 0.31 * 1.1:.1f} GB.[/dim]")
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
table = Table(title=f"Quantizations for {params}B model ({vram:.1f} GB VRAM)")
|
|
283
|
+
table.add_column("Quantization")
|
|
284
|
+
table.add_column("Est. Size", justify="right")
|
|
285
|
+
table.add_column("Fits?", justify="center")
|
|
286
|
+
|
|
287
|
+
from hf2ollama.utils.vram import QUANT_MULTIPLIERS
|
|
288
|
+
|
|
289
|
+
for quant, mult in sorted(QUANT_MULTIPLIERS.items(), key=lambda x: x[1]):
|
|
290
|
+
est = params * mult * 1.1
|
|
291
|
+
fits_str = "[green]Yes[/green]" if est <= vram else "[red]No[/red]"
|
|
292
|
+
table.add_row(quant, f"{est:.1f} GB", fits_str)
|
|
293
|
+
|
|
294
|
+
console.print(table)
|
|
295
|
+
console.print(f"\n[bold]Recommended:[/bold] {fits[-1]} (largest that fits)")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@main.command("network-check")
|
|
299
|
+
def network_check() -> None:
|
|
300
|
+
"""Check HuggingFace reachability and SSL certificate status."""
|
|
301
|
+
from hf2ollama.network.detect import check_network
|
|
302
|
+
|
|
303
|
+
console.print("[bold]Checking network...[/bold]\n")
|
|
304
|
+
status = check_network()
|
|
305
|
+
|
|
306
|
+
table = Table(title="Network Diagnostics")
|
|
307
|
+
table.add_column("Check", style="bold")
|
|
308
|
+
table.add_column("Status")
|
|
309
|
+
table.add_column("Details")
|
|
310
|
+
|
|
311
|
+
hf_status = "[green]OK[/green]" if status.hf_reachable else "[red]UNREACHABLE[/red]"
|
|
312
|
+
table.add_row("HuggingFace reachable", hf_status, status.hf_error or "Connected")
|
|
313
|
+
|
|
314
|
+
ssl_status = "[green]OK[/green]" if status.hf_ssl_ok else "[red]FAIL[/red]"
|
|
315
|
+
table.add_row(
|
|
316
|
+
"SSL certificates",
|
|
317
|
+
ssl_status,
|
|
318
|
+
f"{status.cert_count} system certs" if status.system_certs_available else "Limited certs",
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
proxy_status = "[green]Set[/green]" if status.proxy_configured else "[dim]None[/dim]"
|
|
322
|
+
table.add_row("Proxy configured", proxy_status, status.proxy_url or "No proxy")
|
|
323
|
+
|
|
324
|
+
console.print(table)
|
|
325
|
+
|
|
326
|
+
if not status.hf_reachable:
|
|
327
|
+
console.print("\n[bold yellow]Suggestions:[/bold yellow]")
|
|
328
|
+
if not status.hf_ssl_ok:
|
|
329
|
+
console.print(" Try: [bold]hf2ollama pull --ssl-fix <repo>[/bold]")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core modules - Ollama binary integration and persistent configuration."""
|
hf2ollama/core/config.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Configuration management - persistent settings in ~/.hf2ollama/config.toml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import tomllib
|
|
11
|
+
except ImportError:
|
|
12
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
CONFIG_DIR = Path.home() / ".hf2ollama"
|
|
16
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
17
|
+
DEFAULT_DOWNLOAD_DIR = CONFIG_DIR / "downloads"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Config:
|
|
22
|
+
"""Persistent user configuration for hf2ollama."""
|
|
23
|
+
|
|
24
|
+
download_dir: str = str(DEFAULT_DOWNLOAD_DIR)
|
|
25
|
+
proxy: str | None = None
|
|
26
|
+
no_proxy: str | None = None
|
|
27
|
+
hf_token: str | None = None
|
|
28
|
+
hf_endpoint: str | None = None
|
|
29
|
+
default_quant: str = "Q4_K_M"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_config() -> Config:
|
|
33
|
+
"""Load config from file, falling back to defaults."""
|
|
34
|
+
if not CONFIG_FILE.exists():
|
|
35
|
+
return Config()
|
|
36
|
+
|
|
37
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
38
|
+
data = tomllib.load(f)
|
|
39
|
+
|
|
40
|
+
return Config(
|
|
41
|
+
download_dir=data.get("download_dir", str(DEFAULT_DOWNLOAD_DIR)),
|
|
42
|
+
proxy=data.get("proxy"),
|
|
43
|
+
no_proxy=data.get("no_proxy"),
|
|
44
|
+
hf_token=data.get("hf_token") or os.environ.get("HF_TOKEN"),
|
|
45
|
+
hf_endpoint=data.get("hf_endpoint"),
|
|
46
|
+
default_quant=data.get("default_quant", "Q4_K_M"),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save_config(config: Config) -> None:
|
|
51
|
+
"""Save config to TOML file."""
|
|
52
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
|
|
54
|
+
lines = []
|
|
55
|
+
lines.append(f'download_dir = "{config.download_dir}"')
|
|
56
|
+
lines.append(f'default_quant = "{config.default_quant}"')
|
|
57
|
+
if config.proxy:
|
|
58
|
+
lines.append(f'proxy = "{config.proxy}"')
|
|
59
|
+
if config.no_proxy:
|
|
60
|
+
lines.append(f'no_proxy = "{config.no_proxy}"')
|
|
61
|
+
if config.hf_token:
|
|
62
|
+
lines.append(f'hf_token = "{config.hf_token}"')
|
|
63
|
+
if config.hf_endpoint:
|
|
64
|
+
lines.append(f'hf_endpoint = "{config.hf_endpoint}"')
|
|
65
|
+
|
|
66
|
+
CONFIG_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ensure_download_dir(config: Config) -> Path:
|
|
70
|
+
"""Create and return the download directory."""
|
|
71
|
+
dl_dir = Path(config.download_dir)
|
|
72
|
+
dl_dir.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
return dl_dir
|
hf2ollama/core/ollama.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Ollama binary interaction - find, create, list, remove models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class OllamaModel:
|
|
17
|
+
"""Metadata for a locally installed Ollama model."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
size: str
|
|
21
|
+
modified: str
|
|
22
|
+
model_id: str = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def find_ollama() -> str:
|
|
26
|
+
"""Find the ollama executable."""
|
|
27
|
+
if os.name == "nt":
|
|
28
|
+
local_path = (
|
|
29
|
+
Path(os.environ.get("USERPROFILE", "")) / "AppData/Local/Programs/Ollama/ollama.exe"
|
|
30
|
+
)
|
|
31
|
+
if local_path.exists():
|
|
32
|
+
return str(local_path)
|
|
33
|
+
|
|
34
|
+
found = shutil.which("ollama")
|
|
35
|
+
if found:
|
|
36
|
+
return found
|
|
37
|
+
|
|
38
|
+
raise FileNotFoundError("Ollama not found. Install from https://ollama.com/download")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_model(
|
|
42
|
+
name: str,
|
|
43
|
+
modelfile_path: str,
|
|
44
|
+
working_dir: str | None = None,
|
|
45
|
+
) -> str:
|
|
46
|
+
"""Run `ollama create` with a Modelfile."""
|
|
47
|
+
ollama = find_ollama()
|
|
48
|
+
cwd = working_dir or str(Path(modelfile_path).parent)
|
|
49
|
+
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
[ollama, "create", name, "-f", "Modelfile"],
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
cwd=cwd,
|
|
55
|
+
timeout=600,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if result.returncode != 0:
|
|
59
|
+
raise RuntimeError(f"ollama create failed: {result.stderr or result.stdout}")
|
|
60
|
+
|
|
61
|
+
return result.stdout.strip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def list_models() -> list[OllamaModel]:
|
|
65
|
+
"""List locally installed Ollama models."""
|
|
66
|
+
ollama = find_ollama()
|
|
67
|
+
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
[ollama, "list"],
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
timeout=30,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if result.returncode != 0:
|
|
76
|
+
raise RuntimeError(f"ollama list failed: {result.stderr}")
|
|
77
|
+
|
|
78
|
+
models = []
|
|
79
|
+
lines = result.stdout.strip().splitlines()
|
|
80
|
+
for line in lines[1:]:
|
|
81
|
+
parts = line.split()
|
|
82
|
+
if len(parts) >= 3:
|
|
83
|
+
models.append(
|
|
84
|
+
OllamaModel(
|
|
85
|
+
name=parts[0],
|
|
86
|
+
model_id=parts[1] if len(parts) > 1 else "",
|
|
87
|
+
size=parts[2]
|
|
88
|
+
+ (" " + parts[3] if len(parts) > 3 and parts[3] in ("MB", "GB", "KB") else ""),
|
|
89
|
+
modified=" ".join(parts[4:]) if len(parts) > 4 else "",
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
return models
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def remove_model(name: str) -> str:
|
|
96
|
+
"""Remove an Ollama model."""
|
|
97
|
+
ollama = find_ollama()
|
|
98
|
+
|
|
99
|
+
result = subprocess.run(
|
|
100
|
+
[ollama, "rm", name],
|
|
101
|
+
capture_output=True,
|
|
102
|
+
text=True,
|
|
103
|
+
timeout=60,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if result.returncode != 0:
|
|
107
|
+
raise RuntimeError(f"ollama rm failed: {result.stderr}")
|
|
108
|
+
|
|
109
|
+
return result.stdout.strip()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def show_model(name: str) -> str:
|
|
113
|
+
"""Show model details."""
|
|
114
|
+
ollama = find_ollama()
|
|
115
|
+
|
|
116
|
+
result = subprocess.run(
|
|
117
|
+
[ollama, "show", name],
|
|
118
|
+
capture_output=True,
|
|
119
|
+
text=True,
|
|
120
|
+
timeout=30,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if result.returncode != 0:
|
|
124
|
+
raise RuntimeError(f"ollama show failed: {result.stderr}")
|
|
125
|
+
|
|
126
|
+
return result.stdout.strip()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def model_exists(name: str) -> bool:
|
|
130
|
+
"""Check if a model is already installed."""
|
|
131
|
+
try:
|
|
132
|
+
models = list_models()
|
|
133
|
+
base_name = name.split(":")[0]
|
|
134
|
+
return any(base_name in m.name for m in models)
|
|
135
|
+
except (RuntimeError, FileNotFoundError, subprocess.SubprocessError) as e:
|
|
136
|
+
logger.debug("model_exists check failed: %s", e)
|
|
137
|
+
return False
|
hf2ollama/hf/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HuggingFace integration - API client and file downloads."""
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Alternative download method using embedded PowerShell script (Windows only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_alt_download_available() -> bool:
|
|
15
|
+
"""Check if the PowerShell-based download method is available."""
|
|
16
|
+
if sys.platform != "win32":
|
|
17
|
+
return False
|
|
18
|
+
|
|
19
|
+
pwsh = shutil.which("pwsh")
|
|
20
|
+
return pwsh is not None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_script_path() -> Path:
|
|
24
|
+
"""Get path to the embedded HfDownload.ps1 script."""
|
|
25
|
+
script_dir = Path(__file__).parent.parent / "scripts"
|
|
26
|
+
return script_dir / "HfDownload.ps1"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def alt_download(
|
|
30
|
+
repo: str,
|
|
31
|
+
filename: str,
|
|
32
|
+
out_dir: str,
|
|
33
|
+
) -> Path:
|
|
34
|
+
"""Download a file using the PowerShell-based download method.
|
|
35
|
+
|
|
36
|
+
Requires Windows and PowerShell 7 (pwsh).
|
|
37
|
+
|
|
38
|
+
Raises RuntimeError if the download fails or prerequisites are not met.
|
|
39
|
+
"""
|
|
40
|
+
if not is_alt_download_available():
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
"Alternative download requires Windows + PowerShell 7 (pwsh)."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
script = get_script_path()
|
|
46
|
+
if not script.exists():
|
|
47
|
+
raise RuntimeError(f"HfDownload.ps1 not found at {script}")
|
|
48
|
+
|
|
49
|
+
cmd = [
|
|
50
|
+
"pwsh",
|
|
51
|
+
"-ExecutionPolicy",
|
|
52
|
+
"Bypass",
|
|
53
|
+
"-File",
|
|
54
|
+
str(script),
|
|
55
|
+
"-Repo",
|
|
56
|
+
repo,
|
|
57
|
+
"-File",
|
|
58
|
+
filename,
|
|
59
|
+
"-OutDir",
|
|
60
|
+
out_dir,
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
cmd,
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
timeout=3600,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if result.returncode != 0:
|
|
71
|
+
stderr = result.stderr.strip()
|
|
72
|
+
raise RuntimeError(f"Alternative download failed: {stderr or result.stdout}")
|
|
73
|
+
|
|
74
|
+
dest = Path(out_dir) / filename
|
|
75
|
+
if not dest.exists():
|
|
76
|
+
raise RuntimeError(f"Download completed but file not found at {dest}")
|
|
77
|
+
|
|
78
|
+
return dest
|