diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index ad9f0eaf..25e5b25e 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -1260,7 +1260,7 @@ function renderExcluded() { r.lastChild.append(el("b", "", t("{agent} is signed in, but stays out of this list. ", { agent: x.agentName })), t(x.why)); if (x.provider) { const back = el("button", "link", t("Add it back")); - back.onclick = () => providerAction("save", { id: x.provider }, t("{name} added back", { name: x.agentName })); + back.onclick = () => providerAction("show", { id: x.provider }, t("{name} added back", { name: x.agentName })); r.lastChild.append(" ", back); } box.append(r); diff --git a/internal/gui/providers.go b/internal/gui/providers.go index 7225b1d9..a0454d7e 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -342,6 +342,12 @@ func providerRoutes(mux *http.ServeMux, w Windows, gw *gateway.Server) { } in := req.Provider switch r.PathValue("action") { + case "show": + // a signed-in account the user removed, back with its picks + if err := provider.ShowAccount(in.ID); err != nil { + fail(rw, err) + return + } case "save": // a preset needs nothing but the key; a saved provider keeps // its key when the form left it blank diff --git a/internal/provider/account_test.go b/internal/provider/account_test.go index 600ac688..c69b327a 100644 --- a/internal/provider/account_test.go +++ b/internal/provider/account_test.go @@ -133,12 +133,34 @@ func TestAccountsAreProviders(t *testing.T) { if x := Excluded(); len(x) != 1 || x[0].Provider != "codex" || x[0].Agent != "codex" { t.Fatalf("excluded: %+v", x) } + // saving its picks again (anything that saves it) keeps it removed if err := Save(Provider{ID: "codex", Models: []string{"gpt-5.5"}}); err != nil { t.Fatal(err) } + if _, ok := find(All(), "codex"); ok || len(Excluded()) != 1 { + t.Fatal("a save brought a removed codex back") + } + if err := ShowAccount("codex"); err != nil { + t.Fatal(err) + } if codex, ok = find(All(), "codex"); !ok || len(codex.Models) != 1 || codex.Account == nil || len(Excluded()) != 0 { t.Fatalf("after add back: %+v", codex) } + // only through routing groups holds for an account too + codex.Unlisted = true + if err := Save(codex); err != nil { + t.Fatal(err) + } + if codex, _ = find(All(), "codex"); !codex.Unlisted { + t.Fatal("unlisted lost on an account") + } + for _, e := range Catalog() { + if e.Provider.ID == "codex" { + t.Fatalf("unlisted account in the catalog: %s", e.ID) + } + } + codex.Unlisted = false + Save(codex) // signed out: gone, and a stale picks entry is not a provider Save(Provider{ID: "copilot", Models: []string{"gpt-5.5"}}) diff --git a/internal/provider/provider.go b/internal/provider/provider.go index e60c4533..62860fe3 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -189,7 +189,8 @@ func All() []Provider { if _, taken := find(out, a.ID); taken || picks[a.ID].Hidden { continue } - a.Models, a.Fallback, a.Routing, a.Affinity = picks[a.ID].Models, picks[a.ID].Fallback, picks[a.ID].Routing, picks[a.ID].Affinity + pk := picks[a.ID] + a.Models, a.Unlisted, a.Fallback, a.Routing, a.Affinity = pk.Models, pk.Unlisted, pk.Fallback, pk.Routing, pk.Affinity out = append(out, a) } return out @@ -256,8 +257,9 @@ func Save(p Provider) error { } if a, ok := find(Accounts(), p.ID); ok { // an account keeps only the user's model picks; the rest is the - // agent's own sign-in. Saving it again brings a removed one back. - p = Provider{ID: a.ID, Models: p.Models, Unlisted: p.Unlisted, Fallback: p.Fallback, Routing: p.Routing, Affinity: p.Affinity} + // agent's own sign-in. One the user removed stays removed: only + // ShowAccount brings it back. + p = Provider{ID: a.ID, Models: p.Models, Unlisted: p.Unlisted, Fallback: p.Fallback, Routing: p.Routing, Affinity: p.Affinity, Hidden: hiddenAccount(a.ID)} } else { if slices.Contains(accountIDs, p.ID) && !stored(p.ID) { // taken, it would hide that subscription once signed in @@ -333,6 +335,15 @@ func stored(id string) bool { return false } +func hiddenAccount(id string) bool { + for _, p := range load().Providers { + if p.ID == id { + return p.Hidden + } + } + return false +} + // ShowAccount brings back the signed-in account of an agent the user had // removed from magpie. func ShowAccount(id string) error { diff --git a/providers_cli.go b/providers_cli.go index 03f5a3e9..055a1ce7 100644 --- a/providers_cli.go +++ b/providers_cli.go @@ -26,6 +26,7 @@ const providerUsage = `usage: magpie provider add add a preset vendor e.g. magpie provider add deepseek sk-… again, it adds another (deepseek-2); k=v pairs too: id, name, header.X-Foo magpie provider add k=v… add a custom vendor k: url, anthropic, responses, key, models, catalog, icon, header.X-Foo, balance, balance.path, models.url + magpie provider set k=v… change a provider's settings, with the same k=v pairs as add magpie provider key change the API key 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) @@ -38,6 +39,7 @@ const providerUsage = `usage: magpie provider add "Own Claude" anthropic=https://gw.example.com key=sk-… catalog=anthropic magpie provider add "My Relay" url=https://relay.example.com/v1 key=sk-… header.X-Org-Id=acme magpie provider add anthropic sk-… id=anthropic-ws2 name="Anthropic WS2" header.anthropic-workspace-id=wrkspc_… + magpie provider set my-relay models.url=https://relay.example.com/api/models catalog= magpie provider add "My Relay" url=https://relay.example.com/v1 key=sk-… balance=https://relay.example.com/api/usage/token balance.path='$data.total_available / 500000'` // providers: `magpie providers` @@ -191,6 +193,28 @@ func providerCmd(args []string) error { switch verb { case "add": return addProvider(rest) + case "set": + // the same k=v pairs as add, on a provider already here + if len(rest) < 2 { + return fmt.Errorf("magpie provider set k=v…\n\n%s", providerUsage) + } + p, err := provider.Find(rest[0]) + if err != nil { + return err + } + for _, kv := range rest[1:] { + if k, _, _ := strings.Cut(kv, "="); strings.EqualFold(k, "id") { + return fmt.Errorf("a provider's id can't change; groups and agents name it by it") + } + } + if err := applyPairs(p, rest[1:]); err != nil { + return err + } + if err := provider.Save(*p); err != nil { + return err + } + fmt.Println(green.Render("✓"), "saved", p.Name, muted.Render("("+p.ID+")")) + return nil case "key": if len(rest) != 2 { return fmt.Errorf("magpie provider key ") @@ -362,6 +386,13 @@ func addProvider(rest []string) error { if len(rest) == 0 { return fmt.Errorf("magpie provider add or magpie provider add k=v…\n\n%s", providerUsage) } + if len(rest) == 1 && slices.ContainsFunc(provider.Excluded(), func(x provider.Exclusion) bool { return x.Provider == strings.ToLower(rest[0]) }) { + // a signed-in account the user removed comes back with its picks + if err := provider.ShowAccount(strings.ToLower(rest[0])); err != nil { + return err + } + return announce(strings.ToLower(rest[0])) + } var p provider.Provider if pr, err := provider.FromPreset(strings.ToLower(rest[0])); err == nil { p = pr