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
14 changes: 7 additions & 7 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ require (
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.12.1
github.com/vbauerster/mpb/v8 v8.15.1
golang.org/x/crypto v0.53.0
golang.org/x/crypto v0.55.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
google.golang.org/grpc v1.83.1
google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.12
oras.land/oras-go/v2 v2.6.2
)

Expand Down Expand Up @@ -153,16 +154,15 @@ require (
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/term v0.44.0 // indirect
golang.org/x/text v0.39.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/api v0.264.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)
12 changes: 12 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -395,16 +395,22 @@ golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWP
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
Expand All @@ -431,10 +437,14 @@ golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXR
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
Expand All @@ -449,6 +459,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
Expand Down
1 change: 1 addition & 0 deletions pkg/backend/build/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package build
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"runtime"
Expand Down
4 changes: 4 additions & 0 deletions pkg/modelfile/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ var (
"*.ftz", // FastText compressed model
"*.ark", // Kaldi ark format (speech/audio models)
"*.db", // Database files (LMDB, etc.)

// TensorFlow SavedModel literal-name files (no extension).
"feature_map", // TF SavedModel feature map definition
"checkpoint", // TF checkpoint pointer file (literal name)
}

// Code file patterns - supported script and notebook files.
Expand Down
9 changes: 9 additions & 0 deletions pkg/modelfile/constants_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@ func TestInferFileType(t *testing.T) {
{"at threshold", "borderline", WeightFileSizeThreshold, FileTypeCode},
// Just above threshold should be model
{"above threshold", "borderline", WeightFileSizeThreshold + 1, FileTypeModel},

// TF SavedModel literal-name files: must be MODEL even when 0 bytes,
// independent of the size heuristic that would otherwise classify them as CODE.
{"feature_map literal", "feature_map", 0, FileTypeModel},
{"feature_map small", "feature_map", 1024, FileTypeModel},
{"checkpoint literal small", "checkpoint", 32, FileTypeModel},
// Negative: the literal patterns must not match same-stem-different-extension files.
{"feature_map.json is config", "feature_map.json", 1024, FileTypeConfig},
{"checkpoint.bin is model via *.bin", "checkpoint.bin", 1024, FileTypeModel},
}

assert := assert.New(t)
Expand Down
151 changes: 151 additions & 0 deletions pkg/modelfile/modelfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,12 @@ func NewModelfileByWorkspace(workspace string, config *configmodelfile.GenerateC
}

mf.generateByConfig(config)

// Best-effort: fill mf.format from MODEL file evidence when the user did not
// pass --format. Failure (no recognizable signal, panic in the loop, etc.)
// MUST NOT abort generation — Format is metadata, not load-bearing.
mf.inferFormat()

return mf, nil
}

Expand Down Expand Up @@ -346,13 +352,158 @@ func (mf *modelfile) generateByWorkspace(config *configmodelfile.GenerateConfig)
return err
}

// ONNX external_data post-processing: any tensor file referenced by an .onnx
// file via external_data.location is unconditionally a model weight file,
// regardless of its name or size. Walker may have classified small external
// tensor files as code/config/doc by extension/size heuristic; reclassify them.
mf.reclassifyONNXExternalData()

if mf.model.Size() == 0 && mf.code.Size() == 0 && mf.dataset.Size() == 0 {
return fmt.Errorf("no model/code/dataset found - you have to create the Modelfile by yourself")
}

return nil
}

// reclassifyONNXExternalData scans every .onnx file already in mf.model,
// extracts external_data.location paths, and moves those paths from whichever
// bucket the walker placed them in into mf.model.
//
// To avoid bypassing the walker's filtering (ExcludePatterns, isSkippable, file
// count / size limits, workspace boundary), this function ONLY reclassifies
// paths that are already present in one of the existing hashsets (config /
// code / doc / model). Paths that the walker excluded — including paths
// outside the workspace produced by a malformed `../` location — are silently
// ignored. ONNX parse failures degrade gracefully: a WARNING is printed and
// the affected .onnx's external tensors keep whatever classification the
// walker assigned (the pre-fix behavior).
func (mf *modelfile) reclassifyONNXExternalData() {
walkerCollected := func(rel string) bool {
return mf.model.Contains(rel) || mf.code.Contains(rel) ||
mf.config.Contains(rel) || mf.doc.Contains(rel)
}

for _, raw := range mf.model.Values() {
modelRel, ok := raw.(string)
if !ok || !strings.HasSuffix(strings.ToLower(modelRel), ".onnx") {
continue
}
onnxAbs := filepath.Join(mf.workspace, modelRel)
extPaths, err := ExtractONNXExternalDataPaths(onnxAbs)
if err != nil {
fmt.Fprintf(os.Stderr,
"WARNING: modelfile: failed to parse ONNX external_data from %s: %v "+
"-- external tensor files (if any) will keep walker-assigned classification\n",
modelRel, err)
continue
}
onnxDir := filepath.Dir(modelRel)
for _, ext := range extPaths {
// Reject absolute external_data.location values outright. ONNX
// spec defines location as relative to the .onnx file's
// directory, so an absolute path is malformed; worse,
// filepath.Join silently strips the leading separator
// (Join(".", "/etc/secret") -> "etc/secret"), which would let
// an unrelated workspace file get reclassified to MODEL.
if filepath.IsAbs(ext) {
continue
}
relExt := filepath.Clean(filepath.Join(onnxDir, ext))
// Walker membership check absorbs all of:
// - exclude pattern (walker dropped it -> not in any bucket)
// - skippable directories (.git, etc.)
// - file count / size limits (walker errored before adding)
// - workspace boundary (walker never sees ../outside paths)
// - file simply doesn't exist on disk
if !walkerCollected(relExt) {
continue
}
mf.code.Remove(relExt)
mf.config.Remove(relExt)
mf.doc.Remove(relExt)
mf.model.Add(relExt)
Comment thread
aftersnow marked this conversation as resolved.
}
}
}

// inferFormat fills mf.format from filename evidence collected by the walker
// when the user did not pass --format on the CLI. It only emits a value for
// highly specific signals (saved_model.pb[txt] / *.onnx / *.gguf /
// *.safetensors); generic extensions like *.bin / *.pt are left alone because
// they appear in many formats and would produce false positives.
//
// Priority order, when multiple signals coexist:
//
// 1. tensorflow — saved_model.pb / saved_model.pbtxt (SavedModel directory)
// 2. onnx — *.onnx
// 3. gguf — *.gguf
// 4. safetensors — *.safetensors
//
// SavedModel and ONNX are listed first because their layouts are uniquely
// identifiable; safetensors is last because it sometimes coexists with raw
// PyTorch shards in HF repos.
//
// We scan ALL four walker buckets (model / config / code / doc), not just
// mf.model. Reason: signals like `saved_model.pbtxt` are not in
// ModelFilePatterns and the walker therefore lands them in code/doc; if we
// scanned only mf.model, a SavedModel that ships only the .pbtxt variant would
// silently fall through. A set-based scan over every bucket closes that gap
// without changing how the walker classifies each individual file.
//
// Failure modes (no recognized signal, panic from a malformed value in the
// hashset, etc.) MUST NOT abort generation. The recover() guard ensures any
// unexpected panic degrades to "format stays empty" rather than killing the
// whole modelfile build. Format is best-effort metadata; the package gracefully
// handles a blank Format throughout the build/push/pull pipeline.
func (mf *modelfile) inferFormat() {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr,
"WARNING: modelfile: format inference panicked, leaving Format empty: %v\n", r)
}
}()

if mf.format != "" {
return
}

var hasSavedModel, hasONNX, hasGGUF, hasSafetensors bool
scan := func(set *hashset.Set) {
for _, raw := range set.Values() {
rel, ok := raw.(string)
if !ok {
continue
}
base := strings.ToLower(filepath.Base(rel))
switch {
case base == "saved_model.pb" || base == "saved_model.pbtxt":
hasSavedModel = true
case strings.HasSuffix(base, ".onnx"):
hasONNX = true
case strings.HasSuffix(base, ".gguf"):
hasGGUF = true
case strings.HasSuffix(base, ".safetensors"):
hasSafetensors = true
}
}
}
scan(mf.model)
scan(mf.config)
scan(mf.code)
scan(mf.doc)

switch {
case hasSavedModel:
mf.format = "tensorflow"
case hasONNX:
mf.format = "onnx"
case hasGGUF:
mf.format = "gguf"
case hasSafetensors:
mf.format = "safetensors"
}
}

// generateByModelConfig generates the modelfile by the model config, such as config.json and generation_config.json.
func (mf *modelfile) generateByModelConfig() error {
// Get config map from json files. Collect all the keys and values from the config files
Expand Down
Loading
Loading