tui, cli: adding a provider says how many models its list had, or why it had none, and m (magpie provider refresh <id>) fetches the list again (akic404 on Discord: 还是tui cli问题,没有获取上游模型功能,新建之后模型数是0)

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 · <the first URL's answer> · 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 <id>; provider refresh <id> (and fetch) is that by its GUI name, and models <id> now goes through Refetch too, naming dropped picks. The ids were never names: a remote magpie's are <its id>/<the other magpie's provider id>/<model> (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.
This commit is contained in:
yetone committed 2026-10-04 01:24:47 +08:00
1 parent af046769fb
commit 2719dd6ac3
6 files changed
+240 -14

No files matched your search

+5 -2
View File
@@ -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
+68 -7
View File
@@ -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}
}
}}
}
+92 -1
View File
@@ -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()))
}
}
+1 -1
View File
@@ -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")
+61
View File
@@ -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 <id> 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")
}
}
+13 -3
View File
@@ -34,6 +34,7 @@ const providerUsage = `usage:
magpie provider icon <id> <file|name> give a custom provider a picture (PNG, JPEG, SVG…) or a built-in icon
magpie provider fallback <id> <provider/model>… where requests go when it's out of quota or down (none clears)
magpie provider models <id> [ids…] fetch the vendor's model list, or choose which models to expose
magpie provider refresh <id> fetch the vendor's model list again (as the app's Refresh)
magpie provider account-models <id> [account|key [ids…|all]]
the models one account or key alone serves; all: every model the provider has
magpie provider listed <id> 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 <id> [model ids to expose…]")
return fmt.Errorf("magpie provider %s <id> [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 <id> fetches its list · magpie provider models <id> <ids…> 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 <id>`