From 037189ebdbb15a48c4c2c1467e5b7e6769880182 Mon Sep 17 00:00:00 2001 From: yetone Date: Tue, 29 Sep 2026 17:37:46 +0800 Subject: [PATCH] =?UTF-8?q?agents:=20a=20provider=20switched=20off=20or=20?= =?UTF-8?q?removed,=20or=20its=20last=20account=20signed=20out,=20moves=20?= =?UTF-8?q?the=20agents=20on=20its=20models=20to=20others=20(#200:=20?= =?UTF-8?q?=E5=B8=8C=E6=9C=9B=E5=85=B3=E9=97=AD=E6=9F=90=E4=B8=AAllm=20pro?= =?UTF-8?q?vider=E7=9A=84=E6=97=B6=E5=80=99=EF=BC=8C=E6=9B=B4=E6=94=B9agen?= =?UTF-8?q?t=E9=80=89=E6=8B=A9=E7=9A=84model=E5=88=B0=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E7=9A=84model=20=E2=80=A6=20=E5=85=B3=E9=97=AD=E8=BF=99?= =?UTF-8?q?=E4=B8=AAprovider=E4=B9=8B=E5=90=8E=EF=BC=8C=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E4=BD=BF=E7=94=A8=E4=BC=9A=E6=8A=A5=E9=94=99=EF=BC=8C?= =?UTF-8?q?=E5=9B=A0=E4=B8=BAcopilot/=20=E5=BC=80=E5=A4=B4=E7=9A=84?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E4=B8=8D=E5=AD=98=E5=9C=A8)=20=E2=80=94=20sw?= =?UTF-8?q?itching=20Copilot=20off=20took=20copilot/=E2=80=A6=20out=20of?= =?UTF-8?q?=20the=20catalog=20and=20the=20agents'=20model=20lists,=20but?= =?UTF-8?q?=20an=20agent=20picked=20on=20one=20kept=20it=20in=20its=20conf?= =?UTF-8?q?ig=20and=20its=20next=20request=20was=20refused=20as=20switched?= =?UTF-8?q?=20off.=20Now=20every=20field=20on=20one=20of=20magpie's=20mode?= =?UTF-8?q?ls=20(the=20model,=20Claude=20Code's=20opus/sonnet/haiku/fable?= =?UTF-8?q?=20tiers,=20any=20other)=20whose=20model=20the=20change=20stopp?= =?UTF-8?q?ed=20serving=20is=20moved:=20to=20the=20same=20model=20from=20a?= =?UTF-8?q?=20provider=20still=20on,=20the=20same=20id=20first,=20else=20o?= =?UTF-8?q?ne=20spelt=20another=20way=20(claude-sonnet-4.5=20and=20claude-?= =?UTF-8?q?sonnet-4-5-20250929),=20else=20back=20to=20the=20agent's=20own?= =?UTF-8?q?=20default=20(a=20Claude=20Code=20tier:=20following=20the=20mai?= =?UTF-8?q?n=20model).=20It=20is=20done=20around=20switching=20off,=20remo?= =?UTF-8?q?ving,=20a=20key=20removed=20or=20off,=20and=20a=20sign-in=20sig?= =?UTF-8?q?ned=20out=20or=20off,=20in=20the=20window,=20the=20tray=20panel?= =?UTF-8?q?,=20the=20TUI=20and=20the=20CLI;=20the=20page's=20status=20line?= =?UTF-8?q?,=20the=20TUI=20and=20the=20CLI=20say=20which=20agent=20went=20?= =?UTF-8?q?to=20what.=20A=20session=20started=20before=20the=20switch=20st?= =?UTF-8?q?ill=20asks=20for=20the=20old=20model:=20the=20gateway=20now=20t?= =?UTF-8?q?akes=20a=20switched-off=20provider's=20model=20as=20unserved,?= =?UTF-8?q?=20so=20Claude=20Code's=20stand-in=20(its=20model=20as=20set=20?= =?UTF-8?q?now)=20answers=20it=20rather=20than=20a=20refusal.=20Tested=20i?= =?UTF-8?q?n=20a=20sandbox=20HOME:=20Claude=20Code=20on=20the=20main=20mod?= =?UTF-8?q?el=20and=20three=20tiers=20plus=20Hermes,=20switched=20off=20an?= =?UTF-8?q?d=20removed=20(same=20id,=20other=20spelling,=20default,=20a=20?= =?UTF-8?q?tier=20following),=20switched=20on=20again=20moving=20nothing;?= =?UTF-8?q?=20/api/provider/off=20returns=20the=20moves;=20the=20gateway?= =?UTF-8?q?=20serves=20a=20switched-off=20model=20through=20the=20stand-in?= =?UTF-8?q?=20(fails=20without=20the=20change)=20and=20still=20refuses=20i?= =?UTF-8?q?t=20to=20an=20agent=20left=20on=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounts_cli.go | 5 +- internal/agent/reseat.go | 141 ++++++++++++++++++++++++++ internal/agent/reseat_test.go | 134 ++++++++++++++++++++++++ internal/gateway/gateway.go | 8 +- internal/gateway/off_test.go | 27 +++++ internal/gui/assets/app.js | 19 +++- internal/gui/assets/i18n.js | 2 + internal/gui/providers.go | 44 +++++--- internal/gui/providers_reseat_test.go | 58 +++++++++++ internal/tui/pages.go | 26 +++-- providers_cli.go | 16 ++- 11 files changed, 452 insertions(+), 28 deletions(-) create mode 100644 internal/agent/reseat.go create mode 100644 internal/agent/reseat_test.go create mode 100644 internal/gui/providers_reseat_test.go diff --git a/accounts_cli.go b/accounts_cli.go index 25448dc4..f9cd85af 100644 --- a/accounts_cli.go +++ b/accounts_cli.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/yetone/magpie/internal/agent" "github.com/yetone/magpie/internal/proc" "github.com/yetone/magpie/internal/provider" ) @@ -82,10 +83,12 @@ func accountsCmd(args []string) error { return err } if args[1] == "forget" { - if err := provider.ForgetLogin(id, args[3]); err != nil { + moved, err := agent.Reseat(func() error { return provider.ForgetLogin(id, args[3]) }) + if err != nil { return err } fmt.Println(green.Render("✓"), "forgot", args[3]) + printMoved(moved) return nil } if err := provider.SwitchLogin(id, args[3]); err != nil { diff --git a/internal/agent/reseat.go b/internal/agent/reseat.go new file mode 100644 index 00000000..8d6a8a2f --- /dev/null +++ b/internal/agent/reseat.go @@ -0,0 +1,141 @@ +package agent + +import ( + "regexp" + "slices" + "strings" + + "github.com/yetone/magpie/internal/provider" +) + +// A provider switched off or removed, or the last account of a sign-in +// signed out, takes its models out of the catalog, but an agent picked one +// of them keeps asking for it, and its next request is refused (#200: +// Copilot switched off, an agent still on copilot/…). Reseat moves such +// agents along with the change: to the same model from a provider still on, +// else back to the agent's own default. + +// Move is one of an agent's fields moved off a model magpie no longer serves. +type Move struct { + Agent string `json:"agent"` // the agent's name + Field string `json:"field"` // model, or a tier (Claude Code's haiku …) + From string `json:"from"` // the catalog model it was on + To string `json:"to"` // the one it is on now; "" its own default +} + +// String is the move as the CLI and the TUI say it. +func (m Move) String() string { + to := m.To + if to == "" { + to = "its default" + } + return m.Agent + " " + m.Field + ": " + m.From + " → " + to +} + +// pick is a field on one of magpie's models. +type pick struct { + a *Agent + key, val string + ref string // the catalog model +} + +// picks reads every detected agent's fields that are on a model magpie serves. +func picks() []pick { + var out []pick + for _, a := range Detected() { + if len(a.Fields) == 0 { + continue + } + vals := a.Values() + for _, f := range a.Fields { + v := vals[f.Key] + if v == "" || f.Options == nil { + continue + } + for _, o := range f.Options(vals) { + if o.Value == v && o.Ref != "" { + out = append(out, pick{a, f.Key, v, o.Ref}) + break + } + } + } + } + return out +} + +// Reseat makes a change to the providers (change) and moves every agent +// whose model it stopped serving: to that model from another provider +// that serves it, else to the agent's own default. It answers the moves. +func Reseat(change func() error) ([]Move, error) { + before := picks() + if err := change(); err != nil { + return nil, err + } + var moves []Move + // the last fields first: a Claude Code tier moved after the main model + // would have followed it, whatever model of its own there is for it + for i := len(before) - 1; i >= 0; i-- { + p := before[i] + if served(p.ref) { + continue + } + f := p.a.Field(p.key) + vals := p.a.Values() + // changed already, with another field + if f == nil || vals[p.key] != p.val { + continue + } + var to Option + if f.Options != nil { + to = sameModel(f.Options(vals), p.ref) + } + if err := p.a.Apply(p.key, to.Value); err != nil { + return moves, err + } + moves = append(moves, Move{Agent: p.a.Name, Field: f.Label, From: p.ref, To: to.Ref}) + } + slices.Reverse(moves) + return moves, nil +} + +// served: magpie takes requests for the catalog model. +func served(ref string) bool { + _, _, ok := provider.Resolve(ref) + return ok +} + +// sameModel is the option for ref's model from another provider still +// serving it: the same id, else one that differs only in how it is spelt +// (claude-sonnet-4.5 and claude-sonnet-4-5-20250929). None for a routing +// group, which is nobody else's. +func sameModel(opts []Option, ref string) Option { + pid, model, ok := strings.Cut(ref, "/") + if !ok || pid+"/" == provider.GroupPrefix { + return Option{} + } + want := modelKey(model) + var alike Option + for _, o := range opts { + if o.Ref == "" || strings.HasPrefix(o.Ref, provider.GroupPrefix) || !served(o.Ref) { + continue + } + _, m, _ := strings.Cut(o.Ref, "/") + if m == model { + return o + } + if alike.Ref == "" && modelKey(m) == want { + alike = o + } + } + return alike +} + +var dated = regexp.MustCompile(`-(\d{8}|\d{4}-\d{2}-\d{2})$`) + +// modelKey is a model id as providers agree on it: its last part, lower +// case, with dots and underscores as dashes and no date. +func modelKey(m string) string { + m = strings.ToLower(m[strings.LastIndex(m, "/")+1:]) + m = strings.NewReplacer(".", "-", "_", "-").Replace(m) + return dated.ReplaceAllString(m, "") +} diff --git a/internal/agent/reseat_test.go b/internal/agent/reseat_test.go new file mode 100644 index 00000000..1bef550f --- /dev/null +++ b/internal/agent/reseat_test.go @@ -0,0 +1,134 @@ +package agent + +import ( + "os" + "path/filepath" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// reseatHome is a sandbox HOME with two providers, one of them "cop" in the +// place of Copilot, and Claude Code and Hermes Agent there. +func reseatHome(t *testing.T) string { + t.Helper() + home := effortHome(t) + t.Setenv("PATH", t.TempDir()) // no agent's binary is found, or run + for _, p := range []provider.Provider{ + {ID: "cop", Name: "Cop", Chat: "https://cop.example/v1", Key: "k", Models: []string{"gpt-5", "claude-sonnet-4.5", "only-here"}}, + {ID: "ds", Name: "DS", Chat: "https://ds.example/v1", Key: "k", Models: []string{"gpt-5", "claude-sonnet-4-5-20250929"}}, + } { + if err := provider.Save(p); err != nil { + t.Fatal(err) + } + } + os.MkdirAll(filepath.Join(home, ".claude"), 0o755) + os.MkdirAll(filepath.Join(home, ".hermes"), 0o755) + return home +} + +func mustFind(t *testing.T, id string) *Agent { + t.Helper() + a, err := Find(id) + if err != nil { + t.Fatal(err) + } + return a +} + +func mustApply(t *testing.T, a *Agent, key, v string) { + t.Helper() + if err := a.Apply(key, v); err != nil { + t.Fatalf("%s %s=%s: %v", a.ID, key, v, err) + } + if got := a.Field(key).Get(); got != v { + t.Fatalf("%s %s: %q, want %q", a.ID, key, got, v) + } +} + +// #200: a provider switched off takes its models away from the agents on +// them — to the same model from a provider still on, the same model spelt +// another way, else the agent's own default — not left for the next +// request to be refused. +func TestReseatOff(t *testing.T) { + reseatHome(t) + c, h := mustFind(t, "claude"), mustFind(t, "hermes") + mustApply(t, c, "model", "cop/gpt-5") + mustApply(t, c, "opus", "cop/claude-sonnet-4.5") + mustApply(t, c, "haiku", "cop/only-here") + mustApply(t, c, "sonnet", "ds/gpt-5") + mustApply(t, h, "model", "magpie/cop/only-here") + + moves, err := Reseat(func() error { return provider.SetOff("cop", true) }) + if err != nil { + t.Fatal(err) + } + want := map[string]string{"model": "ds/gpt-5", "opus": "ds/claude-sonnet-4-5-20250929", + "haiku": "", "sonnet": "", "fable": ""} // haiku follows the main model; sonnet was ds/gpt-5 already + for k, w := range want { + if got := c.Field(k).Get(); got != w { + t.Errorf("claude %s: %q, want %q", k, got, w) + } + } + if got := h.Field("model").Get(); got != "" { + t.Errorf("hermes: %q, want its own default", got) + } + got := map[string]Move{} + for _, m := range moves { + got[m.Agent+" "+m.Field] = m + } + for k, w := range map[string]Move{ + "Claude Code model": {Agent: "Claude Code", Field: "model", From: "cop/gpt-5", To: "ds/gpt-5"}, + "Claude Code opus": {Agent: "Claude Code", Field: "opus", From: "cop/claude-sonnet-4.5", To: "ds/claude-sonnet-4-5-20250929"}, + "Claude Code haiku": {Agent: "Claude Code", Field: "haiku", From: "cop/only-here"}, // follows the main model + "Hermes Agent model": {Agent: "Hermes Agent", Field: "model", From: "cop/only-here"}, + } { + if got[k] != w { + t.Errorf("%s: %+v, want %+v", k, got[k], w) + } + } + if len(moves) != 4 { + t.Errorf("moves: %+v", moves) + } + + // switched on again, nothing is moved back or elsewhere + moves, err = Reseat(func() error { return provider.SetOff("cop", false) }) + if err != nil || len(moves) != 0 { + t.Fatalf("on again: %+v %v", moves, err) + } +} + +// A provider removed moves its agents as one switched off does; one no +// agent is on moves nothing. +func TestReseatDelete(t *testing.T) { + reseatHome(t) + c := mustFind(t, "claude") + mustApply(t, c, "model", "cop/only-here") + moves, err := Reseat(func() error { return provider.Delete("ds") }) + if err != nil || len(moves) != 0 { + t.Fatalf("an unused provider: %+v %v", moves, err) + } + if moves, err = Reseat(func() error { return provider.Delete("cop") }); err != nil || len(moves) != 1 { + t.Fatalf("delete: %+v %v", moves, err) + } + // nothing else serves it: Claude Code as installed + if got := c.Field("model").Get(); got != "" { + t.Fatalf("claude: %q", got) + } +} + +func TestModelKey(t *testing.T) { + for a, b := range map[string]string{ + "claude-sonnet-4.5": "claude-sonnet-4-5-20250929", + "anthropic/claude-opus-4.6": "claude-opus-4-6", + "gpt-5.1-codex": "GPT-5.1-codex", + "gemini-2.5-pro-2025-06-17": "gemini-2.5-pro", + } { + if modelKey(a) != modelKey(b) { + t.Errorf("%s ≠ %s", a, b) + } + } + if modelKey("gpt-5") == modelKey("gpt-5-mini") { + t.Error("gpt-5 = gpt-5-mini") + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index f96ddd29..516b5074 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -76,8 +76,10 @@ func standIn(agent, asked string) string { } // unserved: magpie shows no entry for the model — not a catalog id or -// model, nor a ready provider's "provider/model" (a routing group's id is -// taken as served). +// model, nor the "provider/model" of a provider that is on (a routing +// group's id is taken as served). A switched-off provider's is unserved: a +// session that started before it was switched off still asks for the model +// its agent was then on, and the agent has been moved since (#200). func unserved(asked string) bool { if asked == "" || strings.HasPrefix(asked, provider.GroupPrefix) { return false @@ -92,7 +94,7 @@ func unserved(asked string) bool { } } if pid, _, ok := strings.Cut(id, "/"); ok { - if p, err := provider.Find(pid); err == nil && p.Ready() { + if p, err := provider.Find(pid); err == nil && p.On() { return false } } diff --git a/internal/gateway/off_test.go b/internal/gateway/off_test.go index a68fb9a4..381595db 100644 --- a/internal/gateway/off_test.go +++ b/internal/gateway/off_test.go @@ -93,3 +93,30 @@ func TestProviderOff(t *testing.T) { t.Fatalf("b/vendor/m with b on again: %d %s", code, body) } } + +// A session started before its provider was switched off asks for the +// model its agent was on then; the agent has since been moved to another +// (agent.Reseat, #200), and that one stands in. An agent still on it is +// told the provider is switched off, as before. +func TestProviderOffStandIn(t *testing.T) { + fresh(t) + a, b := &keyed{}, &keyed{} + serveOn(t, "a", "ka", []string{"m"}, a) + serveOn(t, "b", "kb", []string{"m"}, b) + if err := provider.SetOff("b", true); err != nil { + t.Fatal(err) + } + now := "a/m" + StandIn = func(agent, model string) string { return now } + t.Cleanup(func() { StandIn = nil }) + s := New() + code, body := postAs(t, s, "", `{"model":"b/m","messages":[{"role":"user","content":"hi"}]}`) + if code != 200 || !strings.Contains(body, "from ka") || len(b.tried) != 0 { + t.Fatalf("b/m with its agent moved to a/m: %d %s, b tried %v", code, body, b.tried) + } + now = "b/m" + code, body = postAs(t, s, "", `{"model":"b/m","messages":[{"role":"user","content":"hi"}]}`) + if code != 404 || !strings.Contains(body, "switched off in Magpie") || len(b.tried) != 0 { + t.Fatalf("b/m with its agent still on it: %d %s, b tried %v", code, body, b.tried) + } +} diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index f8172ba9..5dba2022 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -1638,7 +1638,7 @@ async function switchProvider(p, on, s) { renderProviders(); state = await api("state"); renderAgents(); - status(t(on ? "{name} is on" : "{name} is off: agents no longer get its models", { name: p.name }), "ok"); + saidMoved(t(on ? "{name} is on" : "{name} is off: agents no longer get its models", { name: p.name })); } catch (e) { s?.classList.toggle("on", !on); status(e.message, "err"); @@ -4225,7 +4225,7 @@ async function accountAction(path, body, okMsg) { renderProviders(); state = await api("state"); renderAgents(); - if (okMsg) status(okMsg, "ok"); + saidMoved(okMsg); return true; } catch (e) { if (!editorError(e.message, "err")) status(e.message, "err"); @@ -4452,13 +4452,26 @@ async function providerAction(action, body, okMsg, base = "provider/") { renderProviders(); state = await api("state"); renderAgents(); - if (okMsg) status(okMsg, "ok"); + saidMoved(okMsg); } catch (e) { if (!editorError(e.message, "err")) status(e.message, "err"); document.querySelector(".editor .busy")?.classList.remove("busy"); } } +// saidMoved says what was done, and which agents it moved off models it +// took away (a provider switched off or removed, the last account signed +// out: #200), each to the same model elsewhere or back to its default. +function saidMoved(okMsg) { + const moved = (providers.moved || []).map((m) => { + const who = m.field === "model" ? m.agent : m.agent + " " + m.field; + return m.to ? t("{agent} moved to {model}", { agent: who, model: m.to }) + : t("{agent} is back on its default", { agent: who }); + }); + const msg = [okMsg, ...moved].filter(Boolean).join(" · "); + if (msg) status(msg, "ok", moved.length ? 9000 : undefined); +} + // editorError shows what went wrong inside the open provider editor, by its // buttons, until the next edit or try; the page's status line sits behind // the dialog. False when no editor is open. diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 0311674b..1b8549ba 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -232,6 +232,8 @@ const I18N = { "Off · switch it on and agents get its models again": "已关闭 · 开启后它的模型会重新写入 Agent", "{name} is on": "{name} 已开启", "{name} is off: agents no longer get its models": "{name} 已关闭:不再把它的模型写入 Agent", + "{agent} moved to {model}": "{agent} 已改用 {model}", + "{agent} is back on its default": "{agent} 已恢复为默认模型", "Switched off: agents aren't given its models and no request goes to it. Its keys and settings are kept; switch it on to use it again.": "已关闭:它的模型不会写入 Agent,也不会有请求发往它。密钥和设置都会保留,开启即可重新使用。", "API key {masked}": "API 密钥 {masked}", "Local servers need no key": "本地服务无需密钥", diff --git a/internal/gui/providers.go b/internal/gui/providers.go index be68b02f..a11a2378 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -151,6 +151,9 @@ type providersJSON struct { // signed in to after Codex was switched to another; "" when none is // left behind (provider.CodexDaemonStale). CodexDaemon string `json:"codexDaemon,omitempty"` + // Moved is the agents the change moved off models it stopped serving + // (agent.Reseat), for the page to say so. + Moved []agent.Move `json:"moved,omitempty"` } // agentModel is the model an agent is on, as magpie's catalog names it. @@ -454,6 +457,7 @@ func providerRoutes(mux *http.ServeMux, w Windows) { return } in := req.Provider + var moved []agent.Move switch r.PathValue("action") { case "show": // a signed-in account the user removed, back with its picks @@ -576,8 +580,12 @@ func providerRoutes(mux *http.ServeMux, w Windows) { case "off", "on": // switched off, it stays with its keys, but agents are given // none of its models; the files they keep them in follow, - // through catalog.Changed - if err := provider.SetOff(in.ID, r.PathValue("action") == "off"); err != nil { + // through catalog.Changed, and the agents on one of its models + // are moved to another + var err error + if moved, err = agent.Reseat(func() error { + return provider.SetOff(in.ID, r.PathValue("action") == "off") + }); err != nil { fail(rw, err) return } @@ -588,7 +596,8 @@ func providerRoutes(mux *http.ServeMux, w Windows) { return } case "delete": - if err := provider.Delete(in.ID); err != nil { + var err error + if moved, err = agent.Reseat(func() error { return provider.Delete(in.ID) }); err != nil { fail(rw, err) return } @@ -640,7 +649,9 @@ func providerRoutes(mux *http.ServeMux, w Windows) { http.NotFound(rw, r) return } - writeJSON(rw, providersState()) + st := providersState() + st.Moved = moved + writeJSON(rw, st) }) // How much of its allowance each of an agent's accounts has used. mux.HandleFunc("GET /api/login/usage", func(rw http.ResponseWriter, r *http.Request) { @@ -656,18 +667,21 @@ func providerRoutes(mux *http.ServeMux, w Windows) { fail(rw, err) return } - var err error + var change func() error switch r.PathValue("action") { case "switch": - err = provider.SwitchLogin(in.Agent, in.User) + change = func() error { return provider.SwitchLogin(in.Agent, in.User) } case "forget": - err = provider.ForgetLogin(in.Agent, in.User) + change = func() error { return provider.ForgetLogin(in.Agent, in.User) } case "on", "off": - err = provider.SetLoginOn(in.Agent, in.User, r.PathValue("action") == "on") + change = func() error { return provider.SetLoginOn(in.Agent, in.User, r.PathValue("action") == "on") } default: http.NotFound(rw, r) return } + // the last account signed out or off takes the sign-in's models + // away: the agents on them are moved to others + moved, err := agent.Reseat(change) if err != nil { fail(rw, err) return @@ -675,7 +689,9 @@ func providerRoutes(mux *http.ServeMux, w Windows) { // an agent on its own models goes through magpie while more of // its accounts are on, and straight to its vendor again once not agent.SyncCatalog() - writeJSON(rw, providersState()) + st := providersState() + st.Moved = moved + writeJSON(rw, st) }) // Codex's background app-server, left on the account before a switch: // restarting it (which ends the Codex sessions on it), or letting it be. @@ -707,6 +723,7 @@ func providerRoutes(mux *http.ServeMux, w Windows) { return } var err error + var moved []agent.Move switch r.PathValue("action") { case "add": err = provider.AddKey(in.ID, in.Name, in.Key, in.Protocol) @@ -715,11 +732,12 @@ func providerRoutes(mux *http.ServeMux, w Windows) { case "use": err = provider.UseKey(in.ID, in.Ref) case "remove": - err = provider.RemoveKey(in.ID, in.Ref) + // the last key gone, or off, takes the provider's models away + moved, err = agent.Reseat(func() error { return provider.RemoveKey(in.ID, in.Ref) }) case "rename": err = provider.RenameKey(in.ID, in.Ref, in.Name) case "on", "off": - err = provider.SetKeyOn(in.ID, in.Ref, r.PathValue("action") == "on") + moved, err = agent.Reseat(func() error { return provider.SetKeyOn(in.ID, in.Ref, r.PathValue("action") == "on") }) default: http.NotFound(rw, r) return @@ -739,7 +757,9 @@ func providerRoutes(mux *http.ServeMux, w Windows) { cancel() } } - writeJSON(rw, providersState()) + st := providersState() + st.Moved = moved + writeJSON(rw, st) }) // Adding a subscription: magpie opens the vendor's sign-in in the // browser and the window follows it until the account is in. diff --git a/internal/gui/providers_reseat_test.go b/internal/gui/providers_reseat_test.go new file mode 100644 index 00000000..024cba11 --- /dev/null +++ b/internal/gui/providers_reseat_test.go @@ -0,0 +1,58 @@ +package gui + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/yetone/magpie/internal/agent" + "github.com/yetone/magpie/internal/provider" +) + +// #200: switching a provider off moves Claude Code off its model, to the +// same model from a provider still on, and the page is told so. +func TestProviderOffMovesAgents(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + t.Setenv("PATH", t.TempDir()) + for _, p := range []provider.Provider{ + {ID: "cop", Name: "Cop", Chat: "https://cop.example/v1", Key: "k", Models: []string{"gpt-5"}}, + {ID: "ds", Name: "DS", Chat: "https://ds.example/v1", Key: "k", Models: []string{"gpt-5"}}, + } { + if err := provider.Save(p); err != nil { + t.Fatal(err) + } + } + os.MkdirAll(filepath.Join(home, ".claude"), 0o755) + c, err := agent.Find("claude") + if err != nil { + t.Fatal(err) + } + if err := c.Apply("model", "cop/gpt-5"); err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + providerRoutes(mux, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest("POST", "/api/provider/off", strings.NewReader(`{"id":"cop"}`))) + if w.Code != 200 { + t.Fatalf("%d %s", w.Code, w.Body) + } + var st providersJSON + if err := json.Unmarshal(w.Body.Bytes(), &st); err != nil { + t.Fatal(err) + } + want := agent.Move{Agent: "Claude Code", Field: "model", From: "cop/gpt-5", To: "ds/gpt-5"} + if len(st.Moved) != 1 || st.Moved[0] != want { + t.Fatalf("moved: %+v", st.Moved) + } + if got := c.Field("model").Get(); got != "ds/gpt-5" { + t.Fatalf("claude: %q", got) + } +} diff --git a/internal/tui/pages.go b/internal/tui/pages.go index b5b2843c..11953745 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -156,7 +156,8 @@ func (m model) updateProviders(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if off { what = "switched off: agents are given none of its models" } - return m, saveProvider(p.ID, func(p *provider.Provider) { p.Off = off }, p.Name+" "+what) + id := p.ID + return m, reseatCmd(func() error { return provider.SetOff(id, off) }, p.Name+" "+what) case "t": m.flash, m.flashOK = "testing "+p.Name+"…", true return m, testCmd(p) @@ -167,16 +168,27 @@ func (m model) updateProviders(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } m.confirm = "" - return m, func() tea.Msg { - if err := provider.Delete(p.ID); err != nil { - return flashMsg{text: err.Error()} - } - return flashMsg{text: "removed " + p.Name, ok: true} - } + id := p.ID + return m, reseatCmd(func() error { return provider.Delete(id) }, "removed "+p.Name) } return m, nil } +// reseatCmd makes a change that may take models away from the agents on +// them, and says which it moved to others (agent.Reseat). +func reseatCmd(change func() error, done string) tea.Cmd { + return func() tea.Msg { + moved, err := agent.Reseat(change) + if err != nil { + return flashMsg{text: err.Error()} + } + for _, mv := range moved { + done += "; moved " + mv.String() + } + return flashMsg{text: done, ok: true} + } +} + // saveProvider changes one provider and saves it. func saveProvider(id string, change func(*provider.Provider), done string) tea.Cmd { return func() tea.Msg { diff --git a/providers_cli.go b/providers_cli.go index 9e992a9e..a12f223f 100644 --- a/providers_cli.go +++ b/providers_cli.go @@ -343,10 +343,12 @@ func providerCmd(args []string) error { if err != nil { return err } - if err := provider.Delete(p.ID); err != nil { + moved, err := agent.Reseat(func() error { return provider.Delete(p.ID) }) + if err != nil { return err } fmt.Println(green.Render("✓"), "removed", p.Name) + printMoved(moved) return nil case "test": // with models named, a request to each of them; else one per endpoint @@ -413,9 +415,11 @@ func providerCmd(args []string) error { if err != nil { return err } - if err := provider.SetOff(p.ID, verb == "off"); err != nil { + moved, err := agent.Reseat(func() error { return provider.SetOff(p.ID, verb == "off") }) + if err != nil { return err } + defer printMoved(moved) if verb == "off" { fmt.Println(green.Render("✓"), p.Name, muted.Render("is switched off: agents are given none of its models")) } else { @@ -823,3 +827,11 @@ func fetchedFrom(p provider.Provider) string { } return p.Name } + +// printMoved says which agents a change moved off models it stopped +// serving (agent.Reseat). +func printMoved(moved []agent.Move) { + for _, m := range moved { + fmt.Println(green.Render("✓"), "moved", m.String()) + } +}