mirror.peers names the mirrors kept in step with mirror.api. The mirror's management calls go to the first that can be reached, and each mirror is asked every fifteen seconds whether it answers: a shard is sent its model's and adapters' address on one that does while the mirror the catalog names is down. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
296 lines
10 KiB
Go
296 lines
10 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
|
|
)
|
|
|
|
// The mirror, as the control plane reaches it: its management API, with the
|
|
// address and certificate server.yaml gives under mirror.
|
|
|
|
type consoleMirrorMsg struct {
|
|
res mirror.ModelsResponse
|
|
err error
|
|
}
|
|
|
|
func (m consoleModel) mirrorClient() (*mirror.Client, error) {
|
|
cfg, err := config.Load(m.file)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg.Mirror.API == "" {
|
|
return nil, errors.New("no mirror: mirror.api in server.yaml is empty")
|
|
}
|
|
return mirror.NewFailoverClient(append([]string{cfg.Mirror.API}, cfg.Mirror.Peers...), cfg.Mirror.TLS, 10*time.Second)
|
|
}
|
|
|
|
func (m consoleModel) fetchMirror() tea.Cmd {
|
|
return func() tea.Msg {
|
|
c, err := m.mirrorClient()
|
|
if err != nil {
|
|
return consoleMirrorMsg{err: err}
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
res, err := c.List(ctx)
|
|
return consoleMirrorMsg{res, err}
|
|
}
|
|
}
|
|
|
|
// mirrorCall runs one request to the mirror in the background and reports it.
|
|
func (m *consoleModel) mirrorCall(title string, do func(context.Context, *mirror.Client) (string, error)) tea.Cmd {
|
|
m.busy = title
|
|
mm := *m
|
|
return func() tea.Msg {
|
|
c, err := mm.mirrorClient()
|
|
if err != nil {
|
|
return actionDoneMsg{title: title, err: err}
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
text, err := do(ctx, c)
|
|
return actionDoneMsg{title: title, text: text, err: err}
|
|
}
|
|
}
|
|
|
|
// 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 strings.HasPrefix(md.Source, dir) {
|
|
names = append(names, md.Name)
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
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 = 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 {
|
|
text := "Every file of this version is deleted from the mirror."
|
|
if len(used) > 0 {
|
|
text += "\n\n" + sWarn.Render("! ") + "The catalog uses it: " + strings.Join(used, ", ") +
|
|
".\nNodes that do not hold it already cannot start them."
|
|
}
|
|
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, g.model, g.version)
|
|
return fmt.Sprintf("Removed %d file(s).", len(removed)), err
|
|
})
|
|
}}
|
|
return nil
|
|
}})
|
|
return &menuDialog{title: what, items: items}
|
|
}
|
|
|
|
func (m *consoleModel) mirrorFetchForm() {
|
|
m.openForm(&formDialog{title: "Download into the mirror",
|
|
note: "The mirror downloads it once; nodes then fetch it from the mirror.",
|
|
fields: []formField{
|
|
textF("from", "", "an https:// URL, like a Hugging Face resolve link"),
|
|
textF("model", "", "one directory level, like qwen2.5-7b"),
|
|
textF("version", time.Now().Format("2006-01-02"), "one directory level; a date works well"),
|
|
textF("file", "", "optional: taken from the URL"),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if !strings.HasPrefix(v[0], "http://") && !strings.HasPrefix(v[0], "https://") {
|
|
return nil, fmt.Errorf("from is a URL")
|
|
}
|
|
if v[1] == "" || v[2] == "" {
|
|
return nil, fmt.Errorf("a model and a version")
|
|
}
|
|
req := mirror.FetchRequest{Source: v[0], Model: v[1], Version: v[2], File: v[3]}
|
|
return m.mirrorCall("Download "+v[1], func(ctx context.Context, c *mirror.Client) (string, error) {
|
|
r, err := c.Fetch(ctx, req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "Downloading. Once it is here, a model's source is\n" + r.URL, nil
|
|
}), nil
|
|
}})
|
|
}
|
|
|
|
// mirrorImportOllamaForm puts a model from an Ollama on this machine into a
|
|
// mirror on this machine: the control plane's own, when the mirror runs
|
|
// beside it. A mirror elsewhere cannot read this machine's files, and its
|
|
// API is given none; there it is `llmgw mirror import-ollama`, on the mirror.
|
|
func (m *consoleModel) mirrorImportOllamaForm() {
|
|
m.openForm(&formDialog{title: "Import from Ollama",
|
|
note: "From an Ollama on this machine into the mirror's directory on this machine.\n" +
|
|
"A mirror elsewhere: run llmgw mirror import-ollama there.",
|
|
fields: []formField{
|
|
textF("model", "", "as Ollama names it, like llama3.2:3b"),
|
|
textF("ollama dir", "", "optional: found where Ollama keeps it"),
|
|
textF("mirror dir", "/srv/models", "the mirror's directory on this machine"),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if v[0] == "" {
|
|
return nil, fmt.Errorf("a model, as Ollama names it")
|
|
}
|
|
if st, err := os.Stat(v[2]); err != nil || !st.IsDir() {
|
|
return nil, fmt.Errorf("%s is not a directory on this machine", v[2])
|
|
}
|
|
title := "Import " + v[0]
|
|
m.busy = title
|
|
o := ollamaImport{name: v[0], ollamaDir: v[1], mirrorDir: v[2], base: m.mirror.res.Base, quiet: true}
|
|
return func() tea.Msg {
|
|
var out bytes.Buffer
|
|
err := o.run(context.Background(), &out)
|
|
return actionDoneMsg{title: title, text: strings.TrimSpace(out.String()), err: err}
|
|
}, nil
|
|
}})
|
|
}
|
|
|
|
func (m consoleModel) mirrorPanel(pw int) (string, string, string) {
|
|
keys := "→ or Enter open · ↑↓ choose · Q leave"
|
|
if m.inPanel {
|
|
keys = "↑↓ row · Enter actions · A download one · I import from Ollama · ← back · Q leave"
|
|
}
|
|
if m.mirror.err != nil {
|
|
return "Mirror", "\n " + sBad.Render(m.mirror.err.Error()), keys
|
|
}
|
|
head := "\n " + sDim.Render("Nodes fetch from "+m.mirror.res.Base) + "\n\n"
|
|
var rows [][]string
|
|
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
|
|
}
|
|
}
|
|
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{g.model, g.version, what, state, bytesShort(total), used})
|
|
}
|
|
if len(rows) == 0 {
|
|
return "Mirror", head + " " + sDim.Render("Empty. A downloads one; I imports one from Ollama."), keys
|
|
}
|
|
return "Mirror", head + table([]string{"MODEL", "VERSION", "FILE", "STATE", "SIZE", "USED BY"}, rows, m.row, pw, m.inPanel), keys
|
|
}
|
|
|
|
// fromFile is the add-model choice that reads a definition file rather
|
|
// than naming a preset.
|
|
const fromFile = "from a file"
|
|
|
|
// addModelForm adds a model that comes with llmgw, or one from a definition
|
|
// file on this machine: the same YAML a model takes in server.yaml.
|
|
func (m *consoleModel) addModelForm() {
|
|
server := "--server=" + m.client.base
|
|
var note strings.Builder
|
|
note.WriteString("A model that comes with llmgw, tried and with its licence:\n")
|
|
for _, p := range catalog.Presets() {
|
|
fmt.Fprintf(¬e, " %s: %s, %s, %s\n", p.Model.Name, p.Makes, presetCard(p.Model), presetLicence(p.Model))
|
|
}
|
|
note.WriteString("A ComfyUI one has the nodes install ComfyUI if the cluster has none.\n" +
|
|
"Or a definition file: the YAML a model takes in server.yaml's models.")
|
|
var choices []string
|
|
for _, n := range catalog.PresetNames() {
|
|
choices = append(choices, presetChoice(m.data.presets, n))
|
|
}
|
|
m.openForm(&formDialog{title: "Add a model",
|
|
note: note.String(),
|
|
fields: []formField{
|
|
choiceF("model", append(choices, fromFile), choices[0]),
|
|
textF("file", "", "for "+fromFile+": like /root/qwen.yaml"),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if v[0] != fromFile {
|
|
// The name, without what the choice says beside it.
|
|
return m.addPreset(strings.Fields(v[0])[0]), nil
|
|
}
|
|
if v[1] == "" {
|
|
return nil, fmt.Errorf("a file, or a model that comes with llmgw")
|
|
}
|
|
if _, err := os.Stat(v[1]); err != nil {
|
|
return nil, err
|
|
}
|
|
return m.runCLI("Add a model", "ctl", server, "model", "add", "-f", v[1]), nil
|
|
}})
|
|
}
|