From 2719dd6ac391db69afa99cee403cf9a1aca60aba Mon Sep 17 00:00:00 2001 From: yetone Date: Sun, 4 Oct 2026 01:21:05 +0800 Subject: [PATCH] =?UTF-8?q?tui,=20cli:=20adding=20a=20provider=20says=20ho?= =?UTF-8?q?w=20many=20models=20its=20list=20had,=20or=20why=20it=20had=20n?= =?UTF-8?q?one,=20and=20m=20(magpie=20provider=20refresh=20)=20fetches?= =?UTF-8?q?=20the=20list=20again=20(akic404=20on=20Discord:=20=E8=BF=98?= =?UTF-8?q?=E6=98=AFtui=20cli=E9=97=AE=E9=A2=98=EF=BC=8C=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E4=B8=8A=E6=B8=B8=E6=A8=A1=E5=9E=8B=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E6=96=B0=E5=BB=BA=E4=B9=8B=E5=90=8E=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=95=B0=E6=98=AF0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI's add fetched the new provider's list and dropped any error, so a remote magpie that couldn't be listed (wrong address, the other magpie not shared, a gateway key it refuses) showed just "added Remote magpie" and 0 models, and the Providers page had no way to ask again: t tests the APIs, the models picker said "t tests it, which asks the vendor for them", which it doesn't. Now the add says "added X · N models from its list · M for agents (↵ picks)", or "added X · no model list · · m asks again" in red; m on the Providers page is the app editor's Refresh (Refetch: picks the new list no longer has are dropped and named), with the same note; the footer lists it and the empty picker points at it. The CLI already said a failed list on add and fetched with provider models ; provider refresh (and fetch) is that by its GUI name, and models now goes through Refetch too, naming dropped picks. The ids were never names: a remote magpie's are // (remote-magpie/custom/qwen3.6-max); the Chinese is the label, "qwen3.6-max · 中转站", the other magpie's provider name it sends as magpie_label, which magpie models and the TUI's picker show beside the id. README says so. TestTUIFetchesAProvidersModels (add over a fake magpie: 2 models; its list grows, m: 3; it refuses, m: the error and the 3 kept) and TestProviderRefreshCmd fail on the old code; TestTUIAddsARemoteMagpieWithItsAddress now expects the error at a dead address. End to end with two sandbox magpies (temp HOMEs, 3461/3462, a fake upstream on 3463): A with "中转站" over the upstream (id custom), B's TUI in tmux adding A at 127.0.0.1:3461: "added Remote magpie · 2 models from its list · 2 for agents", row 2 models, m again the same; a second at 127.0.0.1:3464: "added Remote magpie 2 · no model list · … connection refused · m asks again", 0 models; provider refresh remote-magpie lists remote-magpie/custom/qwen3.6-max "qwen3.6-max · 中转站". go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. --- README.md | 7 ++- internal/tui/pages.go | 75 +++++++++++++++++++++--- internal/tui/remote_magpie_test.go | 93 +++++++++++++++++++++++++++++- internal/tui/tui.go | 2 +- provider_refresh_cli_test.go | 61 ++++++++++++++++++++ providers_cli.go | 16 ++++- 6 files changed, 240 insertions(+), 14 deletions(-) create mode 100644 provider_refresh_cli_test.go diff --git a/README.md b/README.md index f86eb10d..3dd25453 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ magpie provider add "My Relay" url=https://relay.example.com/v1 key=sk-… model magpie providers # host, key, exposed models, who uses what magpie provider deepseek # one provider in detail magpie provider models deepseek # re-fetch the vendor's list (add ids to choose which to expose) +magpie provider refresh deepseek # re-fetch it, and drop picks it no longer has (the TUI: m) magpie provider test deepseek # one tiny request per API, with latency magpie provider key deepseek sk-… # replace the key magpie provider rm deepseek @@ -160,7 +161,8 @@ inferred from today's sign-in. OTLP export never carries the account. One magpie can serve several computers (an office one, a personal one): share it on the network (Settings → Share on local network), and on each other computer add it as a **Remote magpie** — in the app's Add sheet, on the -TUI's Providers page (`a`, then its address and key; `w` changes the address), or +TUI's Providers page (`a`, then its address and key; `w` changes the address, +`m` fetches its list again), or `magpie provider add remote-magpie sk-magpie-… url=http://192.168.1.20:3425 id=office`. Each computer's own magpie still wires its agents, while the providers, routing groups (`office/group/…`) and usage are the shared one's. A request @@ -168,7 +170,8 @@ goes on in the API the agent spoke — Anthropic Messages, Responses, Chat Completions, token counting — and a model the shared magpie's provider serves on another API only is turned into that API once, never on both computers. Its list is the models the shared magpie's agents are shown, each named with -its provider there (`Claude Sonnet 5 · Relay A · office`), and its image +its provider there (`Claude Sonnet 5 · Relay A · office`) — the ids stay ids +(`office/relay-a/claude-sonnet-5`), only these labels carry the names — and its image models are listed under Settings → Images and draw through it. The gateway issues **gateway keys** for clients, separate from a provider's diff --git a/internal/tui/pages.go b/internal/tui/pages.go index 9ea0efb1..724e18a2 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -187,6 +187,10 @@ func (m model) updateProviders(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "t": m.flash, m.flashOK = "testing "+p.Name+"…", true return m, testCmd(p) + case "m": + // the vendor's list asked again, as the app editor's Refresh does + m.flash, m.flashOK = "asking "+p.Name+" for its models…", true + return m, refetchCmd(p.ID) case "d": if m.confirm != "provider/"+p.ID { m.confirm = "provider/" + p.ID @@ -234,6 +238,53 @@ func saveProvider(id string, change func(*provider.Provider), done string) tea.C } } +// refetchCmd asks the provider's vendor for its model list again and says +// how many it has and how many agents are offered, or why there is none: +// the app editor's Refresh, which the TUI had no way to do (akic404 on +// Discord: a provider added here had 0 models and nothing to fetch them). +func refetchCmd(id string) tea.Cmd { + return func() tea.Msg { + p, err := provider.Find(id) + if err != nil { + return flashMsg{text: err.Error()} + } + if p.IsPlugin() { + return flashMsg{text: p.Name + "'s models are what its plugin lists"} + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + ms, dropped, err := p.Refetch(ctx) + if err != nil { + return flashMsg{text: p.Name + ": " + fetchNote(err)} + } + text := p.Name + ": " + modelsNote(id, len(ms)) + if len(dropped) > 0 { + text += " · gone from its list, so no longer picked: " + strings.Join(dropped, ", ") + } + return flashMsg{text: text, ok: len(ms) > 0} + } +} + +// modelsNote says how many models the vendor's list had and how many of +// them agents are offered. +func modelsNote(id string, fetched int) string { + text := fmt.Sprintf("%d models from its list", fetched) + if p, err := provider.Find(id); err == nil { + text += fmt.Sprintf(" · %d for agents (↵ picks)", len(p.Exposed())) + } + return text +} + +// fetchNote is why a model list wasn't had, short enough for the status +// line: the first URL's answer. +func fetchNote(err error) string { + e, _, _ := strings.Cut(err.Error(), "; ") + if r := []rune(e); len(r) > 140 { + e = string(r[:140]) + "…" + } + return "no model list · " + strings.TrimPrefix(e, "no model list: ") + " · m asks again" +} + func testCmd(p provider.Provider) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -309,7 +360,7 @@ func (m *model) openProviderModels(id string) { crumbs: []string{"providers", p.Name, "models"}, input: newInput("filter models"), items: modelOptions(id), - empty: "no models known yet · t tests it, which asks the vendor for them", + empty: "no models known yet · m on the providers list asks the vendor for them", toggle: func(model string) ([]agent.Option, string, bool) { p, err := provider.Find(id) if err != nil { @@ -402,13 +453,23 @@ func addKeyAsk(p provider.Provider) ask { } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() - text := "added " + p.Name - if saved, err := provider.Find(id); err == nil { - if ms, err := saved.Fetch(ctx); err == nil { - text += fmt.Sprintf(" · %d models", len(ms)) - } + saved, err := provider.Find(id) + if err != nil { + return flashMsg{text: err.Error()} } - return flashMsg{text: text, ok: true} + // the vendor's list, and what came of asking it: a list + // that failed said so, not an "added" over 0 models + // (akic404 on Discord) + text := "added " + saved.Name + ms, err := saved.Fetch(ctx) + if err != nil && saved.Decides() { + // a System One API: its list isn't what it is for + return flashMsg{text: text, ok: true} + } + if err != nil { + return flashMsg{text: text + " · " + fetchNote(err)} + } + return flashMsg{text: text + " · " + modelsNote(id, len(ms)), ok: len(ms) > 0} } }} } diff --git a/internal/tui/remote_magpie_test.go b/internal/tui/remote_magpie_test.go index 7b832d21..3b149155 100644 --- a/internal/tui/remote_magpie_test.go +++ b/internal/tui/remote_magpie_test.go @@ -1,6 +1,11 @@ package tui import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" "testing" "github.com/yetone/magpie/internal/provider" @@ -38,7 +43,8 @@ func TestTUIAddsARemoteMagpieWithItsAddress(t *testing.T) { } m = typeIn(m, "sk-magpie-test") m = press(t, m, "enter") - wantFlash(t, m, true, "added Remote magpie") + // nothing answers there: added, and said why it has no models + wantFlash(t, m, false, "added Remote magpie · no model list") p, err := provider.Find("remote-magpie") if err != nil { t.Fatal(err) @@ -79,3 +85,88 @@ func TestTUIAddsARemoteMagpieWithItsAddress(t *testing.T) { t.Fatal("w asked a custom provider's address") } } + +// Adding a provider fetches its list and says what came of it — how many +// models, or why none — and m on the Providers page fetches it again, as +// the app editor's Refresh does (akic404 on Discord: added from the TUI, +// a remote magpie had 0 models, with nothing said and no way to fetch +// them). +func TestTUIFetchesAProvidersModels(t *testing.T) { + home(t) + var mu sync.Mutex + ids := []string{"custom/qwen3.6-max", "custom/glm-5.3"} + down := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + if down { + http.Error(w, `{"error":"invalid gateway key"}`, http.StatusUnauthorized) + return + } + if r.URL.Path != "/v1/models" { + http.NotFound(w, r) + return + } + var data []map[string]any + for _, id := range ids { + data = append(data, map[string]any{"id": id, "object": "model", "display_name": id, "magpie_label": id + " · 中转站"}) + } + json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data}) + })) + defer srv.Close() + set := func(d bool, list ...string) { + mu.Lock() + defer mu.Unlock() + down = d + if list != nil { + ids = list + } + } + at := func(m model, id string) model { + m.reloadProviders() + for i, q := range m.provs { + if q.ID == id { + m.prow = i + } + } + return m + } + + m := press(t, model{w: 160, h: 40}, "2", "a") + m = typeIn(m, "remote-magpie") + m = press(t, m, "enter") + m = typeIn(m, strings.TrimPrefix(srv.URL, "http://")) + m = press(t, m, "enter") + m = typeIn(m, "sk-magpie-test") + m = press(t, m, "enter") + wantFlash(t, m, true, "added Remote magpie · 2 models from its list · 2 for agents") + p, err := provider.Find("remote-magpie") + if err != nil { + t.Fatal(err) + } + var got []string + for _, x := range p.Exposed() { + got = append(got, x.ID) + } + if strings.Join(got, ",") != "custom/qwen3.6-max,custom/glm-5.3" { + t.Fatalf("exposed %v", got) + } + + // the other magpie's list grows: m fetches it again + set(false, "custom/qwen3.6-max", "custom/glm-5.3", "custom/kimi-k3") + m = at(m, "remote-magpie") + m = press(t, m, "m") + wantFlash(t, m, true, "Remote magpie: 3 models from its list · 3 for agents") + if !strings.Contains(m.View(), "3 models") { + t.Fatalf("the row doesn't say 3 models after m:\n%s", m.View()) + } + + // refused: said so, and the list had stays + set(true) + m = press(t, m, "m") + wantFlash(t, m, false, "no model list") + wantFlash(t, m, false, "m asks again") + if p, _ := provider.Find("remote-magpie"); len(p.Exposed()) != 3 { + t.Fatalf("a failed fetch left %d models", len(p.Exposed())) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 69a0cd0a..5ff2cabf 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -670,7 +670,7 @@ func (m model) View() string { switch m.page { case pageProviders: body = m.viewProviders() - footer = hints("↑↓", "provider", "↵", "models", "e", "key", "w", "address", "a", "add", "f", "family", "u", "list/groups only", "o", "on/off", "t", "test", "b", "balances", "d", "remove", "1–6", "pages") + footer = hints("↑↓", "provider", "↵", "models", "e", "key", "w", "address", "a", "add", "f", "family", "u", "list/groups only", "o", "on/off", "m", "fetch models", "t", "test", "b", "balances", "d", "remove", "1–6", "pages") case pageGroups: body = m.viewGroups() footer = hints("↑↓", "group", "↵", "open", "n", "new", "o", "routing", "d", "remove", "u", "bring back", "1–6", "pages", "q", "quit") diff --git a/provider_refresh_cli_test.go b/provider_refresh_cli_test.go new file mode 100644 index 00000000..a997f515 --- /dev/null +++ b/provider_refresh_cli_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "reflect" + "sync" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// magpie provider refresh fetches a provider's list again, as the +// app editor's Refresh does: a pick the new list no longer has is dropped +// (akic404 on Discord: the TUI and CLI had no way to fetch a provider's +// models after it was added). +func TestProviderRefreshCmd(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("USERPROFILE", os.Getenv("HOME")) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("DSH_HOME", "") + var mu sync.Mutex + ids := []string{"alpha", "beta"} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + var data []map[string]any + for _, id := range ids { + data = append(data, map[string]any{"id": id, "object": "model"}) + } + json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data}) + })) + defer srv.Close() + if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Key: "k", Chat: srv.URL + "/v1", Models: []string{"alpha", "beta"}}); err != nil { + t.Fatal(err) + } + if err := providerCmd([]string{"provider", "refresh", "relay"}); err != nil { + t.Fatal(err) + } + if _, ok := mustFind(t, "relay").Fetched(); !ok { + t.Fatal("refresh fetched no list") + } + mu.Lock() + ids = []string{"alpha", "gamma"} + mu.Unlock() + if err := providerCmd([]string{"provider", "refresh", "relay"}); err != nil { + t.Fatal(err) + } + if got := mustFind(t, "relay").Models; !reflect.DeepEqual(got, []string{"alpha"}) { + t.Fatalf("picks after the list lost beta: %v", got) + } + if err := providerCmd([]string{"provider", "refresh", "relay", "alpha"}); err == nil { + t.Fatal("refresh took model ids to pick") + } + if err := providerCmd([]string{"provider", "refresh"}); err == nil { + t.Fatal("refresh without an id") + } +} diff --git a/providers_cli.go b/providers_cli.go index ff956768..581eb487 100644 --- a/providers_cli.go +++ b/providers_cli.go @@ -34,6 +34,7 @@ const providerUsage = `usage: magpie provider icon give a custom provider a picture (PNG, JPEG, SVG…) or a built-in icon magpie provider fallback … where requests go when it's out of quota or down (none clears) magpie provider models [ids…] fetch the vendor's model list, or choose which models to expose + magpie provider refresh fetch the vendor's model list again (as the app's Refresh) magpie provider account-models [account|key [ids…|all]] the models one account or key alone serves; all: every model the provider has magpie provider listed yes|no no: its models serve only through routing groups, not in the list @@ -452,14 +453,17 @@ func providerCmd(args []string) error { fmt.Println(green.Render("✓"), p.Name, muted.Render("is switched on")) } return nil - case "models": + case "models", "refresh", "fetch": if len(rest) < 1 { - return fmt.Errorf("magpie provider models [model ids to expose…]") + return fmt.Errorf("magpie provider %s [model ids to expose…]", verb) } p, err := provider.Find(rest[0]) if err != nil { return err } + if len(rest) > 1 && verb != "models" { + return fmt.Errorf("magpie provider %s fetches its list · magpie provider models picks from it", verb) + } if len(rest) > 1 { p.Models = rest[1:] if len(rest) == 2 && (rest[1] == "-" || rest[1] == "all") { @@ -472,11 +476,17 @@ func providerCmd(args []string) error { } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - ms, err := p.Fetch(ctx) + ms, dropped, err := p.Refetch(ctx) if err != nil { return err } fmt.Println(green.Render("✓"), len(ms), "models from", fetchedFrom(*p)) + if len(dropped) > 0 { + fmt.Println(amber.Render("!"), "gone from its list, so no longer picked:", strings.Join(dropped, ", ")) + } + if q, err := provider.Find(p.ID); err == nil { + p = q // without the picks just dropped + } return showProvider(*p) } // `magpie provider `