unmuzzle 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.
- unmuzzle/__init__.py +3 -0
- unmuzzle/api.py +206 -0
- unmuzzle/cli.py +190 -0
- unmuzzle/download.py +138 -0
- unmuzzle/hfcache.py +54 -0
- unmuzzle/index.py +116 -0
- unmuzzle/mcp_server.py +80 -0
- unmuzzle/publish.py +88 -0
- unmuzzle/sign.py +64 -0
- unmuzzle-0.1.0.dist-info/METADATA +123 -0
- unmuzzle-0.1.0.dist-info/RECORD +15 -0
- unmuzzle-0.1.0.dist-info/WHEEL +5 -0
- unmuzzle-0.1.0.dist-info/entry_points.txt +3 -0
- unmuzzle-0.1.0.dist-info/licenses/LICENSE +21 -0
- unmuzzle-0.1.0.dist-info/top_level.txt +1 -0
unmuzzle/__init__.py
ADDED
unmuzzle/api.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Programmatic API for agents.
|
|
2
|
+
|
|
3
|
+
Agents (Claude Code, Codex, Kimi Code, MCP clients) should call these
|
|
4
|
+
functions, or the MCP server in unmuzzle.mcp_server, instead of scraping
|
|
5
|
+
CLI output. Every function returns plain JSON-serializable dicts and raises
|
|
6
|
+
UnmuzzleError with a machine-actionable message.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable, List, Optional
|
|
14
|
+
|
|
15
|
+
from . import hfcache, sign as signmod
|
|
16
|
+
from .download import DownloadError, download_file, download_torrent, sha256_file
|
|
17
|
+
from .index import ModelEntry, find_model, load_index
|
|
18
|
+
from .publish import build_entry, write_entry
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UnmuzzleError(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _entry_dict(e: ModelEntry) -> dict:
|
|
26
|
+
d = dict(e.raw) if e.raw else {}
|
|
27
|
+
d.update({
|
|
28
|
+
"name": e.name,
|
|
29
|
+
"description": e.description,
|
|
30
|
+
"base_model": e.base_model,
|
|
31
|
+
"license": e.license,
|
|
32
|
+
"tags": e.tags,
|
|
33
|
+
"files": e.files,
|
|
34
|
+
"total_size": e.total_size,
|
|
35
|
+
"mirrors": {"http": e.http, "magnet": e.magnet, "torrent": e.torrent},
|
|
36
|
+
"signed": bool(e.signature),
|
|
37
|
+
"revision": e.revision(),
|
|
38
|
+
})
|
|
39
|
+
return d
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _find(name: str, index: Optional[str]) -> ModelEntry:
|
|
43
|
+
entry = find_model(load_index(index), name)
|
|
44
|
+
if not entry:
|
|
45
|
+
raise UnmuzzleError(f"not found: {name}")
|
|
46
|
+
return entry
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def list_models(tag: Optional[str] = None, index: Optional[str] = None) -> List[dict]:
|
|
50
|
+
entries = load_index(index)
|
|
51
|
+
if tag:
|
|
52
|
+
entries = [e for e in entries if tag in e.tags]
|
|
53
|
+
return [_entry_dict(e) for e in entries]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def model_info(name: str, index: Optional[str] = None) -> dict:
|
|
57
|
+
return _entry_dict(_find(name, index))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def check_signature(entry: ModelEntry, require: bool = False) -> dict:
|
|
61
|
+
"""Verify the entry's minisign signature. Returns a status dict; raises if invalid."""
|
|
62
|
+
if not entry.signature:
|
|
63
|
+
if require:
|
|
64
|
+
raise UnmuzzleError(f"{entry.name} is unsigned and signature was required")
|
|
65
|
+
return {"signed": False, "verified": False}
|
|
66
|
+
if not entry.publisher_pubkey:
|
|
67
|
+
raise UnmuzzleError("entry has a signature but no publisher_pubkey")
|
|
68
|
+
if not signmod.have_minisign():
|
|
69
|
+
if require:
|
|
70
|
+
raise UnmuzzleError("minisign not installed, cannot verify (brew install minisign)")
|
|
71
|
+
return {"signed": True, "verified": False, "warning": "minisign not installed, skipped"}
|
|
72
|
+
if not signmod.verify(entry.canonical_manifest(), entry.signature, entry.publisher_pubkey):
|
|
73
|
+
raise UnmuzzleError(f"SIGNATURE INVALID for {entry.name}")
|
|
74
|
+
return {"signed": True, "verified": True}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get(
|
|
78
|
+
name: str,
|
|
79
|
+
dest: Optional[str] = None,
|
|
80
|
+
method: str = "auto",
|
|
81
|
+
jobs: int = 8,
|
|
82
|
+
require_signature: bool = False,
|
|
83
|
+
index: Optional[str] = None,
|
|
84
|
+
progress: Optional[Callable[[str], None]] = None,
|
|
85
|
+
) -> dict:
|
|
86
|
+
"""Download a model. Returns {revision, method, path, hf_cache, files, bytes}."""
|
|
87
|
+
entry = _find(name, index)
|
|
88
|
+
sig = check_signature(entry, require_signature)
|
|
89
|
+
|
|
90
|
+
if method == "auto":
|
|
91
|
+
if (entry.torrent or entry.magnet) and shutil.which("aria2c"):
|
|
92
|
+
method = "torrent"
|
|
93
|
+
elif entry.http:
|
|
94
|
+
method = "http"
|
|
95
|
+
elif entry.torrent or entry.magnet:
|
|
96
|
+
method = "torrent"
|
|
97
|
+
else:
|
|
98
|
+
raise UnmuzzleError("entry has no usable mirrors")
|
|
99
|
+
if method == "torrent" and not (entry.torrent or entry.magnet):
|
|
100
|
+
raise UnmuzzleError("entry has no torrent or magnet link")
|
|
101
|
+
if method == "http" and not entry.http:
|
|
102
|
+
raise UnmuzzleError("entry has no http mirrors")
|
|
103
|
+
|
|
104
|
+
revision = entry.revision()
|
|
105
|
+
if dest:
|
|
106
|
+
root = Path(dest)
|
|
107
|
+
else:
|
|
108
|
+
root = hfcache.model_cache_dir(entry.name) / ".staging" / revision
|
|
109
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
if method == "torrent":
|
|
113
|
+
download_torrent(entry.torrent or entry.magnet, root)
|
|
114
|
+
for f in entry.files:
|
|
115
|
+
p = root / f["path"]
|
|
116
|
+
if not p.exists() or sha256_file(p) != f["sha256"]:
|
|
117
|
+
raise UnmuzzleError(f"{f['path']}: sha256 mismatch after torrent download")
|
|
118
|
+
else:
|
|
119
|
+
for f in entry.files:
|
|
120
|
+
if progress:
|
|
121
|
+
progress(f"downloading {f['path']}")
|
|
122
|
+
download_file(entry.http, f["path"], root / f["path"], f["size"], f["sha256"],
|
|
123
|
+
jobs=jobs, progress=progress)
|
|
124
|
+
except DownloadError as e:
|
|
125
|
+
raise UnmuzzleError(str(e))
|
|
126
|
+
|
|
127
|
+
result = {
|
|
128
|
+
"name": entry.name,
|
|
129
|
+
"revision": revision,
|
|
130
|
+
"method": method,
|
|
131
|
+
"files": len(entry.files),
|
|
132
|
+
"bytes": entry.total_size,
|
|
133
|
+
"signature": sig,
|
|
134
|
+
}
|
|
135
|
+
if dest:
|
|
136
|
+
result.update(path=str(root), hf_cache=False)
|
|
137
|
+
else:
|
|
138
|
+
snapshot = hfcache.install(entry.name, revision, root, entry.files)
|
|
139
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
140
|
+
result.update(path=str(snapshot), hf_cache=True)
|
|
141
|
+
result["load_with"] = (
|
|
142
|
+
f'from transformers import AutoModelForCausalLM; '
|
|
143
|
+
f'model = AutoModelForCausalLM.from_pretrained("{entry.name}", local_files_only=True)'
|
|
144
|
+
)
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def publish(
|
|
149
|
+
directory: str,
|
|
150
|
+
name: str,
|
|
151
|
+
http_bases: Optional[List[str]] = None,
|
|
152
|
+
magnet: Optional[str] = None,
|
|
153
|
+
torrent_url: Optional[str] = None,
|
|
154
|
+
description: str = "",
|
|
155
|
+
base_model: str = "",
|
|
156
|
+
license: str = "",
|
|
157
|
+
tags: Optional[List[str]] = None,
|
|
158
|
+
sign_key: Optional[str] = None,
|
|
159
|
+
pubkey: Optional[str] = None,
|
|
160
|
+
index_dir: str = "index",
|
|
161
|
+
) -> dict:
|
|
162
|
+
"""Hash a local model dir, optionally sign, and add it to a local index."""
|
|
163
|
+
entry = build_entry(
|
|
164
|
+
directory=Path(directory), name=name, http_bases=list(http_bases or []),
|
|
165
|
+
magnet=magnet, torrent_url=torrent_url, description=description,
|
|
166
|
+
base_model=base_model,
|
|
167
|
+
license_=license, tags=list(tags or []),
|
|
168
|
+
secret_key=Path(sign_key) if sign_key else None, pubkey=pubkey,
|
|
169
|
+
)
|
|
170
|
+
path = write_entry(Path(index_dir), entry)
|
|
171
|
+
return {
|
|
172
|
+
"entry": entry,
|
|
173
|
+
"entry_path": str(path),
|
|
174
|
+
"index": str(Path(index_dir) / "index.json"),
|
|
175
|
+
"signed": bool(entry.get("signature")),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def verify(name: str, index: Optional[str] = None) -> dict:
|
|
180
|
+
"""Re-hash an installed model against the index."""
|
|
181
|
+
entry = _find(name, index)
|
|
182
|
+
snapshot = hfcache.model_cache_dir(entry.name) / "snapshots" / entry.revision()
|
|
183
|
+
if not snapshot.exists():
|
|
184
|
+
raise UnmuzzleError(f"not installed: {entry.name}")
|
|
185
|
+
bad = []
|
|
186
|
+
for f in entry.files:
|
|
187
|
+
p = snapshot / f["path"]
|
|
188
|
+
if not (p.exists() and sha256_file(p) == f["sha256"]):
|
|
189
|
+
bad.append(f["path"])
|
|
190
|
+
return {"name": entry.name, "ok": not bad, "bad_files": bad,
|
|
191
|
+
"checked": len(entry.files), "path": str(snapshot)}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def keygen(key_path: Optional[str] = None) -> dict:
|
|
195
|
+
"""Generate a minisign keypair for signing releases. Non-interactive."""
|
|
196
|
+
if not signmod.have_minisign():
|
|
197
|
+
raise UnmuzzleError("minisign not installed (brew install minisign / apt install minisign)")
|
|
198
|
+
secret = Path(key_path or Path.home() / ".minisign" / "unmuzzle.key")
|
|
199
|
+
pub = secret.with_suffix(".pub")
|
|
200
|
+
if secret.exists():
|
|
201
|
+
raise UnmuzzleError(f"key already exists: {secret}")
|
|
202
|
+
secret.parent.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
subprocess.run(["minisign", "-G", "-W", "-s", str(secret), "-p", str(pub)],
|
|
204
|
+
check=True, capture_output=True)
|
|
205
|
+
return {"secret_key": str(secret), "public_key": str(pub),
|
|
206
|
+
"pubkey": signmod.pubkey_from_secret(secret)}
|
unmuzzle/cli.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""unmuzzle command line interface.
|
|
2
|
+
|
|
3
|
+
Thin wrapper over unmuzzle.api. All output is human-readable by default and
|
|
4
|
+
JSON with --json, so agents can drive everything non-interactively.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from . import __version__, api
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _fmt_size(n) -> str:
|
|
16
|
+
n = float(n)
|
|
17
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
18
|
+
if n < 1024 or unit == "TiB":
|
|
19
|
+
return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} B"
|
|
20
|
+
n /= 1024
|
|
21
|
+
return f"{n} B"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _emit(obj, as_json: bool) -> None:
|
|
25
|
+
if as_json:
|
|
26
|
+
print(json.dumps(obj, indent=2))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cmd_list(args) -> int:
|
|
30
|
+
models = api.list_models(tag=args.tag, index=args.index)
|
|
31
|
+
_emit(models, args.json)
|
|
32
|
+
if not args.json:
|
|
33
|
+
if not models:
|
|
34
|
+
print("index is empty. Publish a model with `unmuzzle publish`.")
|
|
35
|
+
for m in models:
|
|
36
|
+
sig = " [signed]" if m["signed"] else ""
|
|
37
|
+
tags = f" ({', '.join(m['tags'])})" if m["tags"] else ""
|
|
38
|
+
print(f"{m['name']:<44} {_fmt_size(m['total_size']):>10}{sig}{tags}")
|
|
39
|
+
return 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cmd_info(args) -> int:
|
|
43
|
+
m = api.model_info(args.model, index=args.index)
|
|
44
|
+
_emit(m, args.json)
|
|
45
|
+
if not args.json:
|
|
46
|
+
print(f"name: {m['name']}")
|
|
47
|
+
for label, key in (("description", "description"), ("base model", "base_model"),
|
|
48
|
+
("license", "license")):
|
|
49
|
+
if m.get(key):
|
|
50
|
+
print(f"{label + ':':<13}{m[key]}")
|
|
51
|
+
print(f"revision: {m['revision']}")
|
|
52
|
+
print(f"total size: {_fmt_size(m['total_size'])}")
|
|
53
|
+
print(f"mirrors: {len(m['mirrors']['http'])} http"
|
|
54
|
+
+ (", 1 torrent" if m["mirrors"]["magnet"] else ""))
|
|
55
|
+
print(f"signature: {'yes' if m['signed'] else 'no'}")
|
|
56
|
+
print("files:")
|
|
57
|
+
for f in m["files"]:
|
|
58
|
+
print(f" {_fmt_size(f['size']):>10} {f['path']}")
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cmd_get(args) -> int:
|
|
63
|
+
progress = (lambda s: print(s, file=sys.stderr)) if args.json else print
|
|
64
|
+
result = api.get(
|
|
65
|
+
args.model, dest=args.dest, method=args.method, jobs=args.jobs,
|
|
66
|
+
require_signature=args.require_signature, index=args.index,
|
|
67
|
+
progress=progress,
|
|
68
|
+
)
|
|
69
|
+
_emit(result, args.json)
|
|
70
|
+
if not args.json:
|
|
71
|
+
if result["signature"].get("verified"):
|
|
72
|
+
print("signature: OK")
|
|
73
|
+
if result["hf_cache"]:
|
|
74
|
+
print(f"\ninstalled into HF cache: {result['path']}")
|
|
75
|
+
print(f"\nload it with:\n {result['load_with']}")
|
|
76
|
+
else:
|
|
77
|
+
print(f"\ndone. Files in {result['path']}")
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cmd_publish(args) -> int:
|
|
82
|
+
result = api.publish(
|
|
83
|
+
directory=args.dir, name=args.name, http_bases=args.http_base,
|
|
84
|
+
magnet=args.magnet, torrent_url=args.torrent_url,
|
|
85
|
+
description=args.description or "",
|
|
86
|
+
base_model=args.base_model or "", license=args.license or "",
|
|
87
|
+
tags=args.tag, sign_key=args.sign_key, pubkey=args.pubkey,
|
|
88
|
+
index_dir=args.index_dir,
|
|
89
|
+
)
|
|
90
|
+
_emit(result, args.json)
|
|
91
|
+
if not args.json:
|
|
92
|
+
print(f"wrote {result['entry_path']}")
|
|
93
|
+
print(f"regenerated {result['index']}")
|
|
94
|
+
if not result["signed"]:
|
|
95
|
+
print("note: entry is unsigned. Use --sign-key to sign releases.")
|
|
96
|
+
print("\nnext: upload the model files to your mirror(s), then commit and push the index.")
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_verify(args) -> int:
|
|
101
|
+
result = api.verify(args.model, index=args.index)
|
|
102
|
+
_emit(result, args.json)
|
|
103
|
+
if not args.json:
|
|
104
|
+
print(f"{'OK' if result['ok'] else 'BAD'}: {result['checked']} files checked at {result['path']}")
|
|
105
|
+
return 0 if result["ok"] else 1
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_keygen(args) -> int:
|
|
109
|
+
result = api.keygen(args.key)
|
|
110
|
+
_emit(result, args.json)
|
|
111
|
+
if not args.json:
|
|
112
|
+
print(f"secret key: {result['secret_key']} (keep it secret, back it up)")
|
|
113
|
+
print(f"public key: {result['public_key']}")
|
|
114
|
+
print(f"pubkey: {result['pubkey']}")
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def main(argv=None) -> int:
|
|
119
|
+
p = argparse.ArgumentParser(
|
|
120
|
+
prog="unmuzzle",
|
|
121
|
+
description="Agent-native, censorship-resistant distribution for open-weight models.",
|
|
122
|
+
)
|
|
123
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
124
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
125
|
+
|
|
126
|
+
def common(sp, index=False):
|
|
127
|
+
sp.add_argument("--json", action="store_true", help="machine-readable output")
|
|
128
|
+
if index:
|
|
129
|
+
sp.add_argument("--index", default=None,
|
|
130
|
+
help="index URL or local path (default: $UNMUZZLE_INDEX or the unmuzzle index)")
|
|
131
|
+
|
|
132
|
+
sp = sub.add_parser("list", help="list models in the index")
|
|
133
|
+
sp.add_argument("--tag", help="filter by tag")
|
|
134
|
+
common(sp, index=True)
|
|
135
|
+
sp.set_defaults(fn=cmd_list)
|
|
136
|
+
|
|
137
|
+
sp = sub.add_parser("info", help="show files, mirrors, and signature for a model")
|
|
138
|
+
sp.add_argument("model")
|
|
139
|
+
common(sp, index=True)
|
|
140
|
+
sp.set_defaults(fn=cmd_info)
|
|
141
|
+
|
|
142
|
+
sp = sub.add_parser("get", help="download a model and install it into the HF cache")
|
|
143
|
+
sp.add_argument("model")
|
|
144
|
+
sp.add_argument("--dest", help="download plain files to this directory instead of the HF cache")
|
|
145
|
+
sp.add_argument("--method", choices=["auto", "http", "torrent"], default="auto")
|
|
146
|
+
sp.add_argument("--jobs", type=int, default=8, help="parallel chunks per file (http)")
|
|
147
|
+
sp.add_argument("--require-signature", action="store_true",
|
|
148
|
+
help="abort unless the entry has a valid minisign signature")
|
|
149
|
+
common(sp, index=True)
|
|
150
|
+
sp.set_defaults(fn=cmd_get)
|
|
151
|
+
|
|
152
|
+
sp = sub.add_parser("publish", help="hash a local model dir and add it to a local index")
|
|
153
|
+
sp.add_argument("dir", help="directory containing the model files")
|
|
154
|
+
sp.add_argument("--name", required=True, help="org/name")
|
|
155
|
+
sp.add_argument("--http-base", action="append", help="base URL where files will be served (repeatable)")
|
|
156
|
+
sp.add_argument("--magnet", help="magnet link for the torrent of this directory")
|
|
157
|
+
sp.add_argument("--torrent-url", help="URL of the .torrent file (preferred over magnet: works with zero peers via web seeds)")
|
|
158
|
+
sp.add_argument("--description")
|
|
159
|
+
sp.add_argument("--base-model")
|
|
160
|
+
sp.add_argument("--license")
|
|
161
|
+
sp.add_argument("--tag", action="append", help="tag (repeatable)")
|
|
162
|
+
sp.add_argument("--sign-key", help="minisign secret key to sign the manifest")
|
|
163
|
+
sp.add_argument("--pubkey", help="publisher pubkey (base64); derived from --sign-key if omitted")
|
|
164
|
+
sp.add_argument("--index-dir", default="index", help="local index directory to update")
|
|
165
|
+
common(sp)
|
|
166
|
+
sp.set_defaults(fn=cmd_publish)
|
|
167
|
+
|
|
168
|
+
sp = sub.add_parser("verify", help="re-hash an installed model against the index")
|
|
169
|
+
sp.add_argument("model")
|
|
170
|
+
common(sp, index=True)
|
|
171
|
+
sp.set_defaults(fn=cmd_verify)
|
|
172
|
+
|
|
173
|
+
sp = sub.add_parser("keygen", help="generate a minisign keypair for signing releases")
|
|
174
|
+
sp.add_argument("--key", help="secret key path (default: ~/.minisign/unmuzzle.key)")
|
|
175
|
+
common(sp)
|
|
176
|
+
sp.set_defaults(fn=cmd_keygen)
|
|
177
|
+
|
|
178
|
+
args = p.parse_args(argv)
|
|
179
|
+
try:
|
|
180
|
+
return args.fn(args)
|
|
181
|
+
except api.UnmuzzleError as e:
|
|
182
|
+
if getattr(args, "json", False):
|
|
183
|
+
print(json.dumps({"error": str(e)}), file=sys.stderr)
|
|
184
|
+
else:
|
|
185
|
+
print(f"error: {e}", file=sys.stderr)
|
|
186
|
+
return 1
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
sys.exit(main())
|
unmuzzle/download.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Parallel ranged HTTP downloads with resume, plus magnet links via aria2c.
|
|
2
|
+
|
|
3
|
+
HTTP downloads split each file into fixed-size chunks fetched with Range
|
|
4
|
+
requests from any of the entry's mirrors. Chunks land in a .parts directory
|
|
5
|
+
next to the target, so an interrupted download resumes where it stopped.
|
|
6
|
+
Every completed file is verified against the sha256 from the index before
|
|
7
|
+
it is moved into place.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import concurrent.futures as cf
|
|
12
|
+
import hashlib
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import urllib.request
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Callable, List, Optional
|
|
18
|
+
|
|
19
|
+
DEFAULT_CHUNK = 32 * 1024 * 1024 # 32 MiB
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DownloadError(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fetch_range(url: str, start: int, end: int, dest: Path, retries: int = 4) -> None:
|
|
27
|
+
want = end - start + 1
|
|
28
|
+
for attempt in range(retries):
|
|
29
|
+
try:
|
|
30
|
+
req = urllib.request.Request(url, headers={"Range": f"bytes={start}-{end}"})
|
|
31
|
+
with urllib.request.urlopen(req, timeout=300) as r, open(dest, "wb") as f:
|
|
32
|
+
shutil.copyfileobj(r, f, 1024 * 1024)
|
|
33
|
+
if dest.stat().st_size == want:
|
|
34
|
+
return
|
|
35
|
+
except Exception:
|
|
36
|
+
if attempt == retries - 1:
|
|
37
|
+
raise
|
|
38
|
+
raise DownloadError(f"range {start}-{end} from {url}: incomplete after {retries} tries")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def sha256_file(path: Path, buf: int = 8 * 1024 * 1024) -> str:
|
|
42
|
+
h = hashlib.sha256()
|
|
43
|
+
with open(path, "rb") as f:
|
|
44
|
+
for block in iter(lambda: f.read(buf), b""):
|
|
45
|
+
h.update(block)
|
|
46
|
+
return h.hexdigest()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def download_file(
|
|
50
|
+
base_urls: List[str],
|
|
51
|
+
relpath: str,
|
|
52
|
+
dest: Path,
|
|
53
|
+
size: int,
|
|
54
|
+
sha256: str,
|
|
55
|
+
jobs: int = 8,
|
|
56
|
+
chunk_size: int = DEFAULT_CHUNK,
|
|
57
|
+
progress: Optional[Callable[[str], None]] = None,
|
|
58
|
+
) -> Path:
|
|
59
|
+
"""Fetch one file from any of base_urls (base + '/' + relpath), verify sha256."""
|
|
60
|
+
dest = Path(dest)
|
|
61
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
if dest.exists() and dest.stat().st_size == size and sha256_file(dest) == sha256:
|
|
63
|
+
return dest # already have it, verified
|
|
64
|
+
|
|
65
|
+
urls = [f"{base.rstrip('/')}/{relpath}" for base in base_urls]
|
|
66
|
+
parts_dir = dest.parent / (dest.name + ".parts")
|
|
67
|
+
parts_dir.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
|
|
69
|
+
n_chunks = max(1, (size + chunk_size - 1) // chunk_size)
|
|
70
|
+
todo = []
|
|
71
|
+
for i in range(n_chunks):
|
|
72
|
+
start = i * chunk_size
|
|
73
|
+
end = min(size - 1, start + chunk_size - 1)
|
|
74
|
+
part = parts_dir / f"{i:06d}"
|
|
75
|
+
if part.exists() and part.stat().st_size == end - start + 1:
|
|
76
|
+
continue # resume: chunk already done
|
|
77
|
+
todo.append((i, start, end, part))
|
|
78
|
+
|
|
79
|
+
def work(t):
|
|
80
|
+
i, start, end, part = t
|
|
81
|
+
last_err: Optional[Exception] = None
|
|
82
|
+
for url in urls:
|
|
83
|
+
try:
|
|
84
|
+
_fetch_range(url, start, end, part)
|
|
85
|
+
return i
|
|
86
|
+
except Exception as e: # try next mirror
|
|
87
|
+
last_err = e
|
|
88
|
+
raise DownloadError(f"{relpath} chunk {i}: all mirrors failed ({last_err})")
|
|
89
|
+
|
|
90
|
+
done = n_chunks - len(todo)
|
|
91
|
+
if todo:
|
|
92
|
+
with cf.ThreadPoolExecutor(max_workers=jobs) as ex:
|
|
93
|
+
for _ in ex.map(work, todo):
|
|
94
|
+
done += 1
|
|
95
|
+
if progress:
|
|
96
|
+
progress(f" {relpath}: {done}/{n_chunks} chunks")
|
|
97
|
+
|
|
98
|
+
tmp = dest.with_name(dest.name + ".tmp")
|
|
99
|
+
h = hashlib.sha256()
|
|
100
|
+
with open(tmp, "wb") as out:
|
|
101
|
+
for i in range(n_chunks):
|
|
102
|
+
with open(parts_dir / f"{i:06d}", "rb") as p:
|
|
103
|
+
while True:
|
|
104
|
+
block = p.read(8 * 1024 * 1024)
|
|
105
|
+
if not block:
|
|
106
|
+
break
|
|
107
|
+
h.update(block)
|
|
108
|
+
out.write(block)
|
|
109
|
+
if h.hexdigest() != sha256:
|
|
110
|
+
tmp.unlink(missing_ok=True)
|
|
111
|
+
shutil.rmtree(parts_dir, ignore_errors=True)
|
|
112
|
+
raise DownloadError(f"{relpath}: sha256 mismatch (expected {sha256})")
|
|
113
|
+
if tmp.stat().st_size != size:
|
|
114
|
+
tmp.unlink(missing_ok=True)
|
|
115
|
+
raise DownloadError(f"{relpath}: size mismatch (expected {size})")
|
|
116
|
+
tmp.replace(dest)
|
|
117
|
+
shutil.rmtree(parts_dir, ignore_errors=True)
|
|
118
|
+
return dest
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def download_torrent(uri_or_file: str, dest_dir: Path) -> None:
|
|
122
|
+
"""Fetch a torrent (magnet URI, .torrent URL, or local .torrent path) into
|
|
123
|
+
dest_dir using aria2c. Seeding is left to the user.
|
|
124
|
+
|
|
125
|
+
Prefer .torrent files over bare magnets when web seeds are the only
|
|
126
|
+
guaranteed source: a magnet still needs a reachable peer to fetch the
|
|
127
|
+
metadata, a .torrent plus a web seed works with zero peers.
|
|
128
|
+
"""
|
|
129
|
+
if not shutil.which("aria2c"):
|
|
130
|
+
raise DownloadError(
|
|
131
|
+
"torrent downloads need aria2c (brew install aria2 / apt install aria2), "
|
|
132
|
+
"or retry with --method http"
|
|
133
|
+
)
|
|
134
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
subprocess.run(
|
|
136
|
+
["aria2c", "--seed-time=0", "--summary-interval=30", "-d", str(dest_dir), uri_or_file],
|
|
137
|
+
check=True,
|
|
138
|
+
)
|
unmuzzle/hfcache.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Install downloaded files into the Hugging Face hub cache layout.
|
|
2
|
+
|
|
3
|
+
Once installed, `from_pretrained("org/name", local_files_only=True)` loads
|
|
4
|
+
the model with zero changes to user code. Blobs are content-addressed by
|
|
5
|
+
sha256, snapshots symlink (or copy) into blobs, refs/main points at the
|
|
6
|
+
revision.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def hf_cache_root() -> Path:
|
|
17
|
+
return Path(os.environ.get("HF_HOME", Path.home() / ".cache" / "huggingface")) / "hub"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def model_cache_dir(model_id: str) -> Path:
|
|
21
|
+
return hf_cache_root() / f"models--{model_id.replace('/', '--')}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def install(model_id: str, revision: str, staging: Path, files: List[dict]) -> Path:
|
|
25
|
+
"""Link files from staging into the HF cache. Returns the snapshot path."""
|
|
26
|
+
staging = Path(staging)
|
|
27
|
+
cache = model_cache_dir(model_id)
|
|
28
|
+
blobs = cache / "blobs"
|
|
29
|
+
snapshot = cache / "snapshots" / revision
|
|
30
|
+
refs = cache / "refs"
|
|
31
|
+
for d in (blobs, snapshot, refs):
|
|
32
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
|
|
34
|
+
for f in files:
|
|
35
|
+
src = staging / f["path"]
|
|
36
|
+
if not src.exists():
|
|
37
|
+
raise FileNotFoundError(f"missing staged file {src}")
|
|
38
|
+
blob = blobs / f["sha256"]
|
|
39
|
+
if not blob.exists():
|
|
40
|
+
try:
|
|
41
|
+
os.link(src, blob)
|
|
42
|
+
except OSError:
|
|
43
|
+
shutil.copy2(src, blob)
|
|
44
|
+
link = snapshot / f["path"]
|
|
45
|
+
link.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
if link.is_symlink() or link.exists():
|
|
47
|
+
link.unlink()
|
|
48
|
+
try:
|
|
49
|
+
os.symlink(os.path.relpath(blob, link.parent), link)
|
|
50
|
+
except OSError: # filesystem without symlinks
|
|
51
|
+
shutil.copy2(blob, link)
|
|
52
|
+
|
|
53
|
+
(refs / "main").write_text(revision)
|
|
54
|
+
return snapshot
|
unmuzzle/index.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Index loading, validation, and manifest canonicalization.
|
|
2
|
+
|
|
3
|
+
The index is a single JSON document (see SPEC.md). It can live anywhere:
|
|
4
|
+
a git repo, static hosting, IPFS. Clients fetch it over HTTPS or read it
|
|
5
|
+
from a local path.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import urllib.request
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import List, Optional
|
|
16
|
+
|
|
17
|
+
DEFAULT_INDEX = (
|
|
18
|
+
"https://raw.githubusercontent.com/zengjiajun0623/unmuzzle-hub/main/index/index.json"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IndexError(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ModelEntry:
|
|
28
|
+
name: str # "org/name"
|
|
29
|
+
description: str = ""
|
|
30
|
+
base_model: str = ""
|
|
31
|
+
license: str = ""
|
|
32
|
+
tags: List[str] = field(default_factory=list)
|
|
33
|
+
files: List[dict] = field(default_factory=list) # {path, size, sha256}
|
|
34
|
+
http: List[str] = field(default_factory=list) # base URLs, file fetched from base + "/" + path
|
|
35
|
+
magnet: Optional[str] = None
|
|
36
|
+
torrent: Optional[str] = None # URL of the .torrent file (works with zero peers via web seeds)
|
|
37
|
+
publisher_pubkey: Optional[str] = None
|
|
38
|
+
signature: Optional[str] = None # minisign signature over canonical_manifest()
|
|
39
|
+
added: str = ""
|
|
40
|
+
raw: dict = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def total_size(self) -> int:
|
|
44
|
+
return sum(f["size"] for f in self.files)
|
|
45
|
+
|
|
46
|
+
def canonical_manifest(self) -> str:
|
|
47
|
+
"""Deterministic string that gets signed. One 'sha256 path' line per file, sorted."""
|
|
48
|
+
lines = sorted(f'{f["sha256"]} {f["path"]}' for f in self.files)
|
|
49
|
+
return "\n".join(lines) + "\n"
|
|
50
|
+
|
|
51
|
+
def revision(self) -> str:
|
|
52
|
+
"""Content-addressed stand-in for an HF commit sha."""
|
|
53
|
+
return hashlib.sha256(self.canonical_manifest().encode()).hexdigest()[:16]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def validate_entry(d: dict) -> ModelEntry:
|
|
57
|
+
if not isinstance(d, dict):
|
|
58
|
+
raise IndexError("entry is not an object")
|
|
59
|
+
name = d.get("name")
|
|
60
|
+
if not name or "/" not in name:
|
|
61
|
+
raise IndexError(f"bad model name: {name!r} (expected 'org/name')")
|
|
62
|
+
files = d.get("files")
|
|
63
|
+
if not files:
|
|
64
|
+
raise IndexError(f"{name}: no files")
|
|
65
|
+
for f in files:
|
|
66
|
+
for key in ("path", "size", "sha256"):
|
|
67
|
+
if key not in f:
|
|
68
|
+
raise IndexError(f"{name}: file entry missing {key!r}: {f!r}")
|
|
69
|
+
parts = Path(f["path"]).parts
|
|
70
|
+
if f["path"].startswith("/") or ".." in parts:
|
|
71
|
+
raise IndexError(f"{name}: unsafe path {f['path']!r}")
|
|
72
|
+
if not isinstance(f["size"], int) or f["size"] < 0:
|
|
73
|
+
raise IndexError(f"{name}: bad size for {f['path']!r}")
|
|
74
|
+
if len(f["sha256"]) != 64:
|
|
75
|
+
raise IndexError(f"{name}: bad sha256 for {f['path']!r}")
|
|
76
|
+
mirrors = d.get("mirrors") or {}
|
|
77
|
+
entry = ModelEntry(
|
|
78
|
+
name=name,
|
|
79
|
+
description=d.get("description", ""),
|
|
80
|
+
base_model=d.get("base_model", ""),
|
|
81
|
+
license=d.get("license", ""),
|
|
82
|
+
tags=list(d.get("tags", [])),
|
|
83
|
+
files=list(files),
|
|
84
|
+
http=list(mirrors.get("http", [])),
|
|
85
|
+
magnet=mirrors.get("magnet"),
|
|
86
|
+
torrent=mirrors.get("torrent"),
|
|
87
|
+
publisher_pubkey=d.get("publisher_pubkey"),
|
|
88
|
+
signature=d.get("signature"),
|
|
89
|
+
added=d.get("added", ""),
|
|
90
|
+
raw=d,
|
|
91
|
+
)
|
|
92
|
+
if not entry.http and not entry.magnet and not entry.torrent:
|
|
93
|
+
raise IndexError(f"{name}: entry has no mirrors")
|
|
94
|
+
return entry
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_index(source: Optional[str] = None) -> List[ModelEntry]:
|
|
98
|
+
source = source or os.environ.get("UNMUZZLE_INDEX") or DEFAULT_INDEX
|
|
99
|
+
if source.startswith(("http://", "https://")):
|
|
100
|
+
with urllib.request.urlopen(source, timeout=30) as r:
|
|
101
|
+
data = json.loads(r.read().decode())
|
|
102
|
+
else:
|
|
103
|
+
data = json.loads(Path(source).read_text())
|
|
104
|
+
entries = data.get("models") if isinstance(data, dict) else data
|
|
105
|
+
return [validate_entry(e) for e in entries]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def find_model(entries: List[ModelEntry], name: str) -> Optional[ModelEntry]:
|
|
109
|
+
for e in entries:
|
|
110
|
+
if e.name == name:
|
|
111
|
+
return e
|
|
112
|
+
# allow shorthand without org if unambiguous
|
|
113
|
+
matches = [e for e in entries if e.name.split("/", 1)[1] == name]
|
|
114
|
+
if len(matches) == 1:
|
|
115
|
+
return matches[0]
|
|
116
|
+
return None
|
unmuzzle/mcp_server.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""MCP server: expose unmuzzle to any MCP-capable agent.
|
|
2
|
+
|
|
3
|
+
Install: pip install 'unmuzzle[mcp]'
|
|
4
|
+
Run: unmuzzle-mcp (stdio transport)
|
|
5
|
+
|
|
6
|
+
Claude Code: claude mcp add unmuzzle -- unmuzzle-mcp
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from . import api
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
try:
|
|
18
|
+
from mcp.server.fastmcp import FastMCP
|
|
19
|
+
except ImportError:
|
|
20
|
+
raise SystemExit("MCP support needs: pip install 'unmuzzle[mcp]'")
|
|
21
|
+
|
|
22
|
+
mcp = FastMCP("unmuzzle")
|
|
23
|
+
|
|
24
|
+
@mcp.tool()
|
|
25
|
+
def list_models(tag: Optional[str] = None, index: Optional[str] = None) -> str:
|
|
26
|
+
"""List open-weight models in the unmuzzle index. Optionally filter by tag."""
|
|
27
|
+
return json.dumps(api.list_models(tag=tag, index=index))
|
|
28
|
+
|
|
29
|
+
@mcp.tool()
|
|
30
|
+
def model_info(name: str, index: Optional[str] = None) -> str:
|
|
31
|
+
"""Show files, sizes, sha256, mirrors, and signature status for a model (org/name)."""
|
|
32
|
+
return json.dumps(api.model_info(name, index=index))
|
|
33
|
+
|
|
34
|
+
@mcp.tool()
|
|
35
|
+
def get_model(
|
|
36
|
+
name: str,
|
|
37
|
+
dest: Optional[str] = None,
|
|
38
|
+
method: str = "auto",
|
|
39
|
+
require_signature: bool = True,
|
|
40
|
+
index: Optional[str] = None,
|
|
41
|
+
) -> str:
|
|
42
|
+
"""Download a model, verify sha256 + signature, and install it into the
|
|
43
|
+
Hugging Face cache (default) or a plain directory (dest). method is
|
|
44
|
+
auto, http, or torrent."""
|
|
45
|
+
return json.dumps(api.get(name, dest=dest, method=method,
|
|
46
|
+
require_signature=require_signature, index=index))
|
|
47
|
+
|
|
48
|
+
@mcp.tool()
|
|
49
|
+
def publish_model(
|
|
50
|
+
directory: str,
|
|
51
|
+
name: str,
|
|
52
|
+
http_bases: Optional[List[str]] = None,
|
|
53
|
+
magnet: Optional[str] = None,
|
|
54
|
+
torrent_url: Optional[str] = None,
|
|
55
|
+
description: str = "",
|
|
56
|
+
base_model: str = "",
|
|
57
|
+
license: str = "",
|
|
58
|
+
tags: Optional[List[str]] = None,
|
|
59
|
+
sign_key: Optional[str] = None,
|
|
60
|
+
index_dir: str = "index",
|
|
61
|
+
) -> str:
|
|
62
|
+
"""Hash a local model directory, sign the manifest, and add it to a
|
|
63
|
+
local index. Upload the files to the mirror(s) yourself first or right
|
|
64
|
+
after; the entry records where they live."""
|
|
65
|
+
return json.dumps(api.publish(directory, name, http_bases=http_bases,
|
|
66
|
+
magnet=magnet, torrent_url=torrent_url,
|
|
67
|
+
description=description,
|
|
68
|
+
base_model=base_model, license=license,
|
|
69
|
+
tags=tags, sign_key=sign_key, index_dir=index_dir))
|
|
70
|
+
|
|
71
|
+
@mcp.tool()
|
|
72
|
+
def verify_model(name: str, index: Optional[str] = None) -> str:
|
|
73
|
+
"""Re-hash an installed model against the signed index manifest."""
|
|
74
|
+
return json.dumps(api.verify(name, index=index))
|
|
75
|
+
|
|
76
|
+
mcp.run()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
main()
|
unmuzzle/publish.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Build index entries from a local model directory (`unmuzzle publish`)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from datetime import date
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from .download import sha256_file
|
|
10
|
+
from .index import validate_entry
|
|
11
|
+
from . import sign as signmod
|
|
12
|
+
|
|
13
|
+
SKIP_NAMES = {".DS_Store"}
|
|
14
|
+
SKIP_SUFFIXES = (".parts", ".tmp")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _iter_model_files(root: Path):
|
|
18
|
+
for p in sorted(root.rglob("*")):
|
|
19
|
+
if not p.is_file():
|
|
20
|
+
continue
|
|
21
|
+
rel = p.relative_to(root).as_posix()
|
|
22
|
+
if p.name in SKIP_NAMES or any(rel.endswith(s) for s in SKIP_SUFFIXES):
|
|
23
|
+
continue
|
|
24
|
+
if any(part.startswith(".") for part in p.relative_to(root).parts):
|
|
25
|
+
continue
|
|
26
|
+
yield rel, p
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_entry(
|
|
30
|
+
directory: Path,
|
|
31
|
+
name: str,
|
|
32
|
+
http_bases: List[str],
|
|
33
|
+
magnet: Optional[str] = None,
|
|
34
|
+
torrent_url: Optional[str] = None,
|
|
35
|
+
description: str = "",
|
|
36
|
+
base_model: str = "",
|
|
37
|
+
license_: str = "",
|
|
38
|
+
tags: Optional[List[str]] = None,
|
|
39
|
+
secret_key: Optional[Path] = None,
|
|
40
|
+
pubkey: Optional[str] = None,
|
|
41
|
+
) -> dict:
|
|
42
|
+
directory = Path(directory)
|
|
43
|
+
files = []
|
|
44
|
+
for rel, p in _iter_model_files(directory):
|
|
45
|
+
files.append({"path": rel, "size": p.stat().st_size, "sha256": sha256_file(p)})
|
|
46
|
+
if not files:
|
|
47
|
+
raise ValueError(f"no model files found in {directory}")
|
|
48
|
+
|
|
49
|
+
entry = {
|
|
50
|
+
"name": name,
|
|
51
|
+
"description": description,
|
|
52
|
+
"base_model": base_model,
|
|
53
|
+
"license": license_,
|
|
54
|
+
"tags": tags or [],
|
|
55
|
+
"added": date.today().isoformat(),
|
|
56
|
+
"files": files,
|
|
57
|
+
"mirrors": {"http": http_bases, "magnet": magnet, "torrent": torrent_url},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if secret_key:
|
|
61
|
+
manifest = validate_entry(entry).canonical_manifest()
|
|
62
|
+
entry["signature"] = signmod.sign(manifest, Path(secret_key))
|
|
63
|
+
entry["publisher_pubkey"] = pubkey or signmod.pubkey_from_secret(Path(secret_key))
|
|
64
|
+
|
|
65
|
+
validate_entry(entry) # final sanity check
|
|
66
|
+
return entry
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def write_entry(index_dir: Path, entry: dict) -> Path:
|
|
70
|
+
"""Write one entry file and regenerate the combined index.json."""
|
|
71
|
+
index_dir = Path(index_dir)
|
|
72
|
+
models_dir = index_dir / "models"
|
|
73
|
+
models_dir.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
slug = entry["name"].replace("/", "__")
|
|
75
|
+
(models_dir / f"{slug}.json").write_text(json.dumps(entry, indent=2) + "\n")
|
|
76
|
+
regenerate(index_dir)
|
|
77
|
+
return models_dir / f"{slug}.json"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def regenerate(index_dir: Path) -> Path:
|
|
81
|
+
models_dir = Path(index_dir) / "models"
|
|
82
|
+
entries = []
|
|
83
|
+
if models_dir.exists():
|
|
84
|
+
for f in sorted(models_dir.glob("*.json")):
|
|
85
|
+
entries.append(json.loads(f.read_text()))
|
|
86
|
+
out = Path(index_dir) / "index.json"
|
|
87
|
+
out.write_text(json.dumps({"version": 1, "models": entries}, indent=2) + "\n")
|
|
88
|
+
return out
|
unmuzzle/sign.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""minisign-based signing of manifests.
|
|
2
|
+
|
|
3
|
+
The publisher signs the canonical manifest (sha256 + path per file) with
|
|
4
|
+
minisign (OpenBSD signify compatible). Clients verify before installing.
|
|
5
|
+
minisign is a single static binary: brew install minisign.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SignatureError(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def have_minisign() -> bool:
|
|
21
|
+
return shutil.which("minisign") is not None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pubkey_from_secret(secret_key: Path) -> str:
|
|
25
|
+
"""Derive the base64 public key from a secret key via `minisign -R`."""
|
|
26
|
+
with tempfile.TemporaryDirectory() as td:
|
|
27
|
+
pub = Path(td) / "key.pub"
|
|
28
|
+
subprocess.run(
|
|
29
|
+
["minisign", "-R", "-s", str(secret_key), "-p", str(pub)],
|
|
30
|
+
check=True, capture_output=True,
|
|
31
|
+
)
|
|
32
|
+
lines = pub.read_text().splitlines()
|
|
33
|
+
for line in lines:
|
|
34
|
+
line = line.strip()
|
|
35
|
+
if line and not line.startswith("untrusted comment"):
|
|
36
|
+
return line
|
|
37
|
+
raise SignatureError(f"could not parse pubkey from {pub!r}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def sign(manifest: str, secret_key: Path) -> str:
|
|
41
|
+
with tempfile.TemporaryDirectory() as td:
|
|
42
|
+
msg = Path(td) / "manifest.txt"
|
|
43
|
+
sig = Path(td) / "manifest.txt.minisig"
|
|
44
|
+
msg.write_text(manifest)
|
|
45
|
+
subprocess.run(
|
|
46
|
+
["minisign", "-S", "-s", str(secret_key), "-x", str(sig), "-m", str(msg)],
|
|
47
|
+
check=True, capture_output=True,
|
|
48
|
+
)
|
|
49
|
+
return sig.read_text()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def verify(manifest: str, signature: str, pubkey_b64: str) -> bool:
|
|
53
|
+
if not have_minisign():
|
|
54
|
+
raise SignatureError("minisign not installed, cannot verify signature")
|
|
55
|
+
with tempfile.TemporaryDirectory() as td:
|
|
56
|
+
msg = Path(td) / "manifest.txt"
|
|
57
|
+
sig = Path(td) / "manifest.txt.minisig"
|
|
58
|
+
msg.write_text(manifest)
|
|
59
|
+
sig.write_text(signature)
|
|
60
|
+
r = subprocess.run(
|
|
61
|
+
["minisign", "-V", "-P", pubkey_b64, "-x", str(sig), "-m", str(msg)],
|
|
62
|
+
capture_output=True,
|
|
63
|
+
)
|
|
64
|
+
return r.returncode == 0
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unmuzzle
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Censorship-resistant distribution for open-weight models. BitTorrent + HTTP mirrors, signed checksums, Hugging Face cache compatible.
|
|
5
|
+
Author: Jiajun Zeng
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: llm,open-weights,bittorrent,distribution,censorship-resistant
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
13
|
+
Provides-Extra: mcp
|
|
14
|
+
Requires-Dist: mcp>=1.2; extra == "mcp"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# unmuzzle
|
|
18
|
+
|
|
19
|
+
[](https://github.com/zengjiajun0623/unmuzzle-hub/actions/workflows/e2e-verify.yml)
|
|
20
|
+
|
|
21
|
+
Agent-native distribution for open-weight models.
|
|
22
|
+
|
|
23
|
+
AI agents are the users here. `unmuzzle` is built so an agent (Claude Code,
|
|
24
|
+
Codex, Kimi Code, any MCP client) can publish and download models end to end
|
|
25
|
+
with no human in the loop: plain-JSON index, `--json` on every command, an MCP
|
|
26
|
+
server, and no gates, auth flows, or license click-throughs that only a human
|
|
27
|
+
in a browser can complete.
|
|
28
|
+
|
|
29
|
+
It is also censorship-resistant by construction:
|
|
30
|
+
|
|
31
|
+
- **The index is plain JSON in a git repo.** Anyone can mirror or fork it.
|
|
32
|
+
- **Weights move over BitTorrent (web-seeded) and HTTP mirrors.** Web seeds
|
|
33
|
+
keep every torrent alive at zero peers; no central server to block or bill.
|
|
34
|
+
- **Every file is sha256-pinned and the manifest is minisign-signed.** The
|
|
35
|
+
signature, not the host, is the trust root. Any static host can be a mirror.
|
|
36
|
+
- **Installs land in the Hugging Face cache layout**, so `transformers` loads
|
|
37
|
+
them with zero code changes.
|
|
38
|
+
|
|
39
|
+
Zero dependencies. Python 3.9+.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install git+https://github.com/zengjiajun0623/unmuzzle-hub.git
|
|
45
|
+
# optional: aria2 for torrents, minisign for signature verification
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## For agents
|
|
49
|
+
|
|
50
|
+
Read [AGENTS.md](AGENTS.md). It is the complete publish/download protocol.
|
|
51
|
+
Or add the MCP server:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install 'unmuzzle[mcp]'
|
|
55
|
+
claude mcp add unmuzzle -- unmuzzle-mcp
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## For humans
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
unmuzzle list
|
|
62
|
+
unmuzzle info unmuzzle/qwen2.5-14b-abliterated
|
|
63
|
+
unmuzzle get unmuzzle/qwen2.5-14b-abliterated --require-signature --dest ./models
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Every command takes `--json`.
|
|
67
|
+
|
|
68
|
+
## First release: unmuzzle/qwen2.5-14b-abliterated
|
|
69
|
+
|
|
70
|
+
Qwen2.5-14B-Instruct with the CCP-censorship refusal direction weight-baked
|
|
71
|
+
out (GGUF Q4_K_M, ~9 GB, Apache-2.0). Answers factually on Tiananmen, Xinjiang,
|
|
72
|
+
Taiwan, and similar topics. Runs in Ollama/llama.cpp on a 16 GB machine:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
unmuzzle get unmuzzle/qwen2.5-14b-abliterated --require-signature --dest ./models
|
|
76
|
+
ollama create unmuzzle-qwen14b -f Modelfile # Modelfile in the HF mirror repo
|
|
77
|
+
ollama run unmuzzle-qwen14b
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Mirrors: Hugging Face HTTP + web-seeded torrent. Signed with the unmuzzle
|
|
81
|
+
minisign key.
|
|
82
|
+
|
|
83
|
+
## Publish a model
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
unmuzzle keygen # one-time signing identity
|
|
87
|
+
unmuzzle publish ./mymodel \
|
|
88
|
+
--name org/mymodel \
|
|
89
|
+
--http-base https://your-mirror/mymodel \
|
|
90
|
+
--torrent-url https://your-mirror/mymodel.torrent \
|
|
91
|
+
--magnet "magnet:?xt=..." \
|
|
92
|
+
--sign-key ~/.minisign/unmuzzle.key
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Then PR your `index/models/*.json` into this repo, or host your own index and
|
|
96
|
+
point clients at it with `--index` / `$UNMUZZLE_INDEX`. The full recipe,
|
|
97
|
+
including torrent creation with web seeds, is in [AGENTS.md](AGENTS.md).
|
|
98
|
+
Format and threat model: [SPEC.md](SPEC.md).
|
|
99
|
+
|
|
100
|
+
## Why not just Hugging Face / a mirror / a torrent site?
|
|
101
|
+
|
|
102
|
+
- **HF** gates models behind human browser flows and can take them down
|
|
103
|
+
(ERNIE ViLG, GEITje). In mainland China the site itself is blocked, which is
|
|
104
|
+
why hf-mirror.com exists. Right default for ordinary models, wrong single
|
|
105
|
+
point of failure for models someone wants suppressed, and unusable for
|
|
106
|
+
agents hitting gated repos.
|
|
107
|
+
- **Mirrors** (hf-mirror, ModelScope) reintroduce a single operator who can
|
|
108
|
+
censor, and you cannot verify what they serve back to you.
|
|
109
|
+
- **A bare torrent** has no discovery, no publisher identity, and dies at zero
|
|
110
|
+
seeds. unmuzzle adds the index, per-file sha256, publisher signatures, and
|
|
111
|
+
web seeds on top.
|
|
112
|
+
|
|
113
|
+
## Roadmap
|
|
114
|
+
|
|
115
|
+
- [x] first real release (unmuzzle/qwen2.5-14b-abliterated)
|
|
116
|
+
- [x] MCP server for agent-native publish/download
|
|
117
|
+
- [ ] standalone registry site generated from the index
|
|
118
|
+
- [ ] DHT-native peer discovery, built-in torrent client (drop aria2c)
|
|
119
|
+
- [ ] index federation: subscribe to multiple publisher indexes
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
unmuzzle/__init__.py,sha256=n2fVjIxguuZSNrcgYo6TTBxZXUDFp8ZJ1OuxK3cCtTQ,97
|
|
2
|
+
unmuzzle/api.py,sha256=shXaHcc8HQQuwkQiWknFPWMPFdF_eD2im86h5rAbw8A,7627
|
|
3
|
+
unmuzzle/cli.py,sha256=ONEDexvbHBworjd7fCZAvB-s18ClyGgE2rspLzrDwrA,7382
|
|
4
|
+
unmuzzle/download.py,sha256=0BVYDLdUcPlCLzJs7BjPyQ9XoLIx-3bzGVr3_KHK_SM,4968
|
|
5
|
+
unmuzzle/hfcache.py,sha256=Z9BLt5l6oFmyeK-hHn-S7B6vewzHhQFJf0XtPSVFztc,1797
|
|
6
|
+
unmuzzle/index.py,sha256=02rsW61lB0MMxjP1A5Aeu8_13eFJIL_Pt4WM6Bmnuhw,4209
|
|
7
|
+
unmuzzle/mcp_server.py,sha256=MGnd1ys5EcDhjxkQB59UR--Hm-CNnUNVAbNKuTE0UqU,2862
|
|
8
|
+
unmuzzle/publish.py,sha256=Iwll56PdIu7DPFHXzI5-8w5udnALeFQF99Muv-kDSVA,2824
|
|
9
|
+
unmuzzle/sign.py,sha256=TpzU0U7jzrMrN_5GGPDDpH6GJUVYJ_PeyTOxYCNAqJ4,2096
|
|
10
|
+
unmuzzle-0.1.0.dist-info/licenses/LICENSE,sha256=CIzTP7_6jQBHtFLMP1Nl_Huw43O2g56fO4Hx6UwxDEc,1068
|
|
11
|
+
unmuzzle-0.1.0.dist-info/METADATA,sha256=urB7w6lLxnNJlDgRILLGwbl25s1-6cWgFcusq2gD1pA,4448
|
|
12
|
+
unmuzzle-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
13
|
+
unmuzzle-0.1.0.dist-info/entry_points.txt,sha256=JFhw31dwwapKpkXHpkboGKw_eUlngUgTnnhsjrH9Cv4,87
|
|
14
|
+
unmuzzle-0.1.0.dist-info/top_level.txt,sha256=3AK_aVinNJgrjkyrXhQHyoXYC_Kcn92RJNbpFLh6aWQ,9
|
|
15
|
+
unmuzzle-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jiajun Zeng
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
unmuzzle
|