diff --git a/go.mod b/go.mod
index d5d4e851..efb422bb 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 18e313b3..01cd5249 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 98925847..c890d2df 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 e8c9db97..bbfa67bd 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 f6078f50..a4ced7cc 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 bfc63468..aa9079d2 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 00000000..875e2cf6
--- /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 00000000..1aac777f
--- /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" +
+ "\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{"