diff --git a/internal/gateway/fallback.go b/internal/gateway/fallback.go index 5d111d0a..f08a26ee 100644 --- a/internal/gateway/fallback.go +++ b/internal/gateway/fallback.go @@ -315,6 +315,9 @@ func (s *Server) planGroup(g provider.Group, ms []provider.Member, from provider var asides []candidate var wAsides []Weighed out := planLevel(g, ms, 0, from, &pl, &asides, &wAsides) + if sinks(g.Sink, g.Routing) { + out, pl.order = sinkPlanned(out, pl.order) + } out, pl.order = append(out, asides...), append(pl.order, wAsides...) if len(out) == 0 { return nil, pl @@ -412,6 +415,7 @@ func planLevel(g provider.Group, ms []provider.Member, depth int, from provider. } w := weighed(c, u.m.Provider, wg, false, from) w.Routing, w.Via = g.Routing, u.m.Groups() + w.Sunk = nil // weighed as the group's, whose own Sink tells w.Turn = i == 0 && g.Routing == provider.Rotate && len(weighedHeads) > 1 pl.order = append(pl.order, w) out = append(out, c) diff --git a/internal/gateway/routing.go b/internal/gateway/routing.go index 27e8b744..4af296cd 100644 --- a/internal/gateway/routing.go +++ b/internal/gateway/routing.go @@ -338,6 +338,10 @@ func (s *Server) restAfterMarked(c candidate, status int, header http.Header, bo d = min(d, longestQuota) case failRate: d, r.By, r.Failures = s.rateRest(c, header, body, sharedPool, now) + // rate limited with quota left: to the back, where the order sinks + if c.full(now).IsZero() && !(c.isOpenRouterFree() && sharedPool) { + sink(c.restKey(), now) + } case failVerify: d, r.By = verifyRest, "verify" // the error as the agent was given it, and the link in it @@ -567,8 +571,18 @@ func learns(c candidate, lefts map[allowanceKey]left) bool { } // weigh orders one provider's candidates as its routing says, and tells -// what it went by. +// what it went by: with Sink on, those rate limited with quota left behind +// the others (sink.go). func weigh(p provider.Provider, cs []candidate, model string, from provider.Protocol) ([]candidate, weighing) { + cs, wg := weighRouted(p, cs, model, from) + if sinks(p.Sink, p.Routing) { + cs = sinkLast(cs) + } + return cs, wg +} + +// weighRouted is weigh by the routing alone. +func weighRouted(p provider.Provider, cs []candidate, model string, from provider.Protocol) ([]candidate, weighing) { var wg weighing if len(cs) < 2 { return cs, wg diff --git a/internal/gateway/sink.go b/internal/gateway/sink.go new file mode 100644 index 00000000..bb971cea --- /dev/null +++ b/internal/gateway/sink.go @@ -0,0 +1,125 @@ +package gateway + +// Sinking (01huadalang, a WorkBuddy user with several accounts): an +// account kept answering until it is rate limited, then given every +// request again as soon as its rest ends, is the one a vendor's risk +// control notices. With a provider's or group's Sink on, a key or account +// that answers 429 while it still has quota — a rate limit (failRate), not +// a plan used up — goes to the back of the order its routing gives, +// behind every one not rate limited since, the one that sank first ahead +// of those that sank after it. It comes back to the front only once those +// ahead of it have been rate limited in their turn: the load goes round +// the accounts, where without Sink it goes back to the first each time +// that one's rest ends. +// +// What sank is remembered in memory, until magpie restarts — or until it +// sinks again, which sends it to the back once more. Nothing else changes: +// a resting one still waits behind every one ready (restLast), an account +// out of quota or credit rests as before and doesn't sink, and with Sink +// off the order is the routing's alone. + +import ( + "slices" + "sync" + "time" + + "github.com/yetone/magpie/internal/provider" +) + +type sinking struct { + at time.Time // when it was rate limited + n uint64 // in what order it sank +} + +var sunk = struct { + sync.Mutex + m map[string]sinking // candidate restKey → when it sank + n uint64 +}{m: map[string]sinking{}} + +// sink sends what rests by key to the back of the orders that sink. +func sink(key string, now time.Time) { + sunk.Lock() + sunk.n++ + sunk.m[key] = sinking{now, sunk.n} + sunk.Unlock() +} + +// sunkAt is when a candidate sank, if it did. +func sunkAt(c candidate) (sinking, bool) { + sunk.Lock() + defer sunk.Unlock() + s, ok := sunk.m[c.restKey()] + return s, ok +} + +// sinks says whether an order routed so sends the rate limited back: +// in turn goes round already, and a manual group has one member. +func sinks(on bool, routing string) bool { + return on && routing != provider.Rotate && routing != provider.Manual +} + +// sinkOrder is the order of n candidates with those that sank moved +// behind the others, the one that sank first first; the others keep +// theirs. +func sinkOrder(n int, at func(int) candidate) []int { + idx := make([]int, n) + seq := make([]uint64, n) // 0: never sank + for i := range idx { + idx[i] = i + if s, ok := sunkAt(at(i)); ok { + seq[i] = s.n + } + } + slices.SortStableFunc(idx, func(a, b int) int { + switch sa, sb := seq[a], seq[b]; { + case sa == sb: + return 0 + case sa == 0: + return -1 + case sb == 0: + return 1 + case sa < sb: + return -1 + } + return 1 + }) + return idx +} + +// sinkLast is cs with those that sank moved behind the others. +func sinkLast(cs []candidate) []candidate { + if len(cs) < 2 { + return cs + } + idx := sinkOrder(len(cs), func(i int) candidate { return cs[i] }) + out := make([]candidate, len(cs)) + for i, j := range idx { + out[i] = cs[j] + } + return out +} + +// sinkPlanned is sinkLast over a group's planned candidates and what the +// trace says of each, kept together. +func sinkPlanned(out []candidate, order []Weighed) ([]candidate, []Weighed) { + if len(out) < 2 || len(order) < len(out) { + return out, order + } + idx := sinkOrder(len(out), func(i int) candidate { return out[i] }) + cs, ws := make([]candidate, len(out)), make([]Weighed, len(order)) + copy(ws[len(out):], order[len(out):]) + for i, j := range idx { + cs[i], ws[i] = out[j], order[j] + markSunk(&ws[i], out[j]) + } + return cs, ws +} + +// markSunk tells, in the trace, that c sank and when. +func markSunk(w *Weighed, c candidate) { + if s, ok := sunkAt(c); ok { + at := s.at + w.Sunk = &at + } +} diff --git a/internal/gateway/sink_test.go b/internal/gateway/sink_test.go new file mode 100644 index 00000000..d0ff7bdc --- /dev/null +++ b/internal/gateway/sink_test.go @@ -0,0 +1,171 @@ +package gateway + +import ( + "net/http" + "slices" + "testing" + "time" + + "github.com/yetone/magpie/internal/provider" +) + +func forgetSinks(t *testing.T) { + t.Helper() + clear := func() { + sunk.Lock() + sunk.m = map[string]sinking{} + sunk.Unlock() + restingUntil.Lock() + restingUntil.m = map[string]time.Time{} + restingUntil.note = map[string]Rest{} + restingUntil.Unlock() + routed.Lock() + routed.failures = map[string]int{} + routed.Unlock() + } + clear() + t.Cleanup(clear) +} + +// rested ends what rests by key's rest, as if its minute were up. +func rested(key string) { + restingUntil.Lock() + restingUntil.m[key] = time.Now().Add(-time.Second) + restingUntil.Unlock() +} + +func whoOf(cs []candidate) []string { + var out []string + for _, c := range cs { + out = append(out, c.p.Account.User) + } + return out +} + +// 01huadalang (WorkBuddy, several accounts, in order): an account rate +// limited while it has quota left goes back to the front as soon as its +// rest ends, and is the one a vendor's risk control notices. With Sink on +// it goes to the back instead, behind every one not rate limited since, +// and comes round again only once those ahead of it were rate limited in +// their turn. A used-up account doesn't sink, a resting one still waits +// at the back, and with Sink off the order is as it was. +func TestRateLimitedSinksToTheBack(t *testing.T) { + forgetSinks(t) + old := allowances + defer func() { allowances = old }() + used := map[string]float64{"a@x.com": 10, "b@x.com": 10, "c@x.com": 10} + allowances = func(string) map[string]provider.Allowance { + out := map[string]provider.Allowance{} + for u, v := range used { + out[u] = provider.Allowance{{Used: v, Span: 30 * 24 * time.Hour, Resets: time.Now().Add(24 * time.Hour)}} + } + return out + } + s := &Server{} + acct := func(user string) candidate { + return candidate{p: provider.Provider{ID: "workbuddy", Routing: provider.Ordered, Account: &provider.Account{Agent: "workbuddy", User: user}}, model: "m", rest: "workbuddy@" + user} + } + cs := []candidate{acct("a@x.com"), acct("b@x.com"), acct("c@x.com")} + p := provider.Provider{ID: "workbuddy", Routing: provider.Ordered, Sink: true} + order := func(p provider.Provider) []string { + out, _ := weigh(p, slices.Clone(cs), "m", "") + out, _ = restLast(out, planned{order: make([]Weighed, len(out))}) + return whoOf(out) + } + rate := []byte(`{"error":{"message":"Too many requests","type":"rate_limit_error"}}`) + limit := func(i int) { + if r := s.restAfter(cs[i], 429, http.Header{}, rate); r.Why != failRate { + t.Fatalf("%s: %s", cs[i].p.Account.User, r.Why) + } + rested(cs[i].restKey()) + } + want := func(p provider.Provider, w ...string) { + t.Helper() + if got := order(p); !slices.Equal(got, w) { + t.Fatalf("order %v, want %v", got, w) + } + } + off := p + off.Sink = false + + want(p, "a@x.com", "b@x.com", "c@x.com") + limit(0) + want(off, "a@x.com", "b@x.com", "c@x.com") // off: back to the first + want(p, "b@x.com", "c@x.com", "a@x.com") + limit(1) + want(p, "c@x.com", "a@x.com", "b@x.com") + limit(2) + want(p, "a@x.com", "b@x.com", "c@x.com") // round again: a sank first + limit(0) + want(p, "b@x.com", "c@x.com", "a@x.com") + + // in the trace, the one at the back says when it sank + _, wg := weigh(p, slices.Clone(cs), "m", "") + if w := weighed(cs[0], p, wg, false, ""); w.Sunk == nil { + t.Fatal("trace: a sank, and doesn't say so") + } + if w := weighed(cs[0], off, wg, false, ""); w.Sunk != nil { + t.Fatal("trace: sank where the order doesn't sink") + } + + // in turn goes round already: nothing sinks there + turn := p + turn.Routing = provider.Rotate + if !sinks(p.Sink, p.Routing) || sinks(turn.Sink, turn.Routing) { + t.Fatal("sinks: in order sinks, in turn doesn't") + } + + // out of quota, or rate limited with its allowance used up, it doesn't + // sink: it rests until the quota comes back, and keeps its place + forgetSinks(t) + s.restAfter(cs[0], 429, http.Header{}, []byte(`{"error":{"message":"You have hit your usage limit"}}`)) + rested(cs[0].restKey()) + want(p, "a@x.com", "b@x.com", "c@x.com") + used["a@x.com"] = 100 + s.restAfter(cs[0], 429, http.Header{}, rate) + used["a@x.com"] = 10 + rested(cs[0].restKey()) + want(p, "a@x.com", "b@x.com", "c@x.com") + + // a sunk one resting is still behind every one ready + limit(0) + s.restAfter(cs[1], 429, http.Header{}, rate) // b rests on + want(p, "c@x.com", "a@x.com", "b@x.com") +} + +// A routing group with Sink: a member's key rate limited with quota left +// goes behind the other members, in order or not, and the trace's order +// follows it. +func TestGroupRateLimitedSinks(t *testing.T) { + forgetSinks(t) + s := &Server{} + mem := func(id string) provider.Member { + return provider.Member{ID: id + "/m", Path: []string{id + "/m"}, Provider: provider.Provider{ID: id, Chat: "http://127.0.0.1:1/v1", Key: "k-" + id}, Model: "m"} + } + ms := []provider.Member{mem("a"), mem("b"), mem("c")} + ids := func(cs []candidate) []string { + var out []string + for _, c := range cs { + out = append(out, c.p.ID) + } + return out + } + rate := []byte(`{"error":{"message":"Too many requests"}}`) + cs, _ := s.planGroup(provider.Group{ID: "g", Routing: provider.Ordered}, ms, provider.Chat) + s.restAfter(cs[0], 429, http.Header{}, rate) + rested(cs[0].restKey()) + for _, routing := range []string{provider.Ordered, ""} { + g := provider.Group{ID: "g", Routing: routing, Sink: true} + out, pl := s.planGroup(g, ms, provider.Chat) + if got := ids(out); !slices.Equal(got, []string{"b", "c", "a"}) { + t.Fatalf("%q: order %v", routing, got) + } + if pl.order[2].Provider != "a" || pl.order[2].Sunk == nil || pl.order[0].Sunk != nil { + t.Fatalf("%q: trace %+v", routing, pl.order) + } + g.Sink = false + if out, _ := s.planGroup(g, ms, provider.Chat); ids(out)[0] != "a" { + t.Fatalf("%q off: order %v", routing, ids(out)) + } + } +} diff --git a/internal/gateway/trace.go b/internal/gateway/trace.go index bf550c7b..6eba0ab1 100644 --- a/internal/gateway/trace.go +++ b/internal/gateway/trace.go @@ -92,7 +92,8 @@ type GroupRef struct { Routing string `json:"routing"` Affinity string `json:"affinity"` Auto bool `json:"auto,omitempty"` - Members []string `json:"members"` // those ready, as provider/model[:effort fixed on it] + Sink bool `json:"sink,omitempty"` // provider.Group.Sink, as it applies + Members []string `json:"members"` // those ready, as provider/model[:effort fixed on it] // Subs: the groups in the group, at any depth, outermost first Subs []SubGroup `json:"subs,omitempty"` // Via: for each of Members, the groups in the group it is of, as @@ -120,7 +121,7 @@ type NestedRule struct { // groupRef is the trace's g, with its models ms. func groupRef(g provider.Group, ms []provider.Member) *GroupRef { - ref := &GroupRef{ID: g.ID, Name: g.Name, Routing: g.Routing, Affinity: g.Affinity, Auto: g.Auto} + ref := &GroupRef{ID: g.ID, Name: g.Name, Routing: g.Routing, Affinity: g.Affinity, Auto: g.Auto, Sink: sinks(g.Sink, g.Routing)} seen := map[string]bool{} for _, m := range ms { ref.Members = append(ref.Members, provider.WithMemberEffort(m.Provider.ID+"/"+m.Model, m.Effort)) @@ -190,6 +191,10 @@ type Weighed struct { // Via: the groups in the group it is of, outermost first, when it is // of a group in the group asked for Via []string `json:"via,omitempty"` + // Sunk: when it was rate limited with quota left, sending it to the + // back of an order that sinks (sink.go); nil when it didn't, or the + // order doesn't sink + Sunk *time.Time `json:"sunk,omitempty"` } // Try is one candidate trying the request. @@ -276,6 +281,9 @@ func weighed(c candidate, p provider.Provider, wg weighing, fallback bool, from if wg.tokens != nil { w.Tokens = wg.tokens[c.rest] } + if sinks(p.Sink, p.Routing) { + markSunk(&w, c) + } return w } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 77c5bd7e..e42d415e 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -7003,17 +7003,27 @@ const STAYS = [ ["turn", "Within a turn", "A conversation stays put within a turn, while the agent sends tool results back; when you speak again, routing decides afresh."], ["off", "Off", "Every request is routed afresh, whoever answered its conversation before."], ]; +// what one rate limited while it still has quota does then (provider.Sink, +// 01huadalang): keeps its place, back first once its rest ends, or goes to +// the back of the order so the load goes round — as on the Routing page +// (routing.js SINK_OPTS) +const SINKS = [ + [false, "Keeps its place", "One rate limited while it still has quota rests as long as the vendor asks, then takes its place in the order again."], + [true, "Goes to the back", "One rate limited (429) while it still has quota goes to the back of the order, behind every one not rate limited since, and comes round again once those ahead of it are rate limited in turn — so the load goes round rather than back to the first each time. Out of quota, it rests as usual. Kept until magpie restarts."], +]; // routingOfDraft: what the editor's Save sends of them — only what was // picked and differs from what is saved function routingOfDraft(p) { const out = {}; if (draft?.routing !== undefined && draft.routing !== (p.routing || "")) out.routing = draft.routing; if (draft?.affinity !== undefined && draft.affinity !== (p.affinity || "")) out.affinity = draft.affinity; + if (draft?.sink !== undefined && draft.sink !== !!p.sink) out.sink = draft.sink; return out; } function renderRouting(p) { const routingNow = () => draft.routing ?? (p.routing || ""); const affinityNow = () => draft.affinity ?? (p.affinity || ""); + const sinkNow = () => draft.sink ?? !!p.sink; // what Codex or Claude Code sends past magpie goes to the account it is // signed in to, which magpie moves on once Smart would count it spent // and back once the first has room (provider.KeepOnAnAccountWithRoom, @@ -7039,12 +7049,25 @@ function renderRouting(p) { const cur = ROUTINGS.find(([id]) => id === routingNow()) || ROUTINGS[0]; rHint.textContent = t(cur[2]) + own(cur[0]); rUnsaved.hidden = routingNow() === (p.routing || ""); + // in turn goes round already + kLabel.hidden = kWrap.hidden = routingNow() === "rotate"; }; const rPick = segs(ROUTINGS.map(([id, name]) => [id, t(name)]), routingNow(), (routing) => { draft.routing = routing; drawRouting(); }); const rRow = el("div", "route-pick"); rRow.append(rPick, rUnsaved); const rWrap = el("div"); rWrap.append(rRow, rHint); + const kHint = el("div", "hint"), kUnsaved = unsaved(); + const drawSink = () => { + kHint.textContent = t(SINKS[sinkNow() ? 1 : 0][2]); + kUnsaved.hidden = sinkNow() === !!p.sink; + }; + const kPick = segs(SINKS.map(([on, name]) => [on ? "sink" : "", t(name)]), sinkNow() ? "sink" : "", (v) => { draft.sink = v === "sink"; drawSink(); }); + kPick.classList.add("sink-pick"); + const kRow = el("div", "route-pick"); + kRow.append(kPick, kUnsaved); + const kWrap = el("div", "sink-wrap"), kLabel = el("label", "", t("Rate limited")); + kWrap.append(kRow, kHint); const sHint = el("div", "hint"), sUnsaved = unsaved(); const drawStays = () => { const cur = STAYS.find(([id]) => id === affinityNow()) || STAYS[0]; @@ -7057,8 +7080,9 @@ function renderRouting(p) { const sWrap = el("div"); sWrap.append(sRow, sHint); drawRouting(); + drawSink(); drawStays(); - return [el("label", "", t("Routing")), rWrap, el("label", "", t("Stays")), sWrap]; + return [el("label", "", t("Routing")), rWrap, kLabel, kWrap, el("label", "", t("Stays")), sWrap]; } // renderFallback: where requests go when this provider can't take them — diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index b832b793..12d62f8c 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -1783,6 +1783,14 @@ const I18N = { "Stays": "会话保持", "Session": "整个会话", "Within a turn": "一轮之内", + "Rate limited": "被限流时", + "Keeps its place": "保持原位", + "Goes to the back": "排到最后", + "Rate limited to the back": "限流后排到最后", + "One rate limited while it still has quota rests as long as the vendor asks, then takes its place in the order again.": "还有额度却被限流的账号,按厂商要求休息一段时间,之后回到它原来的位置继续用。", + "One rate limited (429) while it still has quota goes to the back of the order, behind every one not rate limited since, and comes round again once those ahead of it are rate limited in turn — so the load goes round rather than back to the first each time. Out of quota, it rests as usual. Kept until magpie restarts.": "还有额度却被限流(429)的账号排到最后,排在所有之后没被限流过的账号后面;等排在它前面的也轮流被限流后,才再轮到它——这样请求在各账号间轮着用,而不是每次休息完又回到第一个。额度用完的照常休息,不受影响。这个顺序会保留到 magpie 重启。", + "{who} was rate limited at {time} while it had quota left, so it went to the back: it comes round again once those ahead of it are rate limited in turn.": "{who} 在 {time} 还有额度时被限流,所以排到了最后:等排在它前面的也轮流被限流后,才再轮到它。", + "rate limited at {time} · at the back": "{time} 被限流 · 排在最后", "A group needs a model in it": "组里至少要有一个模型", "Couldn't save the group: {error}": "分组没能保存:{error}", "Rules": "规则", diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index da376e97..a4148acf 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -311,7 +311,8 @@ const smart = (x) => !x.routing && x.kind === "account"; const someKnown = r.order.some((x) => x.known); for (const x of r.order.slice(1)) { - if (x.rest) out.push(t("{who} is resting — {why}, {when} — so it waits at the back.", { who: who(x), why: `${x.rest.status} · ${failWord(x.rest.why)}`, when: restWhen(x.rest, at(r.time)) })); + if (x.sunk && !x.rest) out.push(t("{who} was rate limited at {time} while it had quota left, so it went to the back: it comes round again once those ahead of it are rate limited in turn.", { who: who(x), time: clock(x.sunk) })); + else if (x.rest) out.push(t("{who} is resting — {why}, {when} — so it waits at the back.", { who: who(x), why: `${x.rest.status} · ${failWord(x.rest.why)}`, when: restWhen(x.rest, at(r.time)) })); else if (smart(x) && x.known && group(x) === "spent") out.push(t("{who} is at {n} — all but used up, it answers only when nothing else can.", { who: who(x), n: pct(x.used) })); else if (smart(x) && x.known && group(x) === "low") out.push(t("{who} is at {n} — kept for when the others can't.", { who: who(x), n: pct(x.used) })); else if (smart(x) && x.learns && someKnown) out.push(t("{who}: what it has left isn't known yet, and its answer tells, so it goes before those known.", { who: who(x) })); @@ -983,6 +984,7 @@ else if (trying.has(id)) s = r.id !== cur.id && agents.size <= 1 ? t("answering another request…") : t("answering…"); else if (answered.has(id)) s = agents.size > 1 ? t("answered {agent}", { agent: agentName(r.agent) }) : t("answered this request"); else if (gave.has(id)) s = t("{status} · {fail} · passed to {agent}", { status: gave.get(id).status, fail: failWord(gave.get(id).fail), agent: agentName(r.agent) }); + else if (w.sunk) s = t("rate limited at {time} · at the back", { time: clock(w.sunk) }); else if (w.kind === "account" && w.known) { const soon = renews(w)[0]; s = !w.routing && w.used >= 98 ? quota(w, "{n} used · all but used up", "{n} left · all but used up") @@ -2137,6 +2139,15 @@ // user picks on its card (provider.Manual, #317) — a provider's keys can't const GROUP_ROUTE_OPTS = [...ROUTE_OPTS, ["manual", "Manual"]]; const AFF_OPTS = [["", "Auto"], ["session", "Session"], ["turn", "Within a turn"], ["off", "Off"]]; + // what one rate limited with quota left does then (provider.Sink), as in + // a provider's editor (app.js SINKS) + const SINK_OPTS = [["", "Keeps its place"], ["sink", "Goes to the back"]]; + const SINK_HINT = { + "": "One rate limited while it still has quota rests as long as the vendor asks, then takes its place in the order again.", + sink: "One rate limited (429) while it still has quota goes to the back of the order, behind every one not rate limited since, and comes round again once those ahead of it are rate limited in turn — so the load goes round rather than back to the first each time. Out of quota, it rests as usual. Kept until magpie restarts.", + }; + // in turn goes round already, and a manual group sends to one member + const sinkable = (routing) => routing !== "rotate" && routing !== "manual"; const AFF_HINT = { "": "A conversation stays with the account or key that answered it while what the vendor cached of it is worth keeping — within a turn always, across turns while it's fresh.", session: "A conversation stays with the account or key that answered it for the whole session, while it can answer.", @@ -2290,6 +2301,11 @@ const tags = el("span", "tags"); tags.append(el("span", "tag", t(m[1]))); if (g.affinity) tags.append(el("span", "tag", t(AFF_OPTS.find(([id]) => id === g.affinity)?.[1] || ""))); + if (g.sink && sinkable(g.routing || "")) { + const k = el("span", "tag sink", t("Rate limited to the back")); + k.title = t(SINK_HINT.sink); + tags.append(k); + } if (g.rules?.length) { const r = el("span", "tag" + (manual ? " idle" : ""), t(g.rules.length === 1 ? "1 rule" : "{n} rules", { n: g.rules.length })); r.title = (manual ? t("The rules wait while you pick the model by hand.") + "\n" : "") + g.rules.map((x, i) => `${i + 1}. ${ruleText(x)} → ${memberLabel(g, x.use)}`).join("\n"); @@ -2298,7 +2314,7 @@ if (!g.ready) tags.append(el("span", "tag bad", t("no member ready"))); const edit = el("button", "text", t("Edit")); edit.onclick = (e) => { e.stopPropagation(); open(); }; - const open = () => { gEdit = { id: g.id, draft: { name: g.name, members: [...g.members], fast: [...(g.fast || [])], off: [...(g.off || [])], routing: g.routing || "", pick: g.pick || "", affinity: g.affinity || "", classifier: g.classifier || "", effort: g.effort || "", levels: [...(g.levels || [])], rules: (g.rules || []).map((r) => ({ ...r, intent: r.intent || "", agents: [...(r.agents || [])], time: r.time ? { ...r.time, days: [...(r.time.days || [])] } : null })) } }; renderGroups(); }; + const open = () => { gEdit = { id: g.id, draft: { name: g.name, members: [...g.members], fast: [...(g.fast || [])], off: [...(g.off || [])], routing: g.routing || "", pick: g.pick || "", affinity: g.affinity || "", sink: !!g.sink, classifier: g.classifier || "", effort: g.effort || "", levels: [...(g.levels || [])], rules: (g.rules || []).map((r) => ({ ...r, intent: r.intent || "", agents: [...(r.agents || [])], time: r.time ? { ...r.time, days: [...(r.time.days || [])] } : null })) } }; renderGroups(); }; row.onclick = open; row.append(ics, main, tags, edit); return row; @@ -2328,7 +2344,7 @@ b.onclick = (e) => { e.stopPropagation(); // the card opens the editor; this picks if (on) return; - groupAction("save", { id: g.id, name: g.name, members: g.members, routing: "manual", pick: id, affinity: g.affinity || "", rules: g.rules || [], effort: g.effort || "", classifier: g.classifier || "", context: g.context || 0, levels: g.levels || [], family: g.family || "", fast: g.fast || [], off: g.off || [] }, + groupAction("save", { id: g.id, name: g.name, members: g.members, routing: "manual", pick: id, affinity: g.affinity || "", sink: !!g.sink, rules: g.rules || [], effort: g.effort || "", classifier: g.classifier || "", context: g.context || 0, levels: g.levels || [], family: g.family || "", fast: g.fast || [], off: g.off || [] }, t("{name}: every request to {model}", { name: g.name, model: memberName(id) })); }; box.append(b); @@ -2473,9 +2489,20 @@ const rHint = el("div", "hint", t(GROUP_HINT[d.routing] || GROUP_HINT[""])); const rw = el("div"); - rw.append(segs(GROUP_ROUTE_OPTS.map(([id, n]) => [id, t(n)]), d.routing, (v) => { d.routing = v; rHint.textContent = t(GROUP_HINT[v] || GROUP_HINT[""]); drawRules(); }), rHint); + rw.append(segs(GROUP_ROUTE_OPTS.map(([id, n]) => [id, t(n)]), d.routing, (v) => { d.routing = v; rHint.textContent = t(GROUP_HINT[v] || GROUP_HINT[""]); drawRules(); drawSink(); }), rHint); rw.append(el("div", "hint", t(GROUP_NEST))); ed.append(el("label", "", t("Routing")), rw); + // one rate limited with quota left: its place, or the back + const kHint = el("div", "hint"), kw = el("div", "sink-wrap"), kLab = el("label", "", t("Rate limited")); + const drawSink = () => { + kHint.textContent = t(SINK_HINT[d.sink ? "sink" : ""]); + kLab.hidden = kw.hidden = !sinkable(d.routing); + }; + const kPick = segs(SINK_OPTS.map(([id, n]) => [id, t(n)]), d.sink ? "sink" : "", (v) => { d.sink = v === "sink"; drawSink(); }); + kPick.classList.add("sink-pick"); + kw.append(kPick, kHint); + ed.append(kLab, kw); + drawSink(); const aHint = el("div", "hint", t(AFF_HINT[d.affinity] || AFF_HINT[""])); const aw = el("div"); aw.append(segs(AFF_OPTS.map(([id, n]) => [id, t(n)]), d.affinity, (v) => { d.affinity = v; aHint.textContent = t(AFF_HINT[v] || AFF_HINT[""]); }), aHint); @@ -2749,7 +2776,7 @@ if (own && !d.levels.length) return status(t("Pick a level to offer, or leave them to its models"), "warn"); saveBtn.classList.add("busy"); // refused, Add can be pressed again (busy, it takes no clicks) - groupAction("save", { id: idOf(), from: g?.id, name: d.name.trim() || idOf(), members: d.members, routing: d.routing, pick: d.pick || "", affinity: d.affinity, rules: d.rules, effort: d.effort, classifier: d.rules.some((r) => r.intent) || d.effort === "auto" ? d.classifier : "", context: g?.context || 0, levels: own ? d.levels : [], family: g?.family || "", fast: d.fast.filter((x) => d.members.includes(x)), off: d.off.filter((x) => d.members.includes(x)) }, t(g ? "{name} saved" : "{name} added", { name: d.name.trim() || idOf() })) + groupAction("save", { id: idOf(), from: g?.id, name: d.name.trim() || idOf(), members: d.members, routing: d.routing, pick: d.pick || "", affinity: d.affinity, sink: !!d.sink && sinkable(d.routing), rules: d.rules, effort: d.effort, classifier: d.rules.some((r) => r.intent) || d.effort === "auto" ? d.classifier : "", context: g?.context || 0, levels: own ? d.levels : [], family: g?.family || "", fast: d.fast.filter((x) => d.members.includes(x)), off: d.off.filter((x) => d.members.includes(x)) }, t(g ? "{name} saved" : "{name} added", { name: d.name.trim() || idOf() })) .then(() => saveBtn.classList.remove("busy")); }; // what goes wrong is said where it is seen, never a click that does nothing @@ -2780,6 +2807,12 @@ ctl.append( lab(t("Routing"), segs(ROUTE_OPTS.map(([id, n]) => [id, t(n)]), p.routing || "", (v) => set("route", { id: p.provider, routing: v }, t("{name}: {routing}", { name: p.name, routing: t(ROUTE_OPTS.find(([id]) => id === v)[1]) })))), lab(t("Stays"), segs(AFF_OPTS.map(([id, n]) => [id, t(n)]), p.affinity || "", (v) => set("affinity", { id: p.provider, affinity: v }, t("{name}: {routing}", { name: p.name, routing: t(AFF_OPTS.find(([id]) => id === v)[1]) }))))); + if (sinkable(p.routing || "")) { + const k = lab(t("Rate limited"), segs(SINK_OPTS.map(([id, n]) => [id, t(n)]), p.sink ? "sink" : "", (v) => set("sink", { id: p.provider, sink: v === "sink" }, t("{name}: {routing}", { name: p.name, routing: t(SINK_OPTS.find(([id]) => id === v)[1]) })))); + k.classList.add("sink-pick"); + k.title = t(SINK_HINT[p.sink ? "sink" : ""]); + ctl.append(k); + } row.append(nm, ctl); row.title = p.who.join(", "); return row; diff --git a/internal/gui/groups.go b/internal/gui/groups.go index e0fed60f..abba365f 100644 --- a/internal/gui/groups.go +++ b/internal/gui/groups.go @@ -88,6 +88,7 @@ type poolJSON struct { Who []string `json:"who"` Routing string `json:"routing"` Affinity string `json:"affinity"` + Sink bool `json:"sink,omitempty"` // provider.Provider.Sink // Protocol: the one its keys are made for, when the provider's keys are // made for more than one — each protocol's keys are a pool of their own Protocol provider.Protocol `json:"protocol,omitempty"` @@ -215,13 +216,13 @@ func groupsState() groupsJSON { continue } if kind, who := onOf(p); kind == "account" && len(who) > 1 { - out.Pools = append(out.Pools, poolJSON{Provider: p.ID, Name: p.Name, Icon: p.Icon, Kind: kind, Who: who, Routing: p.Routing, Affinity: p.Affinity}) + out.Pools = append(out.Pools, poolJSON{Provider: p.ID, Name: p.Name, Icon: p.Icon, Kind: kind, Who: who, Routing: p.Routing, Affinity: p.Affinity, Sink: p.Sink}) continue } pools := keyPools(p) for _, kp := range pools { if len(kp.Who) > 1 { - kp.Provider, kp.Name, kp.Icon, kp.Kind, kp.Routing, kp.Affinity = p.ID, p.Name, p.Icon, "key", p.Routing, p.Affinity + kp.Provider, kp.Name, kp.Icon, kp.Kind, kp.Routing, kp.Affinity, kp.Sink = p.ID, p.Name, p.Icon, "key", p.Routing, p.Affinity, p.Sink if len(pools) == 1 { kp.Protocol = "" } diff --git a/internal/gui/providers.go b/internal/gui/providers.go index eade75c0..735479d3 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -101,6 +101,9 @@ type providerJSON struct { Fallback []string `json:"fallback"` // where requests go when this one can't take them Routing string `json:"routing"` // how requests spread over its keys or accounts Affinity string `json:"affinity"` // how long a conversation stays with who answered it + // Sink: a key or account rate limited with quota left goes to the + // back of the order (provider.Provider.Sink) + Sink bool `json:"sink,omitempty"` // KeepLogin: magpie keeps Codex or Claude Code signed in to the first // account rather than moving it on when that runs low (#524) KeepLogin bool `json:"keepLogin,omitempty"` @@ -350,7 +353,7 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON { Catalog: p.Catalog, Website: p.Website, KeysURL: p.KeysURL, Proxy: p.Proxy, AccountProxies: p.AccountProxies, AccountModels: p.AccountModels, Headers: p.Headers, Searches: p.Searches, BalanceURL: p.BalanceURL, BalancePath: p.BalancePath, ModelsURL: p.ModelsURL, Ready: p.Ready(), Chosen: p.Models, Models: []modelJSON{}, Agents: []providerAgent{}, - Fallback: p.Fallback, Routing: p.Routing, Affinity: p.Affinity, KeepLogin: p.KeepLogin, KeepLoginAs: p.KeepLoginAs, Unlisted: p.Unlisted, Off: p.Off, Contexts: p.Contexts, + Fallback: p.Fallback, Routing: p.Routing, Sink: p.Sink, Affinity: p.Affinity, KeepLogin: p.KeepLogin, KeepLoginAs: p.KeepLoginAs, Unlisted: p.Unlisted, Off: p.Off, Contexts: p.Contexts, MaxConcurrency: p.MaxConcurrency, PluginConcurrency: p.PluginConcurrency(), } if out.Fallback == nil { @@ -725,6 +728,10 @@ func providerRoutes(mux *http.ServeMux, w Windows) { // sent it. Routing *string `json:"routing"` Affinity *string `json:"affinity"` + // Sink, for sink and save: whether one rate limited with + // quota left goes to the back (provider.Provider.Sink); a + // save that leaves it out keeps it + Sink *bool `json:"sink"` // Test, for test: models to send a request each, in place of // one per endpoint Test []string `json:"test"` @@ -754,6 +761,9 @@ func providerRoutes(mux *http.ServeMux, w Windows) { if req.Affinity != nil { in.Affinity = *req.Affinity } + if req.Sink != nil { + in.Sink = *req.Sink + } var moved []agent.Move switch r.PathValue("action") { case "show": @@ -898,6 +908,9 @@ func providerRoutes(mux *http.ServeMux, w Windows) { if req.Affinity == nil { in.Affinity = old.Affinity } + if req.Sink == nil { + in.Sink = old.Sink + } in.KeepLogin = old.KeepLogin // set on its own, with keeplogin in.KeepLoginAs = old.KeepLoginAs in.Off = old.Off // and this with off and on @@ -1016,6 +1029,11 @@ func providerRoutes(mux *http.ServeMux, w Windows) { fail(rw, err) return } + case "sink": + if err := provider.SetSink(in.ID, in.Sink); err != nil { + fail(rw, err) + return + } case "delete": var err error if moved, err = agent.Reseat(func() error { return provider.Delete(in.ID) }); err != nil { diff --git a/internal/gui/providers_routing_test.go b/internal/gui/providers_routing_test.go index cf6f0cbc..613c77bd 100644 --- a/internal/gui/providers_routing_test.go +++ b/internal/gui/providers_routing_test.go @@ -58,3 +58,48 @@ func TestProviderSaveRouting(t *testing.T) { post("save", `{`+form+`,"from":"relay","routing":"","affinity":""}`) is("", "") } + +// Sink (01huadalang: one rate limited with quota left goes to the back) +// is picked in the editor and made with its Save, or set on its own from +// the Routing page, and a Save that doesn't carry it keeps it. +func TestProviderSaveSink(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()) + mux := http.NewServeMux() + providerRoutes(mux, nil) + post := func(action, body string) { + t.Helper() + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest("POST", "/api/provider/"+action, strings.NewReader(body))) + if w.Code != 200 { + t.Fatalf("%s: %d %s", action, w.Code, w.Body) + } + } + is := func(want bool) { + t.Helper() + p, err := provider.Find("relay") + if err != nil { + t.Fatal(err) + } + if p.Sink != want { + t.Fatalf("sink %v, want %v", p.Sink, want) + } + if got := providerInfo(*p, nil).Sink; got != want { + t.Fatalf("listed sink %v, want %v", got, want) + } + } + const form = `"id":"relay","name":"Relay","chat":"http://127.0.0.1:1/v1","models":["sol"]` + post("save", `{`+form+`,"key":"k","new":true}`) + is(false) + post("save", `{`+form+`,"from":"relay","routing":"order","sink":true}`) + is(true) + post("save", `{`+form+`,"from":"relay"}`) + is(true) + post("sink", `{"id":"relay","sink":false}`) + is(false) + post("sink", `{"id":"relay","sink":true}`) + post("save", `{`+form+`,"from":"relay"}`) + is(true) +} diff --git a/internal/gui/tests/rate-limited-sink.test.cjs b/internal/gui/tests/rate-limited-sink.test.cjs new file mode 100644 index 00000000..3b02db95 --- /dev/null +++ b/internal/gui/tests/rate-limited-sink.test.cjs @@ -0,0 +1,183 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// 01huadalang (WorkBuddy, several accounts): an account rate limited while +// it still has quota goes back to the front once its rest ends, so one +// account takes everything until it is limited again — what a vendor's +// risk control notices. "Rate limited" beside a provider's Routing (its +// editor, made with its Save; the Routing page's row of providers with +// several accounts, at once) and a group's: "Keeps its place", the +// default, or "Goes to the back", which sends it behind every one not rate +// limited since. Hidden for In turn, which goes round already. The Routing +// page says which one is at the back and since when. No native select, and +// a click never scrolls the page. English and Chinese. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const now = Date.now(); +const iso = (ms) => new Date(ms).toISOString(); +const base = { icon: "generic", catalog: "", models: [{ id: "glm-5", name: "GLM-5", on: true }], agents: [], fallback: [], headers: {}, routing: "order", affinity: "", ready: true }; +const sub = { + ...base, id: "workbuddy-ai", name: "WorkBuddy AI", chat: "", responses: "", anthropic: "", keyList: [], + account: { + agent: "workbuddy-ai", agentName: "WorkBuddy AI", user: "one@example.test", plan: "Pro", + logins: ["one", "two", "three"].map((n, i) => ({ user: `${n}@example.test`, plan: "Pro", active: i === 0, on: true })), + }, +}; +const members = ["workbuddy-ai/glm-5", "rl/glm-5"]; +const groups = () => ({ + models: [{ id: "workbuddy-ai/glm-5", name: "glm-5", providerName: "WorkBuddy AI", icon: "generic" }, { id: "rl/glm-5", name: "glm-5", providerName: "Relay", icon: "generic" }], + pools: [{ provider: "workbuddy-ai", name: "WorkBuddy AI", icon: "generic", kind: "account", who: ["one@example.test", "two@example.test", "three@example.test"], routing: "order", affinity: "" }], + groups: [{ id: "glm", name: "GLM", members, fast: [], off: [], routing: "order", sink: true, ready: true, memberInfo: members.map((id) => ({ id, ready: true })) }], +}); +const sunkAt = now - 60e3; +const order = ["two", "three", "one"].map((n, i) => ({ + id: `workbuddy-ai@${n}@example.test`, provider: "workbuddy-ai", name: "WorkBuddy AI", who: `${n}@example.test`, kind: "account", model: "glm-5", routing: "order", shared: true, rank: i, + ...(n === "one" ? { sunk: iso(sunkAt) } : {}), +})); +const route = { + id: 7, seq: 7, time: iso(now - 2000), agent: "pi", model: "glm-5", provider: "workbuddy-ai", done: true, status: 200, ms: 900, tokens: 1500, + order, tries: [{ id: order[0].id, model: "glm-5", start: iso(now - 2000), done: true, ms: 800, status: 200 }], +}; + +function serve(lang, posts) { + const state = { agents: [{ id: "pi", name: "Pi", path: "/test/pi", fields: [] }], profiles: [], settings: { lang, theme: "light" } }; + const providers = { providers: [sub], presets: [], excluded: [], gateway: { running: true, window: true, url: "http://127.0.0.1:3999" } }; + return async (r) => { + const url = new URL(r.request().url()); + const json = (data) => r.fulfill({ json: data }); + if (url.pathname === "/boot.js") return r.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:${JSON.stringify(lang)},theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return r.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json(state); + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + if (url.pathname === "/api/gateway/trace") { + if (url.searchParams.get("wait")) return new Promise(() => {}); // nothing more comes + return json({ mine: true, now: new Date().toISOString(), seq: 7, totals: { requests: 1, rerouted: 0, errors: 0 }, routes: [route] }); + } + if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] }); + if (url.pathname === "/api/groups") return json(groups()); + if (url.pathname === "/api/providers") return json(providers); + if (r.request().method() === "POST" && (url.pathname.startsWith("/api/provider") || url.pathname.startsWith("/api/groups/"))) { + posts.push({ path: url.pathname, body: JSON.parse(r.request().postData() || "{}") }); + return json(url.pathname.startsWith("/api/groups/") ? groups() : providers); + } + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + try { await r.fulfill({ body: await fs.readFile(file), contentType }); } catch { await r.fulfill({ status: 404, body: "" }); } + }; +} + +const words = { + en: { label: "Rate limited", keep: "Keeps its place", back: "Goes to the back", turn: "In turn", order: "In order", save: "Save", edit: "Edit", unsaved: "unsaved", tag: "Rate limited to the back", row: "at the back" }, + zh: { label: "被限流时", keep: "保持原位", back: "排到最后", turn: "轮流", order: "按顺序", save: "保存", edit: "编辑", unsaved: "未保存", tag: "限流后排到最后", row: "排在最后" }, +}; +const KEYS = [ + "Rate limited", "Keeps its place", "Goes to the back", "Rate limited to the back", "rate limited at {time} · at the back", + "One rate limited while it still has quota rests as long as the vendor asks, then takes its place in the order again.", + "One rate limited (429) while it still has quota goes to the back of the order, behind every one not rate limited since, and comes round again once those ahead of it are rate limited in turn — so the load goes round rather than back to the first each time. Out of quota, it rests as usual. Kept until magpie restarts.", + "{who} was rate limited at {time} while it had quota left, so it went to the back: it comes round again once those ahead of it are rate limited in turn.", +]; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + const w = words[lang], zh = lang === "zh"; + const launch = () => engine === "webkit" ? webkit.launch() : chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || "chromium" }); + const said = (page, s) => page.evaluate(([k, zh]) => zh ? I18N.zh[k] : k, [s, zh]); + + test(`${engine} ${lang}: a provider's rate limited accounts go to the back, made with its Save`, async (t) => { + const browser = await launch(); + t.after(() => browser.close()); + const page = await (await browser.newContext({ viewport: { width: 900, height: 720 }, reducedMotion: "reduce" })).newPage(); + page.setDefaultTimeout(5000); + const errors = [], posts = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, posts)); + await page.goto("http://magpie.test/?view=providers"); + await page.locator(".row.provider", { hasText: sub.name }).first().click(); + await page.locator(".editor .accts").first().waitFor(); + assert.deepEqual(await page.evaluate((keys) => keys.filter((k) => !I18N.zh[k]), KEYS), [], "every string has its Chinese"); + const label = page.locator(".editor label", { hasText: new RegExp(`^${w.label}$`) }); + const field = label.locator("xpath=following-sibling::div[1]"); + const opt = (name) => field.locator(".segs .opt", { hasText: new RegExp(`^${name}$`) }); + const routing = (name) => page.locator(".editor .segs .opt", { hasText: new RegExp(`^${name}$`) }).first(); + assert.equal(await page.locator(".editor select").count(), 0, "no native select"); + assert.match(await opt(w.keep).getAttribute("class"), /\bon\b/, "keeps its place by default"); + assert.equal(await field.locator(".hint").last().textContent(), await said(page, "One rate limited while it still has quota rests as long as the vendor asks, then takes its place in the order again.")); + const y = await page.evaluate(() => document.scrollingElement.scrollTop); + await opt(w.back).click(); + assert.match(await opt(w.back).getAttribute("class"), /\bon\b/); + assert.match(await field.locator(".hint").last().textContent(), zh ? /排到最后/ : /goes to the back of the order/); + assert.equal(await field.getByText(w.unsaved, { exact: true }).isVisible(), true); + // in turn goes round already: nothing to pick + await routing(w.turn).click(); + assert.equal(await label.isVisible(), false); + await routing(w.order).click(); + assert.equal(await label.isVisible(), true); + assert.equal(await page.evaluate(() => document.scrollingElement.scrollTop), y, "a click never scrolls the page"); + assert.deepEqual(posts, [], "nothing is sent before the Save"); + await page.getByRole("button", { name: w.save, exact: true }).click(); + await page.waitForFunction(() => !document.querySelector(".editor")); + assert.deepEqual(posts.map((x) => x.path), ["/api/provider/save"]); + assert.equal(posts[0].body.sink, true); + assert.deepEqual(errors, []); + }); + + test(`${engine} ${lang}: the Routing page sets it and says who is at the back`, async (t) => { + const browser = await launch(); + t.after(() => browser.close()); + const page = await (await browser.newContext({ viewport: { width: 1100, height: 1400 }, reducedMotion: "reduce" })).newPage(); + page.setDefaultTimeout(5000); + const errors = [], posts = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, posts)); + await page.goto("http://magpie.test/?view=routing"); + + // the one at the back says so, and since when + const stage = page.locator("li", { has: page.locator(".who", { hasText: "one@example.test" }) }).first(); + await stage.waitFor(); + await page.waitForFunction((s) => document.body.innerText.includes(s), w.row); + assert.match(await stage.textContent(), new RegExp(w.row)); + await page.waitForFunction((s) => document.body.innerText.includes(s), zh ? "所以排到了最后" : "so it went to the back"); + + // the providers with several accounts: set at once + const pool = page.locator(".rt-pool", { hasText: "WorkBuddy AI" }); + await pool.waitFor(); + const y = await page.evaluate(() => document.scrollingElement.scrollTop); + await pool.locator(".sink-pick .opt", { hasText: new RegExp(`^${w.back}$`) }).click(); + await page.waitForTimeout(200); + const sent = posts.find((p) => p.path === "/api/provider/sink"); + assert.deepEqual(sent?.body, { id: "workbuddy-ai", sink: true }); + + // the group's card says it; its editor has it, kept by the Save + const card = page.locator(".rt-group", { hasText: "GLM" }); + await card.locator(".tag", { hasText: w.tag }).waitFor(); + await card.locator("button", { hasText: w.edit }).click(); + const ed = page.locator(".rt-gedit"); + const label = ed.locator("label", { hasText: new RegExp(`^${w.label}$`) }); + const field = label.locator("xpath=following-sibling::div[1]"); + await label.waitFor(); + assert.match(await field.locator(".opt", { hasText: new RegExp(`^${w.back}$`) }).getAttribute("class"), /\bon\b/); + await ed.locator(".segs .opt", { hasText: new RegExp(`^${w.turn}$`) }).first().click(); + assert.equal(await label.isVisible(), false, "in turn: nothing to pick"); + await ed.locator(".segs .opt", { hasText: new RegExp(`^${w.order}$`) }).first().click(); + assert.equal(await label.isVisible(), true); + assert.equal(await page.evaluate(() => document.scrollingElement.scrollTop), y, "a click never scrolls the page"); + assert.equal(await page.locator("select").count(), 0, "no native select"); + const b = ed.locator("button.primary", { hasText: w.save }); + await page.mouse.move(550, 600); // the reader scrolls to it + for (let i = 0; i < 10 && (await b.boundingBox()).y > 1400 - 160; i++) { + await page.mouse.wheel(0, 300); + await page.waitForTimeout(150); + } + const bb = await b.boundingBox(); + await page.mouse.click(bb.x + bb.width / 2, bb.y + bb.height / 2); + await page.waitForFunction(() => !document.querySelector(".rt-gedit")); + const saved = posts.find((p) => p.path === "/api/groups/save"); + assert.equal(saved.body.sink, true); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/gui/tests/routing-compact.test.cjs b/internal/gui/tests/routing-compact.test.cjs index 2877928c..da0c058f 100644 --- a/internal/gui/tests/routing-compact.test.cjs +++ b/internal/gui/tests/routing-compact.test.cjs @@ -101,7 +101,16 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", assert.equal(await rows.first().locator(".rt-rwarn").textContent(), w.skip); // a second rule, compacting alone: toggled with nothing moved - await ed.locator("button", { hasText: w.add }).click(); + // (wheeled down to, as the reader would: the editor runs past the + // window and the page keeps still for any other scroll) + const add = ed.locator("button", { hasText: w.add }); + await page.mouse.move(550, 600); + for (let i = 0; i < 10 && (await add.boundingBox()).y > 1400 - 160; i++) { + await page.mouse.wheel(0, 300); + await page.waitForTimeout(150); + } + const ab = await add.boundingBox(); + await page.mouse.click(ab.x + ab.width / 2, ab.y + ab.height / 2); const second = rows.nth(1).locator("button.rt-cond", { hasText: w.cond }); await second.waitFor(); const at = () => page.evaluate(() => [...document.querySelectorAll("*")].filter((e) => e.scrollTop).map((e) => [e.id || e.className, e.scrollTop]).join(";") + "|" + Math.round(document.querySelector(".rt-gedit").getBoundingClientRect().top)); @@ -121,8 +130,10 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", // other scroll, and the mouse where it is: the browsers' scroll // into view before a click can leave it under the footer) await page.mouse.move(550, 600); - await page.mouse.wheel(0, 600); - await page.waitForTimeout(300); + for (let i = 0; i < 10 && (await saveBtn.boundingBox()).y > 1400 - 160; i++) { + await page.mouse.wheel(0, 300); + await page.waitForTimeout(150); + } const sb = await saveBtn.boundingBox(); await page.mouse.click(sb.x + sb.width / 2, sb.y + sb.height / 2); await page.waitForFunction(() => !document.querySelector(".rt-gedit")); diff --git a/internal/provider/group.go b/internal/provider/group.go index 0d69aadf..2f3aca3e 100644 --- a/internal/provider/group.go +++ b/internal/provider/group.go @@ -103,6 +103,10 @@ type Group struct { Members []string `json:"members"` // "provider/model[:effort]" or "group/", in order (see MemberEffort) Routing string `json:"routing,omitempty"` // as Provider.Routing, over all the members' keys and accounts; or Manual Affinity string `json:"affinity,omitempty"` // as Provider.Affinity + // Sink is Provider.Sink over the group's members' accounts and keys + // together: one rate limited with quota left goes behind every other + // member's. Not for "rotate" or Manual. + Sink bool `json:"sink,omitempty"` // Off are the members switched off: kept where they are in the // order, with their rules, but sent nothing until switched on again, // so trying a group without one doesn't mean taking it out. diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 0821bb2b..4573153b 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -85,6 +85,14 @@ type Provider struct { // failing is passed over for as long as that lasts. Routing string `json:"routing,omitempty"` + // Sink sends a key or account that a 429 rate limited while it still + // had quota to the back of the order Routing gives (01huadalang): it + // is tried again only after every one not rate limited since — so the + // load goes round, rather than back to the first each time its rest + // ends. Off, the default, it keeps its place. Not for "rotate", which + // goes round already. See gateway.sink. + Sink bool `json:"sink,omitempty"` + // Affinity is how long a conversation stays with the key or account // that answered it, so the vendor's prompt cache it filled is read // again rather than lost (see Affinities): "" auto, "session", @@ -318,6 +326,7 @@ func All() []Provider { } pk := picks[a.ID] a.Models, a.Unlisted, a.Off, a.Fallback, a.Routing, a.Affinity, a.KeepLogin, a.KeepLoginAs, a.Contexts, a.Family = pk.Models, pk.Unlisted, pk.Off, pk.Fallback, pk.Routing, pk.Affinity, pk.KeepLogin, pk.KeepLoginAs, pk.Contexts, pk.Family + a.Sink = pk.Sink a.Proxy, a.AccountProxies, a.AccountModels = pk.Proxy, pk.AccountProxies, pk.AccountModels a.MaxConcurrency = pk.MaxConcurrency if a.ID == "cursor" { // picked before its efforts were one model @@ -421,7 +430,7 @@ func Save(p Provider) error { if p.ID == "kiro" { key = p.Key } - p = Provider{ID: p.ID, Key: key, Models: p.Models, Unlisted: p.Unlisted, Off: p.Off, Fallback: p.Fallback, Routing: p.Routing, Affinity: p.Affinity, KeepLogin: p.KeepLogin, KeepLoginAs: p.KeepLoginAs, Contexts: p.Contexts, Family: p.Family, Proxy: p.Proxy, AccountProxies: p.AccountProxies, AccountModels: p.AccountModels, MaxConcurrency: p.MaxConcurrency, Hidden: hiddenAccount(p.ID), Quiet: quietAccount(p.ID), Tucked: tuckedAccount(p.ID)} + p = Provider{ID: p.ID, Key: key, Models: p.Models, Unlisted: p.Unlisted, Off: p.Off, Fallback: p.Fallback, Routing: p.Routing, Sink: p.Sink, Affinity: p.Affinity, KeepLogin: p.KeepLogin, KeepLoginAs: p.KeepLoginAs, Contexts: p.Contexts, Family: p.Family, Proxy: p.Proxy, AccountProxies: p.AccountProxies, AccountModels: p.AccountModels, MaxConcurrency: p.MaxConcurrency, Hidden: hiddenAccount(p.ID), Quiet: quietAccount(p.ID), Tucked: tuckedAccount(p.ID)} } else { p.AccountProxies = nil // a provider of a key has no accounts to proxy apart if subscriptionID(p.ID) && !stored(p.ID) { @@ -506,7 +515,7 @@ func AddCopy(p Provider, from string) (string, error) { if p.Key == "" { p.Key, p.KeyName, p.KeyProtocol = src.Key, src.KeyName, src.KeyProtocol p.Keys = slices.Clone(src.Keys) - p.Routing, p.Affinity = src.Routing, src.Affinity + p.Routing, p.Sink, p.Affinity = src.Routing, src.Sink, src.Affinity } if p.BalanceToken == "" { p.BalanceToken = src.BalanceToken diff --git a/internal/provider/routing.go b/internal/provider/routing.go index c7a8e503..22e64afb 100644 --- a/internal/provider/routing.go +++ b/internal/provider/routing.go @@ -37,6 +37,18 @@ func SetRouting(id, routing string) error { return Save(*p) } +// SetSink sends a provider's keys or accounts rate limited with quota +// left to the back of its order, or lets them keep their place +// (Provider.Sink). +func SetSink(id string, sink bool) error { + p, err := Find(id) + if err != nil { + return err + } + p.Sink = sink + return Save(*p) +} + // SetKeepLogin keeps Codex or Claude Code signed in to the account the // user made first (Provider.KeepLogin), or lets magpie move it on again. // Either way it is no longer kept on one of the user's choosing