agents: a provider switched off or removed, or its last account signed out, moves the agents on its models to others (#200: 希望关闭某个llm provider的时候,更改agent选择的model到可用的model … 关闭这个provider之后,第一次使用会报错,因为copilot/ 开头的模型不存在) — switching Copilot off took copilot/… out of the catalog and the agents' model lists, but an agent picked on one kept it in its config and its next request was refused as switched off. Now every field on one of magpie's models (the model, Claude Code's opus/sonnet/haiku/fable tiers, any other) whose model the change stopped serving is moved: to the same model from a provider still on, the same id first, else one spelt another way (claude-sonnet-4.5 and claude-sonnet-4-5-20250929), else back to the agent's own default (a Claude Code tier: following the main model). It is done around switching off, removing, a key removed or off, and a sign-in signed out or off, in the window, the tray panel, the TUI and the CLI; the page's status line, the TUI and the CLI say which agent went to what. A session started before the switch still asks for the old model: the gateway now takes a switched-off provider's model as unserved, so Claude Code's stand-in (its model as set now) answers it rather than a refusal. Tested in a sandbox HOME: Claude Code on the main model and three tiers plus Hermes, switched off and removed (same id, other spelling, default, a tier following), switched on again moving nothing; /api/provider/off returns the moves; the gateway serves a switched-off model through the stand-in (fails without the change) and still refuses it to an agent left on it

This commit is contained in:
yetone committed 2026-09-29 17:56:43 +08:00
1 parent f9c99cf370
commit 037189ebdb
11 files changed
+452 -28

No files matched your search

+4 -1
View File
@@ -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 {
+141
View File
@@ -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, "")
}
+134
View File
@@ -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")
}
}
+5 -3
View File
@@ -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
}
}
+27
View File
@@ -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)
}
}
+16 -3
View File
@@ -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.
+2
View File
@@ -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": "本地服务无需密钥",
+32 -12
View File
@@ -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.
+58
View File
@@ -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)
}
}
+19 -7
View File
@@ -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 {
+14 -2
View File
@@ -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())
}
}