feat: safe tensors

Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
This commit is contained in:
Christopher Phillips 2026-04-30 13:33:17 -04:00
parent ff6c34de7e
commit cd27d55f2a
No known key found for this signature in database
17 changed files with 5380 additions and 25 deletions

View File

@ -3,12 +3,13 @@ package internal
const ( const (
// JSONSchemaVersion is the current schema version output by the JSON encoder // JSONSchemaVersion is the current schema version output by the JSON encoder
// This is roughly following the "SchemaVer" guidelines for versioning the JSON schema. Please see schema/json/README.md for details on how to increment. // This is roughly following the "SchemaVer" guidelines for versioning the JSON schema. Please see schema/json/README.md for details on how to increment.
JSONSchemaVersion = "16.1.3" JSONSchemaVersion = "16.1.4"
// Changelog // Changelog
// 16.1.0 - reformulated the python pdm fields (added "URL" and removed the unused "path" field). // 16.1.0 - reformulated the python pdm fields (added "URL" and removed the unused "path" field).
// 16.1.1 - correct elf package osCpe field according to the document of systemd (also add appCpe field) // 16.1.1 - correct elf package osCpe field according to the document of systemd (also add appCpe field)
// 16.1.2 - placeholder for 16.1.2 changelog // 16.1.2 - placeholder for 16.1.2 changelog
// 16.1.3 - add GGUFFileParts to GGUFFileHeader metadata // 16.1.3 - add GGUFFileParts to GGUFFileHeader metadata
// 16.1.4 - add SafeTensorsMetadata
) )

View File

@ -27,6 +27,7 @@ var knownNonMetadataTypeNames = strset.New(
var knownMetadataTypeNames = strset.New( var knownMetadataTypeNames = strset.New(
"DotnetPortableExecutableEntry", "DotnetPortableExecutableEntry",
"GGUFFileHeader", "GGUFFileHeader",
"SafeTensorsMetadata",
) )
func DiscoverTypeNames() ([]string, error) { func DiscoverTypeNames() ([]string, error) {

View File

@ -64,6 +64,7 @@ func AllTypes() []any {
pkg.RubyGemspec{}, pkg.RubyGemspec{},
pkg.RustBinaryAuditEntry{}, pkg.RustBinaryAuditEntry{},
pkg.RustCargoLockEntry{}, pkg.RustCargoLockEntry{},
pkg.SafeTensorsMetadata{},
pkg.SnapEntry{}, pkg.SnapEntry{},
pkg.SwiftPackageManagerResolvedEntry{}, pkg.SwiftPackageManagerResolvedEntry{},
pkg.SwiplPackEntry{}, pkg.SwiplPackEntry{},

View File

@ -126,6 +126,7 @@ var jsonTypes = makeJSONTypes(
jsonNames(pkg.DotnetPackagesLockEntry{}, "dotnet-packages-lock-entry"), jsonNames(pkg.DotnetPackagesLockEntry{}, "dotnet-packages-lock-entry"),
jsonNames(pkg.CondaMetaPackage{}, "conda-metadata-entry", "CondaPackageMetadata"), jsonNames(pkg.CondaMetaPackage{}, "conda-metadata-entry", "CondaPackageMetadata"),
jsonNames(pkg.GGUFFileHeader{}, "gguf-file-header"), jsonNames(pkg.GGUFFileHeader{}, "gguf-file-header"),
jsonNames(pkg.SafeTensorsMetadata{}, "safetensors-metadata"),
) )
func expandLegacyNameVariants(names ...string) []string { func expandLegacyNameVariants(names ...string) []string {

View File

@ -180,6 +180,7 @@ func DefaultPackageTaskFactories() Factories {
newSimplePackageTaskFactory(conda.NewCondaMetaCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.PackageTag, "conda"), newSimplePackageTaskFactory(conda.NewCondaMetaCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.PackageTag, "conda"),
newSimplePackageTaskFactory(snap.NewCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.ImageTag, "snap"), newSimplePackageTaskFactory(snap.NewCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.ImageTag, "snap"),
newSimplePackageTaskFactory(ai.NewGGUFCataloger, pkgcataloging.DirectoryTag, pkgcataloging.ImageTag, "ai", "model", "gguf", "ml"), newSimplePackageTaskFactory(ai.NewGGUFCataloger, pkgcataloging.DirectoryTag, pkgcataloging.ImageTag, "ai", "model", "gguf", "ml"),
newSimplePackageTaskFactory(ai.NewSafeTensorsCataloger, pkgcataloging.DirectoryTag, pkgcataloging.ImageTag, "ai", "model", "safetensors", "ml"),
// deprecated catalogers //////////////////////////////////////// // deprecated catalogers ////////////////////////////////////////
// these are catalogers that should not be selectable other than specific inclusion via name or "deprecated" tag (to remain backwards compatible) // these are catalogers that should not be selectable other than specific inclusion via name or "deprecated" tag (to remain backwards compatible)

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "anchore.io/schema/syft/json/16.1.3/document", "$id": "anchore.io/schema/syft/json/16.1.4/document",
"$ref": "#/$defs/Document", "$ref": "#/$defs/Document",
"$defs": { "$defs": {
"AlpmDbEntry": { "AlpmDbEntry": {
@ -2741,6 +2741,9 @@
{ {
"$ref": "#/$defs/RustCargoLockEntry" "$ref": "#/$defs/RustCargoLockEntry"
}, },
{
"$ref": "#/$defs/SafetensorsMetadata"
},
{ {
"$ref": "#/$defs/SnapEntry" "$ref": "#/$defs/SnapEntry"
}, },
@ -4029,6 +4032,66 @@
], ],
"description": "RustCargoLockEntry represents a locked dependency from a Cargo.lock file with precise version and checksum information." "description": "RustCargoLockEntry represents a locked dependency from a Cargo.lock file with precise version and checksum information."
}, },
"SafetensorsMetadata": {
"properties": {
"format": {
"type": "string",
"description": "Format is the source format label (always \"safetensors\" for this metadata type).\nPresent because the Docker AI model config blob carries an explicit format field\nthat can also be \"gguf\", and recording it here makes the origin explicit."
},
"architecture": {
"type": "string",
"description": "Architecture is the model architecture (e.g., \"LlamaForCausalLM\",\n\"Qwen3MoeForConditionalGeneration\"), sourced from the Hugging Face config.json\n\"architectures\" array."
},
"quantization": {
"type": "string",
"description": "Quantization describes tensor precision (e.g., \"BF16\", \"F16\", \"F32\", \"INT8\")."
},
"parameters": {
"type": "string",
"description": "Parameters is the parameter count as reported by upstream. Stored as a string\nbecause Docker AI and Hugging Face labels use notation like \"2.68B\" or \"35B-A3B\"."
},
"tensorCount": {
"type": "integer",
"description": "TensorCount is the number of tensor entries in the file header."
},
"totalSize": {
"type": "string",
"description": "TotalSize is the total byte size of tensor data across all shards when known\n(from the Docker AI model config \"size\" field or the sharded index \"total_size\")."
},
"torchDtype": {
"type": "string",
"description": "TorchDtype is the Hugging Face torch_dtype (e.g., \"bfloat16\", \"float16\")."
},
"transformersVersion": {
"type": "string",
"description": "TransformersVersion is the transformers library version recorded in config.json."
},
"shardCount": {
"type": "integer",
"description": "ShardCount is the number of .safetensors shards for a sharded model (1 for a\nsingle-file model)."
},
"userMetadata": {
"additionalProperties": {
"type": "string"
},
"type": "object",
"description": "UserMetadata is the optional \"__metadata__\" map from a .safetensors file header\n(string-to-string key/values set by the producer)."
},
"metadataHash": {
"type": "string",
"description": "MetadataHash is an xxhash of the normalized header metadata, providing a stable\nidentifier for identical model content across repositories or filenames."
},
"parts": {
"items": {
"$ref": "#/$defs/SafetensorsMetadata"
},
"type": "array",
"description": "Parts contains metadata from additional SafeTensors shards or OCI layers that\nwere merged into this package during post-processing."
}
},
"type": "object",
"description": "SafeTensorsMetadata represents metadata extracted from a SafeTensors model."
},
"Schema": { "Schema": {
"properties": { "properties": {
"version": { "version": {

View File

@ -83,7 +83,7 @@ func SourceInfo(p pkg.Package) string {
case pkg.TerraformPkg: case pkg.TerraformPkg:
answer = "acquired package info from Terraform dependency lock file" answer = "acquired package info from Terraform dependency lock file"
case pkg.ModelPkg: case pkg.ModelPkg:
answer = "acquired package info from AI artifact (e.g. GGUF File)" answer = "acquired package info from AI model artifact"
default: default:
answer = "acquired package info from the following paths" answer = "acquired package info from the following paths"
} }

View File

@ -1,6 +1,6 @@
/* /*
Package ai provides concrete Cataloger implementations for AI artifacts and machine learning models, Package ai provides concrete Cataloger implementations for AI artifacts and machine learning models,
including support for GGUF (GPT-Generated Unified Format) model files. including support for GGUF (GPT-Generated Unified Format) and SafeTensors model files.
*/ */
package ai package ai
@ -10,8 +10,9 @@ import (
) )
const ( const (
catalogerName = "gguf-cataloger" catalogerName = "gguf-cataloger"
ggufLayerMediaType = "application/vnd.docker.ai*" ggufLayerMediaType = "application/vnd.docker.ai*"
safeTensorsCatalogerName = "safetensors-cataloger"
) )
// NewGGUFCataloger returns a new cataloger instance for GGUF model files. // NewGGUFCataloger returns a new cataloger instance for GGUF model files.
@ -23,3 +24,17 @@ func NewGGUFCataloger() pkg.Cataloger {
WithParserByMediaType(parseGGUFModel, ggufLayerMediaType). WithParserByMediaType(parseGGUFModel, ggufLayerMediaType).
WithProcessors(ggufMergeProcessor) WithProcessors(ggufMergeProcessor)
} }
// NewSafeTensorsCataloger returns a cataloger for SafeTensors model files,
// covering three discovery paths:
// - **/*.safetensors files (single-file models; header-only parse)
// - **/model.safetensors.index.json files (sharded models)
// - application/vnd.docker.ai.model.config.v0.1+json OCI layers (Docker Model
// Runner artifacts whose config advertises format=="safetensors")
func NewSafeTensorsCataloger() pkg.Cataloger {
return generic.NewCataloger(safeTensorsCatalogerName).
WithParserByGlobs(parseSafeTensorsFile, "**/*.safetensors").
WithParserByGlobs(parseSafeTensorsIndex, "**/*.safetensors.index.json").
WithParserByMediaType(parseSafeTensorsOCIConfig, dockerAIModelConfigMediaType).
WithProcessors(safeTensorsMergeProcessor)
}

View File

@ -20,3 +20,18 @@ func newGGUFPackage(metadata *pkg.GGUFFileHeader, modelName, version, license st
return p return p
} }
func newSafeTensorsPackage(metadata *pkg.SafeTensorsMetadata, modelName, version, license string, locations ...file.Location) pkg.Package {
p := pkg.Package{
Name: modelName,
Version: version,
Locations: file.NewLocationSet(locations...),
Type: pkg.ModelPkg,
Licenses: pkg.NewLicenseSet(pkg.NewLicensesFromValues(license)...),
Metadata: *metadata,
// PURL is intentionally not set: package-url has not yet finalized ML model support.
}
p.SetID()
return p
}

View File

@ -0,0 +1,174 @@
package ai
import (
"encoding/binary"
"encoding/json"
"fmt"
"io"
"sort"
"strings"
"github.com/cespare/xxhash/v2"
)
// SafeTensors file format: [8 bytes u64 LE header size] [N bytes JSON header] [tensor data].
// Reference: https://github.com/huggingface/safetensors#format
const (
maxSafeTensorsHeaderSize = 100 * 1024 * 1024 // 100MB ceiling on header JSON to prevent OOM
)
// safeTensorsHeader is the decoded JSON header. Tensor entries live alongside a
// reserved "__metadata__" key holding a string-to-string producer map. We decode
// tensor entries into a generic map so we can iterate and count without a fixed
// schema for every field.
type safeTensorsHeader struct {
metadata map[string]string
tensors map[string]safeTensorsEntry
}
// safeTensorsEntry describes a single tensor within the header JSON.
type safeTensorsEntry struct {
DType string `json:"dtype"`
Shape []int64 `json:"shape"`
DataOffsets []int64 `json:"data_offsets"`
}
// readSafeTensorsHeader reads and parses the JSON header from a .safetensors file.
// It returns the decoded header plus the on-disk size of the header JSON in bytes.
func readSafeTensorsHeader(r io.Reader) (*safeTensorsHeader, uint64, error) {
var lenBuf [8]byte
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
return nil, 0, fmt.Errorf("failed to read header length: %w", err)
}
headerLen := binary.LittleEndian.Uint64(lenBuf[:])
if headerLen == 0 {
return nil, 0, fmt.Errorf("safetensors header length is zero")
}
if headerLen > maxSafeTensorsHeaderSize {
return nil, 0, fmt.Errorf("safetensors header size %d exceeds maximum %d", headerLen, maxSafeTensorsHeaderSize)
}
body := make([]byte, headerLen)
if _, err := io.ReadFull(r, body); err != nil {
return nil, 0, fmt.Errorf("failed to read header body: %w", err)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return nil, 0, fmt.Errorf("failed to decode safetensors header JSON: %w", err)
}
h := &safeTensorsHeader{tensors: make(map[string]safeTensorsEntry, len(raw))}
for key, val := range raw {
if key == "__metadata__" {
if err := json.Unmarshal(val, &h.metadata); err != nil {
return nil, 0, fmt.Errorf("failed to decode __metadata__: %w", err)
}
continue
}
var entry safeTensorsEntry
if err := json.Unmarshal(val, &entry); err != nil {
// Not all entries must conform; skip anything we cannot decode rather than fail.
continue
}
h.tensors[key] = entry
}
return h, headerLen, nil
}
// parameterCount sums the element counts across all tensors in the header.
func (h *safeTensorsHeader) parameterCount() uint64 {
var total uint64
for _, t := range h.tensors {
count := uint64(1)
for _, dim := range t.Shape {
if dim <= 0 {
count = 0
break
}
count *= uint64(dim)
}
total += count
}
return total
}
// dominantDType returns the dtype that accounts for the largest fraction of parameters.
// For mixed-precision models the "dominant" dtype is still a useful summary.
func (h *safeTensorsHeader) dominantDType() string {
sizeByDType := make(map[string]uint64)
for _, t := range h.tensors {
count := uint64(1)
for _, dim := range t.Shape {
if dim <= 0 {
count = 0
break
}
count *= uint64(dim)
}
sizeByDType[t.DType] += count
}
var best string
var bestSize uint64
for dtype, size := range sizeByDType {
if size > bestSize || (size == bestSize && dtype < best) {
best = dtype
bestSize = size
}
}
return best
}
// metadataHash returns a stable xxhash64 over the tensor entries + __metadata__.
// Tensor keys are sorted to keep the hash deterministic across producers.
func (h *safeTensorsHeader) metadataHash() string {
type entry struct {
Name string `json:"name"`
Entry safeTensorsEntry `json:"entry"`
}
entries := make([]entry, 0, len(h.tensors))
for name, t := range h.tensors {
entries = append(entries, entry{Name: name, Entry: t})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
type hashInput struct {
Tensors []entry `json:"tensors"`
Metadata map[string]string `json:"metadata,omitempty"`
}
b, err := json.Marshal(hashInput{Tensors: entries, Metadata: h.metadata})
if err != nil {
return ""
}
return fmt.Sprintf("%016x", xxhash.Sum64(b))
}
// normalizeDType maps a safetensors/torch dtype label to an uppercase quantization
// shorthand matching conventions used elsewhere in syft (e.g., BF16, F16, I8).
func normalizeDType(dtype string) string {
switch strings.ToUpper(dtype) {
case "BF16":
return "BF16"
case "F16", "FP16", "FLOAT16", "HALF":
return "F16"
case "F32", "FP32", "FLOAT32", "FLOAT":
return "F32"
case "F64", "FP64", "FLOAT64", "DOUBLE":
return "F64"
case "I8", "INT8":
return "I8"
case "U8", "UINT8":
return "U8"
case "I16", "INT16":
return "I16"
case "I32", "INT32":
return "I32"
case "I64", "INT64":
return "I64"
case "BOOL":
return "BOOL"
default:
return strings.ToUpper(dtype)
}
}

View File

@ -0,0 +1,305 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"path"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/unknown"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
// parseSafeTensorsFile parses a single .safetensors file by reading only its
// JSON header, then enriches the resulting package with metadata from sibling
// config.json and README.md files when the resolver can find them.
func parseSafeTensorsFile(_ context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
defer internal.CloseAndLogError(reader, reader.Path())
header, _, err := readSafeTensorsHeader(&io.LimitedReader{R: reader, N: maxSafeTensorsHeaderSize + 8})
if err != nil {
return nil, nil, fmt.Errorf("failed to read safetensors header: %w", err)
}
md := pkg.SafeTensorsMetadata{
Format: "safetensors",
TensorCount: uint64(len(header.tensors)),
Quantization: normalizeDType(header.dominantDType()),
ShardCount: 1,
UserMetadata: header.metadata,
MetadataHash: header.metadataHash(),
}
if p := header.parameterCount(); p > 0 {
md.Parameters = formatParameterCount(p)
}
name, version, license := enrichFromSiblings(resolver, reader.Path(), &md)
if name == "" {
name = modelNameFromPath(reader.Path())
}
p := newSafeTensorsPackage(
&md,
name,
version,
license,
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
)
return []pkg.Package{p}, nil, unknown.IfEmptyf([]pkg.Package{p}, "unable to parse safetensors file")
}
// parseSafeTensorsIndex parses a model.safetensors.index.json file for a sharded
// model. The index lists every tensor and the shard file it lives in; from this
// we derive tensor count, unique shard count, and (when present) the producer-
// declared total_size.
func parseSafeTensorsIndex(_ context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
defer internal.CloseAndLogError(reader, reader.Path())
var doc struct {
Metadata struct {
TotalSize json.Number `json:"total_size"`
} `json:"metadata"`
WeightMap map[string]string `json:"weight_map"`
}
if err := json.NewDecoder(reader).Decode(&doc); err != nil {
return nil, nil, fmt.Errorf("failed to decode safetensors index JSON: %w", err)
}
shards := make(map[string]struct{}, 4)
for _, shard := range doc.WeightMap {
shards[shard] = struct{}{}
}
md := pkg.SafeTensorsMetadata{
Format: "safetensors",
TensorCount: uint64(len(doc.WeightMap)),
ShardCount: len(shards),
}
if doc.Metadata.TotalSize != "" {
md.TotalSize = formatByteSize(doc.Metadata.TotalSize.String())
}
name, version, license := enrichFromSiblings(resolver, reader.Path(), &md)
if name == "" {
name = modelNameFromIndexPath(reader.Path())
}
p := newSafeTensorsPackage(
&md,
name,
version,
license,
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
)
return []pkg.Package{p}, nil, unknown.IfEmptyf([]pkg.Package{p}, "unable to parse safetensors index")
}
// enrichFromSiblings looks for a sibling config.json and README.md next to the
// safetensors artifact and folds their values into the metadata struct. It
// returns a name, version, and license string derived from those sources, with
// the caller free to fall back to a filename-derived default.
func enrichFromSiblings(resolver file.Resolver, sourcePath string, md *pkg.SafeTensorsMetadata) (name, version, license string) {
if resolver == nil {
return "", "", ""
}
dir := path.Dir(sourcePath)
if cfg := readSiblingJSON(resolver, path.Join(dir, "config.json")); cfg != nil {
if md.Architecture == "" && len(cfg.Architectures) > 0 {
md.Architecture = cfg.Architectures[0]
}
if md.TorchDtype == "" {
md.TorchDtype = cfg.TorchDtype
}
if md.TransformersVersion == "" {
md.TransformersVersion = cfg.TransformersVersion
}
if cfg.NameOrPath != "" {
name = path.Base(cfg.NameOrPath)
}
}
if fm := readReadmeFrontmatter(resolver, path.Join(dir, "README.md")); fm != nil {
if license == "" {
license = fm.License
}
if name == "" && len(fm.BaseModel) > 0 {
name = path.Base(fm.BaseModel[0])
}
}
return name, version, license
}
// hfConfig is a minimal projection of Hugging Face config.json fields we care about.
type hfConfig struct {
Architectures []string `json:"architectures"`
TorchDtype string `json:"torch_dtype"`
TransformersVersion string `json:"transformers_version"`
NameOrPath string `json:"_name_or_path"`
}
func readSiblingJSON(resolver file.Resolver, p string) *hfConfig {
locations, err := resolver.FilesByPath(p)
if err != nil || len(locations) == 0 {
return nil
}
rc, err := resolver.FileContentsByLocation(locations[0])
if err != nil {
return nil
}
defer internal.CloseAndLogError(rc, p)
var cfg hfConfig
if err := json.NewDecoder(rc).Decode(&cfg); err != nil {
log.Debugf("failed to decode %s: %v", p, err)
return nil
}
return &cfg
}
// readmeFrontmatter holds the subset of YAML frontmatter fields we extract.
type readmeFrontmatter struct {
License string `yaml:"license"`
BaseModel []string `yaml:"base_model"`
}
// readReadmeFrontmatter extracts the leading YAML frontmatter block from a README.
// The block is delimited by "---" lines at the start of the file.
func readReadmeFrontmatter(resolver file.Resolver, p string) *readmeFrontmatter {
locations, err := resolver.FilesByPath(p)
if err != nil || len(locations) == 0 {
return nil
}
rc, err := resolver.FileContentsByLocation(locations[0])
if err != nil {
return nil
}
defer internal.CloseAndLogError(rc, p)
buf, err := io.ReadAll(io.LimitReader(rc, 1024*1024))
if err != nil {
return nil
}
return parseFrontmatter(buf)
}
// parseFrontmatter pulls the YAML block between the first and second "---" lines
// of a file (if present) and decodes known fields from it.
func parseFrontmatter(buf []byte) *readmeFrontmatter {
trimmed := bytes.TrimLeft(buf, "\xef\xbb\xbf \t\r\n")
if !bytes.HasPrefix(trimmed, []byte("---")) {
return nil
}
rest := trimmed[3:]
// trim the newline directly following the opening delimiter
if i := bytes.IndexByte(rest, '\n'); i >= 0 {
rest = rest[i+1:]
}
end := bytes.Index(rest, []byte("\n---"))
if end < 0 {
return nil
}
var fm readmeFrontmatter
if err := yaml.Unmarshal(rest[:end], &fm); err != nil {
log.Debugf("failed to parse README frontmatter: %v", err)
return nil
}
// base_model may also appear as a scalar; yaml.Unmarshal will fail silently in that case.
if fm.License == "" && len(fm.BaseModel) == 0 {
var alt struct {
License string `yaml:"license"`
BaseModel string `yaml:"base_model"`
}
if err := yaml.Unmarshal(rest[:end], &alt); err == nil {
fm.License = alt.License
if alt.BaseModel != "" {
fm.BaseModel = []string{alt.BaseModel}
}
}
}
return &fm
}
// modelNameFromPath turns "/models/foo/model.safetensors" into "foo".
// For a bare filename "weights.safetensors" we return "weights".
func modelNameFromPath(p string) string {
base := strings.TrimSuffix(filepath.Base(p), ".safetensors")
dir := filepath.Base(filepath.Dir(p))
if dir != "" && dir != "." && dir != string(filepath.Separator) {
return dir
}
return base
}
// modelNameFromIndexPath derives a model name from the index filename's parent
// directory, defaulting to "safetensors-model" if no useful directory name exists.
func modelNameFromIndexPath(p string) string {
dir := filepath.Base(filepath.Dir(p))
if dir != "" && dir != "." && dir != string(filepath.Separator) {
return dir
}
return "safetensors-model"
}
// formatParameterCount prints a count like 6_700_000_000 as "6.7B" using B/M/K
// thresholds matching the notation used by Hugging Face and Docker AI labels.
func formatParameterCount(n uint64) string {
switch {
case n >= 1_000_000_000:
return fmt.Sprintf("%.2fB", float64(n)/1_000_000_000)
case n >= 1_000_000:
return fmt.Sprintf("%.2fM", float64(n)/1_000_000)
case n >= 1_000:
return fmt.Sprintf("%.2fK", float64(n)/1_000)
default:
return fmt.Sprintf("%d", n)
}
}
// formatByteSize turns a numeric string (bytes) into a human-friendly size like
// "71.90GB". Non-numeric inputs are passed through unchanged so we never lose
// producer-declared strings such as "71.90GB".
func formatByteSize(s string) string {
var n uint64
if _, err := fmt.Sscanf(s, "%d", &n); err != nil || n == 0 {
return s
}
const (
kb = 1024
mb = kb * 1024
gb = mb * 1024
tb = gb * 1024
)
switch {
case n >= tb:
return fmt.Sprintf("%.2fTB", float64(n)/float64(tb))
case n >= gb:
return fmt.Sprintf("%.2fGB", float64(n)/float64(gb))
case n >= mb:
return fmt.Sprintf("%.2fMB", float64(n)/float64(mb))
case n >= kb:
return fmt.Sprintf("%.2fKB", float64(n)/float64(kb))
default:
return fmt.Sprintf("%dB", n)
}
}
// integrity checks
var (
_ generic.Parser = parseSafeTensorsFile
_ generic.Parser = parseSafeTensorsIndex
)

View File

@ -0,0 +1,215 @@
package ai
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/unknown"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
// Docker AI OCI media types used by Docker Model Runner artifacts.
const (
dockerAIModelConfigMediaType = "application/vnd.docker.ai.model.config.v0.1+json"
dockerAIModelFileMediaType = "application/vnd.docker.ai.model.file"
dockerAILicenseMediaType = "application/vnd.docker.ai.license"
)
// dockerAIModelConfig mirrors the JSON shape of the vnd.docker.ai.model.config
// blob written by Docker Model Runner for AI artifacts. Only fields we use are
// declared; unknown fields are ignored.
type dockerAIModelConfig struct {
Config struct {
Format string `json:"format"`
Quantization string `json:"quantization"`
Parameters string `json:"parameters"`
Size string `json:"size"`
SafeTensors struct {
TensorCount json.Number `json:"tensor_count"`
} `json:"safetensors"`
} `json:"config"`
}
// parseSafeTensorsOCIConfig parses a Docker AI model-config blob. When the blob
// advertises format=="safetensors" it emits a single named package whose
// metadata is enriched by scanning sibling OCI layers (README.md for license +
// base_model name, config.json for architecture, LICENSE text for a license
// fallback). For any other format it emits nothing so the GGUF cataloger can
// claim the image.
func parseSafeTensorsOCIConfig(_ context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
defer internal.CloseAndLogError(reader, reader.Path())
body, err := io.ReadAll(io.LimitReader(reader, 1024*1024))
if err != nil {
return nil, nil, fmt.Errorf("failed to read docker AI model config: %w", err)
}
var cfg dockerAIModelConfig
if err := json.Unmarshal(body, &cfg); err != nil {
return nil, nil, fmt.Errorf("failed to decode docker AI model config: %w", err)
}
if !strings.EqualFold(cfg.Config.Format, "safetensors") {
return nil, nil, nil
}
md := pkg.SafeTensorsMetadata{
Format: "safetensors",
Quantization: cfg.Config.Quantization,
Parameters: cfg.Config.Parameters,
TotalSize: cfg.Config.Size,
}
if n, err := cfg.Config.SafeTensors.TensorCount.Int64(); err == nil && n > 0 {
md.TensorCount = uint64(n)
}
name, license := enrichFromDockerAILayers(resolver, &md)
p := newSafeTensorsPackage(
&md,
name,
"",
license,
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
)
return []pkg.Package{p}, nil, unknown.IfEmptyf([]pkg.Package{p}, "unable to parse docker AI safetensors config")
}
// enrichFromDockerAILayers walks sibling Docker AI layers via the OCI resolver
// and mines them for a model name, architecture, and license. README.md carries
// YAML frontmatter with license + base_model; HF config.json carries
// architectures/torch_dtype/transformers_version; the vnd.docker.ai.license
// blob is plain license text.
func enrichFromDockerAILayers(resolver file.Resolver, md *pkg.SafeTensorsMetadata) (name, license string) {
ociResolver, ok := resolver.(file.OCIMediaTypeResolver)
if !ok {
return "", ""
}
modelFileLocations, err := ociResolver.FilesByMediaType(dockerAIModelFileMediaType)
if err != nil {
log.Debugf("failed to list docker AI model-file layers: %v", err)
}
for _, loc := range modelFileLocations {
rc, err := resolver.FileContentsByLocation(loc)
if err != nil {
continue
}
buf, readErr := io.ReadAll(io.LimitReader(rc, 4*1024*1024))
internal.CloseAndLogError(rc, loc.RealPath)
if readErr != nil {
continue
}
classifyAndMerge(buf, md, &name, &license)
}
if license == "" {
license = readDockerAILicense(resolver, ociResolver)
}
return name, license
}
// classifyAndMerge sniffs a vnd.docker.ai.model.file blob (which can be README.md,
// config.json, generation_config.json, tokenizer.json, etc.) and folds useful
// fields into the metadata struct and out-parameters.
func classifyAndMerge(buf []byte, md *pkg.SafeTensorsMetadata, name, license *string) {
trimmed := trimLeadingWhitespace(buf)
switch {
case hasPrefix(trimmed, "---"):
if fm := parseFrontmatter(buf); fm != nil {
if *license == "" {
*license = fm.License
}
if *name == "" && len(fm.BaseModel) > 0 {
*name = lastPathSegment(fm.BaseModel[0])
}
}
case hasPrefix(trimmed, "{"):
var cfg hfConfig
if err := json.Unmarshal(buf, &cfg); err != nil {
return
}
if md.Architecture == "" && len(cfg.Architectures) > 0 {
md.Architecture = cfg.Architectures[0]
}
if md.TorchDtype == "" {
md.TorchDtype = cfg.TorchDtype
}
if md.TransformersVersion == "" {
md.TransformersVersion = cfg.TransformersVersion
}
if *name == "" && cfg.NameOrPath != "" {
*name = lastPathSegment(cfg.NameOrPath)
}
}
}
// readDockerAILicense extracts a short license identifier from the first line
// of a vnd.docker.ai.license layer. Docker packages the full license text, so
// we only peek at a prefix looking for well-known titles like "Apache License".
func readDockerAILicense(resolver file.Resolver, ociResolver file.OCIMediaTypeResolver) string {
locations, err := ociResolver.FilesByMediaType(dockerAILicenseMediaType)
if err != nil || len(locations) == 0 {
return ""
}
rc, err := resolver.FileContentsByLocation(locations[0])
if err != nil {
return ""
}
defer internal.CloseAndLogError(rc, locations[0].RealPath)
buf, err := io.ReadAll(io.LimitReader(rc, 2048))
if err != nil {
return ""
}
text := strings.ToLower(string(buf))
switch {
case strings.Contains(text, "apache license") && strings.Contains(text, "version 2.0"):
return "Apache-2.0"
case strings.Contains(text, "mit license"):
return "MIT"
case strings.Contains(text, "bsd 3-clause"):
return "BSD-3-Clause"
case strings.Contains(text, "bsd 2-clause"):
return "BSD-2-Clause"
case strings.Contains(text, "gnu general public license") && strings.Contains(text, "version 3"):
return "GPL-3.0"
}
return ""
}
func hasPrefix(b []byte, s string) bool {
return len(b) >= len(s) && string(b[:len(s)]) == s
}
func trimLeadingWhitespace(b []byte) []byte {
i := 0
for i < len(b) && (b[i] == ' ' || b[i] == '\t' || b[i] == '\r' || b[i] == '\n') {
i++
}
// strip a leading UTF-8 BOM if present
if len(b)-i >= 3 && b[i] == 0xEF && b[i+1] == 0xBB && b[i+2] == 0xBF {
i += 3
}
return b[i:]
}
func lastPathSegment(s string) string {
if i := strings.LastIndexAny(s, "/\\"); i >= 0 {
return s[i+1:]
}
return s
}
// integrity check
var _ generic.Parser = parseSafeTensorsOCIConfig

View File

@ -57,3 +57,44 @@ func ggufMergeProcessor(pkgs []pkg.Package, rels []artifact.Relationship, err er
return namedPkgs, rels, err return namedPkgs, rels, err
} }
// safeTensorsMergeProcessor mirrors ggufMergeProcessor for SafeTensors packages.
// When scanning an OCI AI artifact, the model-config blob produces one named
// package and individual .safetensors shard layers (if we ever decide to parse
// them directly) would produce nameless packages. Any nameless SafeTensors
// packages are collapsed into the named one's Parts slice.
func safeTensorsMergeProcessor(pkgs []pkg.Package, rels []artifact.Relationship, err error) ([]pkg.Package, []artifact.Relationship, error) {
if err != nil {
return pkgs, rels, err
}
if len(pkgs) == 0 {
return pkgs, rels, err
}
var namedPkgs []pkg.Package
var namelessParts []pkg.SafeTensorsMetadata
for _, p := range pkgs {
if p.Name != "" {
namedPkgs = append(namedPkgs, p)
continue
}
if md, ok := p.Metadata.(pkg.SafeTensorsMetadata); ok {
md.MetadataHash = ""
namelessParts = append(namelessParts, md)
}
}
if len(namedPkgs) == 0 {
return nil, rels, err
}
if len(namedPkgs) == 1 && len(namelessParts) > 0 {
winner := &namedPkgs[0]
if md, ok := winner.Metadata.(pkg.SafeTensorsMetadata); ok {
md.Parts = namelessParts
winner.Metadata = md
}
}
return namedPkgs, rels, err
}

59
syft/pkg/safetensors.go Normal file
View File

@ -0,0 +1,59 @@
package pkg
// SafeTensorsMetadata represents metadata extracted from a SafeTensors model.
// SafeTensors is a simple, safe serialization format for storing tensors, used
// as the default weight format for Hugging Face transformer models. Syft may
// populate this struct from three sources:
// - a single .safetensors file (header-only parse)
// - a sharded model described by model.safetensors.index.json
// - a Docker AI OCI model artifact config blob (vnd.docker.ai.model.config.v0.1+json)
//
// The Model Name, License, and Version fields have all been lifted up to be on
// the syft Package.
type SafeTensorsMetadata struct {
// Format is the source format label (always "safetensors" for this metadata type).
// Present because the Docker AI model config blob carries an explicit format field
// that can also be "gguf", and recording it here makes the origin explicit.
Format string `json:"format,omitempty" cyclonedx:"format"`
// Architecture is the model architecture (e.g., "LlamaForCausalLM",
// "Qwen3MoeForConditionalGeneration"), sourced from the Hugging Face config.json
// "architectures" array.
Architecture string `json:"architecture,omitempty" cyclonedx:"architecture"`
// Quantization describes tensor precision (e.g., "BF16", "F16", "F32", "INT8").
Quantization string `json:"quantization,omitempty" cyclonedx:"quantization"`
// Parameters is the parameter count as reported by upstream. Stored as a string
// because Docker AI and Hugging Face labels use notation like "2.68B" or "35B-A3B".
Parameters string `json:"parameters,omitempty" cyclonedx:"parameters"`
// TensorCount is the number of tensor entries in the file header.
TensorCount uint64 `json:"tensorCount,omitempty" cyclonedx:"tensorCount"`
// TotalSize is the total byte size of tensor data across all shards when known
// (from the Docker AI model config "size" field or the sharded index "total_size").
TotalSize string `json:"totalSize,omitempty" cyclonedx:"totalSize"`
// TorchDtype is the Hugging Face torch_dtype (e.g., "bfloat16", "float16").
TorchDtype string `json:"torchDtype,omitempty" cyclonedx:"torchDtype"`
// TransformersVersion is the transformers library version recorded in config.json.
TransformersVersion string `json:"transformersVersion,omitempty" cyclonedx:"transformersVersion"`
// ShardCount is the number of .safetensors shards for a sharded model (1 for a
// single-file model).
ShardCount int `json:"shardCount,omitempty" cyclonedx:"shardCount"`
// UserMetadata is the optional "__metadata__" map from a .safetensors file header
// (string-to-string key/values set by the producer).
UserMetadata map[string]string `json:"userMetadata,omitempty" cyclonedx:"userMetadata"`
// MetadataHash is an xxhash of the normalized header metadata, providing a stable
// identifier for identical model content across repositories or filenames.
MetadataHash string `json:"metadataHash,omitempty" cyclonedx:"metadataHash"`
// Parts contains metadata from additional SafeTensors shards or OCI layers that
// were merged into this package during post-processing.
Parts []SafeTensorsMetadata `json:"parts,omitempty" cyclonedx:"parts"`
}

View File

@ -52,7 +52,7 @@ func NewFromRegistry(ctx context.Context, cfg Config) (source.Source, error) {
} }
metadata := buildMetadata(art) metadata := buildMetadata(art)
tempDir, resolver, err := fetchAndStoreGGUFHeaders(ctx, client, art) tempDir, resolver, err := fetchAndStoreModelHeaders(ctx, client, art)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -77,38 +77,111 @@ func validateAndFetchArtifact(ctx context.Context, client *registryClient, refer
return nil, err return nil, err
} }
if len(art.GGUFLayers) == 0 { if art.Format == "" {
return nil, fmt.Errorf("model artifact has no GGUF layers") return nil, fmt.Errorf("model artifact has no GGUF or SafeTensors weight layers")
} }
return art, nil return art, nil
} }
// fetchAndStoreGGUFHeaders fetches GGUF layer headers and stores them in temp files. // fetchAndStoreModelHeaders fetches the blobs needed to catalog a Docker AI
func fetchAndStoreGGUFHeaders(ctx context.Context, client *registryClient, artifact *modelArtifact) (string, *fileresolver.ContainerImageModel, error) { // model artifact and stores them on disk so the ContainerImageModel resolver
tempDir, err := os.MkdirTemp("", "syft-oci-gguf") // can serve them by media type:
//
// - For GGUF: the first maxHeaderBytes of each weight layer (existing behavior).
// - For SafeTensors: the model-config blob (already in memory as RawConfig)
// plus each companion layer in full. We deliberately skip the multi-GB
// safetensors weight layers — the config blob carries aggregate metadata
// (format, quantization, parameter count, tensor count, total size) that
// the cataloger needs, and individual shard headers are not yet used.
func fetchAndStoreModelHeaders(ctx context.Context, client *registryClient, artifact *modelArtifact) (string, *fileresolver.ContainerImageModel, error) {
tempDir, err := os.MkdirTemp("", "syft-oci-model")
if err != nil { if err != nil {
return "", nil, fmt.Errorf("failed to create temp directory: %w", err) return "", nil, fmt.Errorf("failed to create temp directory: %w", err)
} }
cleanup := func() {
if osErr := os.RemoveAll(tempDir); osErr != nil {
log.Errorf("unable to remove temp directory (%s): %v", tempDir, osErr)
}
}
layerFiles := make(map[string]fileresolver.LayerInfo) layerFiles := make(map[string]fileresolver.LayerInfo)
// GGUF weight-layer headers (unchanged).
for _, layer := range artifact.GGUFLayers { for _, layer := range artifact.GGUFLayers {
li, err := fetchSingleGGUFHeader(ctx, client, artifact.Reference, layer, tempDir) li, err := fetchSingleGGUFHeader(ctx, client, artifact.Reference, layer, tempDir)
if err != nil { if err != nil {
osErr := os.RemoveAll(tempDir) cleanup()
if osErr != nil {
log.Errorf("unable to remove temp directory (%s): %v", tempDir, err)
}
return "", nil, err return "", nil, err
} }
layerFiles[layer.Digest.String()] = li layerFiles[layer.Digest.String()] = li
} }
// For SafeTensors artifacts, expose the model-config blob to the resolver
// so parseSafeTensorsOCIConfig can match it by media type. RawConfig was
// already fetched as part of the manifest walk.
if artifact.Format == modelFormatSafeTensors && len(artifact.RawConfig) > 0 {
li, err := storeConfigBlobAsLayer(artifact, tempDir)
if err != nil {
cleanup()
return "", nil, err
}
layerFiles[artifact.Manifest.Config.Digest.String()] = li
}
// Companion layers (README, config.json, tokenizer.json, LICENSE). Small by
// convention; fetched in full up to maxCompanionBytes.
if artifact.Format == modelFormatSafeTensors {
for _, layer := range artifact.CompanionLayers {
li, err := fetchCompanionLayer(ctx, client, artifact.Reference, layer, tempDir)
if err != nil {
cleanup()
return "", nil, err
}
layerFiles[layer.Digest.String()] = li
}
}
resolver := fileresolver.NewContainerImageModel(tempDir, layerFiles) resolver := fileresolver.NewContainerImageModel(tempDir, layerFiles)
return tempDir, resolver, nil return tempDir, resolver, nil
} }
// storeConfigBlobAsLayer writes the already-fetched raw config bytes to a temp
// file so the resolver can serve them via media type.
func storeConfigBlobAsLayer(artifact *modelArtifact, tempDir string) (fileresolver.LayerInfo, error) {
digest := artifact.Manifest.Config.Digest.String()
safeDigest := strings.ReplaceAll(digest, ":", "-")
tempPath := filepath.Join(tempDir, safeDigest+".config.json")
if err := os.WriteFile(tempPath, artifact.RawConfig, 0600); err != nil {
return fileresolver.LayerInfo{}, fmt.Errorf("failed to write config blob: %w", err)
}
return fileresolver.LayerInfo{
TempPath: tempPath,
MediaType: string(artifact.Manifest.Config.MediaType),
}, nil
}
// fetchCompanionLayer downloads a companion (non-weight) layer to a temp file.
// Unlike weight layers we fetch up to maxCompanionBytes, which comfortably
// covers READMEs, HF config.json, tokenizer.json, and LICENSE text.
func fetchCompanionLayer(ctx context.Context, client *registryClient, ref name.Reference, layer v1.Descriptor, tempDir string) (fileresolver.LayerInfo, error) {
data, err := client.fetchBlobRange(ctx, ref, layer.Digest, maxCompanionBytes)
if err != nil {
return fileresolver.LayerInfo{}, fmt.Errorf("failed to fetch companion layer: %w", err)
}
safeDigest := strings.ReplaceAll(layer.Digest.String(), ":", "-")
tempPath := filepath.Join(tempDir, safeDigest+".blob")
if err := os.WriteFile(tempPath, data, 0600); err != nil {
return fileresolver.LayerInfo{}, fmt.Errorf("failed to write companion temp file: %w", err)
}
return fileresolver.LayerInfo{
TempPath: tempPath,
MediaType: string(layer.MediaType),
}, nil
}
// fetchSingleGGUFHeader fetches a single GGUF layer header and writes it to a temp file. // fetchSingleGGUFHeader fetches a single GGUF layer header and writes it to a temp file.
func fetchSingleGGUFHeader(ctx context.Context, client *registryClient, ref name.Reference, layer v1.Descriptor, tempDir string) (fileresolver.LayerInfo, error) { func fetchSingleGGUFHeader(ctx context.Context, client *registryClient, ref name.Reference, layer v1.Descriptor, tempDir string) (fileresolver.LayerInfo, error) {
headerData, err := client.fetchBlobRange(ctx, ref, layer.Digest, maxHeaderBytes) headerData, err := client.fetchBlobRange(ctx, ref, layer.Digest, maxHeaderBytes)

View File

@ -26,9 +26,22 @@ const (
// Reference: https://www.docker.com/blog/oci-artifacts-for-ai-model-packaging/ // Reference: https://www.docker.com/blog/oci-artifacts-for-ai-model-packaging/
modelConfigMediaTypePrefix = "application/vnd.docker.ai.model.config." modelConfigMediaTypePrefix = "application/vnd.docker.ai.model.config."
ggufLayerMediaType = "application/vnd.docker.ai.gguf.v3" ggufLayerMediaType = "application/vnd.docker.ai.gguf.v3"
safetensorsLayerMediaType = "application/vnd.docker.ai.safetensors"
// Maximum bytes to read/return for GGUF headers // Companion metadata layers packaged alongside the weight tensors.
// model.file covers README.md / config.json / tokenizer.json / generation_config.json.
modelFileMediaType = "application/vnd.docker.ai.model.file"
licenseMediaType = "application/vnd.docker.ai.license"
// Weight format labels surfaced on modelArtifact.Format.
modelFormatGGUF = "gguf"
modelFormatSafeTensors = "safetensors"
// Maximum bytes to read/return for weight-layer headers (GGUF + safetensors).
maxHeaderBytes = 8 * 1024 * 1024 // 8 MB maxHeaderBytes = 8 * 1024 * 1024 // 8 MB
// Maximum bytes to fetch for a companion metadata layer (README, config.json, license).
// These blobs are small by convention; cap well below a safetensors header.
maxCompanionBytes = 4 * 1024 * 1024 // 4 MB
) )
// registryClient handles OCI registry interactions for model artifacts. // registryClient handles OCI registry interactions for model artifacts.
@ -110,7 +123,25 @@ type modelArtifact struct {
RawManifest []byte RawManifest []byte
RawConfig []byte RawConfig []byte
ManifestDigest string ManifestDigest string
GGUFLayers []v1.Descriptor
// Format identifies the weight storage format advertised by the manifest's
// layer media types. Empty means no recognized weight layers were found.
Format string
// GGUFLayers are descriptors for layers carrying GGUF-format weights.
// We fetch the first few MB of each to read the header.
GGUFLayers []v1.Descriptor
// SafeTensorsLayers are descriptors for layers carrying SafeTensors-format weights.
// For safetensors we do NOT fetch these layers — the model-config blob already
// contains the aggregate metadata we need — but we record them here for counting
// and for future per-shard parsing.
SafeTensorsLayers []v1.Descriptor
// CompanionLayers are non-weight layers (README, config.json, license) that
// we do fetch (in full, given their small size) so companion-file parsing
// in the safetensors cataloger can find them via media type.
CompanionLayers []v1.Descriptor
} }
func (c *registryClient) fetchModelArtifact(ctx context.Context, refStr string) (*modelArtifact, error) { func (c *registryClient) fetchModelArtifact(ctx context.Context, refStr string) (*modelArtifact, error) {
@ -151,18 +182,39 @@ func (c *registryClient) fetchModelArtifact(ctx context.Context, refStr string)
} }
ggufLayers := extractGGUFLayers(manifest) ggufLayers := extractGGUFLayers(manifest)
safetensorsLayers := extractSafeTensorsLayers(manifest)
companionLayers := extractCompanionLayers(manifest)
return &modelArtifact{ return &modelArtifact{
Reference: ref, Reference: ref,
Manifest: manifest, Manifest: manifest,
Config: configFile, Config: configFile,
RawManifest: desc.Manifest, RawManifest: desc.Manifest,
RawConfig: rawConfig, RawConfig: rawConfig,
ManifestDigest: desc.Digest.String(), ManifestDigest: desc.Digest.String(),
GGUFLayers: ggufLayers, Format: detectModelFormat(len(ggufLayers), len(safetensorsLayers)),
GGUFLayers: ggufLayers,
SafeTensorsLayers: safetensorsLayers,
CompanionLayers: companionLayers,
}, nil }, nil
} }
// detectModelFormat returns a single format string when either GGUF or
// SafeTensors weight layers are present. When both appear (not expected in
// practice for Docker Model Runner artifacts), GGUF wins because the GGUF
// cataloger is the more established path. Empty result means the manifest has
// no recognized weight layers.
func detectModelFormat(ggufCount, safetensorsCount int) string {
switch {
case ggufCount > 0:
return modelFormatGGUF
case safetensorsCount > 0:
return modelFormatSafeTensors
default:
return ""
}
}
// isModelArtifact checks if the manifest represents a model artifact. // isModelArtifact checks if the manifest represents a model artifact.
func isModelArtifact(manifest *v1.Manifest) bool { func isModelArtifact(manifest *v1.Manifest) bool {
return strings.HasPrefix(string(manifest.Config.MediaType), modelConfigMediaTypePrefix) return strings.HasPrefix(string(manifest.Config.MediaType), modelConfigMediaTypePrefix)
@ -179,6 +231,33 @@ func extractGGUFLayers(manifest *v1.Manifest) []v1.Descriptor {
return ggufLayers return ggufLayers
} }
// extractSafeTensorsLayers extracts SafeTensors weight-layer descriptors from
// the manifest.
func extractSafeTensorsLayers(manifest *v1.Manifest) []v1.Descriptor {
var out []v1.Descriptor
for _, layer := range manifest.Layers {
if string(layer.MediaType) == safetensorsLayerMediaType {
out = append(out, layer)
}
}
return out
}
// extractCompanionLayers extracts small, non-weight layers that carry
// cataloger-relevant metadata: README.md / config.json / tokenizer.json /
// generation_config.json under vnd.docker.ai.model.file, and the LICENSE under
// vnd.docker.ai.license.
func extractCompanionLayers(manifest *v1.Manifest) []v1.Descriptor {
var out []v1.Descriptor
for _, layer := range manifest.Layers {
switch string(layer.MediaType) {
case modelFileMediaType, licenseMediaType:
out = append(out, layer)
}
}
return out
}
func (c *registryClient) fetchBlobRange(ctx context.Context, ref name.Reference, digest v1.Hash, maxBytes int64) ([]byte, error) { func (c *registryClient) fetchBlobRange(ctx context.Context, ref name.Reference, digest v1.Hash, maxBytes int64) ([]byte, error) {
repo := ref.Context() repo := ref.Context()