From 8ebb060eaa384eac05631d1b2aa320cb63b85d99 Mon Sep 17 00:00:00 2001 From: iamanishx Date: Tue, 10 Feb 2026 23:35:28 +0530 Subject: [PATCH 1/4] feat: made changes for hf model push Signed-off-by: iamanishx --- pkg/distribution/distribution/client.go | 85 +++++++++++++- pkg/distribution/huggingface/client.go | 17 +++ pkg/distribution/huggingface/uploader.go | 134 +++++++++++++++++++++++ pkg/inference/models/api.go | 7 ++ pkg/inference/models/http_handler.go | 19 +++- pkg/inference/models/manager.go | 10 +- 6 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 pkg/distribution/huggingface/uploader.go diff --git a/pkg/distribution/distribution/client.go b/pkg/distribution/distribution/client.go index d9a2cca5f..a68d80a44 100644 --- a/pkg/distribution/distribution/client.go +++ b/pkg/distribution/distribution/client.go @@ -6,10 +6,12 @@ import ( "fmt" "io" "os" + "path/filepath" "slices" "strings" "github.com/docker/model-runner/pkg/distribution/huggingface" + "github.com/docker/model-runner/pkg/distribution/internal/bundle" "github.com/docker/model-runner/pkg/distribution/internal/mutate" "github.com/docker/model-runner/pkg/distribution/internal/progress" "github.com/docker/model-runner/pkg/distribution/internal/store" @@ -589,15 +591,35 @@ func (c *Client) Tag(source string, target string) error { } // PushModel pushes a tagged model from the content store to the registry. -func (c *Client) PushModel(ctx context.Context, tag string, progressWriter io.Writer) (err error) { +func (c *Client) PushModel(ctx context.Context, tag string, progressWriter io.Writer, bearerToken ...string) (err error) { + // Store original reference before normalization (needed for case-sensitive HuggingFace API) + originalReference := tag + // Normalize the model reference for store lookups + normalizedRef := c.normalizeModelName(tag) + + // Handle bearer token for registry authentication + var token string + if len(bearerToken) > 0 && bearerToken[0] != "" { + token = bearerToken[0] + } + + // HuggingFace references use native push (upload raw files to HF Hub) + if isHuggingFaceReference(originalReference) { + return c.pushNativeHuggingFace(ctx, originalReference, normalizedRef, progressWriter, token) + } + // Parse the tag - target, err := c.registry.NewTarget(tag) + registryClient := c.registry + if token != "" { + auth := authn.NewBearer(token) + registryClient = registry.FromClient(c.registry, registry.WithAuth(auth)) + } + target, err := registryClient.NewTarget(tag) if err != nil { return fmt.Errorf("new tag: %w", err) } // Get the model from the store - normalizedRef := c.normalizeModelName(tag) mdl, err := c.store.Read(normalizedRef) if err != nil { return fmt.Errorf("reading model: %w", err) @@ -621,6 +643,63 @@ func (c *Client) PushModel(ctx context.Context, tag string, progressWriter io.Wr return nil } +func (c *Client) pushNativeHuggingFace(ctx context.Context, reference, normalizedRef string, progressWriter io.Writer, token string) error { + repo, _, _ := parseHFReference(reference) + c.log.Infof("Pushing native HuggingFace model: repo=%s", utils.SanitizeForLog(repo)) + + if progressWriter != nil { + _ = progress.WriteProgress(progressWriter, "Preparing HuggingFace upload...", 0, 0, 0, "", oci.ModePush) + } + + modelBundle, err := c.store.BundleForModel(normalizedRef) + if err != nil { + return fmt.Errorf("get model bundle: %w", err) + } + + modelDir := filepath.Join(modelBundle.RootDir(), bundle.ModelSubdir) + files, totalSize, err := huggingface.CollectUploadFiles(modelDir) + if err != nil { + return fmt.Errorf("collect bundle files: %w", err) + } + if len(files) == 0 { + return fmt.Errorf("no model files found to upload") + } + + hfOpts := []huggingface.ClientOption{ + huggingface.WithUserAgent(registry.DefaultUserAgent), + } + if token != "" { + hfOpts = append(hfOpts, huggingface.WithToken(token)) + } + hfClient := huggingface.NewClient(hfOpts...) + + if progressWriter != nil { + msg := fmt.Sprintf("Uploading %d files (%.2f MB total)", len(files), float64(totalSize)/1024/1024) + _ = progress.WriteProgress(progressWriter, msg, uint64(totalSize), 0, 0, "", oci.ModePush) + } + + if err := huggingface.UploadFiles(ctx, hfClient, repo, files, totalSize, progressWriter); err != nil { + var authErr *huggingface.AuthError + var notFoundErr *huggingface.NotFoundError + if errors.As(err, &authErr) { + return registry.ErrUnauthorized + } + if errors.As(err, ¬FoundErr) { + return registry.ErrModelNotFound + } + if writeErr := progress.WriteError(progressWriter, fmt.Sprintf("Error: %s", err.Error()), oci.ModePush); writeErr != nil { + c.log.Warnf("Failed to write error message: %v", writeErr) + } + return fmt.Errorf("upload model to HuggingFace: %w", err) + } + + if err := progress.WriteSuccess(progressWriter, "Model pushed successfully", oci.ModePush); err != nil { + c.log.Warnf("Failed to write success message: %v", err) + } + + return nil +} + // WriteLightweightModel writes a model to the store without transferring layer data. // This is used for config-only modifications where the layer data hasn't changed. // The layers must already exist in the store. diff --git a/pkg/distribution/huggingface/client.go b/pkg/distribution/huggingface/client.go index 6c7439b75..2d8ff59dd 100644 --- a/pkg/distribution/huggingface/client.go +++ b/pkg/distribution/huggingface/client.go @@ -241,6 +241,23 @@ func (c *Client) checkResponse(resp *http.Response, repo string) error { } } +func (c *Client) checkUploadResponse(resp *http.Response, repo string) error { + switch resp.StatusCode { + case http.StatusOK, http.StatusCreated, http.StatusAccepted: + return nil + default: + return c.checkResponse(resp, repo) + } +} + +func escapePath(value string) string { + parts := strings.Split(value, "/") + for i, part := range parts { + parts[i] = url.PathEscape(part) + } + return strings.Join(parts, "/") +} + // AuthError indicates authentication failure type AuthError struct { Repo string diff --git a/pkg/distribution/huggingface/uploader.go b/pkg/distribution/huggingface/uploader.go new file mode 100644 index 000000000..186cbe969 --- /dev/null +++ b/pkg/distribution/huggingface/uploader.go @@ -0,0 +1,134 @@ +package huggingface + +import ( + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/docker/model-runner/pkg/distribution/internal/progress" + "github.com/docker/model-runner/pkg/distribution/oci" +) + +type UploadFile struct { + LocalPath string + RepoPath string + Size int64 + ID string +} + +type uploadProgressReader struct { + reader io.Reader + progressWriter io.Writer + totalImageSize uint64 + fileSize uint64 + fileID string + bytesRead uint64 + lastReported uint64 +} + +func (pr *uploadProgressReader) Read(p []byte) (n int, err error) { + n, err = pr.reader.Read(p) + if n > 0 { + pr.bytesRead += uint64(n) + if pr.progressWriter != nil && (pr.bytesRead-pr.lastReported >= progress.MinBytesForUpdate || pr.bytesRead == pr.fileSize) { + _ = progress.WriteProgress(pr.progressWriter, "", pr.totalImageSize, pr.fileSize, pr.bytesRead, pr.fileID, oci.ModePush) + pr.lastReported = pr.bytesRead + } + } + return n, err +} + +func CollectUploadFiles(rootDir string) ([]UploadFile, int64, error) { + root := filepath.Clean(rootDir) + var files []UploadFile + var totalSize int64 + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return fmt.Errorf("resolve relative path: %w", err) + } + rel = filepath.Clean(rel) + if rel == "." || strings.HasPrefix(rel, "..") { + return fmt.Errorf("invalid relative path: %s", rel) + } + + info, err := d.Info() + if err != nil { + return fmt.Errorf("stat file: %w", err) + } + + repoPath := filepath.ToSlash(rel) + files = append(files, UploadFile{ + LocalPath: path, + RepoPath: repoPath, + Size: info.Size(), + ID: fileIDFromPath(repoPath), + }) + totalSize += info.Size() + return nil + }) + if err != nil { + return nil, 0, err + } + + return files, totalSize, nil +} + +func UploadFiles(ctx context.Context, client *Client, repo string, files []UploadFile, totalSize int64, progressWriter io.Writer) error { + if client == nil { + return fmt.Errorf("huggingface client is nil") + } + if repo == "" { + return fmt.Errorf("repository is required") + } + + var safeWriter io.Writer + if progressWriter != nil { + safeWriter = &syncWriter{w: progressWriter} + } + + for _, file := range files { + f, err := os.Open(file.LocalPath) + if err != nil { + return fmt.Errorf("open file %s: %w", file.LocalPath, err) + } + + pr := &uploadProgressReader{ + reader: f, + progressWriter: safeWriter, + totalImageSize: safeUint64(totalSize), + fileSize: safeUint64(file.Size), + fileID: file.ID, + } + + err = client.UploadFile(ctx, repo, file.RepoPath, pr, file.Size) + f.Close() + if err != nil { + return fmt.Errorf("upload %s: %w", file.RepoPath, err) + } + + if safeWriter != nil { + _ = progress.WriteProgress(safeWriter, "", safeUint64(totalSize), safeUint64(file.Size), safeUint64(file.Size), file.ID, oci.ModePush) + } + } + + return nil +} + +func safeUint64(n int64) uint64 { + if n < 0 { + return 0 + } + return uint64(n) +} diff --git a/pkg/inference/models/api.go b/pkg/inference/models/api.go index ffb724c12..c764d4834 100644 --- a/pkg/inference/models/api.go +++ b/pkg/inference/models/api.go @@ -20,6 +20,13 @@ type ModelCreateRequest struct { BearerToken string `json:"bearer-token,omitempty"` } +// ModelPushRequest represents a model push request. It mirrors ModelCreateRequest +// so clients can provide an optional bearer token for registry authentication. +type ModelPushRequest struct { + // BearerToken is an optional bearer token for authentication. + BearerToken string `json:"bearer-token,omitempty"` +} + // SimpleModel is a wrapper that allows creating a model with modified configuration type SimpleModel struct { types.Model diff --git a/pkg/inference/models/http_handler.go b/pkg/inference/models/http_handler.go index a72136735..861323445 100644 --- a/pkg/inference/models/http_handler.go +++ b/pkg/inference/models/http_handler.go @@ -1,11 +1,13 @@ package models import ( + "bytes" "context" "encoding/json" "errors" "fmt" "html" + "io" "net/http" "path" "strconv" @@ -449,7 +451,22 @@ func (h *HTTPHandler) handleTagModel(w http.ResponseWriter, r *http.Request, mod // handlePushModel handles POST /models/{name}/push requests. func (h *HTTPHandler) handlePushModel(w http.ResponseWriter, r *http.Request, model string) { - if err := h.manager.Push(model, r, w); err != nil { + var req ModelPushRequest + if r.Body != nil && r.Body != http.NoBody { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if len(bytes.TrimSpace(body)) > 0 { + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + } + } + + if err := h.manager.Push(model, req.BearerToken, r, w); err != nil { if errors.Is(err, distribution.ErrInvalidReference) { h.log.Warnf("Invalid model reference %q: %v", utils.SanitizeForLog(model, -1), err) http.Error(w, "Invalid model reference", http.StatusBadRequest) diff --git a/pkg/inference/models/manager.go b/pkg/inference/models/manager.go index 7d5001bdb..75cc2aee7 100644 --- a/pkg/inference/models/manager.go +++ b/pkg/inference/models/manager.go @@ -369,7 +369,7 @@ func (m *Manager) Tag(ref, target string) error { } // Push pushes a model from the store to the registry. -func (m *Manager) Push(model string, r *http.Request, w http.ResponseWriter) error { +func (m *Manager) Push(model string, bearerToken string, r *http.Request, w http.ResponseWriter) error { // Set up response headers for streaming w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") @@ -398,7 +398,13 @@ func (m *Manager) Push(model string, r *http.Request, w http.ResponseWriter) err isJSON: isJSON, } - err := m.distributionClient.PushModel(r.Context(), model, progressWriter) + var err error + if bearerToken != "" { + m.log.Infoln("Using provided bearer token for push authentication") + err = m.distributionClient.PushModel(r.Context(), model, progressWriter, bearerToken) + } else { + err = m.distributionClient.PushModel(r.Context(), model, progressWriter) + } if err != nil { return fmt.Errorf("error while pushing model: %w", err) } From 32cb3d73021606a14aa231bd27d0e5c594504e18 Mon Sep 17 00:00:00 2001 From: Manish Biswal Date: Sat, 14 Feb 2026 17:58:07 +0530 Subject: [PATCH 2/4] feat: LFS based file upload and commit Signed-off-by: Manish Biswal --- cmd/cli/commands/push.go | 2 +- cmd/cli/desktop/desktop.go | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/cmd/cli/commands/push.go b/cmd/cli/commands/push.go index 81548fd15..ef9c0b91f 100644 --- a/cmd/cli/commands/push.go +++ b/cmd/cli/commands/push.go @@ -9,7 +9,7 @@ import ( func newPushCmd() *cobra.Command { c := &cobra.Command{ Use: "push MODEL", - Short: "Push a model to Docker Hub", + Short: "Push a model to Docker Hub or Hugging Face", Args: requireExactArgs(1, "push", "MODEL"), RunE: func(cmd *cobra.Command, args []string) error { return pushModel(cmd, desktopClient, args[0]) diff --git a/cmd/cli/desktop/desktop.go b/cmd/cli/desktop/desktop.go index cae6ae832..de1486ed6 100644 --- a/cmd/cli/desktop/desktop.go +++ b/cmd/cli/desktop/desktop.go @@ -223,12 +223,28 @@ func (c *Client) withRetries( } func (c *Client) Push(model string, printer standalone.StatusPrinter) (string, bool, error) { + var hfToken string + modelLower := strings.ToLower(model) + if strings.HasPrefix(modelLower, "hf.co/") || strings.HasPrefix(modelLower, "huggingface.co/") { + hfToken = os.Getenv("HF_TOKEN") + } + return c.withRetries("push", 3, printer, func(attempt int) (string, bool, error, bool) { pushPath := inference.ModelsPrefix + "/" + model + "/push" + var body io.Reader + if hfToken != "" { + jsonData, err := json.Marshal(dmrm.ModelPushRequest{ + BearerToken: hfToken, + }) + if err != nil { + return "", false, fmt.Errorf("error marshaling request: %w", err), false + } + body = bytes.NewReader(jsonData) + } resp, err := c.doRequest( http.MethodPost, pushPath, - nil, // Assuming no body is needed for the push request + body, ) if err != nil { // Only retry on network errors, not on client errors From 2d8971c9c82d1478fff04950f6afe681f6372e2f Mon Sep 17 00:00:00 2001 From: Manish Biswal Date: Sat, 14 Feb 2026 18:00:42 +0530 Subject: [PATCH 3/4] feat: LFS based file upload and commit Signed-off-by: Manish Biswal --- pkg/distribution/distribution/client.go | 8 +- pkg/distribution/huggingface/client.go | 248 ++++++++++++++++++++++- pkg/distribution/huggingface/uploader.go | 123 ++++++++++- 3 files changed, 363 insertions(+), 16 deletions(-) diff --git a/pkg/distribution/distribution/client.go b/pkg/distribution/distribution/client.go index a68d80a44..15dca4287 100644 --- a/pkg/distribution/distribution/client.go +++ b/pkg/distribution/distribution/client.go @@ -592,23 +592,18 @@ func (c *Client) Tag(source string, target string) error { // PushModel pushes a tagged model from the content store to the registry. func (c *Client) PushModel(ctx context.Context, tag string, progressWriter io.Writer, bearerToken ...string) (err error) { - // Store original reference before normalization (needed for case-sensitive HuggingFace API) originalReference := tag - // Normalize the model reference for store lookups normalizedRef := c.normalizeModelName(tag) - // Handle bearer token for registry authentication var token string if len(bearerToken) > 0 && bearerToken[0] != "" { token = bearerToken[0] } - // HuggingFace references use native push (upload raw files to HF Hub) if isHuggingFaceReference(originalReference) { return c.pushNativeHuggingFace(ctx, originalReference, normalizedRef, progressWriter, token) } - // Parse the tag registryClient := c.registry if token != "" { auth := authn.NewBearer(token) @@ -619,13 +614,11 @@ func (c *Client) PushModel(ctx context.Context, tag string, progressWriter io.Wr return fmt.Errorf("new tag: %w", err) } - // Get the model from the store mdl, err := c.store.Read(normalizedRef) if err != nil { return fmt.Errorf("reading model: %w", err) } - // Push the model c.log.Infoln("Pushing model:", utils.SanitizeForLog(tag, -1)) if err := target.Write(ctx, mdl, progressWriter); err != nil { c.log.Errorln("Failed to push image:", err, "reference:", tag) @@ -679,6 +672,7 @@ func (c *Client) pushNativeHuggingFace(ctx context.Context, reference, normalize } if err := huggingface.UploadFiles(ctx, hfClient, repo, files, totalSize, progressWriter); err != nil { + c.log.Errorf("HuggingFace push failed: %v", err) var authErr *huggingface.AuthError var notFoundErr *huggingface.NotFoundError if errors.As(err, &authErr) { diff --git a/pkg/distribution/huggingface/client.go b/pkg/distribution/huggingface/client.go index 2d8ff59dd..b3c9b83c3 100644 --- a/pkg/distribution/huggingface/client.go +++ b/pkg/distribution/huggingface/client.go @@ -1,7 +1,9 @@ package huggingface import ( + "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -25,6 +27,39 @@ type Client struct { baseURL string } +type LFSBatchRequest struct { + Operation string `json:"operation"` + Transfers []string `json:"transfers,omitempty"` + Objects []LFSBatchObject `json:"objects"` +} + +type LFSBatchObject struct { + OID string `json:"oid"` + Size int64 `json:"size"` +} + +type LFSObjectError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type LFSAction struct { + Href string `json:"href"` + Header map[string]string `json:"header,omitempty"` +} + +type LFSObject struct { + OID string `json:"oid"` + Size int64 `json:"size"` + Actions map[string]LFSAction `json:"actions,omitempty"` + Error *LFSObjectError `json:"error,omitempty"` +} + +type LFSBatchResponse struct { + Transfer string `json:"transfer,omitempty"` + Objects []LFSObject `json:"objects"` +} + // ClientOption configures a Client type ClientOption func(*Client) @@ -216,7 +251,207 @@ func (c *Client) GetRepoInfo(ctx context.Context, repo, revision string) (*RepoI return &info, nil } -// setHeaders sets common headers for HuggingFace API requests +type CommitFile struct { + RepoPath string + Content io.Reader +} + +type LFSCommitFile struct { + Path string `json:"path"` + Algo string `json:"algo"` + OID string `json:"oid"` + Size int64 `json:"size"` +} + +type ndjsonEntry struct { + Key string `json:"key"` + Value interface{} `json:"value"` +} + +// CreateCommit creates a commit in a HuggingFace repository using the NDJSON API. +// LFS files must be pre-uploaded via LFSBatch + UploadLFSObject before calling this. +// Small files are sent inline as base64. +func (c *Client) CreateCommit(ctx context.Context, repo, message string, directFiles []CommitFile, lfsFiles []LFSCommitFile) error { + if repo == "" { + return fmt.Errorf("repository is required") + } + + endpoint := fmt.Sprintf("%s/api/models/%s/commit/main", c.baseURL, escapePath(repo)) + + var buf bytes.Buffer + + headerEntry := ndjsonEntry{ + Key: "header", + Value: map[string]interface{}{ + "summary": message, + "description": "", + }, + } + if err := json.NewEncoder(&buf).Encode(headerEntry); err != nil { + return fmt.Errorf("encode commit header: %w", err) + } + + for _, lf := range lfsFiles { + entry := ndjsonEntry{ + Key: "lfsFile", + Value: map[string]interface{}{ + "path": lf.Path, + "algo": lf.Algo, + "oid": lf.OID, + "size": lf.Size, + }, + } + if err := json.NewEncoder(&buf).Encode(entry); err != nil { + return fmt.Errorf("encode lfs file entry %s: %w", lf.Path, err) + } + } + + for _, f := range directFiles { + data, err := io.ReadAll(f.Content) + if err != nil { + return fmt.Errorf("read file %s: %w", f.RepoPath, err) + } + entry := ndjsonEntry{ + Key: "file", + Value: map[string]interface{}{ + "path": f.RepoPath, + "encoding": "base64", + "content": base64.StdEncoding.EncodeToString(data), + }, + } + if err := json.NewEncoder(&buf).Encode(entry); err != nil { + return fmt.Errorf("encode file entry %s: %w", f.RepoPath, err) + } + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &buf) + if err != nil { + return fmt.Errorf("create commit request: %w", err) + } + req.Header.Set("Content-Type", "application/x-ndjson") + c.setHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("create commit: %w", err) + } + defer resp.Body.Close() + + if err := c.checkUploadResponse(resp, repo); err != nil { + return err + } + + return nil +} + +func (c *Client) LFSBatch(ctx context.Context, repo string, objects []LFSBatchObject) (*LFSBatchResponse, error) { + if repo == "" { + return nil, fmt.Errorf("repository is required") + } + if len(objects) == 0 { + return &LFSBatchResponse{}, nil + } + + endpoint := fmt.Sprintf("%s/%s.git/info/lfs/objects/batch", c.baseURL, escapePath(repo)) + reqBody := LFSBatchRequest{ + Operation: "upload", + Transfers: []string{"basic"}, + Objects: objects, + } + data, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("encode lfs batch request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + c.setHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("lfs batch: %w", err) + } + defer resp.Body.Close() + + if err := c.checkResponse(resp, repo); err != nil { + return nil, err + } + + var batchResp LFSBatchResponse + if err := json.NewDecoder(resp.Body).Decode(&batchResp); err != nil { + return nil, fmt.Errorf("decode lfs batch response: %w", err) + } + + return &batchResp, nil +} + +func (c *Client) UploadLFSObject(ctx context.Context, action LFSAction, content io.Reader, size int64) error { + if action.Href == "" { + return fmt.Errorf("upload action href is empty") + } + req, err := http.NewRequestWithContext(ctx, http.MethodPut, action.Href, content) + if err != nil { + return fmt.Errorf("create upload request: %w", err) + } + + for key, value := range action.Header { + req.Header.Set(key, value) + } + if req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/octet-stream") + } + if size > 0 { + req.ContentLength = size + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("upload lfs object: %w", err) + } + defer resp.Body.Close() + + if err := c.checkUploadResponse(resp, ""); err != nil { + return err + } + + return nil +} + +func (c *Client) VerifyLFSObject(ctx context.Context, action LFSAction, oid string, size int64) error { + if action.Href == "" { + return nil + } + data, err := json.Marshal(LFSBatchObject{OID: oid, Size: size}) + if err != nil { + return fmt.Errorf("encode verify request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, action.Href, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("create verify request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + c.setHeaders(req) + for key, value := range action.Header { + req.Header.Set(key, value) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("verify lfs object: %w", err) + } + defer resp.Body.Close() + + if err := c.checkUploadResponse(resp, ""); err != nil { + return err + } + + return nil +} + func (c *Client) setHeaders(req *http.Request) { req.Header.Set("User-Agent", c.userAgent) if c.token != "" { @@ -224,7 +459,6 @@ func (c *Client) setHeaders(req *http.Request) { } } -// checkResponse checks the HTTP response for errors func (c *Client) checkResponse(resp *http.Response, repo string) error { switch resp.StatusCode { case http.StatusOK: @@ -246,7 +480,15 @@ func (c *Client) checkUploadResponse(resp *http.Response, repo string) error { case http.StatusOK, http.StatusCreated, http.StatusAccepted: return nil default: - return c.checkResponse(resp, repo) + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + switch resp.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return &AuthError{Repo: repo, StatusCode: resp.StatusCode} + case http.StatusNotFound: + return &NotFoundError{Repo: repo} + default: + return fmt.Errorf("upload failed (status %d): %s", resp.StatusCode, string(body)) + } } } diff --git a/pkg/distribution/huggingface/uploader.go b/pkg/distribution/huggingface/uploader.go index 186cbe969..6d710e7e4 100644 --- a/pkg/distribution/huggingface/uploader.go +++ b/pkg/distribution/huggingface/uploader.go @@ -2,6 +2,8 @@ package huggingface import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "io" "io/fs" @@ -18,6 +20,7 @@ type UploadFile struct { RepoPath string Size int64 ID string + OID string } type uploadProgressReader struct { @@ -93,16 +96,105 @@ func UploadFiles(ctx context.Context, client *Client, repo string, files []Uploa return fmt.Errorf("repository is required") } + const lfsThreshold = 10 * 1024 * 1024 + var safeWriter io.Writer if progressWriter != nil { safeWriter = &syncWriter{w: progressWriter} } - for _, file := range files { + var lfsFiles []UploadFile + var directFiles []UploadFile + for i := range files { + if files[i].Size >= lfsThreshold { + oid, err := computeFileOID(files[i].LocalPath) + if err != nil { + return fmt.Errorf("compute oid for %s: %w", files[i].RepoPath, err) + } + files[i].OID = oid + lfsFiles = append(lfsFiles, files[i]) + } else { + directFiles = append(directFiles, files[i]) + } + } + + var lfsCommitFiles []LFSCommitFile + if len(lfsFiles) > 0 { + objects := make([]LFSBatchObject, 0, len(lfsFiles)) + for _, file := range lfsFiles { + objects = append(objects, LFSBatchObject{OID: file.OID, Size: file.Size}) + } + batchResp, err := client.LFSBatch(ctx, repo, objects) + if err != nil { + return fmt.Errorf("lfs batch: %w", err) + } + objByOID := make(map[string]LFSObject, len(batchResp.Objects)) + for _, obj := range batchResp.Objects { + objByOID[obj.OID] = obj + } + + for _, file := range lfsFiles { + obj, ok := objByOID[file.OID] + if !ok { + return fmt.Errorf("missing lfs response for %s", file.RepoPath) + } + if obj.Error != nil { + return fmt.Errorf("lfs error for %s: %s", file.RepoPath, obj.Error.Message) + } + + uploadAction, hasUpload := obj.Actions["upload"] + if hasUpload && uploadAction.Href != "" { + f, err := os.Open(file.LocalPath) + if err != nil { + return fmt.Errorf("open file %s: %w", file.LocalPath, err) + } + pr := &uploadProgressReader{ + reader: f, + progressWriter: safeWriter, + totalImageSize: safeUint64(totalSize), + fileSize: safeUint64(file.Size), + fileID: file.ID, + } + err = client.UploadLFSObject(ctx, uploadAction, pr, file.Size) + f.Close() + if err != nil { + return fmt.Errorf("upload lfs %s: %w", file.RepoPath, err) + } + + if verifyAction, ok := obj.Actions["verify"]; ok { + if err := client.VerifyLFSObject(ctx, verifyAction, file.OID, file.Size); err != nil { + return fmt.Errorf("verify lfs %s: %w", file.RepoPath, err) + } + } + + if safeWriter != nil { + _ = progress.WriteProgress(safeWriter, "", safeUint64(totalSize), safeUint64(file.Size), safeUint64(file.Size), file.ID, oci.ModePush) + } + } + + lfsCommitFiles = append(lfsCommitFiles, LFSCommitFile{ + Path: file.RepoPath, + Algo: "sha256", + OID: file.OID, + Size: file.Size, + }) + } + } + + var commitFiles []CommitFile + var openFiles []*os.File + defer func() { + for _, f := range openFiles { + f.Close() + } + }() + + for _, file := range directFiles { f, err := os.Open(file.LocalPath) if err != nil { return fmt.Errorf("open file %s: %w", file.LocalPath, err) } + openFiles = append(openFiles, f) pr := &uploadProgressReader{ reader: f, @@ -112,12 +204,17 @@ func UploadFiles(ctx context.Context, client *Client, repo string, files []Uploa fileID: file.ID, } - err = client.UploadFile(ctx, repo, file.RepoPath, pr, file.Size) - f.Close() - if err != nil { - return fmt.Errorf("upload %s: %w", file.RepoPath, err) - } + commitFiles = append(commitFiles, CommitFile{ + RepoPath: file.RepoPath, + Content: pr, + }) + } + + if err := client.CreateCommit(ctx, repo, "Upload model via docker model push", commitFiles, lfsCommitFiles); err != nil { + return fmt.Errorf("create commit: %w", err) + } + for _, file := range directFiles { if safeWriter != nil { _ = progress.WriteProgress(safeWriter, "", safeUint64(totalSize), safeUint64(file.Size), safeUint64(file.Size), file.ID, oci.ModePush) } @@ -132,3 +229,17 @@ func safeUint64(n int64) uint64 { } return uint64(n) } + +func computeFileOID(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} From fde765f33cb73d68a2a34916d710f515a85174f0 Mon Sep 17 00:00:00 2001 From: imanishx Date: Sun, 15 Feb 2026 05:10:38 +0000 Subject: [PATCH 4/4] docs: regenerate CLI reference docs --- cmd/cli/docs/reference/docker_model_push.yaml | 4 ++-- cmd/cli/docs/reference/model.md | 2 +- cmd/cli/docs/reference/model_push.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/cli/docs/reference/docker_model_push.yaml b/cmd/cli/docs/reference/docker_model_push.yaml index af0fac1a3..65c9ddfbe 100644 --- a/cmd/cli/docs/reference/docker_model_push.yaml +++ b/cmd/cli/docs/reference/docker_model_push.yaml @@ -1,6 +1,6 @@ command: docker model push -short: Push a model to Docker Hub -long: Push a model to Docker Hub +short: Push a model to Docker Hub or Hugging Face +long: Push a model to Docker Hub or Hugging Face usage: docker model push MODEL pname: docker model plink: docker_model.yaml diff --git a/cmd/cli/docs/reference/model.md b/cmd/cli/docs/reference/model.md index e2869cd98..56a4ae350 100644 --- a/cmd/cli/docs/reference/model.md +++ b/cmd/cli/docs/reference/model.md @@ -18,7 +18,7 @@ Docker Model Runner | [`ps`](model_ps.md) | List running models | | [`pull`](model_pull.md) | Pull a model from Docker Hub or HuggingFace to your local environment | | [`purge`](model_purge.md) | Remove all models | -| [`push`](model_push.md) | Push a model to Docker Hub | +| [`push`](model_push.md) | Push a model to Docker Hub or Hugging Face | | [`reinstall-runner`](model_reinstall-runner.md) | Reinstall Docker Model Runner (Docker Engine only) | | [`requests`](model_requests.md) | Fetch requests+responses from Docker Model Runner | | [`restart-runner`](model_restart-runner.md) | Restart Docker Model Runner (Docker Engine only) | diff --git a/cmd/cli/docs/reference/model_push.md b/cmd/cli/docs/reference/model_push.md index b50a425e8..7b040fe0b 100644 --- a/cmd/cli/docs/reference/model_push.md +++ b/cmd/cli/docs/reference/model_push.md @@ -1,7 +1,7 @@ # docker model push -Push a model to Docker Hub +Push a model to Docker Hub or Hugging Face