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/hf/api.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""HuggingFace API client - search repos and list GGUF files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Iterator
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
HF_API_BASE = "https://huggingface.co/api"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class RepoInfo:
|
|
19
|
+
"""Metadata for a HuggingFace model repository."""
|
|
20
|
+
|
|
21
|
+
repo_id: str
|
|
22
|
+
downloads: int
|
|
23
|
+
likes: int
|
|
24
|
+
last_modified: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class GGUFFile:
|
|
29
|
+
"""A GGUF file in a HuggingFace repository."""
|
|
30
|
+
|
|
31
|
+
filename: str
|
|
32
|
+
size_bytes: int
|
|
33
|
+
repo_id: str
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def size_gb(self) -> float:
|
|
37
|
+
"""Return file size in gigabytes."""
|
|
38
|
+
return self.size_bytes / (1024**3)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def size_display(self) -> str:
|
|
42
|
+
"""Return human-readable size string."""
|
|
43
|
+
if self.size_bytes >= 1024**3:
|
|
44
|
+
return f"{self.size_gb:.1f} GB"
|
|
45
|
+
return f"{self.size_bytes / (1024**2):.0f} MB"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@contextmanager
|
|
49
|
+
def _get_client(client: httpx.Client | None) -> Iterator[httpx.Client]:
|
|
50
|
+
"""Yield an httpx client, creating one if not provided."""
|
|
51
|
+
if client is not None:
|
|
52
|
+
yield client
|
|
53
|
+
else:
|
|
54
|
+
owned = httpx.Client(timeout=15.0)
|
|
55
|
+
try:
|
|
56
|
+
yield owned
|
|
57
|
+
finally:
|
|
58
|
+
owned.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def search_gguf_repos(
|
|
62
|
+
query: str,
|
|
63
|
+
limit: int = 20,
|
|
64
|
+
sort: str = "downloads",
|
|
65
|
+
client: httpx.Client | None = None,
|
|
66
|
+
) -> list[RepoInfo]:
|
|
67
|
+
"""Search HuggingFace for GGUF model repos.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
query : str
|
|
72
|
+
Search query string.
|
|
73
|
+
limit : int
|
|
74
|
+
Maximum number of results.
|
|
75
|
+
sort : str
|
|
76
|
+
Sort field (downloads, likes, lastModified).
|
|
77
|
+
client : httpx.Client | None
|
|
78
|
+
Optional shared HTTP client.
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
list[RepoInfo]
|
|
83
|
+
Matching repositories sorted by the given field.
|
|
84
|
+
|
|
85
|
+
"""
|
|
86
|
+
with _get_client(client) as c:
|
|
87
|
+
params = {
|
|
88
|
+
"search": query,
|
|
89
|
+
"filter": "gguf",
|
|
90
|
+
"sort": sort,
|
|
91
|
+
"direction": "-1",
|
|
92
|
+
"limit": str(limit),
|
|
93
|
+
}
|
|
94
|
+
resp = c.get(f"{HF_API_BASE}/models", params=params)
|
|
95
|
+
resp.raise_for_status()
|
|
96
|
+
|
|
97
|
+
results: list[RepoInfo] = []
|
|
98
|
+
for item in resp.json():
|
|
99
|
+
results.append(
|
|
100
|
+
RepoInfo(
|
|
101
|
+
repo_id=item.get("id", ""),
|
|
102
|
+
downloads=item.get("downloads", 0),
|
|
103
|
+
likes=item.get("likes", 0),
|
|
104
|
+
last_modified=item.get("lastModified", ""),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
logger.debug("Search '%s' returned %d results", query, len(results))
|
|
108
|
+
return results
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def list_gguf_files(
|
|
112
|
+
repo_id: str,
|
|
113
|
+
client: httpx.Client | None = None,
|
|
114
|
+
) -> list[GGUFFile]:
|
|
115
|
+
"""List GGUF files in a HuggingFace repo with their sizes.
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
repo_id : str
|
|
120
|
+
HuggingFace repo (e.g., bartowski/Qwen2.5-Coder-7B-Instruct-GGUF).
|
|
121
|
+
client : httpx.Client | None
|
|
122
|
+
Optional shared HTTP client.
|
|
123
|
+
|
|
124
|
+
Returns
|
|
125
|
+
-------
|
|
126
|
+
list[GGUFFile]
|
|
127
|
+
GGUF files sorted by size ascending.
|
|
128
|
+
|
|
129
|
+
"""
|
|
130
|
+
with _get_client(client) as c:
|
|
131
|
+
resp = c.get(f"{HF_API_BASE}/models/{repo_id}/tree/main")
|
|
132
|
+
resp.raise_for_status()
|
|
133
|
+
|
|
134
|
+
files: list[GGUFFile] = []
|
|
135
|
+
for item in resp.json():
|
|
136
|
+
path: str = item.get("path", "")
|
|
137
|
+
if path.lower().endswith(".gguf"):
|
|
138
|
+
files.append(
|
|
139
|
+
GGUFFile(
|
|
140
|
+
filename=path,
|
|
141
|
+
size_bytes=item.get("size", 0),
|
|
142
|
+
repo_id=repo_id,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
logger.debug("Repo '%s' has %d GGUF files", repo_id, len(files))
|
|
146
|
+
return sorted(files, key=lambda f: f.size_bytes)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def find_gguf_by_quant(
|
|
150
|
+
repo_id: str,
|
|
151
|
+
quant: str = "Q4_K_M",
|
|
152
|
+
client: httpx.Client | None = None,
|
|
153
|
+
) -> GGUFFile | None:
|
|
154
|
+
"""Find a specific quantization in a repo.
|
|
155
|
+
|
|
156
|
+
Parameters
|
|
157
|
+
----------
|
|
158
|
+
repo_id : str
|
|
159
|
+
HuggingFace repo ID.
|
|
160
|
+
quant : str
|
|
161
|
+
Quantization level to match in the filename.
|
|
162
|
+
client : httpx.Client | None
|
|
163
|
+
Optional shared HTTP client.
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
GGUFFile | None
|
|
168
|
+
Matching file, or None if not found.
|
|
169
|
+
|
|
170
|
+
"""
|
|
171
|
+
files = list_gguf_files(repo_id, client=client)
|
|
172
|
+
quant_lower = quant.lower()
|
|
173
|
+
|
|
174
|
+
for f in files:
|
|
175
|
+
if quant_lower in f.filename.lower():
|
|
176
|
+
return f
|
|
177
|
+
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def get_download_url(repo_id: str, filename: str) -> str:
|
|
182
|
+
"""Build the direct download URL for a file.
|
|
183
|
+
|
|
184
|
+
Parameters
|
|
185
|
+
----------
|
|
186
|
+
repo_id : str
|
|
187
|
+
HuggingFace repo ID.
|
|
188
|
+
filename : str
|
|
189
|
+
Name of the file to download.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
str
|
|
194
|
+
Direct download URL.
|
|
195
|
+
|
|
196
|
+
"""
|
|
197
|
+
return f"https://huggingface.co/{repo_id}/resolve/main/{filename}"
|
hf2ollama/hf/download.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Standard download with httpx - resumable, with progress bar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from rich.progress import (
|
|
10
|
+
BarColumn,
|
|
11
|
+
DownloadColumn,
|
|
12
|
+
Progress,
|
|
13
|
+
TextColumn,
|
|
14
|
+
TimeRemainingColumn,
|
|
15
|
+
TransferSpeedColumn,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from hf2ollama.hf.api import _get_client
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_DOWNLOAD_TIMEOUT = httpx.Timeout(connect=15.0, read=300.0, write=30.0, pool=10.0)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def download_file(
|
|
26
|
+
url: str,
|
|
27
|
+
dest_path: str,
|
|
28
|
+
client: httpx.Client | None = None,
|
|
29
|
+
chunk_size: int = 4 * 1024 * 1024,
|
|
30
|
+
) -> Path:
|
|
31
|
+
"""Download a file with resume support and progress bar.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
url : str
|
|
36
|
+
Direct download URL.
|
|
37
|
+
dest_path : str
|
|
38
|
+
Local path to write the file.
|
|
39
|
+
client : httpx.Client | None
|
|
40
|
+
Optional shared HTTP client. Creates one if not provided.
|
|
41
|
+
chunk_size : int
|
|
42
|
+
Download chunk size in bytes (default 4MB).
|
|
43
|
+
|
|
44
|
+
Returns
|
|
45
|
+
-------
|
|
46
|
+
Path
|
|
47
|
+
Path to the downloaded file.
|
|
48
|
+
|
|
49
|
+
"""
|
|
50
|
+
dest = Path(dest_path)
|
|
51
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
with _get_client(client) as c:
|
|
54
|
+
existing_size: int = dest.stat().st_size if dest.exists() else 0
|
|
55
|
+
|
|
56
|
+
headers: dict[str, str] = {}
|
|
57
|
+
if existing_size > 0:
|
|
58
|
+
headers["Range"] = f"bytes={existing_size}-"
|
|
59
|
+
logger.info("Resuming download from byte %d", existing_size)
|
|
60
|
+
|
|
61
|
+
with c.stream("GET", url, headers=headers, follow_redirects=True) as resp:
|
|
62
|
+
if resp.status_code == 416:
|
|
63
|
+
logger.info("File already fully downloaded: %s", dest)
|
|
64
|
+
return dest
|
|
65
|
+
|
|
66
|
+
resp.raise_for_status()
|
|
67
|
+
|
|
68
|
+
total_size: int | None = None
|
|
69
|
+
if resp.status_code == 206:
|
|
70
|
+
content_range = resp.headers.get("content-range", "")
|
|
71
|
+
if "/" in content_range:
|
|
72
|
+
total_size = int(content_range.split("/")[-1])
|
|
73
|
+
mode = "ab"
|
|
74
|
+
else:
|
|
75
|
+
cl = resp.headers.get("content-length")
|
|
76
|
+
total_size = int(cl) if cl else None
|
|
77
|
+
existing_size = 0
|
|
78
|
+
mode = "wb"
|
|
79
|
+
|
|
80
|
+
progress = Progress(
|
|
81
|
+
TextColumn("[bold blue]{task.description}"),
|
|
82
|
+
BarColumn(),
|
|
83
|
+
DownloadColumn(),
|
|
84
|
+
TransferSpeedColumn(),
|
|
85
|
+
TimeRemainingColumn(),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
filename = Path(url.split("/")[-1].split("?")[0]).name
|
|
89
|
+
with progress:
|
|
90
|
+
task = progress.add_task(
|
|
91
|
+
filename[:40],
|
|
92
|
+
total=total_size,
|
|
93
|
+
completed=existing_size,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
with open(dest, mode) as f:
|
|
97
|
+
for chunk in resp.iter_bytes(chunk_size=chunk_size):
|
|
98
|
+
f.write(chunk)
|
|
99
|
+
progress.advance(task, len(chunk))
|
|
100
|
+
|
|
101
|
+
return dest
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def validate_file_size(path: str, expected_size: int) -> bool:
|
|
105
|
+
"""Check if downloaded file matches expected size.
|
|
106
|
+
|
|
107
|
+
Parameters
|
|
108
|
+
----------
|
|
109
|
+
path : str
|
|
110
|
+
Path to the downloaded file.
|
|
111
|
+
expected_size : int
|
|
112
|
+
Expected file size in bytes.
|
|
113
|
+
|
|
114
|
+
Returns
|
|
115
|
+
-------
|
|
116
|
+
bool
|
|
117
|
+
True if file size matches.
|
|
118
|
+
|
|
119
|
+
"""
|
|
120
|
+
actual = Path(path).stat().st_size
|
|
121
|
+
return actual == expected_size
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Modelfile generation - chat templates and model family detection."""
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Model family detection and Modelfile generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from hf2ollama.modelfile.templates import TEMPLATES
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def detect_family(filename: str) -> str:
|
|
12
|
+
"""Detect model family from GGUF filename."""
|
|
13
|
+
lower = filename.lower()
|
|
14
|
+
|
|
15
|
+
if re.search(r"deepseek|ds[-_]?r1", lower):
|
|
16
|
+
return "deepseek"
|
|
17
|
+
if re.search(r"llama[-_]?4", lower):
|
|
18
|
+
return "llama4"
|
|
19
|
+
if re.search(r"llama[-_]?3", lower):
|
|
20
|
+
return "llama3"
|
|
21
|
+
if re.search(r"gemma[-_]?[234]?", lower) and "gemma" in lower:
|
|
22
|
+
return "gemma"
|
|
23
|
+
if re.search(r"qwen|qwq", lower):
|
|
24
|
+
return "qwen"
|
|
25
|
+
if re.search(r"mistral|mixtral|nemo", lower):
|
|
26
|
+
return "mistral"
|
|
27
|
+
if re.search(r"phi[-_]?[34]?", lower) and "phi" in lower:
|
|
28
|
+
return "phi"
|
|
29
|
+
if "granite" in lower:
|
|
30
|
+
return "granite"
|
|
31
|
+
if re.search(r"command[-_]?r|c4ai", lower):
|
|
32
|
+
return "command-r"
|
|
33
|
+
if re.search(r"starcoder|codellama", lower):
|
|
34
|
+
return "qwen"
|
|
35
|
+
|
|
36
|
+
return "chatml"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sanitize_model_name(filename: str) -> str:
|
|
40
|
+
"""Generate an Ollama model name from a GGUF filename."""
|
|
41
|
+
base = re.sub(r"\.gguf$", "", filename, flags=re.IGNORECASE)
|
|
42
|
+
|
|
43
|
+
quant_match = re.search(r"[-_](Q\d[^.-]*)", base, re.IGNORECASE)
|
|
44
|
+
tag = ""
|
|
45
|
+
if quant_match:
|
|
46
|
+
tag = quant_match.group(1).lower()
|
|
47
|
+
base = base[: quant_match.start()]
|
|
48
|
+
|
|
49
|
+
name = re.sub(r"[^a-z0-9._-]", "-", base.lower())
|
|
50
|
+
name = re.sub(r"-+", "-", name).strip("-")
|
|
51
|
+
|
|
52
|
+
if tag:
|
|
53
|
+
name = f"{name}:{tag}"
|
|
54
|
+
|
|
55
|
+
return name
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def generate_modelfile(
|
|
59
|
+
gguf_path: str,
|
|
60
|
+
family: str | None = None,
|
|
61
|
+
temperature: float = 0.7,
|
|
62
|
+
top_p: float = 0.9,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Generate an Ollama Modelfile for a GGUF file."""
|
|
65
|
+
filename = Path(gguf_path).name
|
|
66
|
+
|
|
67
|
+
if family is None:
|
|
68
|
+
family = detect_family(filename)
|
|
69
|
+
|
|
70
|
+
tmpl = TEMPLATES.get(family, TEMPLATES["chatml"])
|
|
71
|
+
|
|
72
|
+
lines = [
|
|
73
|
+
f"FROM ./{filename}",
|
|
74
|
+
"",
|
|
75
|
+
f'TEMPLATE """{tmpl.template}"""',
|
|
76
|
+
"",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
for stop in tmpl.stop_tokens:
|
|
80
|
+
lines.append(f'PARAMETER stop "{stop}"')
|
|
81
|
+
|
|
82
|
+
lines.extend(
|
|
83
|
+
[
|
|
84
|
+
"",
|
|
85
|
+
f"PARAMETER temperature {temperature}",
|
|
86
|
+
f"PARAMETER top_p {top_p}",
|
|
87
|
+
]
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Chat templates and stop tokens for each model family.
|
|
2
|
+
|
|
3
|
+
Ported from ConvertToOllama.ps1. These are Go template strings that Ollama
|
|
4
|
+
interprets at runtime. The {{ .System }}, {{ .Prompt }}, {{ .Response }}
|
|
5
|
+
placeholders are Ollama's template variables.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ChatTemplate:
|
|
15
|
+
"""Go template string and stop tokens for an Ollama Modelfile."""
|
|
16
|
+
|
|
17
|
+
template: str
|
|
18
|
+
stop_tokens: list[str]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
TEMPLATES: dict[str, ChatTemplate] = {
|
|
22
|
+
"llama4": ChatTemplate(
|
|
23
|
+
template=(
|
|
24
|
+
"{{- if .System }}<|header_start|>system<|header_end|>\n\n"
|
|
25
|
+
"{{ .System }}<|eot|>{{- end }}\n"
|
|
26
|
+
"<|header_start|>user<|header_end|>\n\n"
|
|
27
|
+
"{{ .Prompt }}<|eot|>\n"
|
|
28
|
+
"<|header_start|>assistant<|header_end|>\n\n"
|
|
29
|
+
"{{ .Response }}<|eot|>"
|
|
30
|
+
),
|
|
31
|
+
stop_tokens=["<|eot|>", "<|end_of_text|>"],
|
|
32
|
+
),
|
|
33
|
+
"llama3": ChatTemplate(
|
|
34
|
+
template=(
|
|
35
|
+
"{{- if .System }}<|start_header_id|>system<|end_header_id|>\n\n"
|
|
36
|
+
"{{ .System }}<|eot_id|>{{- end }}\n"
|
|
37
|
+
"<|start_header_id|>user<|end_header_id|>\n\n"
|
|
38
|
+
"{{ .Prompt }}<|eot_id|>\n"
|
|
39
|
+
"<|start_header_id|>assistant<|end_header_id|>\n\n"
|
|
40
|
+
"{{ .Response }}<|eot_id|>"
|
|
41
|
+
),
|
|
42
|
+
stop_tokens=["<|eot_id|>", "<|end_of_text|>"],
|
|
43
|
+
),
|
|
44
|
+
"gemma": ChatTemplate(
|
|
45
|
+
template=(
|
|
46
|
+
"{{- if .System }}<start_of_turn>user\n"
|
|
47
|
+
"{{ .System }}\n\n"
|
|
48
|
+
"{{ .Prompt }}<end_of_turn>\n"
|
|
49
|
+
"<start_of_turn>model\n"
|
|
50
|
+
"{{ .Response }}<end_of_turn>{{- else }}\n"
|
|
51
|
+
"<start_of_turn>user\n"
|
|
52
|
+
"{{ .Prompt }}<end_of_turn>\n"
|
|
53
|
+
"<start_of_turn>model\n"
|
|
54
|
+
"{{ .Response }}<end_of_turn>{{- end }}"
|
|
55
|
+
),
|
|
56
|
+
stop_tokens=["<end_of_turn>"],
|
|
57
|
+
),
|
|
58
|
+
"qwen": ChatTemplate(
|
|
59
|
+
template=(
|
|
60
|
+
"{{- if .System }}<|im_start|>system\n"
|
|
61
|
+
"{{ .System }}<|im_end|>\n"
|
|
62
|
+
"{{- end }}\n"
|
|
63
|
+
"<|im_start|>user\n"
|
|
64
|
+
"{{ .Prompt }}<|im_end|>\n"
|
|
65
|
+
"<|im_start|>assistant\n"
|
|
66
|
+
"{{ .Response }}<|im_end|>"
|
|
67
|
+
),
|
|
68
|
+
stop_tokens=["<|im_end|>", "<|endoftext|>"],
|
|
69
|
+
),
|
|
70
|
+
"mistral": ChatTemplate(
|
|
71
|
+
template=(
|
|
72
|
+
"{{- if .System }}[INST] {{ .System }}\n\n"
|
|
73
|
+
"{{ .Prompt }} [/INST]{{ .Response }}"
|
|
74
|
+
"{{- else }}[INST] {{ .Prompt }} [/INST]{{ .Response }}{{- end }}"
|
|
75
|
+
),
|
|
76
|
+
stop_tokens=["[INST]", "</s>"],
|
|
77
|
+
),
|
|
78
|
+
"phi": ChatTemplate(
|
|
79
|
+
template=(
|
|
80
|
+
"{{- if .System }}<|system|>\n"
|
|
81
|
+
"{{ .System }}<|end|>\n"
|
|
82
|
+
"{{- end }}\n"
|
|
83
|
+
"<|user|>\n"
|
|
84
|
+
"{{ .Prompt }}<|end|>\n"
|
|
85
|
+
"<|assistant|>\n"
|
|
86
|
+
"{{ .Response }}<|end|>"
|
|
87
|
+
),
|
|
88
|
+
stop_tokens=["<|end|>", "<|endoftext|>"],
|
|
89
|
+
),
|
|
90
|
+
"deepseek": ChatTemplate(
|
|
91
|
+
template=(
|
|
92
|
+
"{{- if .System }}<|begin▁of▁sentence|>"
|
|
93
|
+
"{{ .System }}\n{{- end }}\n"
|
|
94
|
+
"<|User|>{{ .Prompt }}\n"
|
|
95
|
+
"<|Assistant|>{{ .Response }}<|end▁of▁sentence|>"
|
|
96
|
+
),
|
|
97
|
+
stop_tokens=["<|end▁of▁sentence|>"],
|
|
98
|
+
),
|
|
99
|
+
"granite": ChatTemplate(
|
|
100
|
+
template=(
|
|
101
|
+
"{{- if .System }}<|start_of_role|>system<|end_of_role|>\n"
|
|
102
|
+
"{{ .System }}<|end_of_text|>\n"
|
|
103
|
+
"{{- end }}\n"
|
|
104
|
+
"<|start_of_role|>user<|end_of_role|>\n"
|
|
105
|
+
"{{ .Prompt }}<|end_of_text|>\n"
|
|
106
|
+
"<|start_of_role|>assistant<|end_of_role|>\n"
|
|
107
|
+
"{{ .Response }}<|end_of_text|>"
|
|
108
|
+
),
|
|
109
|
+
stop_tokens=["<|end_of_text|>"],
|
|
110
|
+
),
|
|
111
|
+
"command-r": ChatTemplate(
|
|
112
|
+
template=(
|
|
113
|
+
"{{- if .System }}<|START_OF_TURN_TOKEN|><|SYSTEM_TOKEN|>"
|
|
114
|
+
"{{ .System }}<|END_OF_TURN_TOKEN|>{{- end }}\n"
|
|
115
|
+
"<|START_OF_TURN_TOKEN|><|USER_TOKEN|>{{ .Prompt }}<|END_OF_TURN_TOKEN|>\n"
|
|
116
|
+
"<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>{{ .Response }}<|END_OF_TURN_TOKEN|>"
|
|
117
|
+
),
|
|
118
|
+
stop_tokens=["<|END_OF_TURN_TOKEN|>"],
|
|
119
|
+
),
|
|
120
|
+
"chatml": ChatTemplate(
|
|
121
|
+
template=(
|
|
122
|
+
"{{- if .System }}<|im_start|>system\n"
|
|
123
|
+
"{{ .System }}<|im_end|>\n"
|
|
124
|
+
"{{- end }}\n"
|
|
125
|
+
"<|im_start|>user\n"
|
|
126
|
+
"{{ .Prompt }}<|im_end|>\n"
|
|
127
|
+
"<|im_start|>assistant\n"
|
|
128
|
+
"{{ .Response }}<|im_end|>"
|
|
129
|
+
),
|
|
130
|
+
stop_tokens=["<|im_end|>"],
|
|
131
|
+
),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
SUPPORTED_FAMILIES = list(TEMPLATES.keys())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Network modules - environment detection and SSL certificate patching."""
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Network environment detection - SSL and HuggingFace reachability checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import ssl
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class NetworkStatus:
|
|
17
|
+
"""Result of a network diagnostic check."""
|
|
18
|
+
|
|
19
|
+
hf_reachable: bool = False
|
|
20
|
+
hf_ssl_ok: bool = False
|
|
21
|
+
hf_error: str = ""
|
|
22
|
+
proxy_configured: bool = False
|
|
23
|
+
proxy_url: str = ""
|
|
24
|
+
system_certs_available: bool = False
|
|
25
|
+
cert_count: int = 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_network() -> NetworkStatus:
|
|
29
|
+
"""Check HuggingFace reachability and SSL certificate status."""
|
|
30
|
+
status = NetworkStatus()
|
|
31
|
+
|
|
32
|
+
status.proxy_url = os.environ.get("HTTPS_PROXY", os.environ.get("https_proxy", ""))
|
|
33
|
+
status.proxy_configured = bool(status.proxy_url)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
ctx = ssl.create_default_context()
|
|
37
|
+
certs = ctx.get_ca_certs(binary_form=True)
|
|
38
|
+
status.cert_count = len(certs)
|
|
39
|
+
status.system_certs_available = status.cert_count > 50
|
|
40
|
+
except (ssl.SSLError, OSError) as e:
|
|
41
|
+
logger.debug("Failed to read system certs: %s", e)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
httpx.get("https://huggingface.co/api/models?limit=1", timeout=10.0)
|
|
45
|
+
status.hf_reachable = True
|
|
46
|
+
status.hf_ssl_ok = True
|
|
47
|
+
except httpx.ConnectError as e:
|
|
48
|
+
err = str(e).lower()
|
|
49
|
+
if "ssl" in err or "certificate" in err:
|
|
50
|
+
status.hf_reachable = False
|
|
51
|
+
status.hf_ssl_ok = False
|
|
52
|
+
status.hf_error = "SSL certificate verification failed"
|
|
53
|
+
else:
|
|
54
|
+
status.hf_error = str(e)[:100]
|
|
55
|
+
except (httpx.TimeoutException, httpx.HTTPStatusError, OSError) as e:
|
|
56
|
+
status.hf_error = str(e)[:100]
|
|
57
|
+
|
|
58
|
+
return status
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""SSL certificate configuration - use OS trust store instead of certifi defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import ssl
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
_PATCHED = False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def apply_ssl_fix(
|
|
16
|
+
ca_bundle: str | None = None,
|
|
17
|
+
proxy: str | None = None,
|
|
18
|
+
) -> str:
|
|
19
|
+
"""Configure Python to use the OS certificate store for SSL verification.
|
|
20
|
+
|
|
21
|
+
Exports system CA certificates and sets environment variables so that
|
|
22
|
+
httpx, requests, and urllib3 use the OS trust store.
|
|
23
|
+
|
|
24
|
+
Returns the path to the CA bundle being used.
|
|
25
|
+
"""
|
|
26
|
+
global _PATCHED
|
|
27
|
+
if _PATCHED:
|
|
28
|
+
return os.environ.get("SSL_CERT_FILE", "")
|
|
29
|
+
|
|
30
|
+
bundle = ca_bundle or _export_system_certs()
|
|
31
|
+
os.environ["SSL_CERT_FILE"] = bundle
|
|
32
|
+
os.environ["REQUESTS_CA_BUNDLE"] = bundle
|
|
33
|
+
os.environ["CURL_CA_BUNDLE"] = bundle
|
|
34
|
+
|
|
35
|
+
if proxy:
|
|
36
|
+
os.environ["HTTPS_PROXY"] = proxy
|
|
37
|
+
os.environ["HTTP_PROXY"] = proxy
|
|
38
|
+
|
|
39
|
+
_PATCHED = True
|
|
40
|
+
return bundle
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _export_system_certs() -> str:
|
|
44
|
+
"""Export system certificates to a PEM file."""
|
|
45
|
+
certs: set[str] = set()
|
|
46
|
+
|
|
47
|
+
ctx = ssl.create_default_context()
|
|
48
|
+
for cert_der in ctx.get_ca_certs(binary_form=True):
|
|
49
|
+
pem = ssl.DER_cert_to_PEM_cert(cert_der)
|
|
50
|
+
certs.add(pem)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
import certifi
|
|
54
|
+
|
|
55
|
+
with open(certifi.where(), "r", encoding="utf-8") as f:
|
|
56
|
+
content = f.read()
|
|
57
|
+
in_cert = False
|
|
58
|
+
current: list[str] = []
|
|
59
|
+
for line in content.splitlines(True):
|
|
60
|
+
if "BEGIN CERTIFICATE" in line:
|
|
61
|
+
in_cert = True
|
|
62
|
+
current = [line]
|
|
63
|
+
elif "END CERTIFICATE" in line:
|
|
64
|
+
current.append(line)
|
|
65
|
+
certs.add("".join(current))
|
|
66
|
+
in_cert = False
|
|
67
|
+
elif in_cert:
|
|
68
|
+
current.append(line)
|
|
69
|
+
except ImportError:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
cert_dir = Path(tempfile.gettempdir()) / "hf2ollama"
|
|
73
|
+
cert_dir.mkdir(exist_ok=True)
|
|
74
|
+
bundle = cert_dir / "ca_bundle.pem"
|
|
75
|
+
bundle.write_text("\n".join(sorted(certs)), encoding="utf-8")
|
|
76
|
+
return str(bundle)
|