Opening a repository shows its model card, rendered from markdown, with its base model, license and languages above it; the listing names each repository's base model and license under its name. The card is a stranger's text: raw HTML and dangerous links are dropped, images are shown as links rather than loaded, and relative links point into the repository. Adds github.com/yuin/goldmark for the markdown. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
561 lines
17 KiB
Go
561 lines
17 KiB
Go
// Package hf reads the Hugging Face model registry.
|
|
//
|
|
// Enough of it to answer two questions an operator has before mirroring a
|
|
// model: what is there, and which file do I want. Nothing here downloads
|
|
// anything — the mirror does that, over its own network, and the point of the
|
|
// split is that the control plane never carries a model's bytes.
|
|
//
|
|
// Read-only and anonymous. A gated or private repository answers 401 to this
|
|
// client, which is reported rather than worked around: a token belongs to the
|
|
// machine that fetches the weights, not to the one browsing a catalogue.
|
|
package hf
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// DefaultEndpoint is the public registry.
|
|
const DefaultEndpoint = "https://huggingface.co"
|
|
|
|
// Client reads the registry.
|
|
type Client struct {
|
|
base string
|
|
hc *http.Client
|
|
|
|
mu sync.Mutex
|
|
cache map[string]cached
|
|
}
|
|
|
|
// New builds a client against an endpoint, or the public one when empty.
|
|
func New(endpoint string, timeout time.Duration) *Client {
|
|
if endpoint == "" {
|
|
endpoint = DefaultEndpoint
|
|
}
|
|
if timeout <= 0 {
|
|
// Short. This runs while somebody is looking at a page, and a registry
|
|
// that is slow today is better reported than waited for.
|
|
timeout = 10 * time.Second
|
|
}
|
|
return &Client{base: strings.TrimSuffix(endpoint, "/"),
|
|
hc: &http.Client{Timeout: timeout}}
|
|
}
|
|
|
|
// Model is a repository as a search result shows it.
|
|
type Model struct {
|
|
ID string `json:"id"`
|
|
Downloads int64 `json:"downloads"`
|
|
Likes int64 `json:"likes"`
|
|
PipelineTag string `json:"pipeline_tag"`
|
|
Tags []string `json:"tags"`
|
|
LastModified time.Time `json:"lastModified"`
|
|
// Gated is true when the repository asks for terms to be accepted. It
|
|
// decodes from either a bool or a string ("auto", "manual"), which is why
|
|
// it is not a plain bool here.
|
|
Gated Gated `json:"gated"`
|
|
Private bool `json:"private"`
|
|
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
TrendingScore float64 `json:"trendingScore"`
|
|
// GGUF is what the registry read from the repository's weights, absent
|
|
// for one that has none.
|
|
GGUF *GGUFInfo `json:"gguf"`
|
|
}
|
|
|
|
// GGUFInfo is the registry's summary of a repository's GGUF weights.
|
|
type GGUFInfo struct {
|
|
// Total is the parameter count.
|
|
Total int64 `json:"total"`
|
|
Architecture string `json:"architecture"`
|
|
ContextLength int64 `json:"context_length"`
|
|
}
|
|
|
|
// Params is the parameter count, 0 when the registry does not know it.
|
|
func (m Model) Params() int64 {
|
|
if m.GGUF == nil {
|
|
return 0
|
|
}
|
|
return m.GGUF.Total
|
|
}
|
|
|
|
// Architecture is the GGUF architecture, empty when unknown.
|
|
func (m Model) Architecture() string {
|
|
if m.GGUF == nil {
|
|
return ""
|
|
}
|
|
return m.GGUF.Architecture
|
|
}
|
|
|
|
// Gated is Hugging Face's gating flag, which is false or a word.
|
|
type Gated struct{ Reason string }
|
|
|
|
// UnmarshalJSON accepts false, true, or a string.
|
|
//
|
|
// The field is documented as a boolean and is a string in practice for most
|
|
// gated repositories. Decoding it as a bool fails the whole response, which
|
|
// would make one gated model in a page of results hide the other nineteen.
|
|
func (g *Gated) UnmarshalJSON(b []byte) error {
|
|
var s string
|
|
if err := json.Unmarshal(b, &s); err == nil {
|
|
g.Reason = s
|
|
return nil
|
|
}
|
|
var v bool
|
|
if err := json.Unmarshal(b, &v); err != nil {
|
|
return nil // unknown shape: treat as not gated rather than fail the page
|
|
}
|
|
if v {
|
|
g.Reason = "yes"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Is reports whether the repository is gated at all.
|
|
func (g Gated) Is() bool { return g.Reason != "" && g.Reason != "false" }
|
|
|
|
// Search finds repositories by name.
|
|
func (c *Client) Search(ctx context.Context, query string, limit int) ([]Model, error) {
|
|
if strings.TrimSpace(query) == "" {
|
|
return nil, fmt.Errorf("search for something")
|
|
}
|
|
// By downloads, because the thing somebody means by a short name is
|
|
// almost always the one everybody else meant too.
|
|
return c.Explore(ctx, Query{Search: query, Sort: SortDownloads, Limit: limit})
|
|
}
|
|
|
|
// Sort orders an exploration.
|
|
type Sort string
|
|
|
|
// The orders the registry offers that are worth offering.
|
|
const (
|
|
SortTrending Sort = "trendingScore"
|
|
SortDownloads Sort = "downloads"
|
|
SortLikes Sort = "likes"
|
|
SortCreated Sort = "createdAt"
|
|
)
|
|
|
|
// Query is what to list from the registry.
|
|
type Query struct {
|
|
Search string
|
|
Sort Sort
|
|
// Task is a pipeline tag: text-generation, feature-extraction. Empty is
|
|
// any.
|
|
Task string
|
|
// GGUF keeps to repositories with GGUF weights, which is what llama.cpp
|
|
// loads.
|
|
GGUF bool
|
|
// MaxParams bounds the parameter count; 0 is no bound. The registry
|
|
// applies it, so a page of small models is a full page.
|
|
MaxParams int64
|
|
Limit int
|
|
}
|
|
|
|
// exploreFields is everything a listing shows. Asking for any field with
|
|
// expand returns only the fields asked for, so all of them are named.
|
|
var exploreFields = []string{"downloads", "likes", "pipeline_tag", "tags", "lastModified",
|
|
"createdAt", "gated", "private", "trendingScore", "gguf"}
|
|
|
|
// Explore lists repositories: the trending ones, the newest, or a search.
|
|
//
|
|
// Remembered for a few minutes. The mirror page opens on this, and a page
|
|
// that waits on a registry on the far side of the internet every time it is
|
|
// opened is slow for news that changes by the hour.
|
|
func (c *Client) Explore(ctx context.Context, q Query) ([]Model, error) {
|
|
if q.Limit <= 0 || q.Limit > 100 {
|
|
q.Limit = 20
|
|
}
|
|
if q.Sort == "" {
|
|
q.Sort = SortTrending
|
|
}
|
|
v := url.Values{
|
|
"limit": {fmt.Sprint(q.Limit)},
|
|
"sort": {string(q.Sort)},
|
|
"direction": {"-1"},
|
|
"expand[]": exploreFields,
|
|
}
|
|
if s := strings.TrimSpace(q.Search); s != "" {
|
|
v.Set("search", s)
|
|
}
|
|
if q.Task != "" {
|
|
v.Set("pipeline_tag", q.Task)
|
|
}
|
|
if q.GGUF {
|
|
v.Set("filter", "gguf")
|
|
}
|
|
if q.MaxParams > 0 {
|
|
v.Set("num_parameters", fmt.Sprintf("min:0,max:%d", q.MaxParams))
|
|
}
|
|
path := "/api/models?" + v.Encode()
|
|
|
|
c.mu.Lock()
|
|
if e, ok := c.cache[path]; ok && time.Since(e.at) < exploreTTL {
|
|
c.mu.Unlock()
|
|
return e.models, nil
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
var out []Model
|
|
if err := c.get(ctx, path, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
c.mu.Lock()
|
|
if c.cache == nil || len(c.cache) > 200 {
|
|
c.cache = map[string]cached{}
|
|
}
|
|
c.cache[path] = cached{at: time.Now(), models: out}
|
|
c.mu.Unlock()
|
|
return out, nil
|
|
}
|
|
|
|
// exploreTTL is how long a listing is reused.
|
|
const exploreTTL = 10 * time.Minute
|
|
|
|
type cached struct {
|
|
at time.Time
|
|
models []Model
|
|
}
|
|
|
|
// File is one file in a repository.
|
|
type File struct {
|
|
Path string
|
|
// Bytes is the real size. For a file stored in LFS — every weights file —
|
|
// the plain size field is the size of the pointer, a few hundred bytes, so
|
|
// reading that one would report a 5 GB model as 133 bytes.
|
|
Bytes int64
|
|
// SHA256 is what the registry publishes for an LFS file, so it can be
|
|
// compared with what the mirror computes after fetching. Empty for a small
|
|
// file stored in git, where the oid is a git blob hash and not a checksum
|
|
// of the content.
|
|
SHA256 string
|
|
}
|
|
|
|
// IsWeights reports whether this file is a model rather than documentation.
|
|
//
|
|
// By extension, which is crude and right: the registry does not label them, and
|
|
// an operator filling a mirror wants the four files out of thirty that are
|
|
// worth gigabytes.
|
|
func (f File) IsWeights() bool {
|
|
for _, ext := range []string{".gguf", ".safetensors", ".bin", ".pt", ".pth"} {
|
|
if strings.HasSuffix(strings.ToLower(f.Path), ext) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Repo is a repository's current revision and what is in it.
|
|
type Repo struct {
|
|
ID string
|
|
// Revision is the commit the listing came from.
|
|
//
|
|
// Carried so a mirrored copy can name it. Fetching from "main" records
|
|
// nothing about what was fetched, and a repository that is re-uploaded
|
|
// under the same branch leaves two different sets of weights with the same
|
|
// provenance and no way to tell them apart.
|
|
Revision string
|
|
Gated Gated
|
|
Files []File
|
|
// Architecture is the GGUF architecture the registry read from the
|
|
// repository's weights, empty for a repository with none. What decides
|
|
// whether llama.cpp can load it, known before anything is downloaded.
|
|
Architecture string
|
|
|
|
// From the model card's header: what somebody asks before reading the
|
|
// rest of it.
|
|
License string
|
|
BaseModel []string
|
|
Languages []string
|
|
// Card is the model card's text, markdown, without its header. Empty when
|
|
// the repository has none or it could not be read — a repository is
|
|
// still worth fetching from without its README.
|
|
Card string
|
|
}
|
|
|
|
// ShortRevision is the revision as a person would write it.
|
|
func (r Repo) ShortRevision() string {
|
|
if len(r.Revision) > 8 {
|
|
return r.Revision[:8]
|
|
}
|
|
return r.Revision
|
|
}
|
|
|
|
// Repo reads a repository's revision and file list.
|
|
func (c *Client) Repo(ctx context.Context, id string) (Repo, error) {
|
|
id = strings.Trim(strings.TrimSpace(id), "/")
|
|
if !strings.Contains(id, "/") {
|
|
return Repo{}, fmt.Errorf("%q is not a repository; it looks like owner/name", id)
|
|
}
|
|
|
|
var meta struct {
|
|
ID string `json:"id"`
|
|
SHA string `json:"sha"`
|
|
Gated Gated `json:"gated"`
|
|
GGUF *struct {
|
|
Architecture string `json:"architecture"`
|
|
} `json:"gguf"`
|
|
CardData struct {
|
|
License string `json:"license"`
|
|
BaseModel stringish `json:"base_model"`
|
|
Language stringish `json:"language"`
|
|
} `json:"cardData"`
|
|
}
|
|
// Expanded to what this reads: asking for gguf alone would leave out the
|
|
// revision and the gate.
|
|
if err := c.get(ctx, "/api/models/"+id+"?expand[]=sha&expand[]=gated&expand[]=gguf&expand[]=cardData", &meta); err != nil {
|
|
return Repo{}, err
|
|
}
|
|
|
|
var tree []struct {
|
|
Type string `json:"type"`
|
|
Path string `json:"path"`
|
|
Size int64 `json:"size"`
|
|
LFS *struct {
|
|
OID string `json:"oid"`
|
|
Size int64 `json:"size"`
|
|
} `json:"lfs"`
|
|
}
|
|
// By revision rather than by branch, so the listing and the URLs built from
|
|
// it describe the same commit even if the branch moves in between.
|
|
rev := meta.SHA
|
|
if rev == "" {
|
|
rev = "main"
|
|
}
|
|
if err := c.get(ctx, "/api/models/"+id+"/tree/"+rev, &tree); err != nil {
|
|
return Repo{}, err
|
|
}
|
|
|
|
r := Repo{ID: meta.ID, Revision: meta.SHA, Gated: meta.Gated,
|
|
License: meta.CardData.License, BaseModel: meta.CardData.BaseModel,
|
|
Languages: meta.CardData.Language}
|
|
if meta.GGUF != nil {
|
|
r.Architecture = meta.GGUF.Architecture
|
|
}
|
|
if r.ID == "" {
|
|
r.ID = id
|
|
}
|
|
hasCard := false
|
|
for _, e := range tree {
|
|
if e.Type != "file" {
|
|
continue
|
|
}
|
|
if e.Path == "README.md" {
|
|
hasCard = true
|
|
}
|
|
f := File{Path: e.Path, Bytes: e.Size}
|
|
if e.LFS != nil {
|
|
f.Bytes, f.SHA256 = e.LFS.Size, e.LFS.OID
|
|
}
|
|
r.Files = append(r.Files, f)
|
|
}
|
|
if hasCard {
|
|
// Best effort: the files are what the page is for.
|
|
if b, err := c.raw(ctx, "/"+r.ID+"/raw/"+rev+"/README.md", maxCard); err == nil {
|
|
r.Card = stripFrontMatter(string(b))
|
|
}
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// maxCard is as much of a model card as is read. Most are a few kilobytes;
|
|
// the cap is for the one that is not.
|
|
const maxCard = 256 << 10
|
|
|
|
// stripFrontMatter drops a card's YAML header, which the registry already
|
|
// hands over as cardData and which reads as noise at the top of the text.
|
|
func stripFrontMatter(s string) string {
|
|
s = strings.TrimPrefix(s, "\ufeff")
|
|
if !strings.HasPrefix(s, "---") {
|
|
return strings.TrimSpace(s)
|
|
}
|
|
rest := s[3:]
|
|
if i := strings.Index(rest, "\n---"); i >= 0 {
|
|
rest = rest[i+4:]
|
|
if j := strings.IndexByte(rest, '\n'); j >= 0 {
|
|
rest = rest[j+1:]
|
|
} else {
|
|
rest = ""
|
|
}
|
|
return strings.TrimSpace(rest)
|
|
}
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
// stringish is a card field written as one string or a list of them.
|
|
type stringish []string
|
|
|
|
// UnmarshalJSON accepts a string, a list of strings, or anything else as
|
|
// nothing: a card header is free-form YAML, and one odd field should not
|
|
// cost the page its file list.
|
|
func (v *stringish) UnmarshalJSON(b []byte) error {
|
|
var one string
|
|
if json.Unmarshal(b, &one) == nil {
|
|
if one != "" {
|
|
*v = []string{one}
|
|
}
|
|
return nil
|
|
}
|
|
var many []string
|
|
if json.Unmarshal(b, &many) == nil {
|
|
*v = many
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BaseModel is the model a repository was made from, read from its tags:
|
|
// "base_model:quantized:Qwen/Qwen3-8B" and its kin. Empty when not said.
|
|
func (m Model) BaseModel() string {
|
|
for _, t := range m.Tags {
|
|
if !strings.HasPrefix(t, "base_model:") {
|
|
continue
|
|
}
|
|
rest := strings.TrimPrefix(t, "base_model:")
|
|
// The relation is optional: base_model:X or base_model:quantized:X.
|
|
if i := strings.Index(rest, ":"); i >= 0 && !strings.Contains(rest[:i], "/") {
|
|
rest = rest[i+1:]
|
|
}
|
|
if rest != "" {
|
|
return rest
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// License is the license tag, empty when there is none.
|
|
func (m Model) License() string {
|
|
for _, t := range m.Tags {
|
|
if strings.HasPrefix(t, "license:") {
|
|
return strings.TrimPrefix(t, "license:")
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// DownloadURL is where the mirror should fetch a file from.
|
|
//
|
|
// Pinned to the revision the listing came from. "main" would mean the mirror
|
|
// fetches whatever is there when it gets round to it, which is not necessarily
|
|
// what was on screen when somebody pressed the button.
|
|
func (r Repo) DownloadURL(base, path string) string {
|
|
if base == "" {
|
|
base = DefaultEndpoint
|
|
}
|
|
rev := r.Revision
|
|
if rev == "" {
|
|
rev = "main"
|
|
}
|
|
return strings.TrimSuffix(base, "/") + "/" + r.ID + "/resolve/" + rev + "/" + path
|
|
}
|
|
|
|
func (c *Client) get(ctx context.Context, path string, out any) error {
|
|
body, err := c.raw(ctx, path, 4<<20)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(body, out); err != nil {
|
|
return fmt.Errorf("the registry's answer was not the JSON expected: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// raw reads a path from the registry, at most limit bytes of it.
|
|
func (c *Client) raw(ctx context.Context, path string, limit int64) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := c.hc.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reaching the model registry: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Capped: this is a third party, and a page that renders what it sends
|
|
// should not be handed a gigabyte of it.
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, limit))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch {
|
|
case resp.StatusCode == http.StatusNotFound:
|
|
return nil, fmt.Errorf("the registry has nothing at %s", path)
|
|
case resp.StatusCode == http.StatusUnauthorized, resp.StatusCode == http.StatusForbidden:
|
|
// Said plainly, because the next thing somebody does is check their
|
|
// network. It is the repository, not the connection.
|
|
return nil, fmt.Errorf("the registry refused this anonymously; the repository is gated or private")
|
|
case resp.StatusCode >= 300:
|
|
return nil, fmt.Errorf("the registry answered %d", resp.StatusCode)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// shardPattern matches the "-00002-of-00004" a split file carries before its
|
|
// extension. Five digits in practice, but not required: the count is what
|
|
// matters and a repository that used three would otherwise list every part as
|
|
// a separate model.
|
|
var shardPattern = regexp.MustCompile(`^(.*)-(\d+)-of-(\d+)(\.[^.]+)$`)
|
|
|
|
// FileSet is one thing worth fetching: a file, or every shard of one.
|
|
//
|
|
// The distinction the page turns on. Two GGUF files in a repository are usually
|
|
// alternatives — q4_k_m or q8_0, you want one — while two safetensors files are
|
|
// halves of the same weights and useless apart. Fetching "all the weights" is
|
|
// right for the second and wasteful for the first, so neither is offered:
|
|
// a set is offered, and a set is whole.
|
|
type FileSet struct {
|
|
// Name is what to call it. For shards it keeps the -of- notation, because
|
|
// a repository can hold both the split and the joined copy of the same
|
|
// quantisation and they must not read as one entry.
|
|
Name string
|
|
Files []File
|
|
Bytes int64
|
|
Shards int
|
|
}
|
|
|
|
// SHA256 is the checksum when there is one to give.
|
|
//
|
|
// Empty for a shard set: several files have several checksums and no single
|
|
// value for the set, and showing one of them invites comparing it against the
|
|
// wrong thing.
|
|
func (s FileSet) SHA256() string {
|
|
if len(s.Files) == 1 {
|
|
return s.Files[0].SHA256
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Group collects shards into sets, leaving every other file on its own.
|
|
//
|
|
// Order is preserved from the listing, so a repository that sorts its files
|
|
// sensibly still reads sensibly here.
|
|
func Group(files []File) []FileSet {
|
|
var out []FileSet
|
|
at := map[string]int{}
|
|
for _, f := range files {
|
|
m := shardPattern.FindStringSubmatch(f.Path)
|
|
if m == nil {
|
|
out = append(out, FileSet{Name: f.Path, Files: []File{f}, Bytes: f.Bytes})
|
|
continue
|
|
}
|
|
prefix, total, ext := m[1], m[3], m[4]
|
|
name := prefix + "-*-of-" + total + ext
|
|
i, ok := at[name]
|
|
if !ok {
|
|
at[name] = len(out)
|
|
out = append(out, FileSet{Name: name})
|
|
i = at[name]
|
|
}
|
|
out[i].Files = append(out[i].Files, f)
|
|
out[i].Bytes += f.Bytes
|
|
out[i].Shards = len(out[i].Files)
|
|
}
|
|
return out
|
|
}
|