Files
yetone 037189ebdb 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
2026-09-29 17:56:43 +08:00

123 lines
4.5 KiB
Go

package gateway
import (
"slices"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// A provider switched off (#163) is kept, but agents are given none of its
// models, no request, group or fallback goes to it, and one naming it is
// told it is switched off; switched on, all of it comes back.
func TestProviderOff(t *testing.T) {
fresh(t)
a, b := &keyed{}, &keyed{}
serveOn(t, "a", "ka", []string{"m", "only-a"}, a)
serveOn(t, "b", "kb", []string{"vendor/m", "only-b"}, b)
if err := provider.SaveGroup(provider.Group{Name: "Mine", Members: []string{"b/vendor/m", "a/only-a"}, Routing: provider.Ordered}); err != nil {
t.Fatal(err)
}
pa, _ := provider.Find("a")
pa.Fallback = []string{"b/only-b"}
if err := provider.Save(*pa); err != nil {
t.Fatal(err)
}
catalog := func() string {
var ids []string
for _, e := range provider.Catalog() {
if e.Provider.Account == nil { // signed-in agents the machine may have aside
ids = append(ids, e.ID)
}
}
return strings.Join(ids, " ")
}
const all = "group/mine group/auto-m a/m a/only-a b/vendor/m b/only-b"
if got := catalog(); got != all {
t.Fatalf("catalog: %s", got)
}
if err := provider.SetOff("b", true); err != nil {
t.Fatal(err)
}
pb, err := provider.Find("b")
if err != nil || !pb.Off || pb.Key != "kb" {
t.Fatalf("switched off, b is kept with its key: %+v %v", pb, err)
}
if got := catalog(); got != "group/mine a/m a/only-a" {
t.Fatalf("catalog with b off: %s", got)
}
if _, _, ok := provider.Resolve("b/vendor/m"); ok {
t.Fatal("b/vendor/m resolves with b off")
}
if _, _, ok := provider.Resolve("only-b"); ok {
t.Fatal("only-b resolves with b off")
}
if p, m, ok := provider.Resolve("group/mine"); !ok || p.ID != "a" || m != "only-a" {
t.Fatalf("group with b off: %v %s %s", ok, p.ID, m)
}
if slices.ContainsFunc(provider.Served(), func(e provider.Entry) bool { return e.Provider.ID == "b" }) {
t.Fatal("b's models served with b off")
}
s := New()
code, body := postAs(t, s, "", `{"model":"b/vendor/m","messages":[{"role":"user","content":"hi"}]}`)
if code != 404 || !strings.Contains(body, "switched off in Magpie") || len(b.tried) != 0 {
t.Fatalf("b/vendor/m with b off: %d %s, b tried %v", code, body, b.tried)
}
code, body = postAs(t, s, "", `{"model":"only-b","messages":[{"role":"user","content":"hi"}]}`)
if code != 404 || !strings.Contains(body, "switched off in Magpie") || len(b.tried) != 0 {
t.Fatalf("only-b with b off: %d %s, b tried %v", code, body, b.tried)
}
code, body = postAs(t, s, "", `{"model":"group/mine","messages":[{"role":"user","content":"hi"}]}`)
if code != 200 || !strings.Contains(body, "from ka") || len(b.tried) != 0 {
t.Fatalf("group/mine with b off: %d %s, b tried %v", code, body, b.tried)
}
// a's fallback is b's: with b off there is none to go to
a.fail = map[string]int{"ka": 429}
code, _ = postAs(t, s, "", `{"model":"a/m","messages":[{"role":"user","content":"hi"}]}`)
if code != 429 || len(b.tried) != 0 {
t.Fatalf("a/m falling back to b off: %d, b tried %v", code, b.tried)
}
a.fail = nil
if err := provider.SetOff("b", false); err != nil {
t.Fatal(err)
}
if got := catalog(); got != all {
t.Fatalf("catalog with b on again: %s", got)
}
code, body = postAs(t, s, "", `{"model":"b/vendor/m","messages":[{"role":"user","content":"hi"}]}`)
if code != 200 || !strings.Contains(body, "from kb") {
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)
}
}