Skip to content

Commit b988d41

Browse files
authored
Merge pull request #70 from afogel/safetensors_extract_hyperparameters
feat: safetensors hyperparameter extraction with GGUF parity
2 parents dc31236 + 93e5347 commit b988d41

13 files changed

Lines changed: 1228 additions & 111 deletions

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ dependencies = [
3636
"torch>=2.0.0",
3737
"transformers>=4.36.0",
3838
"uvicorn>=0.24.0",
39+
"safetensors>=0.4.0",
3940
]
4041

4142
[project.optional-dependencies]
@@ -71,5 +72,9 @@ pythonpath = [
7172

7273
[dependency-groups]
7374
dev = [
75+
"pytest>=7.0.0",
76+
"pytest-cov>=4.0.0",
77+
"pytest-mock>=3.10.0",
78+
"ruff",
7479
"gguf>=0.6.0",
7580
]

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ nltk>=3.8.0
1919
python-dateutil>=2.8.0
2020
jsonschema>=4.17.0
2121
sentencepiece>=0.1.99
22+
safetensors>=0.4.0
2223

2324
# Test dependencies
2425
pytest>=7.0.0

src/models/__init__.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from .schemas import (
2-
DataSource,
3-
ConfidenceLevel,
2+
DataSource,
3+
ConfidenceLevel,
44
ExtractionResult,
5-
GenerateRequest,
6-
BatchRequest,
7-
AIBOMResponse,
5+
GenerateRequest,
6+
BatchRequest,
7+
AIBOMResponse,
88
EnhancementReport
99
)
1010
from .registry import get_field_registry_manager

src/models/config_parsing.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""
2+
config.json parsing for HuggingFace model repositories.
3+
4+
Extracts hyperparameters using llama.cpp's find_hparam key fallback chains.
5+
Works for any model format (safetensors, GGUF, pytorch, etc.) — the config.json
6+
schema is format-agnostic.
7+
"""
8+
from typing import Dict, List, Optional, Union
9+
10+
# Exact key fallback order from llama.cpp convert_hf_to_gguf.py
11+
# (see research.md section 12.4 for source line references)
12+
HPARAM_KEYS: Dict[str, List[str]] = {
13+
"block_count": ["n_layers", "num_hidden_layers", "n_layer", "num_layers"],
14+
"context_length": ["max_position_embeddings", "n_ctx", "n_positions",
15+
"max_length", "max_sequence_length", "model_max_length"],
16+
"embedding_length": ["hidden_size", "n_embd", "dim"],
17+
"feed_forward_length": ["intermediate_size", "n_inner", "hidden_dim"],
18+
"attention_head_count": ["num_attention_heads", "n_head", "n_heads"],
19+
"attention_head_count_kv": ["num_key_value_heads", "n_kv_heads"],
20+
"rope_dimension_count": ["rotary_dim", "rope_dim"],
21+
"vocab_size": ["vocab_size"],
22+
"architecture": ["model_type"],
23+
}
24+
25+
26+
ParsedConfig = Dict[str, Optional[Union[str, int]]]
27+
28+
29+
def parse_config(config: dict) -> ParsedConfig:
30+
"""Extract hyperparameters from config.json using llama.cpp's find_hparam key fallback chains.
31+
32+
Handles VLM models that nest text params under text_config (llama.cpp L800-802).
33+
34+
Returns a dict with canonical keys (block_count, embedding_length, etc.)
35+
and None for any fields not found in the config.
36+
"""
37+
# VLM merge: text_config values override root, mirroring llama.cpp
38+
if "text_config" in config:
39+
merged = dict(config)
40+
merged.update(config["text_config"])
41+
config = merged
42+
43+
result: ParsedConfig = {}
44+
for canonical_name, candidate_keys in HPARAM_KEYS.items():
45+
value = None
46+
for key in candidate_keys:
47+
if key in config:
48+
value = config[key]
49+
break
50+
result[canonical_name] = value
51+
52+
return result

src/models/extractor.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,18 @@
1414
from .schemas import DataSource, ConfidenceLevel, ExtractionResult
1515
from .registry import get_field_registry_manager
1616
from .model_file_extractors import ModelFileExtractor, default_extractors
17+
from .config_parsing import parse_config as _parse_hparams_from_config
1718

1819
logger = logging.getLogger(__name__)
1920

21+
22+
def _build_hyperparameters_from_config(config_data: dict) -> Optional[str]:
23+
"""Build hyperparameter JSON from config.json using llama.cpp key fallback chains."""
24+
parsed = _parse_hparams_from_config(config_data)
25+
hp = {k: v for k, v in parsed.items() if v is not None and k != "architecture"}
26+
return json.dumps(hp) if hp else None
27+
28+
2029
class EnhancedExtractor:
2130
"""
2231
Registry-integrated enhanced extractor that automatically picks up new fields
@@ -512,6 +521,13 @@ def _try_model_card_extraction(self, field_name: str, context: Dict[str, Any]) -
512521

513522
def _try_config_extraction(self, field_name: str, context: Dict[str, Any]) -> Any:
514523
"""Try to extract field from configuration files"""
524+
# Hyperparameter extraction from config.json using llama.cpp key fallback chains
525+
if field_name == "hyperparameter":
526+
config_data = context.get("config_data")
527+
if config_data:
528+
return _build_hyperparameters_from_config(config_data)
529+
return None
530+
515531
# Config file mappings
516532
config_mappings = {
517533
'model_type': ('config_data', 'model_type'),
@@ -520,13 +536,13 @@ def _try_config_extraction(self, field_name: str, context: Dict[str, Any]) -> An
520536
'tokenizer_class': ('tokenizer_config', 'tokenizer_class'),
521537
'typeOfModel': ('config_data', 'model_type')
522538
}
523-
539+
524540
if field_name in config_mappings:
525541
config_type, config_key = config_mappings[field_name]
526542
config_source = context.get(config_type)
527543
if config_source:
528544
return config_source.get(config_key)
529-
545+
530546
return None
531547

532548
def _try_text_pattern_extraction(self, field_name: str, context: Dict[str, Any]) -> Any:
Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,29 @@
11
import logging
2-
from typing import Protocol, Dict, Any, List, runtime_checkable
2+
from typing import Protocol, Dict, List, Union, runtime_checkable
33

4-
from .gguf_metadata import fetch_gguf_metadata_from_repo, map_to_metadata
4+
from huggingface_hub import list_repo_files
5+
6+
from .gguf_metadata import fetch_gguf_metadata_from_repo, map_to_metadata as gguf_map_to_metadata
7+
from .safetensors_metadata import fetch_safetensors_metadata, map_to_metadata as st_map_to_metadata
58

69
logger = logging.getLogger(__name__)
710

811

912
@runtime_checkable
1013
class ModelFileExtractor(Protocol):
1114
def can_extract(self, model_id: str) -> bool: ...
12-
def extract_metadata(self, model_id: str) -> Dict[str, Any]: ...
15+
def extract_metadata(self, model_id: str) -> Dict[str, Union[str, int, dict]]: ...
1316

1417

1518
class GGUFFileExtractor:
1619

1720
def can_extract(self, model_id: str) -> bool:
1821
try:
19-
from huggingface_hub import list_repo_files
2022
return any(f.endswith(".gguf") for f in list_repo_files(model_id))
2123
except Exception:
2224
return False
2325

24-
def extract_metadata(self, model_id: str) -> Dict[str, Any]:
25-
from huggingface_hub import list_repo_files
26-
26+
def extract_metadata(self, model_id: str) -> Dict[str, Union[str, int, dict]]:
2727
try:
2828
files = list_repo_files(model_id)
2929
gguf_files = [f for f in files if f.endswith(".gguf")]
@@ -34,11 +34,30 @@ def extract_metadata(self, model_id: str) -> Dict[str, Any]:
3434
if model_info is None:
3535
return {}
3636

37-
return map_to_metadata(model_info)
37+
return gguf_map_to_metadata(model_info)
3838
except Exception as e:
3939
logger.warning(f"GGUF extraction failed for {model_id}: {e}")
4040
return {}
4141

4242

43+
class SafetensorsFileExtractor:
44+
45+
def can_extract(self, model_id: str) -> bool:
46+
try:
47+
return any(f.endswith(".safetensors") for f in list_repo_files(model_id))
48+
except Exception:
49+
return False
50+
51+
def extract_metadata(self, model_id: str) -> Dict[str, Union[str, int, dict]]:
52+
try:
53+
info = fetch_safetensors_metadata(model_id)
54+
if info is None:
55+
return {}
56+
return st_map_to_metadata(info)
57+
except Exception as e:
58+
logger.warning(f"Safetensors extraction failed for {model_id}: {e}")
59+
return {}
60+
61+
4362
def default_extractors() -> List[ModelFileExtractor]:
44-
return [GGUFFileExtractor()]
63+
return [SafetensorsFileExtractor(), GGUFFileExtractor()]

src/models/safetensors_metadata.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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

Comments
 (0)