zeroquantz 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.
Files changed (105) hide show
  1. zeroquantz/__init__.py +14 -0
  2. zeroquantz/__main__.py +8 -0
  3. zeroquantz/agent/__init__.py +16 -0
  4. zeroquantz/agent/dispatcher.py +520 -0
  5. zeroquantz/agent/intents.py +46 -0
  6. zeroquantz/agent/parser.py +255 -0
  7. zeroquantz/benchmark/__init__.py +7 -0
  8. zeroquantz/benchmark/latency.py +66 -0
  9. zeroquantz/benchmark/memory.py +41 -0
  10. zeroquantz/benchmark/quality.py +38 -0
  11. zeroquantz/benchmark/runner.py +151 -0
  12. zeroquantz/cli/__init__.py +7 -0
  13. zeroquantz/cli/app.py +98 -0
  14. zeroquantz/cli/commands.py +459 -0
  15. zeroquantz/cli/interactive.py +56 -0
  16. zeroquantz/core/__init__.py +7 -0
  17. zeroquantz/core/artifacts.py +179 -0
  18. zeroquantz/core/context.py +127 -0
  19. zeroquantz/core/events.py +30 -0
  20. zeroquantz/core/exceptions.py +105 -0
  21. zeroquantz/core/session.py +202 -0
  22. zeroquantz/core/subenv.py +202 -0
  23. zeroquantz/deploy/__init__.py +25 -0
  24. zeroquantz/deploy/assets.py +161 -0
  25. zeroquantz/deploy/launcher.py +80 -0
  26. zeroquantz/deploy/runtime_env.py +66 -0
  27. zeroquantz/deploy/targets.py +154 -0
  28. zeroquantz/export/__init__.py +8 -0
  29. zeroquantz/export/exporter.py +68 -0
  30. zeroquantz/export/report.py +203 -0
  31. zeroquantz/hardware/__init__.py +15 -0
  32. zeroquantz/hardware/capabilities.py +152 -0
  33. zeroquantz/hardware/detector.py +200 -0
  34. zeroquantz/hardware/gpu.py +31 -0
  35. zeroquantz/models/__init__.py +8 -0
  36. zeroquantz/models/architecture.py +168 -0
  37. zeroquantz/models/downloader.py +161 -0
  38. zeroquantz/models/hf_auth.py +105 -0
  39. zeroquantz/models/inspector.py +249 -0
  40. zeroquantz/models/metadata.py +108 -0
  41. zeroquantz/models/search.py +71 -0
  42. zeroquantz/optimization/__init__.py +22 -0
  43. zeroquantz/optimization/candidate.py +272 -0
  44. zeroquantz/optimization/constraints.py +70 -0
  45. zeroquantz/optimization/fit.py +203 -0
  46. zeroquantz/optimization/pareto.py +66 -0
  47. zeroquantz/optimization/planner.py +297 -0
  48. zeroquantz/optimization/recommender.py +149 -0
  49. zeroquantz/profiling/__init__.py +18 -0
  50. zeroquantz/profiling/calibration.py +74 -0
  51. zeroquantz/profiling/sensitivity.py +234 -0
  52. zeroquantz/quantization/__init__.py +17 -0
  53. zeroquantz/quantization/backends/__init__.py +8 -0
  54. zeroquantz/quantization/backends/bitsandbytes.py +210 -0
  55. zeroquantz/quantization/backends/torchao.py +198 -0
  56. zeroquantz/quantization/base.py +136 -0
  57. zeroquantz/quantization/catalog.py +321 -0
  58. zeroquantz/quantization/config.py +106 -0
  59. zeroquantz/quantization/gguf_pipeline.py +210 -0
  60. zeroquantz/quantization/isolated.py +248 -0
  61. zeroquantz/quantization/memory.py +133 -0
  62. zeroquantz/quantization/native.py +91 -0
  63. zeroquantz/quantization/registry.py +101 -0
  64. zeroquantz/render.py +341 -0
  65. zeroquantz/runtimes/__init__.py +18 -0
  66. zeroquantz/runtimes/base.py +64 -0
  67. zeroquantz/runtimes/compatibility.py +91 -0
  68. zeroquantz/runtimes/registry.py +70 -0
  69. zeroquantz/runtimes/transformers.py +53 -0
  70. zeroquantz/runtimes/vllm.py +83 -0
  71. zeroquantz/tui/__init__.py +13 -0
  72. zeroquantz/tui/app.py +77 -0
  73. zeroquantz/tui/banner.py +47 -0
  74. zeroquantz/tui/screens/__init__.py +25 -0
  75. zeroquantz/tui/screens/confirm.py +41 -0
  76. zeroquantz/tui/screens/execute.py +194 -0
  77. zeroquantz/tui/screens/model_select.py +206 -0
  78. zeroquantz/tui/screens/plan.py +177 -0
  79. zeroquantz/tui/screens/quantize_select.py +272 -0
  80. zeroquantz/tui/screens/settings.py +219 -0
  81. zeroquantz/tui/screens/token.py +94 -0
  82. zeroquantz/tui/screens/welcome.py +128 -0
  83. zeroquantz/tui/screens/workspace.py +175 -0
  84. zeroquantz/tui/styles/app.tcss +424 -0
  85. zeroquantz/tui/widgets/__init__.py +9 -0
  86. zeroquantz/tui/widgets/chip.py +36 -0
  87. zeroquantz/tui/widgets/sidebar.py +107 -0
  88. zeroquantz/tui/widgets/status_bar.py +43 -0
  89. zeroquantz/utils/__init__.py +8 -0
  90. zeroquantz/utils/config.py +46 -0
  91. zeroquantz/utils/env.py +78 -0
  92. zeroquantz/utils/logging.py +73 -0
  93. zeroquantz/utils/metrics.py +98 -0
  94. zeroquantz/utils/paths.py +57 -0
  95. zeroquantz/utils/units.py +134 -0
  96. zeroquantz/verification/__init__.py +17 -0
  97. zeroquantz/verification/logits.py +55 -0
  98. zeroquantz/verification/report.py +186 -0
  99. zeroquantz/verification/weights.py +44 -0
  100. zeroquantz/version.py +8 -0
  101. zeroquantz-0.1.0.dist-info/METADATA +72 -0
  102. zeroquantz-0.1.0.dist-info/RECORD +105 -0
  103. zeroquantz-0.1.0.dist-info/WHEEL +4 -0
  104. zeroquantz-0.1.0.dist-info/entry_points.txt +2 -0
  105. zeroquantz-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,200 @@
1
+ """Detect the local hardware and turn it into a :class:`HardwareProfile`.
2
+
3
+ Uses PyTorch's CUDA APIs as the primary source and NVML (``pynvml`` /
4
+ ``nvidia-ml-py``) for richer telemetry (free memory, driver version). Every probe
5
+ is wrapped so that a missing library or an unavailable subsystem degrades
6
+ gracefully to a CPU profile instead of raising.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from zeroquantz.hardware.capabilities import HardwareProfile, derive_precision_support
12
+ from zeroquantz.hardware.gpu import GpuInfo
13
+ from zeroquantz.utils import units
14
+ from zeroquantz.utils.logging import get_logger
15
+
16
+ log = get_logger(__name__)
17
+
18
+ # Presets used only by :meth:`HardwareDetector.simulate`, so that recommendations
19
+ # can be produced for a target card you are not currently running on.
20
+ _GPU_PRESETS: dict[str, tuple[float, tuple[int, int]]] = {
21
+ # name substring -> (vram GiB, compute capability)
22
+ "rtx 4090": (24.0, (8, 9)),
23
+ "rtx 4080": (16.0, (8, 9)),
24
+ "rtx 4070": (12.0, (8, 9)),
25
+ "rtx 4060": (8.0, (8, 9)),
26
+ "rtx 3090": (24.0, (8, 6)),
27
+ "rtx 3080": (10.0, (8, 6)),
28
+ "a100": (80.0, (8, 0)),
29
+ "h100": (80.0, (9, 0)),
30
+ "l40s": (48.0, (8, 9)),
31
+ "l4": (24.0, (8, 9)),
32
+ "t4": (16.0, (7, 5)),
33
+ "v100": (32.0, (7, 0)),
34
+ }
35
+
36
+
37
+ class HardwareDetector:
38
+ """Probe the machine for GPUs, VRAM, compute capability, and CUDA versions."""
39
+
40
+ @staticmethod
41
+ def detect() -> HardwareProfile:
42
+ gpus: list[GpuInfo] = []
43
+ cuda_available = False
44
+ cuda_version: str | None = None
45
+ torch_version: str | None = None
46
+ driver_version: str | None = None
47
+ notes: list[str] = []
48
+
49
+ # ---- PyTorch probe --------------------------------------------------
50
+ try:
51
+ import torch
52
+
53
+ torch_version = torch.__version__
54
+ cuda_version = getattr(torch.version, "cuda", None)
55
+ cuda_available = bool(torch.cuda.is_available())
56
+ if cuda_available:
57
+ for i in range(torch.cuda.device_count()):
58
+ props = torch.cuda.get_device_properties(i)
59
+ try:
60
+ free_b, _total_b = torch.cuda.mem_get_info(i)
61
+ available_gb = units.bytes_to_gb(free_b)
62
+ except Exception: # pragma: no cover - driver dependent
63
+ available_gb = None
64
+ gpus.append(
65
+ GpuInfo(
66
+ index=i,
67
+ name=props.name,
68
+ total_vram_gb=round(units.bytes_to_gb(props.total_memory), 2),
69
+ available_vram_gb=(
70
+ round(available_gb, 2) if available_gb is not None else None
71
+ ),
72
+ compute_capability=(props.major, props.minor),
73
+ multiprocessor_count=getattr(props, "multi_processor_count", None),
74
+ )
75
+ )
76
+ except ImportError:
77
+ notes.append("PyTorch is not installed; GPU detection is limited.")
78
+ except Exception as exc: # pragma: no cover - defensive
79
+ log.warning("torch hardware probe failed: %s", exc)
80
+ notes.append(f"PyTorch hardware probe failed: {exc}")
81
+
82
+ # ---- NVML probe (driver version, free memory, CPU-only fallback) ----
83
+ nvml_gpus = HardwareDetector._probe_nvml()
84
+ if nvml_gpus is not None:
85
+ driver_version = nvml_gpus.get("driver")
86
+ if not gpus and nvml_gpus.get("gpus"):
87
+ # torch unavailable but NVML present -> still report the devices.
88
+ gpus = nvml_gpus["gpus"]
89
+ cuda_available = bool(gpus)
90
+ elif gpus and nvml_gpus.get("gpus"):
91
+ # Enrich torch data with NVML free memory where torch could not.
92
+ for gpu, nv in zip(gpus, nvml_gpus["gpus"], strict=False):
93
+ if gpu.available_vram_gb is None and nv.available_vram_gb is not None:
94
+ gpu.available_vram_gb = nv.available_vram_gb
95
+
96
+ # ---- CPU / RAM ------------------------------------------------------
97
+ cpu_cores, system_ram_gb = HardwareDetector._probe_cpu()
98
+
99
+ primary_cc = gpus[0].compute_capability if gpus else None
100
+ precision = derive_precision_support(primary_cc, cuda_available=cuda_available)
101
+
102
+ if not gpus:
103
+ notes.append("No CUDA GPU detected — running in CPU/planning-only mode.")
104
+
105
+ return HardwareProfile(
106
+ gpus=gpus,
107
+ cuda_available=cuda_available,
108
+ cuda_version=cuda_version,
109
+ driver_version=driver_version,
110
+ torch_version=torch_version,
111
+ cpu_cores=cpu_cores,
112
+ system_ram_gb=system_ram_gb,
113
+ precision_support=precision,
114
+ notes=notes,
115
+ )
116
+
117
+ @staticmethod
118
+ def _probe_nvml() -> dict | None:
119
+ try:
120
+ import pynvml # provided by nvidia-ml-py or the legacy pynvml package
121
+ except ImportError:
122
+ return None
123
+ try:
124
+ pynvml.nvmlInit()
125
+ except Exception: # pragma: no cover - no driver
126
+ return None
127
+ try:
128
+ driver = _as_str(pynvml.nvmlSystemGetDriverVersion())
129
+ gpus: list[GpuInfo] = []
130
+ for i in range(pynvml.nvmlDeviceGetCount()):
131
+ handle = pynvml.nvmlDeviceGetHandleByIndex(i)
132
+ mem = pynvml.nvmlDeviceGetMemoryInfo(handle)
133
+ try:
134
+ major, minor = pynvml.nvmlDeviceGetCudaComputeCapability(handle)
135
+ cc: tuple[int, int] | None = (major, minor)
136
+ except Exception:
137
+ cc = None
138
+ gpus.append(
139
+ GpuInfo(
140
+ index=i,
141
+ name=_as_str(pynvml.nvmlDeviceGetName(handle)),
142
+ total_vram_gb=round(units.bytes_to_gb(mem.total), 2),
143
+ available_vram_gb=round(units.bytes_to_gb(mem.free), 2),
144
+ compute_capability=cc,
145
+ )
146
+ )
147
+ return {"driver": driver, "gpus": gpus}
148
+ except Exception as exc: # pragma: no cover - defensive
149
+ log.debug("NVML probe failed: %s", exc)
150
+ return None
151
+ finally:
152
+ try:
153
+ pynvml.nvmlShutdown()
154
+ except Exception:
155
+ pass
156
+
157
+ @staticmethod
158
+ def _probe_cpu() -> tuple[int | None, float | None]:
159
+ cores: int | None = None
160
+ ram_gb: float | None = None
161
+ try:
162
+ import os
163
+
164
+ cores = os.cpu_count()
165
+ except Exception:
166
+ pass
167
+ try:
168
+ import psutil
169
+
170
+ ram_gb = round(units.bytes_to_gb(psutil.virtual_memory().total), 1)
171
+ except Exception:
172
+ pass
173
+ return cores, ram_gb
174
+
175
+ @staticmethod
176
+ def simulate(gpu_name: str) -> HardwareProfile:
177
+ """Build a profile for a *named* GPU without needing that hardware present.
178
+
179
+ Powers ``recommend --gpu <name>``: plan for the card you will deploy on.
180
+ Raises :class:`ValueError` if the name is not in the preset table.
181
+ """
182
+ key = gpu_name.strip().lower()
183
+ match = next((preset for name, preset in _GPU_PRESETS.items() if name in key), None)
184
+ if match is None:
185
+ known = ", ".join(sorted(_GPU_PRESETS))
186
+ raise ValueError(f"unknown GPU preset {gpu_name!r}; known presets: {known}")
187
+ vram, cc = match
188
+ gpu = GpuInfo(index=0, name=gpu_name, total_vram_gb=vram, compute_capability=cc)
189
+ return HardwareProfile(
190
+ gpus=[gpu],
191
+ cuda_available=True,
192
+ precision_support=derive_precision_support(cc, cuda_available=True),
193
+ notes=[f"Simulated hardware profile for '{gpu_name}'."],
194
+ )
195
+
196
+
197
+ def _as_str(value: object) -> str:
198
+ if isinstance(value, bytes):
199
+ return value.decode("utf-8", errors="replace")
200
+ return str(value)
@@ -0,0 +1,31 @@
1
+ """Per-GPU information model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from zeroquantz.utils import units
8
+
9
+
10
+ class GpuInfo(BaseModel):
11
+ """A single detected CUDA device."""
12
+
13
+ index: int
14
+ name: str
15
+ total_vram_gb: float = Field(ge=0)
16
+ available_vram_gb: float | None = Field(default=None, ge=0)
17
+ compute_capability: tuple[int, int] | None = None
18
+ multiprocessor_count: int | None = None
19
+ uuid: str | None = None
20
+
21
+ @property
22
+ def sm(self) -> str | None:
23
+ """Compute capability rendered like ``"SM89"``."""
24
+ if self.compute_capability is None:
25
+ return None
26
+ major, minor = self.compute_capability
27
+ return f"SM{major}{minor}"
28
+
29
+ @property
30
+ def total_vram_bytes(self) -> float:
31
+ return units.gb_to_bytes(self.total_vram_gb)
@@ -0,0 +1,8 @@
1
+ """Model inspection, metadata, and (lazy) loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from zeroquantz.models.inspector import ModelInspector
6
+ from zeroquantz.models.metadata import ModelProfile
7
+
8
+ __all__ = ["ModelInspector", "ModelProfile"]
@@ -0,0 +1,168 @@
1
+ """Read a Hugging Face ``config.json`` into normalized dimensions, and estimate a
2
+ parameter count from those dimensions when exact tensor headers are unavailable.
3
+
4
+ The estimate targets modern decoder-only transformers (LLaMA/Qwen/Mistral-style
5
+ gated MLP with grouped-query attention), which is v0.1's scope. It is clearly
6
+ labelled as an *estimate* everywhere it surfaces.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ # Families that use a 3-matrix gated MLP (gate/up/down). Anything else is assumed
14
+ # to use a 2-matrix MLP. Only affects the *estimate* path; the safetensors path is
15
+ # exact regardless.
16
+ _GATED_MLP_FAMILIES = {
17
+ "llama",
18
+ "mistral",
19
+ "mixtral",
20
+ "qwen2",
21
+ "qwen3",
22
+ "qwen2_moe",
23
+ "gemma",
24
+ "gemma2",
25
+ "gemma3",
26
+ "phi3",
27
+ "phi",
28
+ "stablelm",
29
+ "starcoder2",
30
+ "cohere",
31
+ "olmo",
32
+ "granite",
33
+ "internlm2",
34
+ "yi",
35
+ }
36
+
37
+ _NON_GATED_MLP_FAMILIES = {"gpt2", "gpt_neox", "gptj", "opt", "bloom", "falcon", "mpt"}
38
+
39
+
40
+ def _first(config: dict, *keys: str, default: Any = None) -> Any:
41
+ for key in keys:
42
+ if key in config and config[key] is not None:
43
+ return config[key]
44
+ return default
45
+
46
+
47
+ def extract_dimensions(config: dict) -> dict[str, Any]:
48
+ """Pull normalized architecture dimensions out of a raw HF config dict."""
49
+ architectures = config.get("architectures") or []
50
+ architecture = architectures[0] if architectures else None
51
+
52
+ num_heads = _first(config, "num_attention_heads", "n_head", "num_heads")
53
+ hidden = _first(config, "hidden_size", "n_embd", "d_model", "hidden_dim")
54
+ head_dim = config.get("head_dim")
55
+ if head_dim is None and hidden and num_heads:
56
+ head_dim = hidden // num_heads
57
+
58
+ return {
59
+ "architecture": architecture,
60
+ "model_type": config.get("model_type"),
61
+ "num_layers": _first(config, "num_hidden_layers", "n_layer", "num_layers"),
62
+ "hidden_size": hidden,
63
+ "intermediate_size": _first(
64
+ config, "intermediate_size", "n_inner", "ffn_dim", "d_ff"
65
+ ),
66
+ "num_attention_heads": num_heads,
67
+ "num_key_value_heads": _first(config, "num_key_value_heads", default=num_heads),
68
+ "head_dim": head_dim,
69
+ "vocab_size": config.get("vocab_size"),
70
+ "max_position_embeddings": _first(
71
+ config, "max_position_embeddings", "n_positions", "max_seq_len", "seq_length"
72
+ ),
73
+ # HF's PretrainedConfig default is True; large models usually override to False.
74
+ "tie_word_embeddings": bool(config.get("tie_word_embeddings", True)),
75
+ }
76
+
77
+
78
+ def uses_gated_mlp(model_type: str | None) -> bool:
79
+ """Whether a model family uses a 3-matrix gated MLP (gate/up/down)."""
80
+ if model_type is None:
81
+ return True # modern default
82
+ mt = model_type.lower()
83
+ if mt in _NON_GATED_MLP_FAMILIES:
84
+ return False
85
+ if mt in _GATED_MLP_FAMILIES:
86
+ return True
87
+ return True
88
+
89
+
90
+ # Backwards-compatible private alias.
91
+ _uses_gated_mlp = uses_gated_mlp
92
+
93
+
94
+ def estimate_parameter_count(config: dict) -> int | None:
95
+ """Estimate total parameters from config dimensions, or ``None`` if the config
96
+ lacks the dimensions needed."""
97
+ dims = extract_dimensions(config)
98
+ layers = dims["num_layers"]
99
+ hidden = dims["hidden_size"]
100
+ inter = dims["intermediate_size"]
101
+ heads = dims["num_attention_heads"]
102
+ kv_heads = dims["num_key_value_heads"] or heads
103
+ head_dim = dims["head_dim"]
104
+ vocab = dims["vocab_size"]
105
+
106
+ if not all((layers, hidden, heads, vocab)):
107
+ return None
108
+ if head_dim is None:
109
+ head_dim = hidden // heads
110
+ if inter is None:
111
+ inter = 4 * hidden # common fallback ratio
112
+
113
+ q_proj = hidden * (heads * head_dim)
114
+ o_proj = (heads * head_dim) * hidden
115
+ kv_proj = hidden * (kv_heads * head_dim)
116
+ attn = q_proj + o_proj + 2 * kv_proj
117
+
118
+ if _uses_gated_mlp(dims["model_type"]):
119
+ mlp = 3 * hidden * inter
120
+ else:
121
+ mlp = 2 * hidden * inter
122
+
123
+ norms = 2 * hidden # two RMS/LayerNorms per block
124
+ per_layer = attn + mlp + norms
125
+
126
+ embed = vocab * hidden
127
+ lm_head = 0 if dims["tie_word_embeddings"] else vocab * hidden
128
+ final_norm = hidden
129
+
130
+ return int(layers * per_layer + embed + lm_head + final_norm)
131
+
132
+
133
+ # safetensors dtype codes -> canonical names
134
+ _ST_DTYPES = {
135
+ "F64": "float64",
136
+ "F32": "float32",
137
+ "F16": "float16",
138
+ "BF16": "bfloat16",
139
+ "I64": "int64",
140
+ "I32": "int32",
141
+ "I16": "int16",
142
+ "I8": "int8",
143
+ "U8": "uint8",
144
+ "F8_E4M3": "fp8",
145
+ "F8_E5M2": "fp8",
146
+ }
147
+
148
+ # dtypes that represent actual model weights (not integer buffers / rotary caches)
149
+ _WEIGHT_DTYPES = {"float32", "float16", "bfloat16", "float64", "fp8"}
150
+
151
+
152
+ def normalize_st_dtype(code: str) -> str:
153
+ return _ST_DTYPES.get(str(code).upper(), str(code).lower())
154
+
155
+
156
+ def dominant_weight_dtype(param_count_by_dtype: dict[str, int]) -> str:
157
+ """Given ``{dtype_code: n_params}`` pick the dtype that best represents the
158
+ model's weights (largest float bucket, ignoring integer buffers)."""
159
+ normalized: dict[str, int] = {}
160
+ for code, count in param_count_by_dtype.items():
161
+ name = normalize_st_dtype(code)
162
+ normalized[name] = normalized.get(name, 0) + int(count)
163
+
164
+ weight_only = {d: c for d, c in normalized.items() if d in _WEIGHT_DTYPES}
165
+ pool = weight_only or normalized
166
+ if not pool:
167
+ return "float16"
168
+ return max(pool.items(), key=lambda kv: kv[1])[0]
@@ -0,0 +1,161 @@
1
+ """Download a model's weights from the Hub with real-time byte progress.
2
+
3
+ Files are fetched one at a time (so a download can be cancelled between files and
4
+ overall byte progress is exact), hooking each file's tqdm bar to report byte
5
+ deltas. The essential files are selected — safetensors weights (or .bin when
6
+ there are no safetensors) plus config/tokenizer — skipping other formats
7
+ (.gguf/.onnx/.h5/…) and repo cruft.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ from zeroquantz.core.exceptions import ZeroQuantzError
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable
21
+
22
+
23
+ class DownloadCancelled(ZeroQuantzError):
24
+ """Raised when a download is cancelled by the user."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class DownloadFile:
29
+ name: str
30
+ size: int
31
+
32
+
33
+ _KEEP_SUFFIXES = (".safetensors", ".json", ".model", ".txt", ".jinja", ".tiktoken")
34
+ _WEIGHT_FALLBACK = (".bin", ".pth")
35
+ _SKIP_SUFFIXES = (".gguf", ".onnx", ".h5", ".msgpack", ".pt", ".ckpt", ".gitattributes", ".md")
36
+ # Alternative-format subdirectories that hold duplicate configs/tokenizers/weights
37
+ # we never need for a transformers/vLLM load — skip them so we don't download, e.g.,
38
+ # an `onnx/` copy of the tokenizer alongside the real one.
39
+ _SKIP_DIRS = frozenset({"onnx", "coreml", "tf", "flax", "tflite", "openvino"})
40
+
41
+
42
+ def plan_download(model_id: str, *, revision: str | None = None, token: str | None = None) -> tuple[list[DownloadFile], int]:
43
+ """Return the essential files to download and their total size in bytes."""
44
+ from huggingface_hub import HfApi
45
+
46
+ api = HfApi(token=token)
47
+ try:
48
+ info = api.model_info(model_id, revision=revision, files_metadata=True)
49
+ except Exception as exc:
50
+ raise ZeroQuantzError(
51
+ f"Could not read the file list for '{model_id}'.", detail=str(exc)
52
+ ) from exc
53
+
54
+ siblings = info.siblings or []
55
+ names = [s.rfilename for s in siblings]
56
+ has_safetensors = any(n.endswith(".safetensors") for n in names)
57
+
58
+ files: list[DownloadFile] = []
59
+ for sib in siblings:
60
+ name = sib.rfilename
61
+ low = name.lower()
62
+ if low.endswith(_SKIP_SUFFIXES):
63
+ continue
64
+ if "/" in low and low.split("/", 1)[0] in _SKIP_DIRS:
65
+ continue
66
+ keep = (
67
+ low.endswith(_KEEP_SUFFIXES)
68
+ or "tokenizer" in low
69
+ or (low.endswith(_WEIGHT_FALLBACK) and not has_safetensors)
70
+ )
71
+ if keep:
72
+ files.append(DownloadFile(name=name, size=int(sib.size or 0)))
73
+ total = sum(f.size for f in files)
74
+ return files, total
75
+
76
+
77
+ def _make_tqdm(on_delta: Callable[[int], None]):
78
+ from tqdm.auto import tqdm as _base
79
+
80
+ class _ProgressTqdm(_base):
81
+ def __init__(self, *args, **kwargs) -> None:
82
+ # Silence the child bar (would corrupt the TUI); we report via on_delta.
83
+ kwargs["disable"] = True
84
+ super().__init__(*args, **kwargs)
85
+
86
+ def update(self, n: float | None = 1) -> bool | None:
87
+ # hf_hub only uses this tqdm_class for a file's byte download, so every
88
+ # positive delta is bytes. (We must NOT gate on ``self.unit``: tqdm's
89
+ # __init__ returns early when ``disable=True`` and never assigns it.)
90
+ try:
91
+ if n:
92
+ on_delta(int(n))
93
+ except Exception:
94
+ pass
95
+ return super().update(n)
96
+
97
+ return _ProgressTqdm
98
+
99
+
100
+ def download_model(
101
+ model_id: str,
102
+ files: list[DownloadFile],
103
+ *,
104
+ revision: str | None = None,
105
+ token: str | None = None,
106
+ cancel: threading.Event | None = None,
107
+ on_progress: Callable[[int], None] | None = None,
108
+ ) -> str:
109
+ """Download ``files`` for ``model_id``; return the local snapshot directory.
110
+
111
+ Progress is reported two ways so it is always correct and always reaches
112
+ ``total``: intra-file byte deltas (from each file's tqdm) give a smooth bar,
113
+ and a snap to the exact byte boundary after every completed file guarantees
114
+ the reported total lands on 100% even when deltas are unavailable — e.g. tiny
115
+ files with no progress bar, or the ``hf_transfer`` accelerator, which doesn't
116
+ drive our tqdm.
117
+ """
118
+ from huggingface_hub import hf_hub_download, snapshot_download
119
+
120
+ total = sum(f.size for f in files)
121
+ completed = 0 # bytes from fully-finished files
122
+
123
+ def report(done: int) -> None:
124
+ if on_progress:
125
+ on_progress(min(done, total))
126
+
127
+ for f in files:
128
+ if cancel is not None and cancel.is_set():
129
+ raise DownloadCancelled("Download cancelled.")
130
+ file_seen = 0
131
+
132
+ # ``_base``/``_size`` are bound at closure creation (per file); ``completed``
133
+ # is not mutated during this file's download, so the pre-file base is correct.
134
+ def bump(delta: int, _base: int = completed, _size: int = f.size) -> None:
135
+ nonlocal file_seen
136
+ file_seen = min(file_seen + delta, _size)
137
+ report(_base + file_seen)
138
+
139
+ try:
140
+ hf_hub_download(
141
+ model_id, f.name, revision=revision, token=token,
142
+ tqdm_class=_make_tqdm(bump),
143
+ )
144
+ except Exception as exc:
145
+ raise ZeroQuantzError(
146
+ f"Failed to download '{f.name}' from '{model_id}'.", detail=str(exc)
147
+ ) from exc
148
+ completed += f.size
149
+ report(completed) # snap to the file boundary (covers any missed deltas)
150
+
151
+ report(total) # guarantee the bar reaches 100%
152
+
153
+ # Resolve the snapshot root from the now-cached files (no re-download).
154
+ try:
155
+ root = snapshot_download(
156
+ model_id, revision=revision, token=token,
157
+ allow_patterns=[f.name for f in files], local_files_only=True,
158
+ )
159
+ except Exception:
160
+ root = str(Path.home() / ".cache" / "huggingface")
161
+ return root
@@ -0,0 +1,105 @@
1
+ """Hugging Face token management.
2
+
3
+ Validates and persists a token using the standard huggingface_hub token store, so
4
+ every subsequent Hub call (search, inspect, download) authenticates automatically
5
+ without threading a token through the code. The token itself is never logged or
6
+ displayed — only a masked form or the resolved username.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import io
13
+ import os
14
+
15
+ from zeroquantz.core.exceptions import ZeroQuantzError
16
+ from zeroquantz.utils.logging import get_logger
17
+
18
+ log = get_logger(__name__)
19
+
20
+ _ENV_VARS = ("HF_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HUGGINGFACE_HUB_TOKEN")
21
+
22
+
23
+ def current_token() -> str | None:
24
+ """The effective token (env var or saved file), or None. No network."""
25
+ try:
26
+ from huggingface_hub import get_token
27
+
28
+ return get_token()
29
+ except Exception:
30
+ for var in _ENV_VARS:
31
+ if os.environ.get(var):
32
+ return os.environ[var]
33
+ return None
34
+
35
+
36
+ def masked(token: str | None) -> str | None:
37
+ if not token:
38
+ return None
39
+ t = token.strip()
40
+ return f"{t[:3]}…{t[-4:]}" if len(t) > 10 else "set"
41
+
42
+
43
+ def whoami_name(token: str | None = None) -> str | None:
44
+ """Resolve the username for a token (network). Raises if invalid/unreachable."""
45
+ from huggingface_hub import whoami
46
+
47
+ info = whoami(token=token) if token else whoami()
48
+ if isinstance(info, dict):
49
+ return info.get("name") or info.get("fullname")
50
+ return None
51
+
52
+
53
+ def save_token(token: str) -> str | None:
54
+ """Validate ``token`` (via whoami) and persist it. Returns the username.
55
+
56
+ Raises :class:`ZeroQuantzError` with an actionable message if the token is
57
+ invalid or the Hub is unreachable.
58
+ """
59
+ token = token.strip()
60
+ if not token:
61
+ raise ZeroQuantzError("No token entered.")
62
+
63
+ try:
64
+ name = whoami_name(token)
65
+ except Exception as exc:
66
+ raise ZeroQuantzError(
67
+ "That Hugging Face token was rejected.",
68
+ detail=str(exc),
69
+ suggestions=["create a token at https://huggingface.co/settings/tokens"],
70
+ ) from exc
71
+
72
+ # Effective immediately for this process...
73
+ os.environ["HF_TOKEN"] = token
74
+ # ...and persisted for future sessions (quietly — no stray prints in the TUI).
75
+ with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
76
+ try:
77
+ from huggingface_hub import HfFolder
78
+
79
+ HfFolder.save_token(token)
80
+ except Exception:
81
+ try:
82
+ from huggingface_hub import login
83
+
84
+ login(token=token, add_to_git_credential=False)
85
+ except Exception as exc: # pragma: no cover - persistence best-effort
86
+ log.warning("token set for this session but not persisted: %s", exc)
87
+ return name
88
+
89
+
90
+ def clear_token() -> None:
91
+ """Remove the token from the environment and the saved store."""
92
+ for var in _ENV_VARS:
93
+ os.environ.pop(var, None)
94
+ with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
95
+ try:
96
+ from huggingface_hub import logout
97
+
98
+ logout()
99
+ except Exception: # pragma: no cover
100
+ try:
101
+ from huggingface_hub import HfFolder
102
+
103
+ HfFolder.delete_token()
104
+ except Exception:
105
+ pass