diff --git a/go.mod b/go.mod index d5d4e85..efb422b 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/prometheus/client_model v0.6.3 github.com/prometheus/common v0.71.0 github.com/spf13/cobra v1.10.2 + github.com/yuin/goldmark v1.8.6 golang.org/x/crypto v0.57.0 golang.org/x/net v0.59.0 golang.org/x/sync v0.23.0 diff --git a/go.sum b/go.sum index 18e313b..01cd524 100644 --- a/go.sum +++ b/go.sum @@ -77,6 +77,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= diff --git a/internal/hf/hf.go b/internal/hf/hf.go index 9892584..c890d2d 100644 --- a/internal/hf/hf.go +++ b/internal/hf/hf.go @@ -267,6 +267,16 @@ type Repo struct { // 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. @@ -291,10 +301,15 @@ func (c *Client) Repo(ctx context.Context, id string) (Repo, error) { 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", &meta); err != nil { + if err := c.get(ctx, "/api/models/"+id+"?expand[]=sha&expand[]=gated&expand[]=gguf&expand[]=cardData", &meta); err != nil { return Repo{}, err } @@ -317,26 +332,112 @@ func (c *Client) Repo(ctx context.Context, id string) (Repo, error) { return Repo{}, err } - r := Repo{ID: meta.ID, Revision: meta.SHA, Gated: meta.Gated} + 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 @@ -354,38 +455,47 @@ func (r Repo) DownloadURL(base, path string) string { } func (c *Client) get(ctx context.Context, path string, out any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil) + body, err := c.raw(ctx, path, 4<<20) if err != nil { return err } - resp, err := c.hc.Do(req) - if err != nil { - return 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, 4<<20)) - if err != nil { - return err - } - switch { - case resp.StatusCode == http.StatusNotFound: - return 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 fmt.Errorf("the registry refused this anonymously; the repository is gated or private") - case resp.StatusCode >= 300: - return fmt.Errorf("the registry answered %d", resp.StatusCode) - } 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 diff --git a/internal/hf/hf_test.go b/internal/hf/hf_test.go index e8c9db9..bbfa67b 100644 --- a/internal/hf/hf_test.go +++ b/internal/hf/hf_test.go @@ -230,3 +230,39 @@ func TestExploringAsksForWhatThePageFilters(t *testing.T) { t.Errorf("the registry was asked %d times", calls) } } + +func TestARepositoryCarriesItsCardWithoutTheHeader(t *testing.T) { + c := registry(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/api/models/q/m/tree/"): + _, _ = w.Write([]byte(`[{"type":"file","path":"README.md","size":40},{"type":"file","path":"m.gguf","size":1}]`)) + case r.URL.Path == "/api/models/q/m": + _, _ = w.Write([]byte(`{"id":"q/m","sha":"abc","cardData":{"license":"apache-2.0","base_model":"Qwen/Qwen2.5-0.5B","language":["en","tr"]}}`)) + case r.URL.Path == "/q/m/raw/abc/README.md": + _, _ = w.Write([]byte("---\nlicense: apache-2.0\n---\n\n# M\n\nA model.\n")) + default: + http.NotFound(w, r) + } + }) + got, err := c.Repo(t.Context(), "q/m") + if err != nil { + t.Fatal(err) + } + if got.Card != "# M\n\nA model." { + t.Errorf("card = %q", got.Card) + } + if got.License != "apache-2.0" || len(got.BaseModel) != 1 || got.BaseModel[0] != "Qwen/Qwen2.5-0.5B" || + len(got.Languages) != 2 { + t.Errorf("card header = %+v", got) + } +} + +func TestAListingNamesTheBaseModelAndLicenseFromItsTags(t *testing.T) { + m := Model{Tags: []string{"gguf", "base_model:quantized:Qwen/Qwen3-8B", "base_model:Qwen/Qwen3-8B", "license:apache-2.0"}} + if m.BaseModel() != "Qwen/Qwen3-8B" || m.License() != "apache-2.0" { + t.Errorf("base %q, license %q", m.BaseModel(), m.License()) + } + if (Model{Tags: []string{"base_model:org/x"}}).BaseModel() != "org/x" { + t.Error("a base model with no relation should read as itself") + } +} diff --git a/internal/web/admin_mirror.go b/internal/web/admin_mirror.go index f6078f5..a4ced7c 100644 --- a/internal/web/admin_mirror.go +++ b/internal/web/admin_mirror.go @@ -122,7 +122,11 @@ type adminMirrorData struct { Query string Explore *explorer // Back is the listing an open repository was reached from. - Back template.URL + Back template.URL + // Card is the open repository's model card, rendered. + Card template.HTML + // RepoURL is the open repository on the registry's own site. + RepoURL string Repo *hf.Repo RepoSets []repoSet RegistryErr string @@ -466,6 +470,8 @@ func (p *AccountPages) explore(r *http.Request, d *adminMirrorData) { return } d.Repo = &got + d.RepoURL = strings.TrimSuffix(got.DownloadURL("", ""), "/resolve/"+revOrMain(got.Revision)+"/") + d.Card = renderCard(got.Card, d.RepoURL+"/blob/"+revOrMain(got.Revision)+"/") var weights []hf.File for _, f := range got.Files { if !f.IsWeights() { @@ -532,3 +538,10 @@ func (p *AccountPages) checkSource(ctx context.Context, source string) gguf.Verd } return gguf.Check(ctx, url, p.llamaVersion) } + +func revOrMain(rev string) string { + if rev == "" { + return "main" + } + return rev +} diff --git a/internal/web/mirror_explore_test.go b/internal/web/mirror_explore_test.go index bfc6346..aa9079d 100644 --- a/internal/web/mirror_explore_test.go +++ b/internal/web/mirror_explore_test.go @@ -97,3 +97,20 @@ func TestAnAdapterIsNotListedAsAModel(t *testing.T) { t.Errorf("adapters should be left out and small models kept: %s", body) } } + +func TestARepositoryShowsItsCard(t *testing.T) { + reg := &fakeRegistry{repo: hf.Repo{ + ID: "Qwen/X-GGUF", Revision: "abc123def456", License: "apache-2.0", + BaseModel: []string{"Qwen/X"}, Card: "# X\n\nA **small** model. See [the license](LICENSE).", + Files: []hf.File{{Path: "x.gguf", Bytes: 10}}, + }} + body := mirrorGet(t, mirrorPageLlama(t, MirrorDeps{Mirror: &fakeMirror{}, Registry: reg}), + "/admin/mirror?repo=Qwen/X-GGUF").Body.String() + for _, want := range []string{"small", "license apache-2.0", "Qwen/X", + `href="https://huggingface.co/Qwen/X-GGUF/blob/abc123def456/LICENSE"`, + `href="https://huggingface.co/Qwen/X-GGUF"`} { + if !strings.Contains(body, want) { + t.Errorf("missing %s", want) + } + } +} diff --git a/internal/web/modelcard.go b/internal/web/modelcard.go new file mode 100644 index 0000000..875e2cf --- /dev/null +++ b/internal/web/modelcard.go @@ -0,0 +1,114 @@ +package web + +import ( + "bytes" + "html/template" + "net/url" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// renderCard turns a model card into HTML for the mirror page. +// +// The card is a stranger's text, so what it may do is narrow: +// - raw HTML is dropped, which goldmark does unless told otherwise, and +// links to javascript: and the like are emptied, which it also does; +// - images are not loaded but shown as links, because an admin page that +// fetches whatever a README points at tells a stranger who is looking; +// - relative links, to the repository's own LICENSE and files, are made to +// point at the repository rather than at this page. +func renderCard(card, repoBase string) template.HTML { + if strings.TrimSpace(card) == "" { + return "" + } + md := goldmark.New( + goldmark.WithExtensions(extension.GFM), + goldmark.WithParserOptions(parser.WithASTTransformers( + util.Prioritized(cardLinks{base: repoBase}, 100))), + goldmark.WithRendererOptions( + renderer.WithNodeRenderers(util.Prioritized(imageAsLink{}, 100))), + ) + var out bytes.Buffer + if err := md.Convert([]byte(card), &out); err != nil { + return "" + } + // Safe by construction: goldmark in its default, safe mode, with the two + // changes above. Not something a template may escape again. + return template.HTML(out.String()) //nolint:gosec +} + +// cardLinks resolves a card's relative links against its repository. +type cardLinks struct{ base string } + +func (c cardLinks) Transform(doc *ast.Document, _ text.Reader, _ parser.Context) { + base, err := url.Parse(c.base) + if err != nil || c.base == "" { + return + } + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + var dest *[]byte + switch l := n.(type) { + case *ast.Link: + dest = &l.Destination + case *ast.Image: + dest = &l.Destination + default: + return ast.WalkContinue, nil + } + u, err := url.Parse(string(*dest)) + if err != nil || u.IsAbs() || strings.HasPrefix(string(*dest), "#") { + return ast.WalkContinue, nil + } + *dest = []byte(base.ResolveReference(u).String()) + return ast.WalkContinue, nil + }) +} + +// imageAsLink renders an image as a link to it, named by its alt text. +type imageAsLink struct{} + +func (imageAsLink) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(ast.KindImage, func(w util.BufWriter, src []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + img := n.(*ast.Image) + alt := strings.TrimSpace(string(nodeText(img, src))) + if alt == "" { + alt = "image" + } + dest := img.Destination + if html.IsDangerousURL(dest) { + _, _ = w.WriteString(`[` + template.HTMLEscapeString(alt) + `]`) + return ast.WalkSkipChildren, nil + } + _, _ = w.WriteString(`[` + + template.HTMLEscapeString(alt) + `]`) + return ast.WalkSkipChildren, nil + }) +} + +// nodeText is the plain text beneath a node. +func nodeText(n ast.Node, src []byte) []byte { + var b bytes.Buffer + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + if t, ok := c.(*ast.Text); ok { + b.Write(t.Segment.Value(src)) + continue + } + b.Write(nodeText(c, src)) + } + return b.Bytes() +} diff --git a/internal/web/modelcard_test.go b/internal/web/modelcard_test.go new file mode 100644 index 0000000..1aac777 --- /dev/null +++ b/internal/web/modelcard_test.go @@ -0,0 +1,30 @@ +package web + +import ( + "strings" + "testing" +) + +func TestAModelCardCannotRunOrLoadAnything(t *testing.T) { + card := "# Qwen\n\nA **small** model.\n\n\n\n" + + "\n\n[click](javascript:alert(1))\n\n" + + "![benchmark](https://tracker.example/pixel.png)\n\n[license](LICENSE)\n\n" + + "| a | b |\n|---|---|\n| 1 | 2 |\n" + got := string(renderCard(card, "https://huggingface.co/Qwen/X/blob/abc/")) + + for _, bad := range []string{"[benchmark]`) { + t.Errorf("an image should become a link: %s", got) + } + if !strings.Contains(got, `href="https://huggingface.co/Qwen/X/blob/abc/LICENSE"`) { + t.Errorf("a relative link should point into the repository: %s", got) + } + if !strings.Contains(got, "small") || !strings.Contains(got, "") { + t.Errorf("the markdown should render, tables included: %s", got) + } +} diff --git a/internal/web/static/base.css b/internal/web/static/base.css index 347ad70..65a9116 100644 --- a/internal/web/static/base.css +++ b/internal/web/static/base.css @@ -476,3 +476,15 @@ table.explore .cannot { color: var(--bad); } .tag { display: inline-block; font-size: .7rem; font-weight: 600; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 0 .4rem; margin-left: .3rem; vertical-align: 1px; } .tag.new { color: var(--accent); border-color: var(--accent); } +table.explore .from { font-size: .75rem; color: var(--muted); margin-top: .15rem; overflow-wrap: anywhere; } +.repo-meta { display: flex; flex-wrap: wrap; gap: .25rem 1rem; font-size: .85rem; color: var(--muted); margin: .25rem 0 .75rem; } +/* A README is written for its own site. Held to a box so the file list, which + is what this page is for, stays within reach below it. */ +details.model-card { border: 1px solid var(--line); border-radius: 8px; background: var(--card); margin: 0 0 1rem; } +details.model-card > summary { cursor: pointer; padding: .5rem .8rem; font-weight: 600; font-size: .9rem; } +details.model-card .card-body { max-height: 28rem; overflow: auto; padding: 0 1rem 1rem; font-size: .88rem; line-height: 1.55; } +.card-body h1 { font-size: 1.25rem; } .card-body h2 { font-size: 1.1rem; } .card-body h3, .card-body h4 { font-size: .95rem; } +.card-body pre { overflow-x: auto; padding: .6rem .75rem; background: var(--bg); border-radius: 6px; font-size: .8rem; } +.card-body table { width: auto; max-width: 100%; display: block; overflow-x: auto; font-size: .8rem; } +.card-body td, .card-body th { padding: .25rem .5rem; } +.card-body a.img { font-size: .8rem; } diff --git a/internal/web/templates/admin_mirror.html b/internal/web/templates/admin_mirror.html index de32b44..9af8a5f 100644 --- a/internal/web/templates/admin_mirror.html +++ b/internal/web/templates/admin_mirror.html @@ -216,7 +216,8 @@ document.addEventListener('click', function (e) { {{range .Rows}} + {{if .New}}new{{end}}{{if .Gated.Is}}gated{{end}} + {{if or .BaseModel .License}}
{{with .BaseModel}}from {{.}}{{end}}{{if and .BaseModel .License}} · {{end}}{{.License}}
{{end}} @@ -239,6 +240,18 @@ directory, or give the mirror a token.

{{if .Repo}}

← back to the list

{{.Repo.ID}} at {{.Repo.ShortRevision}}

+

+ {{with .Repo.BaseModel}}from {{range $i, $b := .}}{{if $i}}, {{end}}{{$b}}{{end}}{{end}} + {{with .Repo.License}}license {{.}}{{end}} + {{with .Repo.Languages}}{{join .}}{{end}} + on Hugging Face ↗ +

+{{if .Card}} +
+ Model card +
{{.Card}}
+
+{{end}} {{if .RepoSets}}
DownloadsLikesUpdated
{{.ID}} - {{if .New}}new{{end}}{{if .Gated.Is}}gated{{end}} {{.ParamsLabel}} {{if .Architecture}}{{.Architecture}}{{if .Verdict.Checked}}{{if .Verdict.Servable}} ✓{{else}} ✗{{end}}{{end}}{{else}}—{{end}} {{.ContextLabel}}
WhatSizePublished checksum