Serve vision models with their projector and original weights on vLLM; group the mirror by version
model serve takes --projector, fetched beside the weights and loaded by llama.cpp with --mmproj, and --format safetensors with --gpu-class and --tp, which fetches the weights with their config and tokenizer and runs them on vLLM. The console offers both, F switching between GGUF and original weights. The mirror view is one row per version with its files on Enter. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7aefb236d6
commit
a16097cfe8
12 files changed
+509
-81
No files matched your search
@@ -76,6 +76,25 @@ is not an update.
|
||||
A model from `server.yaml` is reported but not changed: its source is edited
|
||||
there.
|
||||
|
||||
### Models of several files: vision projectors, original weights
|
||||
|
||||
- A vision model's projector (`mmproj`) is fetched beside its GGUF weights
|
||||
and loaded with them: `llmgw ctl model serve REPO --projector FILE`, or the
|
||||
choice offered by the console. The model is marked as taking images. Nodes
|
||||
load it once their agent is this version or later.
|
||||
- `--format safetensors` serves a repository's original weights, with their
|
||||
config.json, tokenizer and shard index, on vLLM across `--tp` GPUs of
|
||||
`--gpu-class` (the fleet's one class when there is one). In the console,
|
||||
**F** on Serve a model switches to them.
|
||||
- The mirror keeps where it fetched each file beside it, and reports that
|
||||
and the checksum after it restarts. The console's Mirror view shows one
|
||||
row per version of a model, with its files on Enter.
|
||||
- An update watches every file of a model, the projector and the tokenizer
|
||||
included; one whose files the mirror cannot trace is found on the
|
||||
registry by name, commit and size, or given with `llmgw ctl model origin`.
|
||||
- `llmgw_model_update_available` is published, and `ModelUpdateAvailable`
|
||||
alerts on it.
|
||||
|
||||
### The manual, in the binary
|
||||
|
||||
`llmgw docs` reads every document — installation, operation, the
|
||||
|
||||
+19
-9
@@ -111,9 +111,13 @@ which): requests, tokens, errors and spend.
|
||||
team or project is guaranteed and may at most hold, and what it holds now.
|
||||
**A** adds one; on a row, change or remove it.
|
||||
|
||||
**Mirror** is what the model mirror holds, how far each download has got, and
|
||||
which models use each file. **A** downloads a URL into it; on a row, where
|
||||
nodes fetch it from, or remove it — with a warning when a model still uses it.
|
||||
**Mirror** is what the model mirror holds, one row for each version of each
|
||||
model — a file, or the set of files a model of several is: shards, a vision
|
||||
projector, a config and tokenizer — with how far its download has got and
|
||||
which models use it. **A** downloads a URL into it; on a row, its files with
|
||||
their checksums and where each was fetched from, the source a model
|
||||
definition names for it, or remove it — with a warning when a model still
|
||||
uses it.
|
||||
|
||||
**Logs** shows a node's agent log, newest at the bottom, errors and warnings
|
||||
coloured.
|
||||
@@ -181,12 +185,18 @@ to restart it. Another control plane keeps its own copy.
|
||||
certificate and starts its agent (`sudo llmgw node add`).
|
||||
- **Replicate the store** — moves the store onto two more machines under
|
||||
Patroni (offered only while it is not replicated).
|
||||
- **Serve a model** — Hugging Face's GGUF models: trending, most downloaded,
|
||||
most liked or newest (**S** changes which), or **/** to search. Enter on
|
||||
one lists its files with their sizes; the one chosen is fetched into the
|
||||
mirror, checked, added to the catalog and started, and a replica is asked
|
||||
to say hello (`llmgw ctl model serve`). `mirror.registry` points it at
|
||||
another registry, for a site without the internet.
|
||||
- **Serve a model** — Hugging Face's models: trending, most downloaded,
|
||||
most liked or newest (**S** changes which), or **/** to search. **F**
|
||||
switches between GGUF files, which llama.cpp runs on the CPU or a GPU, and
|
||||
original safetensors weights, which vLLM runs on GPUs. Enter on a GGUF
|
||||
repository lists its files with their sizes; a vision model offers its
|
||||
projector too, which is fetched beside it and loaded with it so the model
|
||||
reads images. Enter on original weights offers them with their config and
|
||||
tokenizer, a GPU class and how many GPUs a replica spans. What is chosen is
|
||||
fetched into the mirror, checked, added to the catalog and started, and a
|
||||
replica is asked to say hello (`llmgw ctl model serve`).
|
||||
`mirror.registry` points it at another registry, for a site without the
|
||||
internet.
|
||||
- **Upgrade the cluster** to a release: this machine, then every node in turn
|
||||
(`sudo llmgw ctl upgrade`).
|
||||
- **Restart all replicas**, one at a time.
|
||||
|
||||
@@ -93,7 +93,7 @@ func (l *LlamaCpp) Commands(s ShardSpec) ([]Command, error) {
|
||||
bin := l.bin()
|
||||
p := s.Profile
|
||||
|
||||
model, err := ggufIn(p.ModelPath)
|
||||
model, projector, err := ggufIn(p.ModelPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -120,6 +120,11 @@ func (l *LlamaCpp) Commands(s ShardSpec) ([]Command, error) {
|
||||
if p.ServedModelName != "" {
|
||||
args = append(args, "--alias", p.ServedModelName)
|
||||
}
|
||||
// A vision model's projector, fetched beside it: what turns an image in
|
||||
// a request into something the language model reads.
|
||||
if projector != "" && !hasFlag(p.Args, "--mmproj", "-mm") {
|
||||
args = append(args, "--mmproj", projector)
|
||||
}
|
||||
if !hasFlag(p.Args, "--parallel", "-np") {
|
||||
n := l.cfg.Parallel
|
||||
if n <= 0 {
|
||||
@@ -203,30 +208,46 @@ func hasFlag(args []string, names ...string) bool {
|
||||
//
|
||||
// A path that is already a file is returned untouched, which is every
|
||||
// single-file model and every hand-staged one.
|
||||
func ggufIn(path string) (string, error) {
|
||||
//
|
||||
// A vision projector in the directory — an mmproj file — is not the model:
|
||||
// it is returned second, to be loaded beside it.
|
||||
func ggufIn(path string) (model, projector string, err error) {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil || !st.IsDir() {
|
||||
// Not a directory, or not there at all — the second is the engine's to
|
||||
// report, with the message it would have given anyway.
|
||||
return path, nil //nolint:nilerr // a missing path is the engine's error to give
|
||||
return path, "", nil //nolint:nilerr // a missing path is the engine's error to give
|
||||
}
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
var names []string
|
||||
var names, projectors []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") {
|
||||
lower := strings.ToLower(e.Name())
|
||||
if e.IsDir() || !strings.HasSuffix(lower, ".gguf") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(lower, "mmproj") {
|
||||
projectors = append(projectors, e.Name())
|
||||
} else {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "", fmt.Errorf("%s holds no .gguf file", path)
|
||||
if len(projectors) > 0 {
|
||||
return "", "", fmt.Errorf("%s holds a vision projector and no model", path)
|
||||
}
|
||||
return "", "", fmt.Errorf("%s holds no .gguf file", path)
|
||||
}
|
||||
// Sorted, so a split model's first part comes first: the parts are named
|
||||
// "-00001-of-00004" and lexical order is shard order.
|
||||
sort.Strings(names)
|
||||
return filepath.Join(path, names[0]), nil
|
||||
if len(projectors) > 0 {
|
||||
sort.Strings(projectors)
|
||||
projector = filepath.Join(path, projectors[0])
|
||||
}
|
||||
return filepath.Join(path, names[0]), projector, nil
|
||||
}
|
||||
|
||||
// UseInstalled points the engine at a build the agent installed and checked.
|
||||
|
||||
@@ -204,7 +204,7 @@ func TestASetDirectoryResolvesToItsFirstPart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
got, err := ggufIn(dir)
|
||||
got, _, err := ggufIn(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func TestAPlainFilePathIsUntouched(t *testing.T) {
|
||||
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := ggufIn(p); got != p {
|
||||
if got, _, _ := ggufIn(p); got != p {
|
||||
t.Errorf("expected the path unchanged, got %s", got)
|
||||
}
|
||||
}
|
||||
@@ -231,7 +231,7 @@ func TestADirectoryWithNoWeightsSaysSo(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ggufIn(dir); err == nil {
|
||||
if _, _, err := ggufIn(dir); err == nil {
|
||||
t.Error("a directory with no .gguf in it is not a model llama.cpp can load")
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,7 @@ func TestAMissingPathIsLeftToTheEngine(t *testing.T) {
|
||||
// The engine's own "no such file" names the path and the flag it came
|
||||
// from, which is more use than anything this could say.
|
||||
p := "/nonexistent/model.gguf"
|
||||
got, err := ggufIn(p)
|
||||
got, _, err := ggufIn(p)
|
||||
if err != nil || got != p {
|
||||
t.Errorf("expected it passed through, got %q %v", got, err)
|
||||
}
|
||||
@@ -361,3 +361,28 @@ func TestLlamaCppSpreadsAReplicaOverItsNodesGPUsByLayer(t *testing.T) {
|
||||
t.Error("a one-GPU replica was given a split mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVisionProjectorIsLoadedBesideTheModelNotAsIt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// "mmproj" sorts before the model's name; it must still not be taken for it.
|
||||
for _, n := range []string{"gemma-3-4b-it-Q4_K_M.gguf", "mmproj-gemma-3-4b-it-f16.gguf"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, n), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cmds, err := (&LlamaCpp{}).Commands(ShardSpec{HTTPPort: 8080, Profile: Profile{ModelPath: dir}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
args := strings.Join(cmds[0].Args, " ")
|
||||
if !strings.Contains(args, "--model "+filepath.Join(dir, "gemma-3-4b-it-Q4_K_M.gguf")) ||
|
||||
!strings.Contains(args, "--mmproj "+filepath.Join(dir, "mmproj-gemma-3-4b-it-f16.gguf")) {
|
||||
t.Errorf("%s", args)
|
||||
}
|
||||
// One the profile names itself is left as it is.
|
||||
cmds, _ = (&LlamaCpp{}).Commands(ShardSpec{HTTPPort: 8080, Profile: Profile{ModelPath: dir,
|
||||
Args: []string{"--mmproj", "/elsewhere.gguf"}}})
|
||||
if strings.Count(strings.Join(cmds[0].Args, " "), "--mmproj") != 1 {
|
||||
t.Errorf("two projectors: %v", cmds[0].Args)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
|
||||
)
|
||||
|
||||
@@ -24,15 +25,27 @@ func newModelServeCmd(client func() *ctlClient) *cobra.Command {
|
||||
"that the nodes' llama.cpp can load it and sizes its memory from its own\n" +
|
||||
"header, adds it to the catalog, waits for a replica, and asks it to say\n" +
|
||||
"hello. Each step is shown as it happens; the first that fails stops it.\n\n" +
|
||||
"A vision model's projector (--projector) is fetched beside it and loaded with\n" +
|
||||
"it. --format safetensors serves the original weights instead, with their\n" +
|
||||
"config.json and tokenizer, on vLLM across --tp GPUs of --gpu-class.\n\n" +
|
||||
" llmgw ctl model serve bartowski/Qwen2.5-3B-Instruct-GGUF\n" +
|
||||
" llmgw ctl model serve Qwen/Qwen2.5-7B-Instruct-GGUF --quant Q5_K_M --name qwen-7b\n" +
|
||||
" llmgw ctl model serve owner/big-GGUF --on-demand",
|
||||
" llmgw ctl model serve owner/big-GGUF --on-demand\n" +
|
||||
" llmgw ctl model serve google/gemma-3-4b-it-GGUF --projector mmproj-model-f16.gguf\n" +
|
||||
" llmgw ctl model serve Qwen/Qwen2.5-7B-Instruct --format safetensors --gpu-class l40s-48g",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
req.Repo = args[0]
|
||||
if onDemand {
|
||||
req.Replicas = 0
|
||||
}
|
||||
// One kind of GPU in the fleet: that one, unless told.
|
||||
if req.Format == "safetensors" && req.GPUClass == "" {
|
||||
var sum adminapi.Summary
|
||||
if err := client().get(cmd.Context(), "/api/v1/summary", &sum); err == nil && len(sum.GPUs) == 1 {
|
||||
req.GPUClass = sum.GPUs[0].Class
|
||||
}
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
var job modelserve.Job
|
||||
if err := client().post(cmd.Context(), "/api/v1/models/serve", body, &job); err != nil {
|
||||
@@ -44,6 +57,10 @@ func newModelServeCmd(client func() *ctlClient) *cobra.Command {
|
||||
f := c.Flags()
|
||||
f.StringVar(&req.Quant, "quant", "", "the quantisation to serve (default Q4_K_M)")
|
||||
f.StringVar(&req.File, "file", "", "the file to serve, instead of choosing by --quant")
|
||||
f.StringVar(&req.Projector, "projector", "", "a vision model's mmproj file, fetched and loaded beside it")
|
||||
f.StringVar(&req.Format, "format", "gguf", "gguf, run by llama.cpp, or safetensors: the original weights, run by vLLM on GPUs")
|
||||
f.StringVar(&req.GPUClass, "gpu-class", "", "for safetensors: the GPUs to run on (default: the fleet's one class)")
|
||||
f.IntVar(&req.TP, "tp", 1, "for safetensors: how many GPUs one replica spans")
|
||||
f.StringVar(&req.Name, "name", "", "the catalog name (default: from the repository)")
|
||||
f.StringSliceVar(&req.Aliases, "alias", nil, "other names clients may use; repeatable")
|
||||
f.StringVar(&req.Description, "description", "", "what it is for, shown to clients in /v1/models")
|
||||
|
||||
@@ -292,7 +292,7 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case consoleMirrorMsg:
|
||||
m.mirror = msg
|
||||
case consoleHFMsg:
|
||||
if msg.query == m.hf.query && msg.sort == m.hf.sort {
|
||||
if msg.query == m.hf.query && msg.sort == m.hf.sort && msg.st == m.hf.safetensors {
|
||||
m.hf.models, m.hf.err = msg.models, msg.err
|
||||
if m.hf.models == nil && msg.err == nil {
|
||||
m.hf.models = []hf.Model{}
|
||||
@@ -447,7 +447,7 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case cQuotas:
|
||||
m.menuD = m.quotaMenu(m.quotas[m.row])
|
||||
case cMirror:
|
||||
m.menuD = m.mirrorMenu(m.mirror.res.Models[m.row])
|
||||
m.menuD = m.mirrorMenu(m.mirrorGroups()[m.row])
|
||||
case cServe:
|
||||
return m, m.fetchRepo(m.hf.models[m.row].ID)
|
||||
case cAccounts:
|
||||
@@ -464,6 +464,12 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.menu == cServe {
|
||||
m.hfSearchDialog()
|
||||
}
|
||||
case "f":
|
||||
if m.menu == cServe {
|
||||
m.hf.safetensors = !m.hf.safetensors
|
||||
m.hf.models, m.hf.err, m.row = nil, nil, 0
|
||||
return m, m.fetchHF()
|
||||
}
|
||||
case "s":
|
||||
if m.menu == cServe {
|
||||
m.hf.sort = (m.hf.sort + 1) % len(hfSorts)
|
||||
@@ -824,7 +830,7 @@ func (m consoleModel) rowCount() int {
|
||||
case cQuotas:
|
||||
return len(m.quotas)
|
||||
case cMirror:
|
||||
return len(m.mirror.res.Models)
|
||||
return len(m.mirrorGroups())
|
||||
case cServe:
|
||||
return len(m.hf.models)
|
||||
case cAccounts:
|
||||
|
||||
+88
-15
@@ -11,6 +11,7 @@ import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
|
||||
)
|
||||
|
||||
@@ -24,15 +25,19 @@ var hfSorts = []struct {
|
||||
}{{"trending", hf.SortTrending}, {"most downloaded", hf.SortDownloads}, {"most liked", hf.SortLikes}, {"newest", hf.SortCreated}}
|
||||
|
||||
type hfState struct {
|
||||
query string
|
||||
sort int
|
||||
models []hf.Model
|
||||
err error
|
||||
query string
|
||||
sort int
|
||||
// safetensors lists original weights, for vLLM on GPUs, rather than
|
||||
// GGUF files for llama.cpp.
|
||||
safetensors bool
|
||||
models []hf.Model
|
||||
err error
|
||||
}
|
||||
|
||||
type consoleHFMsg struct {
|
||||
query string
|
||||
sort int
|
||||
st bool
|
||||
models []hf.Model
|
||||
err error
|
||||
}
|
||||
@@ -56,18 +61,21 @@ func (m consoleModel) registry() *hf.Client {
|
||||
}
|
||||
|
||||
func (m consoleModel) fetchHF() tea.Cmd {
|
||||
c, q, sort := m.registry(), m.hf.query, m.hf.sort
|
||||
c, q, sort, st := m.registry(), m.hf.query, m.hf.sort, m.hf.safetensors
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
hq := hf.Query{Search: q, Sort: hfSorts[sort].sort, GGUF: true, Limit: 50}
|
||||
hq := hf.Query{Search: q, Sort: hfSorts[sort].sort, GGUF: !st, Limit: 50}
|
||||
if st {
|
||||
hq.Filters = []string{"safetensors"}
|
||||
}
|
||||
// Browsing: models that chat. A search finds the rest too, an
|
||||
// embedding model included.
|
||||
if q == "" {
|
||||
hq.Task = "text-generation"
|
||||
}
|
||||
got, err := c.Explore(ctx, hq)
|
||||
return consoleHFMsg{query: q, sort: sort, models: got, err: err}
|
||||
return consoleHFMsg{query: q, sort: sort, st: st, models: got, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +93,17 @@ func (m *consoleModel) fetchRepo(id string) tea.Cmd {
|
||||
// repoMenu offers a repository's GGUF files, smallest first as listed.
|
||||
func (m consoleModel) repoMenu(r hf.Repo) *menuDialog {
|
||||
var items []menuItem
|
||||
if m.hf.safetensors {
|
||||
title := r.ID
|
||||
set, err := modelserve.SafetensorsSet(r)
|
||||
if err != nil {
|
||||
return &menuDialog{title: title, items: []menuItem{{err.Error(), func(*consoleModel) tea.Cmd { return nil }}}}
|
||||
}
|
||||
return &menuDialog{title: title, items: []menuItem{{
|
||||
fmt.Sprintf("Original weights for vLLM: %s in %d files", bytesShort(set.Bytes), len(set.Files)),
|
||||
func(m *consoleModel) tea.Cmd { m.serveVLLMForm(r, set); return nil },
|
||||
}}}
|
||||
}
|
||||
for _, set := range hf.Group(r.Files) {
|
||||
if !strings.HasSuffix(strings.ToLower(set.Files[0].Path), ".gguf") {
|
||||
continue
|
||||
@@ -124,13 +143,19 @@ func (m *consoleModel) serveForm(r hf.Repo, set hf.FileSet) {
|
||||
note += ", licence " + r.License
|
||||
}
|
||||
note += "\nThe mirror fetches it and checks it, it joins the catalog, and a\nreplica is started and asked to say hello. The output is shown as it goes."
|
||||
fields := []formField{
|
||||
textF("name", hf.ModelName(r.ID), "what clients ask for"),
|
||||
textF("replicas", "1", "0 runs none until it is first asked for"),
|
||||
textF("context length", "4096", "what one request may use"),
|
||||
textF("description", "", "optional: shown to clients in /v1/models"),
|
||||
}
|
||||
// A vision model: its projector, loaded beside it, is what reads images.
|
||||
if proj := modelserve.Projectors(r); len(proj) > 0 {
|
||||
fields = append(fields, choiceF("images", append(proj, "none: text only"), proj[0]))
|
||||
fields[len(fields)-1].hint = "the vision projector, fetched and loaded beside the model"
|
||||
}
|
||||
m.openForm(&formDialog{title: "Serve " + r.ID, note: note,
|
||||
fields: []formField{
|
||||
textF("name", hf.ModelName(r.ID), "what clients ask for"),
|
||||
textF("replicas", "1", "0 runs none until it is first asked for"),
|
||||
textF("context length", "4096", "what one request may use"),
|
||||
textF("description", "", "optional: shown to clients in /v1/models"),
|
||||
},
|
||||
fields: fields,
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
n, err := strconv.Atoi(v[1])
|
||||
if err != nil || n < 0 {
|
||||
@@ -147,6 +172,50 @@ func (m *consoleModel) serveForm(r hf.Repo, set hf.FileSet) {
|
||||
if v[3] != "" {
|
||||
args = append(args, "--description="+v[3])
|
||||
}
|
||||
if len(v) > 4 && !strings.HasPrefix(v[4], "none") {
|
||||
args = append(args, "--projector="+v[4])
|
||||
}
|
||||
return m.runAfter(args...), nil
|
||||
}})
|
||||
}
|
||||
|
||||
func (m *consoleModel) serveVLLMForm(r hf.Repo, set hf.FileSet) {
|
||||
server := "--server=" + m.client.base
|
||||
var classes []string
|
||||
for _, g := range m.data.sum.GPUs {
|
||||
classes = append(classes, g.Class)
|
||||
}
|
||||
if len(classes) == 0 {
|
||||
m.dialog, m.dialogTitle = "error", "No GPUs"
|
||||
m.dialogText = "Original weights run on vLLM, on GPUs, and this fleet reports none.\n" +
|
||||
"F goes back to GGUF models, which run on the CPU.\n\n" + sDim.Render("any key")
|
||||
return
|
||||
}
|
||||
m.openForm(&formDialog{title: "Serve " + r.ID,
|
||||
note: fmt.Sprintf("%s in %d files: the weights, config.json and the tokenizer.\n"+
|
||||
"Fetched into the mirror and checked, added, and run by vLLM on the GPUs chosen.", bytesShort(set.Bytes), len(set.Files)),
|
||||
fields: []formField{
|
||||
textF("name", hf.ModelName(r.ID), "what clients ask for"),
|
||||
textF("replicas", "1", "0 runs none until it is first asked for"),
|
||||
textF("context length", "8192", "what one request may use; vLLM's --max-model-len"),
|
||||
textF("description", "", "optional: shown to clients in /v1/models"),
|
||||
choiceF("GPU class", classes, classes[0]),
|
||||
textF("GPUs per replica", "1", "tensor parallel: the cards one replica spans"),
|
||||
},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
for i, what := range map[int]string{1: "replicas", 2: "the context length", 5: "GPUs per replica"} {
|
||||
if n, err := strconv.Atoi(v[i]); err != nil || n < 0 || (i == 5 && n < 1) {
|
||||
return nil, fmt.Errorf("%s is a whole number", what)
|
||||
}
|
||||
}
|
||||
args := []string{"ctl", server, "model", "serve", r.ID, "--format=safetensors", "--gpu-class=" + v[4],
|
||||
"--tp=" + v[5], "--name=" + v[0], "--replicas=" + v[1], "--context-length=" + v[2]}
|
||||
if v[1] == "0" {
|
||||
args = append(args, "--on-demand")
|
||||
}
|
||||
if v[3] != "" {
|
||||
args = append(args, "--description="+v[3])
|
||||
}
|
||||
return m.runAfter(args...), nil
|
||||
}})
|
||||
}
|
||||
@@ -164,7 +233,7 @@ func (m *consoleModel) hfSearchDialog() {
|
||||
func (m consoleModel) hfPanel(pw int) (string, string, string) {
|
||||
keys := "→ or Enter open · ↑↓ choose · Q leave"
|
||||
if m.inPanel {
|
||||
keys = "↑↓ row · Enter choose a file · / search · S order · ← back · Q leave"
|
||||
keys = "↑↓ row · Enter choose · / search · S order · F GGUF or original weights · ← back · Q leave"
|
||||
}
|
||||
what := hfSorts[m.hf.sort].label
|
||||
if m.hf.query != "" {
|
||||
@@ -173,7 +242,11 @@ func (m consoleModel) hfPanel(pw int) (string, string, string) {
|
||||
if m.hf.query == "" {
|
||||
what = "text generation, " + what
|
||||
}
|
||||
head := "\n GGUF models on Hugging Face: " + what + "\n\n"
|
||||
kind := "GGUF models, for llama.cpp"
|
||||
if m.hf.safetensors {
|
||||
kind = "Original weights, for vLLM on GPUs"
|
||||
}
|
||||
head := "\n " + kind + " on Hugging Face: " + what + "\n\n"
|
||||
switch {
|
||||
case m.hf.err != nil:
|
||||
return "Serve from Hugging Face", head + " " + sBad.Render(m.hf.err.Error()) +
|
||||
|
||||
+82
-25
@@ -62,26 +62,75 @@ func (m *consoleModel) mirrorCall(title string, do func(context.Context, *mirror
|
||||
}
|
||||
}
|
||||
|
||||
// usedBy names the catalog's models whose source is this file.
|
||||
func (m consoleModel) usedBy(base string, s mirror.Status) []string {
|
||||
u := mirror.Entry{Model: s.Model, Version: s.Version, File: s.File}.URL(base)
|
||||
dir := strings.TrimSuffix(mirror.Entry{Model: s.Model, Version: s.Version}.URL(base), "/") + "/"
|
||||
// mirrorGroup is one version of one model on the mirror: a file, or the set
|
||||
// of files a model of several is fetched as.
|
||||
type mirrorGroup struct {
|
||||
model, version string
|
||||
files []mirror.Status
|
||||
}
|
||||
|
||||
func (g mirrorGroup) bytes() (done, total int64) {
|
||||
for _, f := range g.files {
|
||||
done += f.BytesDone
|
||||
total += max(f.BytesTotal, f.BytesDone)
|
||||
}
|
||||
return done, total
|
||||
}
|
||||
|
||||
// source is what a model's definition names for it: the file, or the
|
||||
// directory of a set.
|
||||
func (g mirrorGroup) source(base string) string {
|
||||
if len(g.files) == 1 {
|
||||
return mirror.Entry{Model: g.model, Version: g.version, File: g.files[0].File}.URL(base)
|
||||
}
|
||||
return strings.TrimSuffix(mirror.Entry{Model: g.model, Version: g.version}.URL(base), "/") + "/"
|
||||
}
|
||||
|
||||
func (m consoleModel) mirrorGroups() []mirrorGroup {
|
||||
var out []mirrorGroup
|
||||
at := map[string]int{}
|
||||
for _, st := range m.mirror.res.Models {
|
||||
k := st.Model + "/" + st.Version
|
||||
i, ok := at[k]
|
||||
if !ok {
|
||||
i = len(out)
|
||||
at[k] = i
|
||||
out = append(out, mirrorGroup{model: st.Model, version: st.Version})
|
||||
}
|
||||
out[i].files = append(out[i].files, st)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// usedBy names the catalog's models that run from a version's files.
|
||||
func (m consoleModel) usedBy(base string, g mirrorGroup) []string {
|
||||
dir := strings.TrimSuffix(mirror.Entry{Model: g.model, Version: g.version}.URL(base), "/") + "/"
|
||||
var names []string
|
||||
for _, md := range m.data.models {
|
||||
if md.Source == u || strings.HasPrefix(md.Source, dir) {
|
||||
if strings.HasPrefix(md.Source, dir) {
|
||||
names = append(names, md.Name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (m consoleModel) mirrorMenu(s mirror.Status) *menuDialog {
|
||||
what := s.Model + "/" + s.Version
|
||||
used := m.usedBy(m.mirror.res.Base, s)
|
||||
items := []menuItem{{"Where nodes fetch it", func(m *consoleModel) tea.Cmd {
|
||||
func (m consoleModel) mirrorMenu(g mirrorGroup) *menuDialog {
|
||||
what := g.model + "/" + g.version
|
||||
used := m.usedBy(m.mirror.res.Base, g)
|
||||
items := []menuItem{{"Its files, and where nodes fetch them", func(m *consoleModel) tea.Cmd {
|
||||
var b strings.Builder
|
||||
for _, f := range g.files {
|
||||
sum := ""
|
||||
if f.SHA256 != "" {
|
||||
sum = sDim.Render(" sha256 " + f.SHA256[:min(12, len(f.SHA256))])
|
||||
}
|
||||
fmt.Fprintf(&b, "%-50s %9s%s\n", truncate(f.File, 50), bytesShort(max(f.BytesTotal, f.BytesDone)), sum)
|
||||
if f.Source != "" {
|
||||
b.WriteString(sDim.Render(" from "+truncate(f.Source, 90)) + "\n")
|
||||
}
|
||||
}
|
||||
m.dialog, m.dialogTitle = "error", what
|
||||
m.dialogText = mirror.Entry{Model: s.Model, Version: s.Version, File: s.File}.URL(m.mirror.res.Base) +
|
||||
"\n\n" + sDim.Render("the source to give a model definition · any key")
|
||||
m.dialogText = b.String() + "\nA model's source: " + g.source(m.mirror.res.Base) + "\n\n" + sDim.Render("any key")
|
||||
return nil
|
||||
}}}
|
||||
items = append(items, menuItem{"Remove from the mirror…", func(m *consoleModel) tea.Cmd {
|
||||
@@ -93,7 +142,7 @@ func (m consoleModel) mirrorMenu(s mirror.Status) *menuDialog {
|
||||
m.confirmD = &confirmDialog{title: "Remove " + what, text: text,
|
||||
yes: func(m *consoleModel) tea.Cmd {
|
||||
return m.mirrorCall("Remove "+what, func(ctx context.Context, c *mirror.Client) (string, error) {
|
||||
removed, err := c.Remove(ctx, s.Model, s.Version)
|
||||
removed, err := c.Remove(ctx, g.model, g.version)
|
||||
return fmt.Sprintf("Removed %d file(s).", len(removed)), err
|
||||
})
|
||||
}}
|
||||
@@ -139,24 +188,32 @@ func (m consoleModel) mirrorPanel(pw int) (string, string, string) {
|
||||
}
|
||||
head := "\n " + sDim.Render("Nodes fetch from "+m.mirror.res.Base) + "\n\n"
|
||||
var rows [][]string
|
||||
for _, s := range m.mirror.res.Models {
|
||||
state := string(s.State)
|
||||
switch s.State {
|
||||
case mirror.Fetching:
|
||||
if p, ok := s.Percent(); ok {
|
||||
state = sWarn.Render(fmt.Sprintf("fetching %d%%", p))
|
||||
} else {
|
||||
state = sWarn.Render("fetching " + bytesShort(s.BytesDone))
|
||||
for _, g := range m.mirrorGroups() {
|
||||
done, total := g.bytes()
|
||||
state := "present"
|
||||
for _, f := range g.files {
|
||||
switch f.State {
|
||||
case mirror.Fetching:
|
||||
state = sWarn.Render("fetching")
|
||||
if total > 0 {
|
||||
state = sWarn.Render(fmt.Sprintf("fetching %d%%", done*100/total))
|
||||
}
|
||||
case mirror.Failed:
|
||||
state = sBad.Render("failed")
|
||||
}
|
||||
if f.State != mirror.Present {
|
||||
break
|
||||
}
|
||||
case mirror.Failed:
|
||||
state = sBad.Render("failed")
|
||||
}
|
||||
size := bytesShort(s.BytesDone)
|
||||
used := strings.Join(m.usedBy(m.mirror.res.Base, s), ", ")
|
||||
what := g.files[0].File
|
||||
if len(g.files) > 1 {
|
||||
what = fmt.Sprintf("%d files", len(g.files))
|
||||
}
|
||||
used := strings.Join(m.usedBy(m.mirror.res.Base, g), ", ")
|
||||
if used == "" {
|
||||
used = sDim.Render("unused")
|
||||
}
|
||||
rows = append(rows, []string{s.Model, s.Version, s.File, state, size, used})
|
||||
rows = append(rows, []string{g.model, g.version, what, state, bytesShort(total), used})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return "Mirror", head + " " + sDim.Render("Empty. A downloads one."), keys
|
||||
|
||||
@@ -87,6 +87,15 @@ type Request struct {
|
||||
Quant string `json:"quant,omitempty"`
|
||||
// File picks it by name instead.
|
||||
File string `json:"file,omitempty"`
|
||||
// Projector is a vision model's mmproj file in the repository, fetched
|
||||
// beside the weights and loaded with them; empty serves text alone.
|
||||
Projector string `json:"projector,omitempty"`
|
||||
// Format is "gguf", run by llama.cpp, or "safetensors": the original
|
||||
// weights with their config and tokenizer, run by vLLM on GPUs of
|
||||
// GPUClass, across TP of them.
|
||||
Format string `json:"format,omitempty"`
|
||||
GPUClass string `json:"gpu_class,omitempty"`
|
||||
TP int `json:"tp,omitempty"`
|
||||
// Name is the catalog name; the repository suggests one otherwise.
|
||||
Name string `json:"name,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
@@ -179,6 +188,20 @@ func (s *Server) Start(req Request) (Job, error) {
|
||||
if req.Replicas < 0 {
|
||||
return Job{}, errors.New("replicas cannot be negative")
|
||||
}
|
||||
switch req.Format {
|
||||
case "", "gguf":
|
||||
req.Format = "gguf"
|
||||
case "safetensors":
|
||||
if req.GPUClass == "" {
|
||||
return Job{}, errors.New("safetensors weights run on vLLM, on GPUs: name the GPU class, as `llmgw ctl status` lists it")
|
||||
}
|
||||
if req.Projector != "" {
|
||||
return Job{}, errors.New("a projector is for a GGUF model; vLLM reads a vision model's own files")
|
||||
}
|
||||
req.TP = max(req.TP, 1)
|
||||
default:
|
||||
return Job{}, fmt.Errorf("the format is gguf or safetensors, not %q", req.Format)
|
||||
}
|
||||
if req.Quant == "" && req.File == "" {
|
||||
req.Quant = "Q4_K_M"
|
||||
}
|
||||
@@ -251,11 +274,28 @@ func (s *Server) run(j *Job) {
|
||||
s.fail(j, "registry", fmt.Errorf("%s is gated: it needs an account's approval the mirror does not have", req.Repo))
|
||||
return
|
||||
}
|
||||
set, err := pick(repo, req)
|
||||
pickSet := pick
|
||||
if req.Format == "safetensors" {
|
||||
pickSet = pickSafetensors
|
||||
}
|
||||
set, err := pickSet(repo, req)
|
||||
if err != nil {
|
||||
s.fail(j, "registry", err)
|
||||
return
|
||||
}
|
||||
if req.Projector != "" {
|
||||
i := slices.IndexFunc(repo.Files, func(f hf.File) bool {
|
||||
return (f.Path == req.Projector || mirror.LastPathElement(f.Path) == req.Projector) &&
|
||||
strings.Contains(strings.ToLower(f.Path), "mmproj")
|
||||
})
|
||||
if i < 0 {
|
||||
s.fail(j, "registry", fmt.Errorf("%s has no vision projector %q; it has: %s", repo.ID, req.Projector,
|
||||
strings.Join(Projectors(repo), ", ")))
|
||||
return
|
||||
}
|
||||
set.Files = append(set.Files, repo.Files[i])
|
||||
set.Bytes += repo.Files[i].Bytes
|
||||
}
|
||||
name := req.Name
|
||||
if name == "" {
|
||||
name = hf.ModelName(repo.ID)
|
||||
@@ -274,10 +314,20 @@ func (s *Server) run(j *Job) {
|
||||
}
|
||||
|
||||
// 3. Whether the nodes' llama.cpp loads it, and how much memory it needs.
|
||||
if req.Format == "safetensors" {
|
||||
// vLLM reads the model's shape from its config.json, which came
|
||||
// with the weights; the GPUs it needs are the class and width asked.
|
||||
s.step(j, "check", Done, fmt.Sprintf("%.1f GB of weights for vLLM on %d × %s", gb(set.Bytes), req.TP, req.GPUClass))
|
||||
s.addAndStart(ctx, j, repo, set, name, source, catalog.Profile{
|
||||
Name: "gpu", Engine: "vllm", GPUClass: req.GPUClass, TP: req.TP, Weight: 1, Priority: 100,
|
||||
Args: []string{"--max-model-len", strconv.Itoa(req.ContextLength)},
|
||||
})
|
||||
return
|
||||
}
|
||||
s.step(j, "check", Running, "reading its header from the mirror")
|
||||
var header gguf.Header
|
||||
if !strings.HasSuffix(source, "/") {
|
||||
v := gguf.Check(ctx, source, s.deps.LlamaVersion)
|
||||
if u := headerURL(source, set); u != "" {
|
||||
v := gguf.Check(ctx, u, s.deps.LlamaVersion)
|
||||
if !v.Servable {
|
||||
s.fail(j, "check", fmt.Errorf("llama.cpp %s on the nodes cannot run this: %s", s.deps.LlamaVersion, v.Reason))
|
||||
return
|
||||
@@ -302,16 +352,24 @@ func (s *Server) run(j *Job) {
|
||||
}
|
||||
}
|
||||
|
||||
s.addAndStart(ctx, j, repo, set, name, source, catalog.Profile{
|
||||
Name: "cpu", Engine: "llamacpp", Weight: 1, Priority: 100,
|
||||
Args: []string{"--ctx-size", strconv.Itoa(req.CtxPool)}, MinMemoryGB: memGB, CPUCores: cores,
|
||||
})
|
||||
}
|
||||
|
||||
// addAndStart puts the model in the catalog with its profile, waits for a
|
||||
// replica and asks it to answer.
|
||||
func (s *Server) addAndStart(ctx context.Context, j *Job, repo hf.Repo, set hf.FileSet, name, source string, prof catalog.Profile) {
|
||||
req := j.Request
|
||||
// 4. The catalog.
|
||||
m := catalog.Model{
|
||||
Name: name, Aliases: req.Aliases, Description: req.Description, Source: source,
|
||||
RoutingProfile: "chat", ContextLength: req.ContextLength, MaxOutput: 1024,
|
||||
Profiles: []catalog.Profile{{
|
||||
Name: "cpu", Engine: "llamacpp", Weight: 1, Priority: 100,
|
||||
Args: []string{"--ctx-size", strconv.Itoa(req.CtxPool)}, MinMemoryGB: memGB, CPUCores: cores,
|
||||
}},
|
||||
Scaling: catalog.Scaling{Min: req.Replicas, Max: max(req.Replicas, 1)},
|
||||
Origin: &catalog.Origin{Repo: repo.ID, Revision: repo.Revision, Files: originFiles(set)},
|
||||
Profiles: []catalog.Profile{prof},
|
||||
Scaling: catalog.Scaling{Min: req.Replicas, Max: max(req.Replicas, 1)},
|
||||
Capabilities: catalog.Capabilities{Vision: req.Projector != ""},
|
||||
Origin: &catalog.Origin{Repo: repo.ID, Revision: repo.Revision, Files: originFiles(set)},
|
||||
}
|
||||
s.step(j, "catalog", Running, "adding "+name)
|
||||
if err := s.deps.Catalog.Add(ctx, m, req.By); err != nil {
|
||||
@@ -593,3 +651,72 @@ func orUnknown(arch string) string {
|
||||
}
|
||||
return arch
|
||||
}
|
||||
|
||||
// Projectors are a repository's vision projectors: the mmproj files a vision
|
||||
// model loads beside its weights.
|
||||
func Projectors(repo hf.Repo) []string {
|
||||
var out []string
|
||||
for _, f := range repo.Files {
|
||||
if strings.HasSuffix(strings.ToLower(f.Path), ".gguf") && strings.Contains(strings.ToLower(f.Path), "mmproj") {
|
||||
out = append(out, f.Path)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// headerURL is the file whose header says what the model is: the source
|
||||
// itself, or in a directory the first part of the weights, not a projector.
|
||||
func headerURL(source string, set hf.FileSet) string {
|
||||
if !strings.HasSuffix(source, "/") {
|
||||
return source
|
||||
}
|
||||
var names []string
|
||||
for _, f := range set.Files {
|
||||
name := mirror.LastPathElement(f.Path)
|
||||
lower := strings.ToLower(name)
|
||||
if strings.HasSuffix(lower, ".gguf") && !strings.Contains(lower, "mmproj") {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
slices.Sort(names)
|
||||
return source + names[0]
|
||||
}
|
||||
|
||||
// SafetensorsSet is what serving a repository's original weights fetches.
|
||||
func SafetensorsSet(repo hf.Repo) (hf.FileSet, error) { return pickSafetensors(repo, Request{}) }
|
||||
|
||||
// pickSafetensors chooses a repository's original weights and the files an
|
||||
// engine reads beside them: config.json, the tokenizer, the shard index.
|
||||
func pickSafetensors(repo hf.Repo, _ Request) (hf.FileSet, error) {
|
||||
var weights []hf.File
|
||||
for _, f := range repo.Files {
|
||||
if !strings.Contains(f.Path, "/") && strings.HasSuffix(f.Path, ".safetensors") {
|
||||
weights = append(weights, f)
|
||||
}
|
||||
}
|
||||
sets := hf.Group(weights)
|
||||
if len(sets) == 0 {
|
||||
return hf.FileSet{}, fmt.Errorf("%s has no safetensors weights at its top", repo.ID)
|
||||
}
|
||||
// Sharded "model-00001-of-00004" or a single "model.safetensors"; a
|
||||
// repository with a second, consolidated copy keeps it under another name.
|
||||
set := sets[0]
|
||||
for _, st := range sets {
|
||||
if strings.HasPrefix(st.Name, "model") {
|
||||
set = st
|
||||
break
|
||||
}
|
||||
}
|
||||
companions := hf.Companions(repo.Files)
|
||||
if !slices.ContainsFunc(companions, func(f hf.File) bool { return f.Path == "config.json" }) {
|
||||
return hf.FileSet{}, fmt.Errorf("%s has no config.json, which vLLM reads the model's shape from", repo.ID)
|
||||
}
|
||||
for _, f := range companions {
|
||||
set.Files = append(set.Files, f)
|
||||
set.Bytes += f.Bytes
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/binary"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -223,3 +224,68 @@ func TestARepositoryThatIsNotOneIsRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVisionModelIsServedWithItsProjectorBesideIt(t *testing.T) {
|
||||
files := []hf.File{
|
||||
{Path: "gemma-3-4b-it-Q4_K_M.gguf", Bytes: 2 << 30, SHA256: "aa"},
|
||||
{Path: "mmproj-gemma-3-4b-it-f16.gguf", Bytes: 800 << 20, SHA256: "bb"},
|
||||
}
|
||||
s, m, c, _ := setup(t, files, map[string]string{"gemma-3-4b-it-Q4_K_M.gguf": "aa", "mmproj-gemma-3-4b-it-f16.gguf": "bb"})
|
||||
job, err := s.Start(Request{Repo: "google/gemma-3-4b-it-GGUF", Quant: "Q4_K_M",
|
||||
Projector: "mmproj-gemma-3-4b-it-f16.gguf", Replicas: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if j := wait(t, s, job.ID); j.State != Done {
|
||||
t.Fatalf("%s %+v", j.Error, j.Steps)
|
||||
}
|
||||
if len(m.got) != 2 {
|
||||
t.Fatalf("fetched %+v", m.got)
|
||||
}
|
||||
got := c.added[0]
|
||||
if !strings.HasSuffix(got.Source, "/") || !got.Capabilities.Vision || len(got.Origin.Files) != 2 {
|
||||
t.Errorf("%s vision=%v origin=%+v", got.Source, got.Capabilities.Vision, got.Origin)
|
||||
}
|
||||
// A projector the repository does not have is named with those it has.
|
||||
job, _ = s.Start(Request{Repo: "google/gemma-3-4b-it-GGUF", Quant: "Q4_K_M", Projector: "mmproj-x.gguf", Name: "g2"})
|
||||
if j := wait(t, s, job.ID); j.State != Failed || !strings.Contains(j.Error, "mmproj-gemma-3-4b-it-f16.gguf") {
|
||||
t.Errorf("%+v", j)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginalWeightsAreServedOnVLLMWithTheirConfigAndTokenizer(t *testing.T) {
|
||||
files := []hf.File{
|
||||
{Path: "model-00001-of-00002.safetensors", Bytes: 4 << 30, SHA256: "a1"},
|
||||
{Path: "model-00002-of-00002.safetensors", Bytes: 3 << 30, SHA256: "a2"},
|
||||
{Path: "model.safetensors.index.json", Bytes: 30000},
|
||||
{Path: "config.json", Bytes: 700},
|
||||
{Path: "tokenizer.json", Bytes: 7 << 20},
|
||||
{Path: "tokenizer_config.json", Bytes: 7000},
|
||||
{Path: "README.md", Bytes: 9000},
|
||||
{Path: "original/consolidated.safetensors", Bytes: 7 << 30},
|
||||
}
|
||||
s, m, c, _ := setup(t, files, map[string]string{"model-00001-of-00002.safetensors": "a1", "model-00002-of-00002.safetensors": "a2"})
|
||||
if _, err := s.Start(Request{Repo: "Qwen/Qwen2.5-7B-Instruct", Format: "safetensors"}); err == nil {
|
||||
t.Error("safetensors with no GPU class was taken")
|
||||
}
|
||||
job, err := s.Start(Request{Repo: "Qwen/Qwen2.5-7B-Instruct", Format: "safetensors", GPUClass: "l40s-48g", Replicas: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if j := wait(t, s, job.ID); j.State != Done {
|
||||
t.Fatalf("%s %+v", j.Error, j.Steps)
|
||||
}
|
||||
var got []string
|
||||
for _, r := range m.got {
|
||||
got = append(got, r.File)
|
||||
}
|
||||
if len(got) != 6 || slices.Contains(got, "README.md") || slices.Contains(got, "consolidated.safetensors") {
|
||||
t.Errorf("fetched %v", got)
|
||||
}
|
||||
mod := c.added[0]
|
||||
p := mod.Profiles[0]
|
||||
if !strings.HasSuffix(mod.Source, "/") || p.Engine != "vllm" || p.GPUClass != "l40s-48g" || p.TP != 1 ||
|
||||
!slices.Contains(p.Args, "--max-model-len") {
|
||||
t.Errorf("%s %+v", mod.Source, p)
|
||||
}
|
||||
}
|
||||
@@ -509,9 +509,9 @@ func (s *Server) runUpdate(j *Job) {
|
||||
// 3. Whether the nodes load it, and whether it still fits what is
|
||||
// reserved for it.
|
||||
s.step(j, "check", Running, "reading its header from the mirror")
|
||||
detail := "a set of shards; not read"
|
||||
if !strings.HasSuffix(source, "/") {
|
||||
v := gguf.Check(ctx, source, s.deps.LlamaVersion)
|
||||
detail := "no header to read"
|
||||
if u := headerURL(source, set); u != "" {
|
||||
v := gguf.Check(ctx, u, s.deps.LlamaVersion)
|
||||
if !v.Servable {
|
||||
s.fail(j, "check", fmt.Errorf("llama.cpp %s on the nodes cannot run the new weights: %s", s.deps.LlamaVersion, v.Reason))
|
||||
return
|
||||
|
||||
+11
-4
@@ -68,6 +68,10 @@ type Model struct {
|
||||
// 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 *struct {
|
||||
Total int64 `json:"total"`
|
||||
} `json:"safetensors,omitempty"`
|
||||
// Siblings are the repository's files, when asked for with WithFiles.
|
||||
Siblings []struct {
|
||||
RFilename string `json:"rfilename"`
|
||||
@@ -84,10 +88,13 @@ type GGUFInfo struct {
|
||||
|
||||
// Params is the parameter count, 0 when the registry does not know it.
|
||||
func (m Model) Params() int64 {
|
||||
if m.GGUF == nil {
|
||||
return 0
|
||||
switch {
|
||||
case m.GGUF != nil:
|
||||
return m.GGUF.Total
|
||||
case m.Safetensors != nil:
|
||||
return m.Safetensors.Total
|
||||
}
|
||||
return m.GGUF.Total
|
||||
return 0
|
||||
}
|
||||
|
||||
// Architecture is the GGUF architecture, empty when unknown.
|
||||
@@ -172,7 +179,7 @@ type Query struct {
|
||||
// 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"}
|
||||
"createdAt", "gated", "private", "trendingScore", "gguf", "safetensors"}
|
||||
|
||||
// Explore lists repositories: the trending ones, the newest, or a search.
|
||||
//
|
||||
|
||||
Reference in new issue
Block a user