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.
@@ -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
@@ -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)