mirror of
https://github.com/anchore/syft.git
synced 2026-10-11 21:57:22 +02:00
fix(ai): GGUF layer handling and safetensors parameter counts (#5343)
* fix(ai): keep merging GGUF layers when one fails to parse a single unparseable layer in an OCI GGUF artifact skipped the merge, so the named model lost its parts and nameless layers were dropped. The error is still returned. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix(ai): only send GGUF layers to the GGUF cataloger the media type glob `application/vnd.docker.ai*` also matched config, model.file, license and safetensors layers, so every safetensors OCI model reported spurious "invalid GGUF magic number" unknowns. Match `application/vnd.docker.ai.gguf.v3` exactly. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix(ai): don't wrap safetensors parameter counts - tensors whose shape product overflows uint64 are skipped instead of wrapping - per-file and per-shard parameter totals saturate instead of wrapping - parameter count and dominant dtype are computed in one pass - GGUF licenses are created with the scan context, and drop no-op unknown checks Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * refactor(ai): derive safetensors model info from the header in one place the file and OCI layer parsers built identical metadata from a parsed header. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix(ai): keep the GGUF media type with the GGUF cataloger also corrects stale comments in the GGUF merge processor. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> --------- Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
@@ -11,8 +11,12 @@ import (
|
||||
|
||||
const (
|
||||
catalogerName = "gguf-cataloger"
|
||||
ggufLayerMediaType = "application/vnd.docker.ai*"
|
||||
safeTensorsCatalogerName = "safetensors-cataloger"
|
||||
|
||||
// dockerAIGGUFMediaType mirrors ggufLayerMediaType in
|
||||
// syft/source/ocimodelsource/registry_client.go. It is matched exactly: the
|
||||
// other application/vnd.docker.ai.* layers are not GGUF.
|
||||
dockerAIGGUFMediaType = "application/vnd.docker.ai.gguf.v3"
|
||||
)
|
||||
|
||||
// NewGGUFCataloger returns a new cataloger instance for GGUF model files.
|
||||
@@ -21,7 +25,7 @@ const (
|
||||
func NewGGUFCataloger() pkg.Cataloger {
|
||||
return generic.NewCataloger(catalogerName).
|
||||
WithParserByGlobs(parseGGUFModel, "**/*.gguf").
|
||||
WithParserByMediaType(parseGGUFModel, ggufLayerMediaType).
|
||||
WithParserByMediaType(parseGGUFModel, dockerAIGGUFMediaType).
|
||||
WithProcessors(ggufMergeProcessor)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/internal/tmpdir"
|
||||
"github.com/anchore/syft/syft/internal/fileresolver"
|
||||
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
|
||||
@@ -138,3 +145,42 @@ func TestGGUFCataloger(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGGUFCataloger_ociLayers(t *testing.T) {
|
||||
// a GGUF OCI artifact is merged into one named package, while the other
|
||||
// application/vnd.docker.ai.* layers (config, model.file, license and
|
||||
// safetensors weights) never reach the GGUF parser.
|
||||
named := newTestGGUFBuilder().
|
||||
withStringKV("general.architecture", "llama").
|
||||
withStringKV("general.name", "llama3-8b").
|
||||
build()
|
||||
part := newTestGGUFBuilder().withStringKV("general.architecture", "llama").build()
|
||||
|
||||
dir := t.TempDir()
|
||||
layers := map[string]fileresolver.LayerInfo{}
|
||||
for digest, l := range map[string]struct {
|
||||
mediaType string
|
||||
content []byte
|
||||
}{
|
||||
"sha256:named": {dockerAIGGUFMediaType, named},
|
||||
"sha256:part": {dockerAIGGUFMediaType, part},
|
||||
"sha256:config": {dockerAIModelConfigMediaTypes[0], []byte("not a gguf file")},
|
||||
"sha256:file": {dockerAIModelFileMediaType, []byte("not a gguf file")},
|
||||
"sha256:license": {dockerAILicenseMediaType, []byte("not a gguf file")},
|
||||
"sha256:weights": {dockerAISafeTensorsMediaType, []byte("not a gguf file")},
|
||||
} {
|
||||
p := filepath.Join(dir, digest[len("sha256:"):])
|
||||
require.NoError(t, os.WriteFile(p, l.content, 0o600))
|
||||
layers[digest] = fileresolver.LayerInfo{TempPath: p, MediaType: l.mediaType}
|
||||
}
|
||||
resolver := fileresolver.NewContainerImageModel(dir, layers, "example/model:latest")
|
||||
ctx := tmpdir.WithValue(context.Background(), tmpdir.FromPath(t.TempDir()))
|
||||
|
||||
pkgs, _, err := NewGGUFCataloger().Catalog(ctx, resolver)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, pkgs, 1)
|
||||
assert.Equal(t, "llama3-8b", pkgs[0].Name)
|
||||
header, ok := pkgs[0].Metadata.(pkg.GGUFFileHeader)
|
||||
require.True(t, ok)
|
||||
assert.Len(t, header.Parts, 1)
|
||||
}
|
||||
|
||||
@@ -8,17 +8,17 @@ import (
|
||||
// ggufMergeProcessor consolidates multiple GGUF packages into a single package
|
||||
// representing the AI model. When scanning OCI images with multiple layers,
|
||||
// each layer may produce a separate package. This processor finds the package
|
||||
// with a name and merges metadata from nameless packages into its GGUFFileParts field.
|
||||
// with a name and merges metadata from nameless packages into its Parts field.
|
||||
// Only packages with a non-empty name are returned in the final result.
|
||||
func ggufMergeProcessor(pkgs []pkg.Package, rels []artifact.Relationship, err error) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
if err != nil {
|
||||
return pkgs, rels, err
|
||||
}
|
||||
// don't early-return on err: it aggregates per-file failures, and skipping the
|
||||
// merge would drop every nameless layer and the named package's Parts along
|
||||
// with it (see safeTensorsMergeProcessor).
|
||||
if len(pkgs) == 0 {
|
||||
return pkgs, rels, err
|
||||
}
|
||||
|
||||
// Separate packages with names from those without
|
||||
// separate packages with names from those without
|
||||
var namedPkgs []pkg.Package
|
||||
var namelessHeaders []pkg.GGUFFileHeader
|
||||
|
||||
@@ -27,14 +27,14 @@ func ggufMergeProcessor(pkgs []pkg.Package, rels []artifact.Relationship, err er
|
||||
namedPkgs = append(namedPkgs, p)
|
||||
} else {
|
||||
if header, ok := p.Metadata.(pkg.GGUFFileHeader); ok {
|
||||
// We do not want a kv hash for nameless headers
|
||||
// we do not want a kv hash for nameless headers
|
||||
header.MetadataKeyValuesHash = ""
|
||||
namelessHeaders = append(namelessHeaders, header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no named packages, return nothing
|
||||
// if there are no named packages, return nothing
|
||||
if len(namedPkgs) == 0 {
|
||||
return nil, rels, err
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ func mergeShardsInto(merged *pkg.SafeTensorsModelInfo, shards []pkg.SafeTensorsM
|
||||
seenKV := map[string]bool{}
|
||||
for _, s := range shards {
|
||||
shardTensorTotal += s.TensorCount
|
||||
merged.Parameters += s.Parameters
|
||||
merged.Parameters = saturatingAdd(merged.Parameters, s.Parameters)
|
||||
firstNonEmpty(&merged.Quantization, s.Quantization)
|
||||
for _, kv := range s.UserMetadata {
|
||||
if seenKV[kv.Key] {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -164,3 +165,10 @@ func TestRollupHash(t *testing.T) {
|
||||
assert.NotEqual(t, "a", ab)
|
||||
assert.NotEqual(t, "b", ab)
|
||||
}
|
||||
|
||||
func TestMergeShardsInto_parametersSaturate(t *testing.T) {
|
||||
var merged pkg.SafeTensorsModelInfo
|
||||
shards := []pkg.SafeTensorsModelInfo{{Parameters: math.MaxUint64 - 1}, {Parameters: 2}}
|
||||
mergeShardsInto(&merged, shards)
|
||||
assert.Equal(t, uint64(math.MaxUint64), merged.Parameters)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
func newGGUFPackage(metadata *pkg.GGUFFileHeader, modelName, version, license string, locations ...file.Location) pkg.Package {
|
||||
func newGGUFPackage(ctx context.Context, metadata *pkg.GGUFFileHeader, 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)...),
|
||||
Licenses: pkg.NewLicenseSet(pkg.NewLicensesFromValuesWithContext(ctx, license)...),
|
||||
Metadata: *metadata,
|
||||
// NOTE: PURL is intentionally not set as the package-url spec
|
||||
// has not yet finalized support for ML model packages
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -103,6 +104,7 @@ func TestNewGGUFPackage(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
actual := newGGUFPackage(
|
||||
context.Background(),
|
||||
tt.metadata,
|
||||
tt.input.modelName,
|
||||
tt.input.version,
|
||||
|
||||
@@ -45,7 +45,7 @@ func copyHeader(w io.Writer, r io.Reader) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper to convert gguf_parser metadata to simpler types
|
||||
// helper to convert gguf_parser metadata to simpler types
|
||||
func convertGGUFMetadataKVs(kvs gguf_parser.GGUFMetadataKVs) map[string]any {
|
||||
result := make(map[string]any)
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/internal/tmpdir"
|
||||
"github.com/anchore/syft/internal/unknown"
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
@@ -79,6 +78,7 @@ func parseGGUFModel(ctx context.Context, _ file.Resolver, _ *generic.Environment
|
||||
|
||||
// Create package from metadata
|
||||
p := newGGUFPackage(
|
||||
ctx,
|
||||
syftMetadata,
|
||||
metadata.Name,
|
||||
modelVersion,
|
||||
@@ -86,7 +86,7 @@ func parseGGUFModel(ctx context.Context, _ file.Resolver, _ *generic.Environment
|
||||
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
|
||||
)
|
||||
|
||||
return []pkg.Package{p}, nil, unknown.IfEmptyf([]pkg.Package{p}, "unable to parse GGUF file")
|
||||
return []pkg.Package{p}, nil, nil
|
||||
}
|
||||
|
||||
// computeKVMetadataHash computes a stable hash of the KV metadata for use as a global identifier
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/bits"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -82,47 +84,67 @@ func readSafeTensorsHeader(r io.Reader) (*safeTensorsHeader, error) {
|
||||
return h, 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
|
||||
// modelInfo returns the metadata derivable from the header bytes alone. Naming,
|
||||
// licenses and ShardCount are left to the merge processor.
|
||||
func (h *safeTensorsHeader) modelInfo() pkg.SafeTensorsModelInfo {
|
||||
params, dtype := h.parameterStats()
|
||||
return pkg.SafeTensorsModelInfo{
|
||||
Format: "safetensors",
|
||||
TensorCount: uint64(len(h.tensors)),
|
||||
Parameters: params,
|
||||
Quantization: normalizeDType(dtype),
|
||||
UserMetadata: userMetadataKeyValues(h.metadata),
|
||||
MetadataHash: h.metadataHash(),
|
||||
}
|
||||
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 {
|
||||
// parameterStats sums the element counts across all tensors and returns the
|
||||
// dtype that accounts for the largest share of them. For mixed-precision models
|
||||
// the "dominant" dtype is still a useful summary. Totals saturate rather than wrap.
|
||||
func (h *safeTensorsHeader) parameterStats() (total uint64, 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)
|
||||
count, ok := tensorElements(t.Shape)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sizeByDType[t.DType] += count
|
||||
total = saturatingAdd(total, count)
|
||||
sizeByDType[t.DType] = saturatingAdd(sizeByDType[t.DType], count)
|
||||
}
|
||||
var best string
|
||||
var bestSize uint64
|
||||
for dtype, size := range sizeByDType {
|
||||
if size > bestSize || (size == bestSize && dtype < best) {
|
||||
best = dtype
|
||||
if size > bestSize || (size == bestSize && dtype < dominantDType) {
|
||||
dominantDType = dtype
|
||||
bestSize = size
|
||||
}
|
||||
}
|
||||
return best
|
||||
return total, dominantDType
|
||||
}
|
||||
|
||||
// tensorElements returns the element count for a shape, or ok=false on a
|
||||
// non-positive dim or when the product overflows uint64.
|
||||
func tensorElements(shape []int64) (uint64, bool) {
|
||||
n := uint64(1)
|
||||
for _, d := range shape {
|
||||
if d <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
hi, lo := bits.Mul64(n, uint64(d))
|
||||
if hi != 0 {
|
||||
return 0, false
|
||||
}
|
||||
n = lo
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// saturatingAdd returns a+b, clamped to math.MaxUint64 instead of wrapping.
|
||||
func saturatingAdd(a, b uint64) uint64 {
|
||||
sum, carry := bits.Add64(a, b, 0)
|
||||
if carry != 0 {
|
||||
return math.MaxUint64
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
// metadataHash returns a stable xxhash64 over the logical tensor content
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"io"
|
||||
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/unknown"
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
@@ -26,22 +25,13 @@ func parseSafeTensorsFile(_ context.Context, _ file.Resolver, _ *generic.Environ
|
||||
return nil, nil, fmt.Errorf("failed to read safetensors header: %w", err)
|
||||
}
|
||||
|
||||
// ShardCount is intentionally not set here: the merge processor is the single
|
||||
// owner of ShardCount and derives it from the number of shards in the group.
|
||||
md := pkg.SafeTensorsModelInfo{
|
||||
Format: "safetensors",
|
||||
TensorCount: uint64(len(header.tensors)),
|
||||
Parameters: header.parameterCount(),
|
||||
Quantization: normalizeDType(header.dominantDType()),
|
||||
UserMetadata: userMetadataKeyValues(header.metadata),
|
||||
MetadataHash: header.metadataHash(),
|
||||
}
|
||||
md := header.modelInfo()
|
||||
|
||||
p := newSafeTensorsPackage(
|
||||
&md,
|
||||
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
|
||||
)
|
||||
return []pkg.Package{p}, nil, unknown.IfEmptyf([]pkg.Package{p}, "unable to parse safetensors file")
|
||||
return []pkg.Package{p}, nil, nil
|
||||
}
|
||||
|
||||
// integrity check
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/unknown"
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
@@ -81,7 +80,7 @@ func parseSafeTensorsOCIConfig(_ context.Context, _ file.Resolver, _ *generic.En
|
||||
&md,
|
||||
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
|
||||
)
|
||||
return []pkg.Package{p}, nil, unknown.IfEmptyf([]pkg.Package{p}, "unable to parse docker AI safetensors config")
|
||||
return []pkg.Package{p}, nil, nil
|
||||
}
|
||||
|
||||
// parseSafeTensorsOCILayer decodes the JSON header of a SafeTensors weight
|
||||
@@ -94,14 +93,7 @@ func parseSafeTensorsOCILayer(_ context.Context, _ file.Resolver, _ *generic.Env
|
||||
return nil, nil, fmt.Errorf("failed to read safetensors layer header: %w", err)
|
||||
}
|
||||
|
||||
md := pkg.SafeTensorsModelInfo{
|
||||
Format: "safetensors",
|
||||
TensorCount: uint64(len(header.tensors)),
|
||||
Parameters: header.parameterCount(),
|
||||
Quantization: normalizeDType(header.dominantDType()),
|
||||
UserMetadata: userMetadataKeyValues(header.metadata),
|
||||
MetadataHash: header.metadataHash(),
|
||||
}
|
||||
md := header.modelInfo()
|
||||
|
||||
p := newSafeTensorsPackage(
|
||||
&md,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -1146,8 +1147,9 @@ func TestSafeTensorsHeader_parameterCountAndDType(t *testing.T) {
|
||||
"small": {DType: "F32", Shape: []int64{16, 16}},
|
||||
"scalar": {DType: "F32", Shape: []int64{}}, // empty shape contributes 1
|
||||
}}
|
||||
assert.Equal(t, uint64(1000*16+16*16+1), h.parameterCount())
|
||||
assert.Equal(t, "BF16", h.dominantDType())
|
||||
params, dtype := h.parameterStats()
|
||||
assert.Equal(t, uint64(1000*16+16*16+1), params)
|
||||
assert.Equal(t, "BF16", dtype)
|
||||
}
|
||||
|
||||
func TestSafeTensorsHeader_parameterCount_nonPositiveDims(t *testing.T) {
|
||||
@@ -1159,7 +1161,29 @@ func TestSafeTensorsHeader_parameterCount_nonPositiveDims(t *testing.T) {
|
||||
"zero": {DType: "F32", Shape: []int64{4, 0}},
|
||||
"negative": {DType: "F32", Shape: []int64{-1, 8}},
|
||||
}}
|
||||
assert.Equal(t, uint64(16), h.parameterCount())
|
||||
params, _ := h.parameterStats()
|
||||
assert.Equal(t, uint64(16), params)
|
||||
}
|
||||
|
||||
func TestSafeTensorsHeader_parameterStats_overflow(t *testing.T) {
|
||||
// a shape whose product overflows uint64 is skipped instead of wrapping (the
|
||||
// first tensor would wrap to 0) and can't flip the dominant dtype; the total
|
||||
// saturates instead of wrapping.
|
||||
h := &safeTensorsHeader{tensors: map[string]safeTensorsEntry{
|
||||
"good": {DType: "BF16", Shape: []int64{4, 4}},
|
||||
"wraps": {DType: "F32", Shape: []int64{4294967296, 4294967296}},
|
||||
"overflow": {DType: "F32", Shape: []int64{math.MaxInt64, 3}},
|
||||
}}
|
||||
params, dtype := h.parameterStats()
|
||||
assert.Equal(t, uint64(16), params)
|
||||
assert.Equal(t, "BF16", dtype)
|
||||
|
||||
h = &safeTensorsHeader{tensors: map[string]safeTensorsEntry{
|
||||
"a": {DType: "F32", Shape: []int64{math.MaxInt64, 2}},
|
||||
"b": {DType: "F32", Shape: []int64{math.MaxInt64, 2}},
|
||||
}}
|
||||
params, _ = h.parameterStats()
|
||||
assert.Equal(t, uint64(math.MaxUint64), params)
|
||||
}
|
||||
|
||||
func TestSafeTensorsHeader_dominantDType_tieBreak(t *testing.T) {
|
||||
@@ -1169,7 +1193,8 @@ func TestSafeTensorsHeader_dominantDType_tieBreak(t *testing.T) {
|
||||
"a": {DType: "F32", Shape: []int64{4, 4}},
|
||||
"b": {DType: "BF16", Shape: []int64{4, 4}},
|
||||
}}
|
||||
assert.Equal(t, "BF16", h.dominantDType())
|
||||
_, dtype := h.parameterStats()
|
||||
assert.Equal(t, "BF16", dtype)
|
||||
}
|
||||
|
||||
func TestNormalizeDType(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -61,3 +62,20 @@ func Test_ggufMergeProcessor(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ggufMergeProcessor_mergesDespiteError(t *testing.T) {
|
||||
// one layer failing to parse must not drop the rest of the model
|
||||
parseErr := errors.New("one layer failed to parse")
|
||||
pkgs := []pkg.Package{
|
||||
{Name: "model", Metadata: pkg.GGUFFileHeader{MetadataKeyValuesHash: "abc"}},
|
||||
{Name: "", Metadata: pkg.GGUFFileHeader{MetadataKeyValuesHash: "part1"}},
|
||||
}
|
||||
|
||||
got, _, err := ggufMergeProcessor(pkgs, nil, parseErr)
|
||||
require.ErrorIs(t, err, parseErr)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "model", got[0].Name)
|
||||
header, ok := got[0].Metadata.(pkg.GGUFFileHeader)
|
||||
require.True(t, ok)
|
||||
assert.Len(t, header.Parts, 1)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user