Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion src/models/extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@

from .schemas import DataSource, ConfidenceLevel, ExtractionResult
from .registry import get_field_registry_manager
from .model_file_extractors import ModelFileExtractor, default_extractors
from .model_file_extractors import (
ModelFileExtractor,
default_extractors,
extract_distribution_metadata,
)
from ..utils.license_utils import LICENSE_MAPPING
from .config_parsing import parse_config as _parse_hparams_from_config

Expand Down Expand Up @@ -205,6 +209,19 @@ def _registry_driven_extraction(self, model_id: str, model_info: Dict[str, Any],
extraction_method="model_file_header",
)

# Format-agnostic distribution metadata (model_file_size, runtime_requirement)
distribution_metadata = self._extract_distribution_metadata(model_id)
if distribution_metadata:
for key, value in distribution_metadata.items():
if value is not None and metadata.get(key) is None:
metadata[key] = value
self.extraction_results[key] = ExtractionResult(
value=value,
source=DataSource.REPOSITORY_FILES,
confidence=ConfidenceLevel.HIGH,
extraction_method="repository_file_listing",
)

# Always extract commit SHA if available (vital for BOM versioning)
if 'commit' not in metadata:
commit_sha = getattr(model_info, 'sha', None)
Expand All @@ -216,6 +233,14 @@ def _registry_driven_extraction(self, model_id: str, model_info: Dict[str, Any],

return metadata

def _extract_distribution_metadata(self, model_id: str) -> Dict[str, Any]:
"""Extract repo-level distribution attributes (size, runtime requirement)."""
try:
return extract_distribution_metadata(model_id, self.hf_api)
except Exception as e:
logger.warning(f"Distribution metadata extraction failed for {model_id}: {e}")
return {}

def _extract_model_file_metadata(self, model_id: str) -> Dict[str, Any]:
for extractor in self.model_file_extractors:
try:
Expand Down
78 changes: 78 additions & 0 deletions src/models/field_registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -1709,6 +1709,84 @@
"reference_urls": {
"genai_aibom_taxonomy": "https://github.com/GenAI-Security-Project/cyclonedx-property-taxonomy"
}
},
"model_file_size": {
"tier": "supplementary",
"weight": 1.0,
"category": "component_model_card",
"description": "Total size in bytes of the model weight files in the repository",
"jsonpath": "$.components[0].modelCard.properties[?(@.name=='owasp:aibom:distribution:modelFileSize')].value",
"aibom_generation": {
"location": "$.components[0].properties",
"rule": "include_if_available",
"source_fields": [
"model_file_size"
],
"validation": "optional",
"data_type": "integer"
},
"extraction": {
"methods": [
"repository_files"
],
"source_priority": [
"repository_files"
]
},
"scoring": {
"points": 1.0,
"required_for_profiles": [
"advanced"
],
"category_contribution": 0.033
},
"validation_message": {
"missing": "Missing supplementary field: model_file_size - total weight file size helpful for deployment planning",
"recommendation": "Add the total size in bytes of the model weight files"
},
"reference_urls": {
"note": "Custom OWASP extension property (owasp:aibom:distribution namespace); not part of the official genai:aibom taxonomy",
"cyclonedx_properties": "https://cyclonedx.org/docs/1.6/json/#components_items_properties"
}
},
"runtime_requirement": {
"tier": "supplementary",
"weight": 1.0,
"category": "component_model_card",
"description": "Runtime needed to execute the model based on its weight format: 'python' (loadable from a Python ML stack such as transformers/PyTorch) or 'llm-stack' (requires an LLM inference stack such as llama.cpp/Ollama for GGUF/GGML weights)",
"jsonpath": "$.components[0].modelCard.properties[?(@.name=='owasp:aibom:distribution:runtimeRequirement')].value",
"aibom_generation": {
"location": "$.components[0].properties",
"rule": "include_if_available",
"source_fields": [
"runtime_requirement"
],
"validation": "optional",
"data_type": "string"
},
"extraction": {
"methods": [
"repository_files"
],
"source_priority": [
"repository_files"
]
},
"scoring": {
"points": 1.0,
"required_for_profiles": [
"advanced"
],
"category_contribution": 0.033
},
"validation_message": {
"missing": "Missing supplementary field: runtime_requirement - runtime/execution environment helpful for deployment",
"recommendation": "Add the runtime requirement ('python' or 'llm-stack') based on the model weight format"
},
"reference_urls": {
"note": "Custom OWASP extension property (owasp:aibom:distribution namespace); not part of the official genai:aibom taxonomy",
"cyclonedx_properties": "https://cyclonedx.org/docs/1.6/json/#components_items_properties"
}
}
}
}
77 changes: 75 additions & 2 deletions src/models/model_file_extractors.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,33 @@
import logging
from typing import Protocol, Dict, List, Union, runtime_checkable
import os
import re
from typing import Optional, Protocol, Dict, List, Union, runtime_checkable

from huggingface_hub import list_repo_files
from huggingface_hub import HfApi, list_repo_files

from .gguf_metadata import fetch_gguf_metadata_from_repo, map_to_metadata as gguf_map_to_metadata
from .safetensors_metadata import fetch_safetensors_metadata, map_to_metadata as st_map_to_metadata

logger = logging.getLogger(__name__)

# Weight-file formats loadable directly from a Python ML stack
# (transformers / PyTorch / TensorFlow / ONNX Runtime, etc.).
PYTHON_WEIGHT_EXTENSIONS = {
".safetensors", ".bin", ".pt", ".pth", ".ckpt",
".h5", ".msgpack", ".onnx", ".tflite", ".pb",
}
# Weight-file formats that require a dedicated LLM inference stack
# (llama.cpp / Ollama / LM Studio, etc.) rather than a Python import.
LLM_STACK_WEIGHT_EXTENSIONS = {".gguf", ".ggml"}

# Taxonomy values for the runtime_requirement attribute.
RUNTIME_PYTHON = "python"
RUNTIME_LLM_STACK = "llm-stack"

# Matches HuggingFace shard suffixes like "-00001-of-00002" or ".00001-of-00003"
# so shards of the same model/quant variant can be grouped together.
_SHARD_SUFFIX_RE = re.compile(r"[-.]?\d{3,5}-of-\d{3,5}")


@runtime_checkable
class ModelFileExtractor(Protocol):
Expand Down Expand Up @@ -61,3 +81,56 @@ def extract_metadata(self, model_id: str) -> Dict[str, Union[str, int, dict]]:

def default_extractors() -> List[ModelFileExtractor]:
return [SafetensorsFileExtractor(), GGUFFileExtractor()]


def extract_distribution_metadata(
model_id: str, hf_api: Optional[HfApi] = None
) -> Dict[str, Union[str, int]]:
"""Extract format-agnostic distribution metadata for a model repo.

Computes two attributes from the repository file listing (with sizes):
* ``model_file_size`` — total size in bytes of all model weight files
* ``runtime_requirement`` — ``python`` if the weights load from a Python ML
stack (e.g. safetensors/PyTorch), else ``llm-stack`` if the only weights
are GGUF/GGML (require llama.cpp/Ollama-style runtimes).

Unlike the per-format ``ModelFileExtractor`` implementations, this runs for
every repo regardless of weight format. Returns an empty dict on failure.
"""
api = hf_api or HfApi()
try:
info = api.model_info(model_id, files_metadata=True)
except Exception as e:
logger.warning(f"Distribution metadata fetch failed for {model_id}: {e}")
return {}

total_size = 0
has_python_weights = False
has_llm_stack_weights = False

for sibling in getattr(info, "siblings", None) or []:
filename = getattr(sibling, "rfilename", "") or ""
ext = os.path.splitext(filename.lower())[1]
size = getattr(sibling, "size", None)

if ext in PYTHON_WEIGHT_EXTENSIONS:
has_python_weights = True
elif ext in LLM_STACK_WEIGHT_EXTENSIONS:
has_llm_stack_weights = True
else:
continue # not a weight file — ignore for size + runtime classification

if isinstance(size, int) and size > 0:
total_size += size

metadata: Dict[str, Union[str, int]] = {}
if total_size > 0:
metadata["model_file_size"] = total_size
# A repo that ships Python-loadable weights can run in Python even if it also
# ships GGUF; only GGUF/GGML-exclusive repos require an LLM stack.
if has_python_weights:
metadata["runtime_requirement"] = RUNTIME_PYTHON
elif has_llm_stack_weights:
metadata["runtime_requirement"] = RUNTIME_LLM_STACK

return metadata
11 changes: 11 additions & 0 deletions src/models/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,17 @@ def _create_model_card_section(self, metadata: Dict[str, Any]) -> Dict[str, Any]
props.append({"name": "genai:aibom:modelcard:quantizationFileType", "value": str(q_dict["file_type"])})
taxonomy_mapped_keys.append("quantization")

# Distribution properties — custom OWASP namespace. These are NOT part of the
# official genai:aibom taxonomy, which forbids unofficial names under genai:aibom.
distribution_mapping = {
"model_file_size": "modelFileSize",
"runtime_requirement": "runtimeRequirement",
}
for p_key, p_name in distribution_mapping.items():
if metadata.get(p_key) is not None:
props.append({"name": f"owasp:aibom:distribution:{p_name}", "value": str(metadata[p_key])})
taxonomy_mapped_keys.append(p_key)

# Basic Fields we've already mapped to structured homes
mapped_fields = [
"primaryPurpose", "typeOfModel", "suppliedBy", "intendedUse",
Expand Down
29 changes: 28 additions & 1 deletion src/templates/result.html
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,38 @@ <h4>📊&nbsp;&nbsp;Model Card</h4>
aibom.components[0].modelCard.modelParameters.task else 'Not specified' }}</span>
</div>
{% endif %}
{# Pull out the distribution (deployment/hardware) properties for a friendly render #}
{% set dist = namespace(file_size=None, runtime=None) %}
{% if aibom.components[0].modelCard.properties %}
{% for prop in aibom.components[0].modelCard.properties %}
{% if prop.name == 'owasp:aibom:distribution:modelFileSize' %}{% set dist.file_size = prop.value %}{% endif %}
{% if prop.name == 'owasp:aibom:distribution:runtimeRequirement' %}{% set dist.runtime = prop.value %}{% endif %}
{% endfor %}
{% endif %}
{% if dist.file_size or dist.runtime %}
<div class="aibom-property">
<span class="property-name">Deployment &amp; Hardware:</span>
<span class="property-value">
{% if dist.file_size %}
<span class="tag">Model Size: {{ dist.file_size | int | filesizeformat }}</span>
{% endif %}
{% if dist.runtime == 'python' %}
<span class="tag">Runtime: Python ML stack (transformers/PyTorch)</span>
<span class="tag">Hardware: CPU-friendly, no extra runtime stack</span>
{% elif dist.runtime == 'llm-stack' %}
<span class="tag">Runtime: LLM runtime (llama.cpp / Ollama / LM Studio)</span>
<span class="tag">Hardware: needs an inference stack + more RAM/VRAM</span>
{% endif %}
</span>
</div>
{% endif %}
{# Any remaining modelCard properties that are not deployment ones #}
{% set other_props = aibom.components[0].modelCard.properties | rejectattr('name', 'in', ['owasp:aibom:distribution:modelFileSize', 'owasp:aibom:distribution:runtimeRequirement']) | list if aibom.components[0].modelCard.properties else [] %}
{% if other_props %}
<div class="aibom-property">
<span class="property-name">Additional Properties:</span>
<span class="property-value">
{% for prop in aibom.components[0].modelCard.properties %}
{% for prop in other_props %}
<span class="tag">{{ prop.name }}: {{ prop.value }}</span>
{% endfor %}
</span>
Expand Down
Loading
Loading