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 `