|
| 1 | +""" |
| 2 | +Safetensors Metadata Extraction for AIBOM Generator |
| 3 | +
|
| 4 | +Extracts hyperparameters from safetensors repos by combining: |
| 5 | +1. config.json — all hyperparameters (mirroring llama.cpp's find_hparam approach) |
| 6 | +2. Safetensors headers — tensor info (parameter count, dtype distribution) |
| 7 | +
|
| 8 | +See research.md section 12 for full format specification and design rationale. |
| 9 | +""" |
| 10 | +import json |
| 11 | +import math |
| 12 | +import logging |
| 13 | +from collections import Counter |
| 14 | +from dataclasses import dataclass, field |
| 15 | +from typing import Dict, Optional, Union |
| 16 | + |
| 17 | +from huggingface_hub import hf_hub_download, HfApi |
| 18 | +from huggingface_hub.errors import EntryNotFoundError |
| 19 | + |
| 20 | +from .config_parsing import parse_config |
| 21 | + |
| 22 | +logger = logging.getLogger(__name__) |
| 23 | + |
| 24 | + |
| 25 | +@dataclass |
| 26 | +class SafetensorsModelInfo: |
| 27 | + """Model information extracted from safetensors repo for AIBOM. |
| 28 | +
|
| 29 | + Parallels GGUFModelInfo but sources hyperparameters from config.json |
| 30 | + (not from the safetensors header, which contains only tensor definitions). |
| 31 | + """ |
| 32 | + # From config.json (via parse_config, same source as llama.cpp) |
| 33 | + architecture: Optional[str] = None |
| 34 | + context_length: Optional[int] = None |
| 35 | + embedding_length: Optional[int] = None |
| 36 | + block_count: Optional[int] = None |
| 37 | + attention_head_count: Optional[int] = None |
| 38 | + attention_head_count_kv: Optional[int] = None |
| 39 | + feed_forward_length: Optional[int] = None |
| 40 | + rope_dimension_count: Optional[int] = None |
| 41 | + vocab_size: Optional[int] = None |
| 42 | + # From tokenizer_config.json |
| 43 | + tokenizer_class: Optional[str] = None |
| 44 | + # From safetensors headers (via get_safetensors_metadata) |
| 45 | + total_parameters: Optional[int] = None |
| 46 | + dtype_counts: Dict[str, int] = field(default_factory=dict) |
| 47 | + user_metadata: Dict[str, str] = field(default_factory=dict) |
| 48 | + |
| 49 | + |
| 50 | +TensorInfoResult = Dict[str, Union[int, Dict[str, int]]] |
| 51 | + |
| 52 | + |
| 53 | +def _extract_tensor_info(tensors: dict) -> TensorInfoResult: |
| 54 | + """Extract parameter count and dtype distribution from safetensors tensor metadata. |
| 55 | +
|
| 56 | + tensors: dict mapping tensor name → object with .dtype and .shape attributes |
| 57 | + (from huggingface_hub's SafetensorsFileMetadata.tensors or compatible mock). |
| 58 | + """ |
| 59 | + total_parameters = 0 |
| 60 | + dtype_counter: Counter = Counter() |
| 61 | + |
| 62 | + for _, tensor in tensors.items(): |
| 63 | + shape = tensor.shape |
| 64 | + param_count = math.prod(shape) if shape else 0 |
| 65 | + total_parameters += param_count |
| 66 | + dtype_counter[tensor.dtype] += 1 |
| 67 | + |
| 68 | + return { |
| 69 | + "total_parameters": total_parameters, |
| 70 | + "dtype_counts": dict(dtype_counter), |
| 71 | + } |
| 72 | + |
| 73 | + |
| 74 | +MetadataValue = Union[str, int, Dict[str, int]] |
| 75 | +MetadataDict = Dict[str, MetadataValue] |
| 76 | + |
| 77 | + |
| 78 | +def map_to_metadata(info: SafetensorsModelInfo) -> MetadataDict: |
| 79 | + """Map SafetensorsModelInfo to the same dict format as gguf_metadata.map_to_metadata(). |
| 80 | +
|
| 81 | + Output structure mirrors GGUF: model_type, typeOfModel, vocab_size, context_length |
| 82 | + at top level; hyperparameter dict with non-None hyperparams; safetensors-specific fields. |
| 83 | + """ |
| 84 | + metadata: MetadataDict = {} |
| 85 | + |
| 86 | + # Core fields (same as gguf_metadata._map_core_fields) |
| 87 | + if info.architecture is not None: |
| 88 | + metadata["model_type"] = info.architecture |
| 89 | + metadata["typeOfModel"] = info.architecture |
| 90 | + |
| 91 | + if info.vocab_size is not None: |
| 92 | + metadata["vocab_size"] = info.vocab_size |
| 93 | + |
| 94 | + if info.context_length is not None: |
| 95 | + metadata["context_length"] = info.context_length |
| 96 | + |
| 97 | + if info.tokenizer_class is not None: |
| 98 | + metadata["tokenizer_class"] = info.tokenizer_class |
| 99 | + |
| 100 | + # Hyperparameter dict (same as gguf_metadata._map_hyperparameters) |
| 101 | + hyperparams: Dict[str, int] = {} |
| 102 | + for field_name in ( |
| 103 | + "context_length", "embedding_length", "block_count", |
| 104 | + "attention_head_count", "attention_head_count_kv", |
| 105 | + "feed_forward_length", "rope_dimension_count", |
| 106 | + ): |
| 107 | + value = getattr(info, field_name) |
| 108 | + if value is not None: |
| 109 | + hyperparams[field_name] = value |
| 110 | + |
| 111 | + if hyperparams: |
| 112 | + metadata["hyperparameter"] = hyperparams |
| 113 | + |
| 114 | + # Safetensors-specific |
| 115 | + if info.total_parameters is not None: |
| 116 | + metadata["safetensors_total_parameters"] = info.total_parameters |
| 117 | + |
| 118 | + return metadata |
| 119 | + |
| 120 | + |
| 121 | +def fetch_safetensors_metadata( |
| 122 | + repo_id: str, *, hf_token: Optional[str] = None |
| 123 | +) -> Optional[SafetensorsModelInfo]: |
| 124 | + """Fetch config.json + safetensors headers from a HuggingFace repo. |
| 125 | +
|
| 126 | + Returns None if config.json is missing (can't extract hyperparameters). |
| 127 | + Returns partial info if safetensors headers are unavailable. |
| 128 | + """ |
| 129 | + # Step 1: Fetch config.json (required) |
| 130 | + try: |
| 131 | + config_path = hf_hub_download(repo_id, "config.json", token=hf_token) |
| 132 | + with open(config_path) as f: |
| 133 | + config = json.load(f) |
| 134 | + except Exception as e: |
| 135 | + logger.warning(f"Could not fetch config.json for {repo_id}: {e}") |
| 136 | + return None |
| 137 | + |
| 138 | + parsed = parse_config(config) |
| 139 | + |
| 140 | + info = SafetensorsModelInfo( |
| 141 | + architecture=parsed.get("architecture"), |
| 142 | + context_length=parsed.get("context_length"), |
| 143 | + embedding_length=parsed.get("embedding_length"), |
| 144 | + block_count=parsed.get("block_count"), |
| 145 | + attention_head_count=parsed.get("attention_head_count"), |
| 146 | + attention_head_count_kv=parsed.get("attention_head_count_kv"), |
| 147 | + feed_forward_length=parsed.get("feed_forward_length"), |
| 148 | + rope_dimension_count=parsed.get("rope_dimension_count"), |
| 149 | + vocab_size=parsed.get("vocab_size"), |
| 150 | + ) |
| 151 | + |
| 152 | + # Step 2: Fetch tokenizer_config.json (optional — adds tokenizer_class) |
| 153 | + try: |
| 154 | + tok_path = hf_hub_download(repo_id, "tokenizer_config.json", token=hf_token) |
| 155 | + with open(tok_path) as f: |
| 156 | + tok_config = json.load(f) |
| 157 | + info.tokenizer_class = tok_config.get("tokenizer_class") |
| 158 | + except Exception: |
| 159 | + pass |
| 160 | + |
| 161 | + # Step 3: Fetch safetensors headers (optional — adds tensor info) |
| 162 | + try: |
| 163 | + api = HfApi() |
| 164 | + repo_meta = api.get_safetensors_metadata(repo_id, token=hf_token) |
| 165 | + |
| 166 | + # Aggregate tensors across all shard files |
| 167 | + all_tensors = {} |
| 168 | + for file_meta in repo_meta.files_metadata.values(): |
| 169 | + all_tensors.update(file_meta.tensors) |
| 170 | + |
| 171 | + tensor_info = _extract_tensor_info(all_tensors) |
| 172 | + info.total_parameters = tensor_info["total_parameters"] |
| 173 | + info.dtype_counts = tensor_info["dtype_counts"] |
| 174 | + except Exception as e: |
| 175 | + logger.info(f"No safetensors metadata for {repo_id}: {e}") |
| 176 | + |
| 177 | + return info |
0 commit comments