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:
1 parent
af046769fb
commit
2719dd6ac3
6 files changed
+240
-14
No files matched your search
@@ -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
@@ -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}
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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>`
|
||||
|
||||
Reference in new issue
Block a user