notebook-llm-cli 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.
- notebook_llm/__init__.py +12 -0
- notebook_llm/__main__.py +3 -0
- notebook_llm/cli.py +422 -0
- notebook_llm/client.py +83 -0
- notebook_llm/config.py +32 -0
- notebook_llm/env.py +9 -0
- notebook_llm/estimate.py +128 -0
- notebook_llm/estimate_data.py +38 -0
- notebook_llm/gpu.py +127 -0
- notebook_llm/installer.py +67 -0
- notebook_llm/library.py +212 -0
- notebook_llm/manager.py +169 -0
- notebook_llm/server.py +101 -0
- notebook_llm/tunnel.py +63 -0
- notebook_llm/ui.py +71 -0
- notebook_llm_cli-0.1.0.dist-info/METADATA +79 -0
- notebook_llm_cli-0.1.0.dist-info/RECORD +21 -0
- notebook_llm_cli-0.1.0.dist-info/WHEEL +5 -0
- notebook_llm_cli-0.1.0.dist-info/entry_points.txt +3 -0
- notebook_llm_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- notebook_llm_cli-0.1.0.dist-info/top_level.txt +1 -0
notebook_llm/estimate.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Will it fit? How fast will it run? (approximate, memory-bandwidth model)"""
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from .estimate_data import BYTES_PER_PARAM, MOE
|
|
7
|
+
|
|
8
|
+
GPU_EFF = 0.65 # fraction of peak bandwidth llama.cpp typically reaches
|
|
9
|
+
MOE_EFF = 0.70 # extra penalty for expert routing
|
|
10
|
+
CPU_BW_GBS = 15.0 # effective system-RAM bandwidth for offloaded layers
|
|
11
|
+
OVERHEAD_PER_GPU = 0.7 # CUDA context + compute buffers (GB)
|
|
12
|
+
KV_FACTORS = {"f16": 1.0, "q8_0": 0.53, "q4_0": 0.28}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Estimate:
|
|
17
|
+
verdict: str # fits | tight | offload | no | cpu
|
|
18
|
+
needed_gb: float
|
|
19
|
+
weights_gb: float
|
|
20
|
+
kv_gb: float
|
|
21
|
+
overhead_gb: float
|
|
22
|
+
gpu_fraction: float # share of weights that sit in VRAM
|
|
23
|
+
tok_s: Optional[float]
|
|
24
|
+
context: int
|
|
25
|
+
note: str = ""
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def label(self) -> str:
|
|
29
|
+
return {"fits": "FITS", "tight": "TIGHT", "offload": "SLOW (CPU offload)",
|
|
30
|
+
"no": "TOO BIG", "cpu": "CPU ONLY"}[self.verdict]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_params(name: str, tag: str) -> Tuple[Optional[float], Optional[float]]:
|
|
34
|
+
"""-> (total params in B, active params in B or None for dense)."""
|
|
35
|
+
key = f"{name}:{tag}"
|
|
36
|
+
base = f"{name}:{tag.split('-')[0]}"
|
|
37
|
+
for k in (key, base):
|
|
38
|
+
if k in MOE:
|
|
39
|
+
return MOE[k]
|
|
40
|
+
active = None
|
|
41
|
+
m = re.search(r"-a(\d+(?:\.\d+)?)b", tag)
|
|
42
|
+
if m:
|
|
43
|
+
active = float(m.group(1))
|
|
44
|
+
mx = re.match(r"(\d+)x(\d+(?:\.\d+)?)b", tag)
|
|
45
|
+
if mx:
|
|
46
|
+
return float(mx.group(1)) * float(mx.group(2)), active
|
|
47
|
+
m = re.match(r"e?(\d+(?:\.\d+)?)([bm])", tag)
|
|
48
|
+
if m:
|
|
49
|
+
v = float(m.group(1)) * (0.001 if m.group(2) == "m" else 1)
|
|
50
|
+
return v, active
|
|
51
|
+
return None, active
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def bytes_per_param(tag: str) -> float:
|
|
55
|
+
t = tag.lower()
|
|
56
|
+
for key, v in BYTES_PER_PARAM:
|
|
57
|
+
if key in t:
|
|
58
|
+
return v
|
|
59
|
+
return 0.61 # Ollama default tags are ~Q4_K_M
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def guess_size_gb(params_b: float, tag: str) -> float:
|
|
63
|
+
return round(params_b * bytes_per_param(tag), 2)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def kv_cache_gb(params_b: float, ctx: int, kv_type: str = "f16") -> float:
|
|
67
|
+
per_token_mb = 0.03 * (max(params_b, 0.3) ** 0.85) * KV_FACTORS.get(kv_type, 1.0)
|
|
68
|
+
return per_token_mb * ctx / 1024
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _overhead(hw) -> float:
|
|
72
|
+
return OVERHEAD_PER_GPU * max(1, hw.gpu.count)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def auto_context(size_gb: float, params_b: float, hw, kv_type: str = "q8_0") -> int:
|
|
76
|
+
if not hw.gpu.available:
|
|
77
|
+
return 4096
|
|
78
|
+
room = hw.gpu.total_vram_gb * 0.92 - size_gb - _overhead(hw)
|
|
79
|
+
for ctx in (32768, 16384, 8192, 4096):
|
|
80
|
+
if kv_cache_gb(params_b, ctx, kv_type) <= 0.7 * room:
|
|
81
|
+
return ctx
|
|
82
|
+
return 4096
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def estimate(size_gb: float, params_b: Optional[float], active_b: Optional[float],
|
|
86
|
+
hw, ctx: int, kv_type: str = "q8_0") -> Estimate:
|
|
87
|
+
params = params_b or size_gb / 0.61
|
|
88
|
+
kv = kv_cache_gb(params, ctx, kv_type)
|
|
89
|
+
moe = bool(active_b and params and active_b < params * 0.8)
|
|
90
|
+
active_bytes = size_gb * (active_b / params) if moe else size_gb
|
|
91
|
+
eff_boost = MOE_EFF if moe else 1.0
|
|
92
|
+
gpu = hw.gpu
|
|
93
|
+
|
|
94
|
+
if not gpu.available:
|
|
95
|
+
needed = size_gb + kv + 0.5
|
|
96
|
+
t = active_bytes / (CPU_BW_GBS * eff_boost) + 0.003
|
|
97
|
+
verdict = "cpu" if needed <= hw.ram_gb * 0.8 else "no"
|
|
98
|
+
return Estimate(verdict, needed, size_gb, kv, 0.5, 0.0, 1 / t if verdict == "cpu" else None,
|
|
99
|
+
ctx, "No GPU found: running from system RAM is slow.")
|
|
100
|
+
|
|
101
|
+
overhead = _overhead(hw)
|
|
102
|
+
needed = size_gb + kv + overhead
|
|
103
|
+
vram = gpu.total_vram_gb
|
|
104
|
+
note = ""
|
|
105
|
+
if needed <= vram * 0.92:
|
|
106
|
+
verdict = "fits"
|
|
107
|
+
elif needed <= vram:
|
|
108
|
+
verdict, note = "tight", "Barely fits: lower context or use a smaller quant if you hit OOM."
|
|
109
|
+
elif needed <= vram + hw.ram_gb * 0.7:
|
|
110
|
+
verdict, note = "offload", "Part of the model spills to system RAM, so speed drops a lot."
|
|
111
|
+
else:
|
|
112
|
+
verdict, note = "no", "Not enough VRAM + RAM."
|
|
113
|
+
|
|
114
|
+
weights_on_gpu = max(0.0, min(size_gb, vram * 0.95 - overhead - kv))
|
|
115
|
+
frac = weights_on_gpu / size_gb if size_gb else 1.0
|
|
116
|
+
if verdict == "no":
|
|
117
|
+
tok = None
|
|
118
|
+
else:
|
|
119
|
+
t = 0.0
|
|
120
|
+
for g in gpu.gpus: # layers split by VRAM share; GPUs run in sequence
|
|
121
|
+
share = g.memory_total_mb / (vram * 1024)
|
|
122
|
+
t += active_bytes * frac * share / (g.bandwidth_gbs * GPU_EFF * eff_boost)
|
|
123
|
+
t += active_bytes * (1 - frac) / (CPU_BW_GBS * eff_boost)
|
|
124
|
+
t += 0.002 + 0.001 * (gpu.count - 1)
|
|
125
|
+
tok = 1 / t
|
|
126
|
+
if tok is not None and not all(g.bandwidth_known for g in gpu.gpus):
|
|
127
|
+
note = (note + " GPU model unknown: speed is a rough guess.").strip()
|
|
128
|
+
return Estimate(verdict, needed, size_gb, kv, overhead, frac, tok, ctx, note)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Static lookup tables (kept separate so gpu.py and estimate.py don't import each other)."""
|
|
2
|
+
|
|
3
|
+
# (substring, memory bandwidth GB/s). First match wins -> keep specific names first.
|
|
4
|
+
_GPU_BW = [
|
|
5
|
+
("b200", 8000), ("h200", 4800), ("h100", 2400),
|
|
6
|
+
("a100", 1555), ("l40", 864), ("a6000", 768), ("a5000", 768), ("a4000", 448),
|
|
7
|
+
("a10g", 600), ("a10", 600), ("v100", 900), ("p100", 732), ("l4", 300), ("t4", 320),
|
|
8
|
+
("rtx 4090", 1008), ("rtx 4080", 717), ("rtx 4070 ti", 504), ("rtx 4070", 504),
|
|
9
|
+
("rtx 3090", 936), ("rtx 3080", 760), ("rtx 3070", 448), ("rtx 3060", 360),
|
|
10
|
+
("rtx 2080 ti", 616), ("rtx 2080", 448), ("rtx 2070", 448), ("gtx 1080 ti", 484),
|
|
11
|
+
("p4000", 243), ("p40", 346), ("k80", 240),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def gpu_bandwidth(name: str):
|
|
16
|
+
"""Returns (GB/s, known?)."""
|
|
17
|
+
n = name.lower()
|
|
18
|
+
if "a100" in n and "80gb" in n:
|
|
19
|
+
return 2039, True
|
|
20
|
+
for key, bw in _GPU_BW:
|
|
21
|
+
if key in n:
|
|
22
|
+
return bw, True
|
|
23
|
+
return 300, False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# MoE models: "name:tag" -> (total params B, active params B)
|
|
27
|
+
MOE = {
|
|
28
|
+
"qwen3-coder:30b": (30.5, 3.3), "qwen3:30b": (30.5, 3.3), "qwen3:235b": (235, 22),
|
|
29
|
+
"qwen3-vl:30b": (31, 3.3), "gpt-oss:20b": (21, 3.6), "gpt-oss:120b": (117, 5.1),
|
|
30
|
+
"mixtral:8x7b": (46.7, 12.9), "mixtral:8x22b": (141, 39),
|
|
31
|
+
"deepseek-coder-v2:16b": (15.7, 2.4), "deepseek-coder-v2:236b": (236, 21),
|
|
32
|
+
"deepseek-v3:671b": (671, 37), "deepseek-r1:671b": (671, 37),
|
|
33
|
+
"llama4:scout": (109, 17), "llama4:maverick": (400, 17),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Approx bytes per parameter for common GGUF quantizations.
|
|
37
|
+
BYTES_PER_PARAM = [("fp16", 2.0), ("f16", 2.0), ("q8", 1.07), ("q6", 0.82), ("q5", 0.72),
|
|
38
|
+
("q4", 0.61), ("q3", 0.48), ("q2", 0.37)]
|
notebook_llm/gpu.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Hardware detection: NVIDIA GPUs (nvidia-smi), system RAM, bandwidth lookup."""
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from .estimate_data import gpu_bandwidth
|
|
9
|
+
|
|
10
|
+
_QUERY = "index,name,memory.total,memory.free,driver_version"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class GPU:
|
|
15
|
+
index: int
|
|
16
|
+
name: str
|
|
17
|
+
memory_total_mb: int
|
|
18
|
+
memory_free_mb: int
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def vram_gb(self) -> float:
|
|
22
|
+
return self.memory_total_mb / 1024
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def bandwidth_gbs(self) -> float:
|
|
26
|
+
return gpu_bandwidth(self.name)[0]
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def bandwidth_known(self) -> bool:
|
|
30
|
+
return gpu_bandwidth(self.name)[1]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class GPUInfo:
|
|
35
|
+
gpus: List[GPU] = field(default_factory=list)
|
|
36
|
+
driver: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def count(self) -> int:
|
|
40
|
+
return len(self.gpus)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def available(self) -> bool:
|
|
44
|
+
return self.count > 0
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def total_vram_gb(self) -> float:
|
|
48
|
+
return round(sum(g.memory_total_mb for g in self.gpus) / 1024, 1)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def used_vram_gb(self) -> float:
|
|
52
|
+
return round(sum(g.memory_total_mb - g.memory_free_mb for g in self.gpus) / 1024, 1)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def indices(self) -> List[int]:
|
|
56
|
+
return [g.index for g in self.gpus]
|
|
57
|
+
|
|
58
|
+
def summary(self) -> str:
|
|
59
|
+
if not self.available:
|
|
60
|
+
return "No NVIDIA GPU detected (CPU mode)"
|
|
61
|
+
names = {}
|
|
62
|
+
for g in self.gpus:
|
|
63
|
+
names[g.name] = names.get(g.name, 0) + 1
|
|
64
|
+
label = " + ".join(f"{n}x {k}" for k, n in names.items())
|
|
65
|
+
return f"{label} | {self.total_vram_gb} GB VRAM"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Hardware:
|
|
70
|
+
gpu: GPUInfo
|
|
71
|
+
ram_gb: float
|
|
72
|
+
cpu_cores: int
|
|
73
|
+
|
|
74
|
+
def summary(self) -> str:
|
|
75
|
+
return f"{self.gpu.summary()} | {self.ram_gb:.0f} GB RAM | {self.cpu_cores} CPU cores"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_nvidia_smi(output: str) -> GPUInfo:
|
|
79
|
+
gpus, driver = [], None
|
|
80
|
+
for line in output.strip().splitlines():
|
|
81
|
+
parts = [p.strip() for p in line.split(",")]
|
|
82
|
+
if len(parts) < 5:
|
|
83
|
+
continue
|
|
84
|
+
try:
|
|
85
|
+
gpus.append(GPU(int(parts[0]), parts[1], int(float(parts[2])), int(float(parts[3]))))
|
|
86
|
+
driver = parts[4]
|
|
87
|
+
except ValueError:
|
|
88
|
+
continue
|
|
89
|
+
return GPUInfo(gpus, driver)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def detect_gpus() -> GPUInfo:
|
|
93
|
+
if not shutil.which("nvidia-smi"):
|
|
94
|
+
return GPUInfo()
|
|
95
|
+
try:
|
|
96
|
+
out = subprocess.run(
|
|
97
|
+
["nvidia-smi", f"--query-gpu={_QUERY}", "--format=csv,noheader,nounits"],
|
|
98
|
+
capture_output=True, text=True, timeout=20, check=True).stdout
|
|
99
|
+
except (subprocess.SubprocessError, OSError):
|
|
100
|
+
return GPUInfo()
|
|
101
|
+
return parse_nvidia_smi(out)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def system_ram_gb() -> float:
|
|
105
|
+
total = 0.0
|
|
106
|
+
try:
|
|
107
|
+
with open("/proc/meminfo") as f:
|
|
108
|
+
for line in f:
|
|
109
|
+
if line.startswith("MemTotal"):
|
|
110
|
+
total = int(line.split()[1]) / 1024 / 1024
|
|
111
|
+
break
|
|
112
|
+
except OSError:
|
|
113
|
+
pass
|
|
114
|
+
for p in ("/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory/memory.limit_in_bytes"):
|
|
115
|
+
try:
|
|
116
|
+
v = open(p).read().strip()
|
|
117
|
+
if v.isdigit():
|
|
118
|
+
lim = int(v) / 1024 ** 3
|
|
119
|
+
if 0 < lim < (total or 1e9):
|
|
120
|
+
total = lim
|
|
121
|
+
except OSError:
|
|
122
|
+
pass
|
|
123
|
+
return round(total, 1)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def detect_hardware() -> Hardware:
|
|
127
|
+
return Hardware(detect_gpus(), system_ram_gb(), os.cpu_count() or 1)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Install system deps, Ollama and cloudflared (idempotent)."""
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import shutil
|
|
5
|
+
import stat
|
|
6
|
+
import subprocess
|
|
7
|
+
import urllib.request
|
|
8
|
+
|
|
9
|
+
from .env import log
|
|
10
|
+
|
|
11
|
+
BIN_DIR = "/tmp/notebook_llm/bin"
|
|
12
|
+
CLOUDFLARED_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-{arch}"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _sh(cmd: str, check: bool = True) -> int:
|
|
16
|
+
return subprocess.run(cmd, shell=True, check=check,
|
|
17
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT).returncode
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ollama_installed() -> bool:
|
|
21
|
+
return bool(shutil.which("ollama"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cloudflared_path():
|
|
25
|
+
p = shutil.which("cloudflared")
|
|
26
|
+
if p:
|
|
27
|
+
return p
|
|
28
|
+
local = os.path.join(BIN_DIR, "cloudflared")
|
|
29
|
+
return local if os.path.exists(local) else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ensure_system_deps() -> None:
|
|
33
|
+
"""zstd is needed by the Ollama installer; pciutils/lshw let it detect GPUs."""
|
|
34
|
+
missing = [p for p, b in (("zstd", "zstd"), ("pciutils", "lspci"), ("lshw", "lshw")) if not shutil.which(b)]
|
|
35
|
+
if not missing:
|
|
36
|
+
return
|
|
37
|
+
log(f"Installing system packages: {', '.join(missing)}")
|
|
38
|
+
_sh("apt-get update -qq", check=False)
|
|
39
|
+
_sh(f"DEBIAN_FRONTEND=noninteractive apt-get install -y -qq {' '.join(missing)}", check=False)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def install_ollama() -> str:
|
|
43
|
+
path = shutil.which("ollama")
|
|
44
|
+
if path:
|
|
45
|
+
return path
|
|
46
|
+
ensure_system_deps()
|
|
47
|
+
log("Installing Ollama (about 30s)...")
|
|
48
|
+
_sh("curl -fsSL https://ollama.com/install.sh | sh")
|
|
49
|
+
path = shutil.which("ollama")
|
|
50
|
+
if not path:
|
|
51
|
+
raise RuntimeError("Ollama install failed. Is Internet enabled in the notebook settings?")
|
|
52
|
+
return path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def install_cloudflared() -> str:
|
|
56
|
+
path = cloudflared_path()
|
|
57
|
+
if path:
|
|
58
|
+
return path
|
|
59
|
+
arch = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}.get(platform.machine().lower())
|
|
60
|
+
if not arch:
|
|
61
|
+
raise RuntimeError(f"Unsupported architecture: {platform.machine()}")
|
|
62
|
+
os.makedirs(BIN_DIR, exist_ok=True)
|
|
63
|
+
local = os.path.join(BIN_DIR, "cloudflared")
|
|
64
|
+
log("Downloading cloudflared...")
|
|
65
|
+
urllib.request.urlretrieve(CLOUDFLARED_URL.format(arch=arch), local)
|
|
66
|
+
os.chmod(local, os.stat(local).st_mode | stat.S_IEXEC)
|
|
67
|
+
return local
|
notebook_llm/library.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Search the Ollama library (ollama.com) and resolve exact model sizes via the registry."""
|
|
2
|
+
import html
|
|
3
|
+
import re
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .estimate import Estimate, auto_context, estimate, guess_size_gb, parse_params
|
|
11
|
+
|
|
12
|
+
UA = {"User-Agent": "notebook_llm/0.1 (+ollama library search)"}
|
|
13
|
+
_size_cache: Dict[str, Optional[float]] = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LibraryError(RuntimeError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class LibModel:
|
|
22
|
+
name: str
|
|
23
|
+
description: str = ""
|
|
24
|
+
sizes: List[str] = field(default_factory=list)
|
|
25
|
+
pulls: str = ""
|
|
26
|
+
capabilities: List[str] = field(default_factory=list)
|
|
27
|
+
updated: str = ""
|
|
28
|
+
cloud: bool = False # also/only offered on Ollama Cloud
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def local(self) -> bool:
|
|
32
|
+
"""Can it be pulled and run on our own GPU?"""
|
|
33
|
+
return bool(self.sizes) or not self.cloud
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Candidate:
|
|
38
|
+
ref: str # e.g. qwen3-coder:30b
|
|
39
|
+
size_gb: float
|
|
40
|
+
exact: bool # size from registry (True) or estimated from params (False)
|
|
41
|
+
params_b: Optional[float]
|
|
42
|
+
active_b: Optional[float]
|
|
43
|
+
est: Estimate
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
FALLBACK = [
|
|
47
|
+
LibModel("qwen3-coder", "Alibaba's coding model (MoE)", ["30b"]),
|
|
48
|
+
LibModel("qwen2.5-coder", "Code-specific Qwen models", ["0.5b", "1.5b", "3b", "7b", "14b", "32b"]),
|
|
49
|
+
LibModel("qwen3", "Qwen3 general models", ["0.6b", "1.7b", "4b", "8b", "14b", "30b", "32b"]),
|
|
50
|
+
LibModel("gpt-oss", "OpenAI open-weight models", ["20b", "120b"]),
|
|
51
|
+
LibModel("llama3.1", "Meta Llama 3.1", ["8b", "70b"]),
|
|
52
|
+
LibModel("llama3.2", "Meta Llama 3.2 small models", ["1b", "3b"]),
|
|
53
|
+
LibModel("gemma3", "Google Gemma 3", ["1b", "4b", "12b", "27b"]),
|
|
54
|
+
LibModel("deepseek-r1", "Reasoning models", ["1.5b", "7b", "8b", "14b", "32b", "70b"]),
|
|
55
|
+
LibModel("deepseek-coder-v2", "Coding MoE model", ["16b"]),
|
|
56
|
+
LibModel("phi4", "Microsoft Phi-4", ["14b"]),
|
|
57
|
+
LibModel("mistral", "Mistral 7B", ["7b"]),
|
|
58
|
+
LibModel("codellama", "Meta Code Llama", ["7b", "13b", "34b", "70b"]),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _clean(s: str) -> str:
|
|
63
|
+
return html.unescape(re.sub(r"<[^>]+>", "", s)).strip()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_SIZE_RE = re.compile(r"^(\d+(?:\.\d+)?[bm]|\d+x\d+(?:\.\d+)?b)$") # lowercase only: "270m" is a size, "7M" is a pull count
|
|
67
|
+
_PULL_RE = re.compile(r"^[\d.,]+[KMB]?$")
|
|
68
|
+
_NOT_CAPS = {"pulls", "tags", "pull", "tag", "updated"}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _finish(name, desc, spans, updated="") -> LibModel:
|
|
72
|
+
sizes, caps, pulls, cloud = [], [], "", False
|
|
73
|
+
for t in spans:
|
|
74
|
+
t = _clean(t)
|
|
75
|
+
if not t:
|
|
76
|
+
continue
|
|
77
|
+
if t.lower() == "cloud":
|
|
78
|
+
cloud = True
|
|
79
|
+
elif _SIZE_RE.match(t):
|
|
80
|
+
sizes.append(t)
|
|
81
|
+
elif _PULL_RE.match(t) and not pulls and any(ch.isdigit() for ch in t):
|
|
82
|
+
pulls = t
|
|
83
|
+
elif re.match(r"^[a-z][a-z-]{1,14}$", t) and t not in _NOT_CAPS:
|
|
84
|
+
caps.append(t)
|
|
85
|
+
return LibModel(name, desc, sizes, pulls, caps, updated, cloud)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_xtest(page: str) -> List[LibModel]:
|
|
89
|
+
out = []
|
|
90
|
+
for block in page.split("<li x-test-model")[1:]:
|
|
91
|
+
href = re.search(r'href="/([^"?#]+)"', block)
|
|
92
|
+
if not href:
|
|
93
|
+
continue
|
|
94
|
+
name = re.sub(r"^library/", "", href.group(1))
|
|
95
|
+
desc = re.search(r"<p[^>]*max-w-lg[^>]*>(.*?)</p>", block, re.S)
|
|
96
|
+
spans = (re.findall(r"x-test-size[^>]*>(.*?)</span>", block, re.S)
|
|
97
|
+
+ re.findall(r"x-test-capability[^>]*>(.*?)</span>", block, re.S)
|
|
98
|
+
+ re.findall(r"x-test-pull-count[^>]*>(.*?)</span>", block, re.S))
|
|
99
|
+
upd = re.search(r"x-test-updated[^>]*>(.*?)</span>", block, re.S)
|
|
100
|
+
out.append(_finish(name, _clean(desc.group(1)) if desc else "", spans, _clean(upd.group(1)) if upd else ""))
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _parse_generic(page: str) -> List[LibModel]:
|
|
105
|
+
"""Layout-agnostic: find /library/<name> links, then read the <span>/<p> tags that follow."""
|
|
106
|
+
hits, seen = [], set()
|
|
107
|
+
for m in re.finditer(r'href="/library/([^"/?#]+)"', page):
|
|
108
|
+
if m.group(1) not in seen:
|
|
109
|
+
seen.add(m.group(1))
|
|
110
|
+
hits.append((m.group(1), m.start()))
|
|
111
|
+
out = []
|
|
112
|
+
for i, (name, pos) in enumerate(hits):
|
|
113
|
+
block = page[pos: hits[i + 1][1] if i + 1 < len(hits) else len(page)]
|
|
114
|
+
desc = re.search(r"<p[^>]*>(.*?)</p>", block, re.S)
|
|
115
|
+
spans = re.findall(r"<span[^>]*>\s*([^<]{1,30}?)\s*</span>", block)
|
|
116
|
+
upd = re.search(r"(\d+\s+(?:day|week|month|year)s?\s+ago)", block)
|
|
117
|
+
out.append(_finish(name, _clean(desc.group(1)) if desc else "", spans, upd.group(1) if upd else ""))
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_search_html(page: str) -> List[LibModel]:
|
|
122
|
+
return _parse_xtest(page) or _parse_generic(page)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def search(query: str = "", page: int = 1, sort: Optional[str] = None, capability: Optional[str] = None,
|
|
126
|
+
timeout: int = 12) -> List[LibModel]:
|
|
127
|
+
"""Search ollama.com/search (20 per page). Falls back to a built-in list if unreachable.
|
|
128
|
+
sort: 'popular' | 'newest'. capability: tools | vision | thinking | embedding | cloud."""
|
|
129
|
+
params = {"q": query}
|
|
130
|
+
if page > 1:
|
|
131
|
+
params["page"] = page
|
|
132
|
+
if sort:
|
|
133
|
+
params["o"] = sort
|
|
134
|
+
if capability:
|
|
135
|
+
params["c"] = capability
|
|
136
|
+
try:
|
|
137
|
+
r = requests.get("https://ollama.com/search", params=params, headers=UA, timeout=timeout)
|
|
138
|
+
r.raise_for_status()
|
|
139
|
+
res = parse_search_html(r.text)
|
|
140
|
+
if capability: # in case the site ignores the filter
|
|
141
|
+
res = [m for m in res if capability in m.capabilities or (capability == "cloud" and m.cloud)] or res
|
|
142
|
+
return res
|
|
143
|
+
except requests.RequestException as e:
|
|
144
|
+
if page > 1:
|
|
145
|
+
return []
|
|
146
|
+
q = query.lower()
|
|
147
|
+
res = [LibModel(m.name, m.description + " [offline list]", list(m.sizes)) for m in FALLBACK
|
|
148
|
+
if q in m.name.lower() or q in m.description.lower()]
|
|
149
|
+
if not res:
|
|
150
|
+
raise LibraryError(f"Could not reach ollama.com ({e}) and nothing matched offline.")
|
|
151
|
+
return res
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def registry_size_gb(name: str, tag: str, timeout: int = 8) -> Optional[float]:
|
|
155
|
+
"""Exact download size = sum of manifest layer sizes."""
|
|
156
|
+
key = f"{name}:{tag}"
|
|
157
|
+
if key in _size_cache:
|
|
158
|
+
return _size_cache[key]
|
|
159
|
+
repo = name if "/" in name else f"library/{name}"
|
|
160
|
+
size = None
|
|
161
|
+
try:
|
|
162
|
+
r = requests.get(f"https://registry.ollama.ai/v2/{repo}/manifests/{tag}",
|
|
163
|
+
headers={**UA, "Accept": "application/vnd.docker.distribution.manifest.v2+json"},
|
|
164
|
+
timeout=timeout)
|
|
165
|
+
if r.ok:
|
|
166
|
+
m = r.json()
|
|
167
|
+
total = sum(l.get("size", 0) for l in m.get("layers", [])) + m.get("config", {}).get("size", 0)
|
|
168
|
+
size = round(total / 1024 ** 3, 2) if total else None
|
|
169
|
+
except (requests.RequestException, ValueError):
|
|
170
|
+
pass
|
|
171
|
+
_size_cache[key] = size
|
|
172
|
+
return size
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def active_hint(description: str, tag_params: Optional[float], single_size: bool) -> Optional[float]:
|
|
176
|
+
"""Pull 'A3B' / '3B active' out of a model description (new MoE models aren't in our table)."""
|
|
177
|
+
d = description.lower()
|
|
178
|
+
m = re.search(r"(\d+(?:\.\d+)?)b[- ]?a(\d+(?:\.\d+)?)b", d)
|
|
179
|
+
if m and (tag_params is None or abs(float(m.group(1)) - tag_params) <= 0.15 * tag_params):
|
|
180
|
+
return float(m.group(2))
|
|
181
|
+
m = re.search(r"(\d+(?:\.\d+)?)b\s+active", d)
|
|
182
|
+
if m and single_size:
|
|
183
|
+
return float(m.group(1))
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def make_candidate(ref: str, hw, ctx: Optional[int] = None, kv_type: str = "q8_0",
|
|
188
|
+
online: bool = True, hint: str = "", single_size: bool = False) -> Candidate:
|
|
189
|
+
name, _, tag = ref.partition(":")
|
|
190
|
+
tag = tag or "latest"
|
|
191
|
+
params, active = parse_params(name, tag)
|
|
192
|
+
if active is None and hint:
|
|
193
|
+
active = active_hint(hint, params, single_size)
|
|
194
|
+
size = registry_size_gb(name, tag) if online else None
|
|
195
|
+
exact = size is not None
|
|
196
|
+
if size is None:
|
|
197
|
+
if params is None:
|
|
198
|
+
params = 7.0 # unknown: assume a 7B
|
|
199
|
+
size = guess_size_gb(params, tag)
|
|
200
|
+
if params is None:
|
|
201
|
+
params = size / 0.61
|
|
202
|
+
c = ctx or auto_context(size, params, hw, kv_type)
|
|
203
|
+
return Candidate(f"{name}:{tag}", size, exact, params, active, estimate(size, params, active, hw, c, kv_type))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def candidates_for(model: LibModel, hw, ctx=None, kv_type="q8_0", online=True) -> List[Candidate]:
|
|
207
|
+
tags = [s for s in model.sizes if "cloud" not in s] or ["latest"]
|
|
208
|
+
refs = [f"{model.name}:{t}" for t in tags]
|
|
209
|
+
one = len(tags) == 1
|
|
210
|
+
with ThreadPoolExecutor(max_workers=8) as ex:
|
|
211
|
+
res = list(ex.map(lambda r: make_candidate(r, hw, ctx, kv_type, online, model.description, one), refs))
|
|
212
|
+
return sorted(res, key=lambda c: c.size_gb)
|