Files
llmgw/internal/hf/hf.go
T
Ilker ManapandClaude Opus 5.5 104b60fbf8
CI / test (push) Successful in 11m40s
A repository's files read from every directory, not only its top
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 12:16:11 +01:00

752 lines
23 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"
"slices"
"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"`
// Safetensors is the registry's summary of original weights.
Safetensors *SafetensorsInfo `json:"safetensors,omitempty"`
// Siblings are the repository's files, when asked for with WithFiles.
Siblings []struct {
RFilename string `json:"rfilename"`
} `json:"siblings,omitempty"`
}
// SafetensorsInfo is the registry's summary of a repository's safetensors.
type SafetensorsInfo struct {
Total int64 `json:"total"`
// Parameters counts them by dtype: {"BF16": 8e9}. A packed quantisation
// (AWQ, GPTQ, MLX) is counted as its I32 or U32 at the count of weights
// it unpacks to, not of the integers that hold them.
Parameters map[string]int64 `json:"parameters,omitempty"`
}
// dtypeBytes is what one parameter of a safetensors dtype takes.
var dtypeBytes = map[string]float64{
"F64": 8, "I64": 8, "U64": 8, "F32": 4, "BF16": 2, "F16": 2, "I16": 2, "U16": 2,
"F8_E4M3": 1, "F8_E5M2": 1, "F8_E8M0": 1, "I8": 1, "U8": 1, "BOOL": 1,
}
// WeightBytes is about how much the safetensors weights take: each dtype's
// count at its size, and a packed quantisation at 4 bits a weight, 8 for
// one tagged 8-bit — I32 or U32 always, U8 for MXFP4 and NVFP4, which the
// registry counts at the weights they unpack to. 0 when the registry has
// no summary.
func (m Model) WeightBytes() int64 {
if m.Safetensors == nil {
return 0
}
if len(m.Safetensors.Parameters) == 0 {
return m.Safetensors.Total * 2
}
packed := 0.5
if m.HasTag("8-bit") {
packed = 1
}
fp4 := m.HasTag("mxfp4") || m.HasTag("nvfp4") || m.HasTag("fp4")
var b float64
for dt, n := range m.Safetensors.Parameters {
switch per, ok := dtypeBytes[dt]; {
case dt == "U8" && fp4:
b += float64(n) * 0.5
case ok:
b += float64(n) * per
case dt == "I32" || dt == "U32":
b += float64(n) * packed
default:
b += float64(n) * 2
}
}
return int64(b)
}
// FP8 reports whether the safetensors weights are in an 8-bit float, which
// vLLM runs only on cards of compute capability 8.9 or newer.
func (m Model) FP8() bool {
if m.Safetensors == nil {
return false
}
for dt := range m.Safetensors.Parameters {
if strings.HasPrefix(dt, "F8_") {
return true
}
}
return false
}
// HasTag reports whether the registry tags the repository so.
func (m Model) HasTag(tag string) bool { return slices.Contains(m.Tags, tag) }
// 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 {
switch {
case m.GGUF != nil:
return m.GGUF.Total
case m.Safetensors != nil:
return m.Safetensors.Total
}
return 0
}
// 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
// Filters are further registry tags every result must carry, such as
// "lora", or "base_model:adapter:Qwen/Qwen2.5-7B-Instruct" for the
// adapters made for one model.
Filters []string
// WithFiles lists each repository's files too, to find one holding a
// file by name.
WithFiles bool
}
// 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", "safetensors"}
// 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 q.WithFiles {
v.Add("expand[]", "siblings")
}
if s := strings.TrimSpace(q.Search); s != "" {
v.Set("search", s)
}
if q.Task != "" {
v.Set("pipeline_tag", q.Task)
}
if q.GGUF {
v.Add("filter", "gguf")
}
for _, f := range q.Filters {
v.Add("filter", f)
}
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
}
// companionNames are the files beside safetensors weights that an engine
// loading the directory reads: the model's shape, its tokenizer, how it was
// quantised, and which shard holds which tensor.
var companionNames = map[string]bool{
"config.json": true, "generation_config.json": true,
"tokenizer.json": true, "tokenizer_config.json": true, "tokenizer.model": true,
"special_tokens_map.json": true, "added_tokens.json": true,
"vocab.json": true, "vocab.txt": true, "merges.txt": true,
"chat_template.jinja": true, "chat_template.json": true,
"preprocessor_config.json": true, "processor_config.json": true,
"quantize_config.json": true, "quant_config.json": true,
}
// Companions are the files of a repository an engine reads beside a
// safetensors set: config.json and the tokenizer, the shard index. Only
// those at the top: the directory an engine is given is one level deep.
//
// Never code (a .py): a model that needs its own Python to load asks the
// engine to run what the repository ships, which is a decision for whoever
// runs the engine, made on purpose, not a file swept in with the tokenizer.
func Companions(files []File) []File {
var out []File
for _, f := range files {
name := f.Path
if strings.Contains(name, "/") {
continue
}
if companionNames[name] || strings.HasSuffix(name, ".safetensors.index.json") ||
strings.HasSuffix(name, ".tiktoken") {
out = append(out, f)
}
}
return out
}
// 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
// Params is the parameter count the registry read from the weights, 0
// when it did not.
Params int64
// 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
}
// Base is the registry's address, for building a file's download URL.
func (c *Client) Base() string { return c.base }
// Repo reads a repository's revision and file list.
func (c *Client) Repo(ctx context.Context, id string) (Repo, error) { return c.RepoAt(ctx, id, "") }
// RepoAt reads a repository as it was at a commit, which may be shortened;
// "" is its latest.
func (c *Client) RepoAt(ctx context.Context, id, rev 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"`
Total int64 `json:"total"`
} `json:"gguf"`
Safetensors *struct {
Total int64 `json:"total"`
} `json:"safetensors"`
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.
at := ""
if rev != "" {
at = "/revision/" + url.PathEscape(rev)
}
if err := c.get(ctx, "/api/models/"+id+at+"?expand[]=sha&expand[]=gated&expand[]=gguf&expand[]=safetensors&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"
}
// Recursive: a repository that keeps each quantisation in a directory of
// its own — ISTA-DASLab's IQ2_XS/, IQ3_S/ — listed no weights at all.
if err := c.get(ctx, "/api/models/"+id+"/tree/"+rev+"?recursive=true", &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, r.Params = meta.GGUF.Architecture, meta.GGUF.Total
}
if meta.Safetensors != nil && meta.Safetensors.Total > 0 {
r.Params = meta.Safetensors.Total
}
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
}
// ModelName is the catalog name a repository suggests: its last element,
// lowercased, with the -GGUF marker dropped. The owner is not part of what
// anybody calls the model, and "-GGUF" describes the file format.
func ModelName(repoID string) string {
name := repoID
if i := strings.LastIndex(name, "/"); i >= 0 {
name = name[i+1:]
}
name = strings.TrimSuffix(name, "-GGUF")
name = strings.TrimSuffix(name, "-gguf")
return strings.ToLower(name)
}
// FileSource reads a source naming one file of a registry repository: hf:
// with the file, or a resolve URL on the registry. A resolve URL already at
// a commit is still looked up, for the file's sha256.
func FileSource(src, base string) (id, rev, file string, ok bool) {
if rest, found := strings.CutPrefix(src, "hf:"); found {
parts := strings.Split(rest, "/")
if len(parts) < 3 {
return "", "", "", false
}
return parts[0] + "/" + parts[1], "", strings.Join(parts[2:], "/"), true
}
u, err := url.Parse(src)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || !isRegistryHost(u.Host, base) {
return "", "", "", false
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) < 5 || parts[2] != "resolve" {
return "", "", "", false
}
return parts[0] + "/" + parts[1], parts[3], strings.Join(parts[4:], "/"), true
}
// isRegistryHost says whether a host is the registry's: Hugging Face's own, or
// the endpoint the cluster names instead.
func isRegistryHost(host, base string) bool {
if host == "huggingface.co" {
return true
}
b, err := url.Parse(base)
return err == nil && b.Host != "" && b.Host == host
}