routing: a group in a group is routed by its own routing whatever the outer group's, and an account with five hours and no week is ranked by its five hours (#576: 只有五小时额度的账号在智能/重置前用完下是否合理;嵌套路由组时上层路由是否会干预下层) — a group routed other than In order (Smart, In turn, Least used, Weekly pace, Manual) put every account and key of every group in it into one pool and weighed them together, so A (Smart) holding A-1 and A-2 (each In order) ranked all five of their accounts and keys by Smart and A-1's and A-2's own In order did nothing; only an outer In order let each inner group route itself. planLevel now plans each group in the group by its own routing, whatever the outer's, and weighs it as one beside the outer group's own models' keys, by the first of its own order that isn't resting; where that lands, the inner group is tried whole in its own order, the trace in the same order. As for the five hours: Smart already ranked such an account (Claude Enterprise) by its five hours, the biggest window it has, so ahead of every week renewing later, but a five hours not started (no reset told) counted as a reset not known and went after every week; Allowance.Renewal now has a window not started that says its span renew that long from now, which Smart sorts by (the trace's renews unchanged). Weekly pace took an account with no week as a fresh week with the share used (100/168 per hour), so a week with 80% left and five days to go went ahead of an Enterprise account whose five hours were being lost; it now goes by what its five hours have left per hour until they renew, the whole span when not started, except where the reading may leave a week out — a Claude account only heard of from Claude Code's rate_limit_event, never read whole by /usage (QuotaWindow.partial, claudeUsageEntry.whole) — which keeps the fresh week. The group editor's Smart and Weekly pace hints and the trace's say how an account with five hours and no week is ranked, a hint under Routing says a group among the models keeps its own routing, and the docs (en, zh) say both. TestGroupInGroupKeepsItsOwnRouting (Outer rotating over two In order groups: b c d a without the change, c d a b with it), TestSmartFiveHoursAlone (a five hours not started went after a week in two days), TestFiveHoursAlone, TestClaudeHeardOnlyIsPartial; TestWeeklyPace and TestAllowancePace now pin the five hours alone. settings-groups, group-member-fast, group-found-off, routing-idle-frame pass in WebKit. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestFXInState, TestProvidersAnswerWhileListsComeIn, TestUserPath, TestClaudeSignedOut flaked; the last passes 3/3 alone).
This commit is contained in:
1 parent
2e1fcb8a77
commit
a802d98bfc
13 files changed
+355
-55
No files matched your search
@@ -290,9 +290,11 @@ func (s *Server) plan(p provider.Provider, model string, from provider.Protocol)
|
||||
// weighed together as the group's routing says — in order, member by
|
||||
// member, each as its own provider's routing orders it; else all as one,
|
||||
// so a subscription whose allowance renews soonest goes first whichever
|
||||
// provider it is of. A group in the group is planned the same way by its
|
||||
// own routing, in its place when the group's is in order. A member's
|
||||
// fallbacks are not the group's.
|
||||
// provider it is of. A group in the group is planned by its own routing
|
||||
// alone, whatever the group's (#576): in order, in its place; else as
|
||||
// one, weighed with the rest by the one it would try first, and tried
|
||||
// whole where that one goes — the group's routing picks between its
|
||||
// groups, never within them. A member's fallbacks are not the group's.
|
||||
func (s *Server) planGroup(g provider.Group, ms []provider.Member, from provider.Protocol) ([]candidate, planned) {
|
||||
var pl planned
|
||||
var asides []candidate
|
||||
@@ -332,28 +334,74 @@ func planLevel(g provider.Group, ms []provider.Member, depth int, from provider.
|
||||
return cs
|
||||
}
|
||||
if g.Routing != provider.Ordered {
|
||||
of := map[string]provider.Member{} // candidate → its model
|
||||
var all []candidate
|
||||
for _, m := range ms {
|
||||
cs := keys(m)
|
||||
for _, c := range cs {
|
||||
of[c.seat()] = m
|
||||
// what the group weighs: each of its models' keys or accounts,
|
||||
// and each group in it as one — planned by its own routing, put
|
||||
// where the one it would try first is, the rest of it after
|
||||
type unit struct {
|
||||
m provider.Member // the model, for one of its keys
|
||||
cs []candidate // a group in g: its own order
|
||||
order []Weighed
|
||||
}
|
||||
var units []unit
|
||||
var heads []candidate
|
||||
at := map[string][]int{} // a head's seat → its units, in turn
|
||||
add := func(u unit, head candidate) {
|
||||
at[head.seat()] = append(at[head.seat()], len(units))
|
||||
units, heads = append(units, u), append(heads, head)
|
||||
}
|
||||
for i := 0; i < len(ms); {
|
||||
m := ms[i]
|
||||
if len(m.Path) > depth+1 {
|
||||
j := i + 1
|
||||
for j < len(ms) && len(ms[j].Path) > depth+1 && ms[j].Path[depth] == m.Path[depth] {
|
||||
j++
|
||||
}
|
||||
var sub planned
|
||||
cs := planLevel(m.Via[depth], ms[i:j], depth+1, from, &sub, asides, wAsides)
|
||||
pl.left = append(pl.left, sub.left...)
|
||||
if len(cs) > 0 {
|
||||
// weighed by the first it would try that isn't resting
|
||||
head := cs[0]
|
||||
for _, c := range cs {
|
||||
if _, resting := restOf(c.restKey()); !resting {
|
||||
if _, resting = restOf(c.restID()); !resting {
|
||||
head = c
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
add(unit{cs: cs, order: sub.order}, head)
|
||||
}
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
all = append(all, cs...)
|
||||
for _, c := range keys(m) {
|
||||
add(unit{m: m}, c)
|
||||
}
|
||||
i++
|
||||
}
|
||||
routing := g.Routing
|
||||
if routing == provider.Manual {
|
||||
routing = "" // the member picked, its keys or accounts weighed smartly
|
||||
}
|
||||
cs, wg := weigh(provider.Provider{ID: provider.GroupPrefix + g.ID, Routing: routing}, all, "", from)
|
||||
for i, c := range cs {
|
||||
m := of[c.seat()]
|
||||
w := weighed(c, m.Provider, wg, false, from)
|
||||
w.Routing, w.Via = g.Routing, m.Groups()
|
||||
w.Turn = i == 0 && g.Routing == provider.Rotate && len(cs) > 1
|
||||
weighedHeads, wg := weigh(provider.Provider{ID: provider.GroupPrefix + g.ID, Routing: routing}, heads, "", from)
|
||||
var out []candidate
|
||||
for i, c := range weighedHeads {
|
||||
k := at[c.seat()][0]
|
||||
at[c.seat()] = at[c.seat()][1:]
|
||||
u := units[k]
|
||||
if u.cs != nil {
|
||||
out = append(out, u.cs...)
|
||||
pl.order = append(pl.order, u.order...)
|
||||
continue
|
||||
}
|
||||
w := weighed(c, u.m.Provider, wg, false, from)
|
||||
w.Routing, w.Via = g.Routing, u.m.Groups()
|
||||
w.Turn = i == 0 && g.Routing == provider.Rotate && len(weighedHeads) > 1
|
||||
pl.order = append(pl.order, w)
|
||||
out = append(out, c)
|
||||
}
|
||||
return cs
|
||||
return out
|
||||
}
|
||||
var out []candidate
|
||||
for i := 0; i < len(ms); {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -75,3 +76,62 @@ func TestGroupsInGroups(t *testing.T) {
|
||||
t.Fatal(o)
|
||||
}
|
||||
}
|
||||
|
||||
// A group in a group is routed by its own routing whatever the group's
|
||||
// (#576): the group's routing picks among its groups, as one each, never
|
||||
// within them. Outer rotating over One and Two, each in order, turns from
|
||||
// One to Two, each tried in its own order — not over all four models,
|
||||
// which would turn to b, One's second, and put a before c.
|
||||
func TestGroupInGroupKeepsItsOwnRouting(t *testing.T) {
|
||||
fresh(t)
|
||||
for _, id := range []string{"a", "b", "c", "d"} {
|
||||
serveOn(t, id, "k"+id, []string{"m"}, &keyed{})
|
||||
}
|
||||
if err := provider.SaveGroup(provider.Group{Name: "One", Members: []string{"a/m", "b/m"}, Routing: provider.Ordered}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := provider.SaveGroup(provider.Group{Name: "Two", Members: []string{"c/m", "d/m"}, Routing: provider.Ordered}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := provider.SaveGroup(provider.Group{Name: "Outer", Members: []string{"group/one", "group/two"}, Routing: provider.Rotate}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := New()
|
||||
order := func() string {
|
||||
r := s.trace.routes[len(s.trace.routes)-1]
|
||||
var out []string
|
||||
for _, w := range r.Order {
|
||||
out = append(out, w.Provider+"["+strings.Join(w.Via, ",")+"]")
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
want := []string{
|
||||
"a[one] b[one] c[two] d[two]",
|
||||
"c[two] d[two] a[one] b[one]",
|
||||
"a[one] b[one] c[two] d[two]",
|
||||
}
|
||||
for i, w := range want {
|
||||
code, body := postAs(t, s, "turn"+string(rune('a'+i)), `{"model":"group/outer","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("%d %s", code, body)
|
||||
}
|
||||
if o := order(); o != w {
|
||||
t.Fatalf("request %d: %s, want %s", i+1, o, w)
|
||||
}
|
||||
}
|
||||
|
||||
// a model of Outer's own is weighed beside its groups, one key each
|
||||
top, _, _ := provider.FindGroup("group/outer")
|
||||
top.Members = []string{"group/one", "c/m"}
|
||||
if err := provider.SaveGroup(top); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for i := range 2 {
|
||||
postAs(t, s, "own"+string(rune('a'+i)), `{"model":"group/outer","messages":[{"role":"user","content":"hi"}]}`)
|
||||
got = append(got, order())
|
||||
}
|
||||
if !slices.Contains(got, "a[one] b[one] c[]") || !slices.Contains(got, "c[] a[one] b[one]") {
|
||||
t.Fatalf("own model beside a group: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -490,8 +490,9 @@ func (c candidate) allowanceKey() allowanceKey {
|
||||
type left struct {
|
||||
used float64
|
||||
renews []time.Time
|
||||
pace float64 // weekly pace: share of its week left per hour until it renews
|
||||
due time.Time // when the window that pace went by renews; zero when not known
|
||||
soon []time.Time // renews as Smart ranks them (Allowance.Renewal)
|
||||
pace float64 // weekly pace: share of its week left per hour until it renews
|
||||
due time.Time // when the window that pace went by renews; zero when not known
|
||||
}
|
||||
|
||||
// learns: c is a subscription whose allowance isn't known yet, of an agent
|
||||
@@ -523,7 +524,7 @@ func weigh(p provider.Provider, cs []candidate, model string, from provider.Prot
|
||||
if a, ok := known[ag][c.p.Account.User]; ok {
|
||||
u, r := a.For(c.model, now)
|
||||
pc, due := a.Pace(c.model, now)
|
||||
wg.lefts[c.allowanceKey()] = left{u, r, pc, due} // one not known counts as unused
|
||||
wg.lefts[c.allowanceKey()] = left{u, r, a.Renewal(c.model, now), pc, due} // one not known counts as unused
|
||||
}
|
||||
}
|
||||
lefts := wg.lefts
|
||||
@@ -534,7 +535,10 @@ func weigh(p provider.Provider, cs []candidate, model string, from provider.Prot
|
||||
// soonest, since what it has left is lost then, while one renewing
|
||||
// later keeps: the biggest window decides — the week, not the five
|
||||
// hours in it — and the next one only when that renews in the
|
||||
// same hour. Those alike stay in their order, keeping the vendor's
|
||||
// same hour. An account with no week (Claude Enterprise's five
|
||||
// hours alone) goes by its five hours, so ahead of every week
|
||||
// but one renewing sooner; a window not started renews its whole
|
||||
// span from now (#576). Those alike stay in their order, keeping the vendor's
|
||||
// prompt cache warm. Past that, whichever has the most left, and
|
||||
// one all but used up only when nothing else can take it. Only the
|
||||
// windows that count the model do: Opus's own weekly allowance
|
||||
@@ -558,7 +562,7 @@ func weigh(p provider.Provider, cs []candidate, model string, from provider.Prot
|
||||
if li, lj := learns(fine[i], lefts), learns(fine[j], lefts); li != lj {
|
||||
return li
|
||||
}
|
||||
ri, rj := lefts[fine[i].allowanceKey()].renews, lefts[fine[j].allowanceKey()].renews
|
||||
ri, rj := lefts[fine[i].allowanceKey()].soon, lefts[fine[j].allowanceKey()].soon
|
||||
for k := 0; k < len(ri) || k < len(rj); k++ {
|
||||
var a, b time.Time // to the hour, so a few minutes don't reorder
|
||||
if k < len(ri) {
|
||||
|
||||
@@ -262,3 +262,41 @@ func TestUnknownClaudeGoesFirst(t *testing.T) {
|
||||
t.Fatalf("other agent: %+v", w)
|
||||
}
|
||||
}
|
||||
|
||||
// Smart with an account that has the five hours alone (Claude
|
||||
// Enterprise): they renew soonest, so it goes first, ahead of weeks — but
|
||||
// behind a week renewing sooner still — and so does it with its five
|
||||
// hours not started, which renew within five hours of now (#576), not
|
||||
// after every week as a reset not known.
|
||||
func TestSmartFiveHoursAlone(t *testing.T) {
|
||||
old := allowances
|
||||
defer func() { allowances = old }()
|
||||
now := time.Now().Truncate(time.Hour).Add(20 * time.Minute)
|
||||
five, week := 5*time.Hour, 7*24*time.Hour
|
||||
weekly := func(in time.Duration) provider.Allowance {
|
||||
return provider.Allowance{
|
||||
{Used: 10, Resets: now.Add(time.Hour), Span: five},
|
||||
{Used: 30, Resets: now.Add(in), Span: week},
|
||||
}
|
||||
}
|
||||
share := map[string]provider.Allowance{
|
||||
"w": weekly(2 * 24 * time.Hour),
|
||||
"e": {{Used: 0, Span: five}}, // not started
|
||||
}
|
||||
allowances = func(string) map[string]provider.Allowance { return share }
|
||||
acct := func(user string) candidate {
|
||||
return candidate{p: provider.Provider{Account: &provider.Account{Agent: "x", User: user}}, model: "m", rest: "s#" + user}
|
||||
}
|
||||
p := provider.Provider{ID: "s", Account: &provider.Account{Agent: "x"}}
|
||||
if got := restsOf(route(p, []candidate{acct("w"), acct("e")}, "m", provider.Chat)); got != "s#e s#w " {
|
||||
t.Fatalf("five hours not started, ahead of a week in two days: %s", got)
|
||||
}
|
||||
share["e"] = provider.Allowance{{Used: 40, Resets: now.Add(3 * time.Hour), Span: five}}
|
||||
if got := restsOf(route(p, []candidate{acct("w"), acct("e")}, "m", provider.Chat)); got != "s#e s#w " {
|
||||
t.Fatalf("five hours started, ahead of a week in two days: %s", got)
|
||||
}
|
||||
share["w"] = weekly(time.Hour)
|
||||
if got := restsOf(route(p, []candidate{acct("e"), acct("w")}, "m", provider.Chat)); got != "s#w s#e " {
|
||||
t.Fatalf("a week renewing within the hour first: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,9 @@ import (
|
||||
// and five days. Alike within a tenth go by the tokens sent lately, then
|
||||
// keep their order; one at 90% or more of any window waits behind the
|
||||
// rest, one all but used up behind those, each by how far; one not known
|
||||
// counts as a fresh week, as does one whose vendor tells the five hours
|
||||
// alone, with their share used; one that tells as it answers goes first.
|
||||
// counts as a fresh week; one with the five hours alone goes by what
|
||||
// they have left per hour until they renew (#576); one that tells as it
|
||||
// answers goes first.
|
||||
//
|
||||
// PIN (2026-10-01): weekly pace is its own option, "pace"; Least used
|
||||
// keeps its most-left-first meaning and Smart is left as it is (yetone,
|
||||
@@ -151,14 +152,28 @@ func TestWeeklyPace(t *testing.T) {
|
||||
if got := order(acct("b@x.com"), acct("a@x.com")); got != "ab" {
|
||||
t.Fatalf("spent by how far: %s", got)
|
||||
}
|
||||
// the vendor tells the five hours alone (Claude's own usage command):
|
||||
// by what they have used, a fresh one as one not known
|
||||
share["a@x.com"] = provider.Allowance{{Used: 80, Resets: now.Add(time.Hour), Span: 5 * time.Hour}}
|
||||
share["b@x.com"] = provider.Allowance{{Used: 5, Resets: now.Add(time.Hour), Span: 5 * time.Hour}}
|
||||
share["c@x.com"] = provider.Allowance{{Used: 0, Resets: now.Add(time.Hour), Span: 5 * time.Hour}}
|
||||
// the account has the five hours alone (Claude Enterprise): what they
|
||||
// have left is lost within the hour, so it goes by that per hour,
|
||||
// ahead of the one not known (a fresh week); alike within a tenth
|
||||
// keep their order (#576)
|
||||
share["a@x.com"] = provider.Allowance{{Used: 80, Resets: now.Add(time.Hour), Span: 5 * time.Hour}} // 20 / 1 h
|
||||
share["b@x.com"] = provider.Allowance{{Used: 5, Resets: now.Add(time.Hour), Span: 5 * time.Hour}} // 95 / 1 h
|
||||
share["c@x.com"] = provider.Allowance{{Used: 0, Resets: now.Add(time.Hour), Span: 5 * time.Hour}} // 100 / 1 h
|
||||
delete(share, "d@x.com")
|
||||
if got := order(acct("a@x.com"), acct("d@x.com"), acct("c@x.com"), acct("b@x.com")); got != "dcba" {
|
||||
t.Fatalf("five hours alone go by their share used, the fresh with the unknown: %s", got)
|
||||
if got := order(acct("a@x.com"), acct("d@x.com"), acct("c@x.com"), acct("b@x.com")); got != "cbad" {
|
||||
t.Fatalf("five hours alone go by what they lose at their reset: %s", got)
|
||||
}
|
||||
// five hours alone, not started, against a week with 80% left and
|
||||
// five days to go: the five hours first (100 / 5 h beats 80 / 120 h);
|
||||
// a week with 40% left renewing in an hour beats them (40 / 1 h)
|
||||
share["a@x.com"] = provider.Allowance{{Used: 0, Span: 5 * time.Hour}}
|
||||
share["b@x.com"] = week(20, 5*24*time.Hour)
|
||||
if got := order(acct("b@x.com"), acct("a@x.com")); got != "ab" {
|
||||
t.Fatalf("five hours alone ahead of a far week: %s", got)
|
||||
}
|
||||
share["b@x.com"] = week(60, time.Hour)
|
||||
if got := order(acct("a@x.com"), acct("b@x.com")); got != "ba" {
|
||||
t.Fatalf("a week renewing sooner with more to lose first: %s", got)
|
||||
}
|
||||
// three paces a twelfth apart: alike pairwise all round, yet an order
|
||||
// — bands drawn from the top, so the first two are alike and the
|
||||
|
||||
@@ -1750,11 +1750,12 @@ const I18N = {
|
||||
"A conversation stays with the account or key that answered it for the whole session, while it can answer.": "整个会话都留在回答过它的账号或 Key 上,只要它还能回答。",
|
||||
"A conversation stays put within a turn, while the agent sends tool results back; when you speak again, routing decides afresh.": "一轮之内(Agent 回传工具结果时)不换;你再次发言时,路由重新决定。",
|
||||
"Every request is routed afresh, whoever answered its conversation before.": "每个请求都重新路由,不管之前是谁回答的这个会话。",
|
||||
"Smart, over every member's accounts and keys together: of the subscriptions with quota to spare, the one whose allowance renews soonest goes first; one resting after a failure goes last.": "智能:把所有成员的账号和 Key 放在一起排——还有余量的订阅里,额度最先重置的排第一;失败后在休息的排最后。",
|
||||
"Smart, over the members' accounts and keys together: of the subscriptions with quota to spare, the one whose allowance renews soonest goes first — the week decides, and an account with five hours and no week (Claude Enterprise) goes by its five hours, so ahead of every week renewing later; one resting after a failure goes last.": "智能:把成员的账号和 Key 放在一起排——还有余量的订阅里,额度最先重置的排第一:看周额度的重置时间;只有 5 小时额度、没有周额度的账号(如 Claude Enterprise)看它的 5 小时重置,所以排在所有更晚重置的周额度前面;失败后在休息的排最后。",
|
||||
"In order: the first model until it can't answer, then the next — each over its own accounts or keys as its provider routes them.": "按顺序:先用第一个模型,它答不了再换下一个——每个成员按其供应商的路由在自己的账号或 Key 间分配。",
|
||||
"In turn: each conversation's next turn goes to the next member's account or key, spreading the load.": "轮流:会话的每一轮交给下一个成员的账号或 Key,均摊负载。",
|
||||
"Least used first: the account or key with the most of its allowance left goes first.": "最少使用优先:剩余额度最多的账号或 Key 排第一。",
|
||||
"Weekly pace: the account with the most of its week left per hour until it renews goes first, so less of each member's week is lost at its reset; a key by the tokens magpie sent it lately.": "重置前用完:每周剩余额度除以距重置的小时数,最高的账号排第一,少让成员的周额度到重置时作废;Key 按 magpie 最近发给它的 token 数排。",
|
||||
"Weekly pace: the account with the most of its week left per hour until it renews goes first, so less of each member's week is lost at its reset — an account with five hours and no week (Claude Enterprise) by what its five hours have left per hour until they renew, so almost always first; a key by the tokens magpie sent it lately.": "重置前用完:每周剩余额度除以距重置的小时数,最高的账号排第一,少让成员的周额度到重置时作废——只有 5 小时额度、没有周额度的账号(如 Claude Enterprise)按 5 小时窗口的剩余额度除以距它重置的小时数算,所以几乎总排第一;Key 按 magpie 最近发给它的 token 数排。",
|
||||
"A group among the models keeps its own routing: this group only picks between it and the others, weighing it by the account or key it would try first, then tries it whole in its own order.": "模型里的路由组按它自己的路由走:本组只在它和其他成员之间挑——拿它自己会先用的那个账号或 Key 来比——选中后按它自己的顺序整体尝试,不会把它的成员拆开和本组其他成员混排。",
|
||||
"Mask the accounts on this page — email addresses and account names — for a screenshot to share": "给本页的账号(邮箱地址和账号名)打码,方便截图分享",
|
||||
"Refresh now, the allowances too; a Claude account's is read by running Claude Code's own /usage": "立即刷新,额度也一并重新读取;Claude 账号的额度通过运行 Claude Code 自带的 /usage 读取",
|
||||
"Least used": "最少使用",
|
||||
@@ -1822,12 +1823,12 @@ const I18N = {
|
||||
"Another magpie serves the gateway; its routing plays live in that magpie's window.": "网关由另一个 magpie 提供,它的路由在那个 magpie 的窗口里实时播放。",
|
||||
"The gateway isn't running, so nothing is routed.": "网关没有运行,因此没有任何路由。",
|
||||
"The gateway is served by a magpie without a window (magpie serve), so its routing can't be watched. Stop it and let this magpie serve the gateway to see routing live.": "网关由一个没有窗口的 magpie(magpie serve)提供,它的路由没法在这里看到。停掉它、让这个 magpie 来提供网关,就能实时看到路由。",
|
||||
"Smart: of the accounts with quota to spare, the one whose allowance renews soonest goes first — what it has left would be lost at the reset. One at 90% or more waits until the others can't answer; one resting after a failure goes last.": "智能:在还有余量的账号里,额度最先重置的排第一——它剩下的额度到重置时就作废了。用到 90% 以上的账号等其他账号都答不了时才用;失败后正在休息的排最后。",
|
||||
"Smart: of the accounts with quota to spare, the one whose allowance renews soonest goes first — what it has left would be lost at the reset. The week decides; an account with five hours and no week goes by its five hours. One at 90% or more waits until the others can't answer; one resting after a failure goes last.": "智能:在还有余量的账号里,额度最先重置的排第一——它剩下的额度到重置时就作废了。看周额度的重置时间;只有 5 小时额度、没有周额度的账号看它的 5 小时重置。用到 90% 以上的账号等其他账号都答不了时才用;失败后正在休息的排最后。",
|
||||
"Smart: keys that suit the request go first — one made for the model's own API — then in their order. One resting after a failure goes last.": "智能:适合这个请求的 Key 排前面——专为该模型原生 API 的 Key 优先——其余按顺序。失败后正在休息的排最后。",
|
||||
"In order: the first answers everything until it can't; then the next.": "按顺序:第一个处理所有请求,直到它不行了,再换下一个。",
|
||||
"In turn: each conversation's next turn goes to the account after the one that answered its last, and a new conversation starts one further along; the requests within a turn stay put, keeping the prompt cache.": "轮流:会话的下一轮交给上一轮回答者之后的那个账号,新会话从再下一个开始;一轮之内的请求不换账号,保住提示词缓存。",
|
||||
"Least used first: the account with the most of its allowance left goes first; a key by the tokens magpie sent it lately.": "最少使用优先:剩余额度最多的账号排第一;Key 按 magpie 最近发给它的 token 数排。",
|
||||
"Weekly pace: the account with the most of its week left per hour until it renews goes first — the one with the most to lose at its reset. One at 90% or more waits until the others can't answer; a key by the tokens magpie sent it lately.": "重置前用完:每周剩余额度除以距重置的小时数,最高的账号排第一——重置时损失最多的那个。用到 90% 以上的等其他账号都答不了时才用;Key 按 magpie 最近发给它的 token 数排。",
|
||||
"Weekly pace: the account with the most of its week left per hour until it renews goes first — the one with the most to lose at its reset; an account with five hours and no week by what its five hours have left per hour. One at 90% or more waits until the others can't answer; a key by the tokens magpie sent it lately.": "重置前用完:每周剩余额度除以距重置的小时数,最高的账号排第一——重置时损失最多的那个;只有 5 小时额度、没有周额度的账号按 5 小时窗口的剩余额度除以距它重置的小时数算。用到 90% 以上的等其他账号都答不了时才用;Key 按 magpie 最近发给它的 token 数排。",
|
||||
"Nothing could take {model}.": "没有谁能处理 {model}。",
|
||||
"{who} is the only one, so it's tried though it is resting.": "只有 {who} 一个,所以虽然它在休息,也还是会试。",
|
||||
"{who} is the only account on for {model} — nothing to choose between.": "{who} 是 {model} 唯一启用的账号——无需选择。",
|
||||
|
||||
@@ -165,11 +165,11 @@
|
||||
const failWord = (why) => t(FAIL[why] || "failed");
|
||||
const API = { anthropic: "Anthropic", chat: "OpenAI", responses: "OpenAI Responses", gemini: "Gemini" };
|
||||
const MODES = {
|
||||
"": ["Smart", "Smart: of the accounts with quota to spare, the one whose allowance renews soonest goes first — what it has left would be lost at the reset. One at 90% or more waits until the others can't answer; one resting after a failure goes last."],
|
||||
"": ["Smart", "Smart: of the accounts with quota to spare, the one whose allowance renews soonest goes first — what it has left would be lost at the reset. The week decides; an account with five hours and no week goes by its five hours. One at 90% or more waits until the others can't answer; one resting after a failure goes last."],
|
||||
order: ["In order", "In order: the first answers everything until it can't; then the next."],
|
||||
rotate: ["In turn", "In turn: each conversation's next turn goes to the account after the one that answered its last, and a new conversation starts one further along; the requests within a turn stay put, keeping the prompt cache."],
|
||||
usage: ["Least used", "Least used first: the account with the most of its allowance left goes first; a key by the tokens magpie sent it lately."],
|
||||
pace: ["Weekly pace", "Weekly pace: the account with the most of its week left per hour until it renews goes first — the one with the most to lose at its reset. One at 90% or more waits until the others can't answer; a key by the tokens magpie sent it lately."],
|
||||
pace: ["Weekly pace", "Weekly pace: the account with the most of its week left per hour until it renews goes first — the one with the most to lose at its reset; an account with five hours and no week by what its five hours have left per hour. One at 90% or more waits until the others can't answer; a key by the tokens magpie sent it lately."],
|
||||
manual: ["Manual", "Manual: every request goes to the model picked on the group's card, over its own accounts or keys."],
|
||||
};
|
||||
const GROUP_ORDER = "In order: member by member, the first model the group names until it can't answer, each over its own accounts or keys as its provider routes them.";
|
||||
@@ -2122,13 +2122,16 @@
|
||||
off: "Every request is routed afresh, whoever answered its conversation before.",
|
||||
};
|
||||
const GROUP_HINT = {
|
||||
"": "Smart, over every member's accounts and keys together: of the subscriptions with quota to spare, the one whose allowance renews soonest goes first; one resting after a failure goes last.",
|
||||
"": "Smart, over the members' accounts and keys together: of the subscriptions with quota to spare, the one whose allowance renews soonest goes first — the week decides, and an account with five hours and no week (Claude Enterprise) goes by its five hours, so ahead of every week renewing later; one resting after a failure goes last.",
|
||||
order: "In order: the first model until it can't answer, then the next — each over its own accounts or keys as its provider routes them.",
|
||||
rotate: "In turn: each conversation's next turn goes to the next member's account or key, spreading the load.",
|
||||
usage: "Least used first: the account or key with the most of its allowance left goes first.",
|
||||
pace: "Weekly pace: the account with the most of its week left per hour until it renews goes first, so less of each member's week is lost at its reset; a key by the tokens magpie sent it lately.",
|
||||
pace: "Weekly pace: the account with the most of its week left per hour until it renews goes first, so less of each member's week is lost at its reset — an account with five hours and no week (Claude Enterprise) by what its five hours have left per hour until they renew, so almost always first; a key by the tokens magpie sent it lately.",
|
||||
manual: "Manual: every request goes to the model you pick on the group's card, over its own accounts or keys; the others, and the rules, wait until you pick another — none takes over when it fails.",
|
||||
};
|
||||
// a group in the group is routed by its own routing, whatever this one's
|
||||
// (planLevel, #576)
|
||||
const GROUP_NEST = "A group among the models keeps its own routing: this group only picks between it and the others, weighing it by the account or key it would try first, then tries it whole in its own order.";
|
||||
const EFFORTS = ["low", "medium", "high", "xhigh", "max"]; // provider.Efforts
|
||||
// what a rule matches, in words
|
||||
function ruleText(r) {
|
||||
@@ -2435,6 +2438,7 @@
|
||||
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(el("div", "hint", t(GROUP_NEST)));
|
||||
ed.append(el("label", "", t("Routing")), rw);
|
||||
const aHint = el("div", "hint", t(AFF_HINT[d.affinity] || AFF_HINT[""]));
|
||||
const aw = el("div");
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// An account with the five hours alone (Claude Enterprise) has nothing
|
||||
// longer to keep for: Pace goes by what its five hours lose at their
|
||||
// reset, the whole span when not started, and Renewal has a five hours
|
||||
// not started renewing within five hours — unless the reading may leave
|
||||
// a week out (a Claude account only heard of as Claude Code answered),
|
||||
// which goes as a fresh week with the share used, as before (#576).
|
||||
func TestFiveHoursAlone(t *testing.T) {
|
||||
now := time.Now()
|
||||
five := 5 * time.Hour
|
||||
near := func(a, b float64) bool { return a-b < 1e-9 && b-a < 1e-9 }
|
||||
|
||||
a := Allowance{{Used: 20, Resets: now.Add(2 * time.Hour), Span: five}}
|
||||
if p, due := a.Pace("m", now); !near(p, 40) || !due.Equal(now.Add(2*time.Hour)) {
|
||||
t.Fatalf("started: %v %v", p, due)
|
||||
}
|
||||
idle := Allowance{{Used: 0, Span: five}}
|
||||
if p, _ := idle.Pace("m", now); !near(p, 20) {
|
||||
t.Fatalf("not started: %v", p)
|
||||
}
|
||||
ws := []QuotaWindow{{Name: "5 hours", Used: 20, ResetsAt: ptr(now.Add(2 * time.Hour)), Span: five, partial: true}}
|
||||
if p, _ := allowanceOf(ws, now).Pace("m", now); !near(p, 80/(7*24.0)) {
|
||||
t.Fatalf("partial reading, a fresh week: %v", p)
|
||||
}
|
||||
// beside a week, the week decides as ever
|
||||
both := Allowance{{Used: 0, Span: five}, {Used: 40, Resets: now.Add(60 * time.Hour), Span: 7 * 24 * time.Hour}}
|
||||
if p, _ := both.Pace("m", now); !near(p, 1) {
|
||||
t.Fatalf("with a week: %v", p)
|
||||
}
|
||||
|
||||
if r := idle.Renewal("m", now); len(r) != 1 || !r[0].Equal(now.Add(five)) {
|
||||
t.Fatalf("renewal not started: %v", r)
|
||||
}
|
||||
if _, r := idle.For("m", now); len(r) != 1 || !r[0].IsZero() {
|
||||
t.Fatalf("For still says not known: %v", r)
|
||||
}
|
||||
if r := (Allowance{{Used: 3}}).Renewal("m", now); len(r) != 1 || !r[0].IsZero() {
|
||||
t.Fatalf("no span, not known: %v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// claudeWindows marks partial what only Claude Code's answers told.
|
||||
func TestClaudeHeardOnlyIsPartial(t *testing.T) {
|
||||
e := claudeUsageEntry{ws: []QuotaWindow{{Name: "5 hours", Used: 10, Span: 5 * time.Hour}}}
|
||||
if ws := e.windows(time.Now()); !ws[0].partial {
|
||||
t.Fatal("heard only: partial")
|
||||
}
|
||||
e.whole = true
|
||||
if ws := e.windows(time.Now()); ws[0].partial {
|
||||
t.Fatal("read by /usage: whole")
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,7 @@ type Limit struct {
|
||||
Span time.Duration // how long the window runs; zero when not known
|
||||
Model string // the only models it counts, by a word in their ids
|
||||
matches func(string) bool
|
||||
partial bool // of a reading that may leave windows out (QuotaWindow.partial)
|
||||
}
|
||||
|
||||
func (l Limit) applies(model string) bool {
|
||||
@@ -119,6 +120,38 @@ func (a Allowance) For(model string, now time.Time) (used float64, renews []time
|
||||
return used, renews
|
||||
}
|
||||
|
||||
// Renewal is For's renews as routing ranks them: a window not started,
|
||||
// or whose reset has passed, that says how long it runs is taken to
|
||||
// renew that long from now — a five hours not started renews within five
|
||||
// hours, the soonest of an account that has nothing longer (#576).
|
||||
func (a Allowance) Renewal(model string, now time.Time) []time.Time {
|
||||
model = strings.ToLower(model)
|
||||
var ls []Limit
|
||||
for _, l := range a {
|
||||
if !l.applies(model) {
|
||||
continue
|
||||
}
|
||||
if !l.Resets.After(now) {
|
||||
l.Resets = time.Time{}
|
||||
if l.Span > 0 {
|
||||
l.Resets = now.Add(l.Span)
|
||||
}
|
||||
}
|
||||
ls = append(ls, l)
|
||||
}
|
||||
sort.SliceStable(ls, func(i, j int) bool {
|
||||
if ls[i].Span != ls[j].Span {
|
||||
return ls[i].Span > ls[j].Span
|
||||
}
|
||||
return ls[i].Resets.After(ls[j].Resets)
|
||||
})
|
||||
out := make([]time.Time, len(ls))
|
||||
for i, l := range ls {
|
||||
out[i] = l.Resets
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Full is when an account used up for model can take it again: the last
|
||||
// reset of its windows that are full, zero when none is or it isn't known.
|
||||
func (a Allowance) Full(model string, share float64, now time.Time) time.Time {
|
||||
@@ -156,23 +189,37 @@ const FreshPace = 100 / (7 * 24.0)
|
||||
// say how long it runs (a plugin may not) is a budget, for as long as its
|
||||
// reset says, else a week. The hours until a reset are never taken as
|
||||
// fewer than one, or a window a minute from renewing would outweigh all
|
||||
// the rest. An account whose vendor tells no budget window (Claude's own
|
||||
// usage command gives the five hours alone) is taken to have a week not
|
||||
// started with the share its fullest window has used: those go by what
|
||||
// they have used, among the rest as the fresh are.
|
||||
// the rest. An account whose vendor tells no budget window, only a short
|
||||
// one (Claude Enterprise's five hours), has nothing longer to keep for:
|
||||
// what its five hours have left is lost at their reset, so it goes by
|
||||
// that, per hour until then — the whole span when not started (#576):
|
||||
// ahead of nearly every week, behind only one with more of it to lose
|
||||
// sooner. Where the reading may leave a week out (a Claude account only
|
||||
// heard of as Claude Code answered, partial) it is instead taken to have
|
||||
// a week not started with the share its fullest window has used: those
|
||||
// go by what they have used, among the rest as the fresh are.
|
||||
func (a Allowance) Pace(model string, now time.Time) (pace float64, due time.Time) {
|
||||
model = strings.ToLower(model)
|
||||
any, used := false, 0.0
|
||||
short, partial, shortPace, shortDue := false, false, 0.0, time.Time{}
|
||||
for _, l := range a {
|
||||
if !l.applies(model) {
|
||||
continue
|
||||
}
|
||||
partial = partial || l.partial
|
||||
u := min(100, max(0, l.Used))
|
||||
if !l.Resets.IsZero() && !l.Resets.After(now) {
|
||||
u = 0 // its reset has passed: empty again
|
||||
}
|
||||
if l.Span != 0 && l.Span < budgetSpan {
|
||||
used = max(used, u)
|
||||
until, renews := l.Span, time.Time{}
|
||||
if l.Resets.After(now) {
|
||||
until, renews = l.Resets.Sub(now), l.Resets
|
||||
}
|
||||
if p := (100 - u) / max(until, time.Hour).Hours(); !short || p < shortPace {
|
||||
shortPace, shortDue, short = p, renews, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
until, renews := l.Span, time.Time{}
|
||||
@@ -187,6 +234,9 @@ func (a Allowance) Pace(model string, now time.Time) (pace float64, due time.Tim
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
if short && !partial {
|
||||
return shortPace, shortDue
|
||||
}
|
||||
return (100 - used) / weekSpan.Hours(), time.Time{}
|
||||
}
|
||||
return pace, due
|
||||
@@ -318,7 +368,7 @@ func allowanceOf(ws []QuotaWindow, now time.Time) Allowance {
|
||||
if w.Aside {
|
||||
continue
|
||||
}
|
||||
l := Limit{Used: w.Used, Span: w.Span, Model: w.Model, matches: w.matches}
|
||||
l := Limit{Used: w.Used, Span: w.Span, Model: w.Model, matches: w.matches, partial: w.partial}
|
||||
if ids := families[w.Model]; ids != nil && w.Family != "" && w.matches == nil {
|
||||
l.Model = ""
|
||||
l.matches = func(model string) bool { return ids[model] }
|
||||
|
||||
@@ -34,7 +34,9 @@ func TestAllowanceOf(t *testing.T) {
|
||||
// first, and when that one renews; the five hours don't count, a week not
|
||||
// started or not known runs from now, a reset passed is a full week again,
|
||||
// a window of no span is a budget, the hours are never fewer than one, and
|
||||
// the five hours alone are a week not started with their share used.
|
||||
// the five hours alone go by what they have left per hour until they
|
||||
// renew — a week not started with their share used where the reading may
|
||||
// leave the week out (#576).
|
||||
func TestAllowancePace(t *testing.T) {
|
||||
now := time.Now()
|
||||
a := Allowance{
|
||||
@@ -63,8 +65,11 @@ func TestAllowancePace(t *testing.T) {
|
||||
if p, _ := (Allowance{{Used: 50, Resets: now.Add(10 * 24 * time.Hour), Span: 30 * 24 * time.Hour}}).Pace("m", now); !near(p, 50.0/240) {
|
||||
t.Fatalf("a month is a budget too: %v", p)
|
||||
}
|
||||
if p, _ := (Allowance{{Used: 20, Resets: now.Add(time.Hour), Span: 5 * time.Hour}}).Pace("m", now); !near(p, 80.0/168) {
|
||||
t.Fatalf("five hours alone: a week not started with their share used: %v", p)
|
||||
if p, _ := (Allowance{{Used: 20, Resets: now.Add(time.Hour), Span: 5 * time.Hour}}).Pace("m", now); !near(p, 80.0/1) {
|
||||
t.Fatalf("five hours alone: what they lose at their reset, per hour: %v", p)
|
||||
}
|
||||
if p, _ := (Allowance{{Used: 20, Resets: now.Add(time.Hour), Span: 5 * time.Hour, partial: true}}).Pace("m", now); !near(p, 80.0/168) {
|
||||
t.Fatalf("five hours alone, the reading partial: a week not started with their share used: %v", p)
|
||||
}
|
||||
// a plugin may tell no span: the window is a budget for as long as its
|
||||
// reset says, else a week
|
||||
|
||||
@@ -51,6 +51,11 @@ type QuotaWindow struct {
|
||||
Aside bool `json:"-"`
|
||||
// matches further scopes pools whose membership isn't one model word.
|
||||
matches func(string) bool
|
||||
// partial is set on the windows of a reading that may leave some out:
|
||||
// a Claude account only heard of as Claude Code answered (its
|
||||
// rate_limit_event names one window at a time), never read whole by
|
||||
// /usage — five hours alone there needn't mean no week.
|
||||
partial bool
|
||||
}
|
||||
|
||||
// SubscriptionQuota is provider-reported allowance usage. This is separate
|
||||
@@ -438,6 +443,16 @@ type claudeUsageEntry struct {
|
||||
tried time.Time // when /usage was last run, answered or not
|
||||
wait time.Duration // how long after tried it runs again unasked
|
||||
err error // what the last run said, when it failed
|
||||
whole bool // /usage was read: ws has every window the account has
|
||||
}
|
||||
|
||||
// windows is what e keeps as of now, partial where /usage never read it.
|
||||
func (e claudeUsageEntry) windows(now time.Time) []QuotaWindow {
|
||||
ws := elapsed(e.ws, now)
|
||||
for i := range ws {
|
||||
ws[i].partial = !e.whole
|
||||
}
|
||||
return ws
|
||||
}
|
||||
|
||||
// claudeAskFloor is the least time between two readings, however often the
|
||||
@@ -544,7 +559,7 @@ func claudeWindows(ctx context.Context, user string, active bool) ([]QuotaWindow
|
||||
case ok && e.err != nil && !(heard && !claudeUsageDenied.MatchString(e.err.Error())):
|
||||
return []QuotaWindow{}, e.err
|
||||
case ok && e.ws != nil:
|
||||
return elapsed(e.ws, now), nil
|
||||
return e.windows(now), nil
|
||||
case active:
|
||||
return []QuotaWindow{}, errClaudeNotAsked
|
||||
default:
|
||||
@@ -557,7 +572,7 @@ func claudeWindows(ctx context.Context, user string, active bool) ([]QuotaWindow
|
||||
// spent as the one it was moved to (nil_1024)
|
||||
kept := func() ([]QuotaWindow, error) {
|
||||
if e.ws != nil {
|
||||
return elapsed(e.ws, now), nil
|
||||
return e.windows(now), nil
|
||||
}
|
||||
return []QuotaWindow{}, errClaudeNotAsked
|
||||
}
|
||||
@@ -584,7 +599,7 @@ func claudeWindows(ctx context.Context, user string, active bool) ([]QuotaWindow
|
||||
}
|
||||
c.Unlock()
|
||||
if e.ws != nil && time.Since(e.heard) < claudeHeard && !claudeUsageDenied.MatchString(err.Error()) {
|
||||
return elapsed(e.ws, time.Now()), nil // a fresh header stands unless the account was refused
|
||||
return e.windows(time.Now()), nil // a fresh header stands unless the account was refused
|
||||
}
|
||||
return ws, err
|
||||
}
|
||||
@@ -592,7 +607,7 @@ func claudeWindows(ctx context.Context, user string, active bool) ([]QuotaWindow
|
||||
if c.m == nil {
|
||||
c.m = map[string]claudeUsageEntry{}
|
||||
}
|
||||
c.m[key] = claudeUsageEntry{at: now, ws: ws, heard: e.heard, tried: now, wait: e.wait}
|
||||
c.m[key] = claudeUsageEntry{at: now, ws: ws, heard: e.heard, tried: now, wait: e.wait, whole: true}
|
||||
c.Unlock()
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
@@ -155,11 +155,11 @@
|
||||
<div class="callout"><b>Groups you get without doing anything.</b> Add a second provider that serves a model you already have — Claude Opus from your Claude subscription and from Copilot, DeepSeek from its own API and from OpenRouter — and magpie makes it a group by itself, <code>group/auto-<model></code>, over every provider that serves it. The name is matched however each vendor spells it (<code>deepseek/deepseek-chat</code>, <code>deepseek-chat</code>). Pick it for an agent to spread its requests over all of them; <code>magpie group rm</code> hides one you don’t want.</div>
|
||||
<div class="table-wrap"><table>
|
||||
<tr><th>Routing</th><th>What goes first</th></tr>
|
||||
<tr><td>Smart <code>smart</code></td><td>The default. Over every member’s accounts and keys together: of the subscriptions with quota to spare, the one whose allowance renews soonest; one resting after a failure goes last.</td></tr>
|
||||
<tr><td>Smart <code>smart</code></td><td>The default. Over the members’ accounts and keys together (a group in the group as one, see below): of the subscriptions with quota to spare, the one whose allowance renews soonest. The week decides; an account with five hours and no week (Claude Enterprise) goes by its five hours, so ahead of every week renewing later. One resting after a failure goes last.</td></tr>
|
||||
<tr><td>In order <code>order</code></td><td>The first model until it can’t answer, then the next.</td></tr>
|
||||
<tr><td>In turn <code>rotate</code></td><td>Each conversation’s next turn goes to the next member’s account or key, spreading the load.</td></tr>
|
||||
<tr><td>Least used <code>usage</code></td><td>The account or key with the most of its allowance left.</td></tr>
|
||||
<tr><td>Weekly pace <code>pace</code></td><td>The account with the most of its week left per hour until it renews — the one with the most to lose at its reset — so less of each member’s week is lost at its reset; one at 90% or more waits until the others can’t answer. A key by the tokens sent it lately.</td></tr>
|
||||
<tr><td>Weekly pace <code>pace</code></td><td>The account with the most of its week left per hour until it renews — the one with the most to lose at its reset — so less of each member’s week is lost at its reset; an account with five hours and no week (Claude Enterprise) by what its five hours have left per hour until they renew, so almost always first. One at 90% or more waits until the others can’t answer. A key by the tokens sent it lately.</td></tr>
|
||||
</table></div>
|
||||
<div class="table-wrap"><table>
|
||||
<tr><th>Stays</th><th>How long a conversation stays with the account or key that answered it</th></tr>
|
||||
@@ -182,6 +182,7 @@
|
||||
<p>A group’s models can include another group: pick it under <em>Routing groups</em> in <span class="ui">Add a model</span>, or <code>models+=group/<id></code> from a terminal. The group in it takes its place in the order and routes among its own models by its own routing and rules. So Coder can be <code>deepseek-v4-pro</code> then <em>Fast</em>, and Fast its own order of cheap models. A Coder rule can send a turn to <code>group/fast</code>, and Fast’s own rules then pick which of its models goes first.</p>
|
||||
<ul>
|
||||
<li><b>No loops.</b> A group can’t be in itself, directly or through the groups in it. Saving Fast with Coder in it, while Coder has Fast, is refused, and the picker doesn’t offer such a group. Groups go at most 8 deep. A group can’t be removed while another has it: take it out there first.</li>
|
||||
<li><b>Each group routes by its own routing.</b> Whatever the outer group’s routing, it never reaches inside a group in it: Smart, In turn, Least used or Weekly pace weighs the inner group as one member, by the account or key the inner group would try first, and where it lands the inner group is tried whole, in its own order. Coder on Smart with Claude (In order) and GPT (In order) in it compares Claude’s first with GPT’s first, not all of their accounts together.</li>
|
||||
<li><b>Failover goes through.</b> If every model in the inner group fails, the outer group’s next member answers, as with any member.</li>
|
||||
<li>A model that appears twice (on its own and in a group in it) is asked once, at its first place.</li>
|
||||
</ul>
|
||||
|
||||
@@ -155,11 +155,11 @@
|
||||
<div class="callout"><b>不用手动建的组。</b>再加一个供应商,只要它提供的模型和已有的同名(比如 Claude 订阅和 Copilot 都有 Claude Opus,DeepSeek 官方 API 和 OpenRouter 都有 DeepSeek),magpie 就会自动把它们组成一个路由组:<code>group/auto-<模型></code>,包含所有提供这个模型的供应商。名字按各家不同的写法匹配(<code>deepseek/deepseek-chat</code> 和 <code>deepseek-chat</code> 算同一个)。给 Agent 选这个组,请求就会分摊到这些供应商上;不想要的可以用 <code>magpie group rm</code> 隐藏。</div>
|
||||
<div class="table-wrap"><table>
|
||||
<tr><th>路由</th><th>谁排在前面</th></tr>
|
||||
<tr><td>智能 <code>smart</code></td><td>默认。把所有成员的账号和 Key 放在一起排:还有余量的订阅里,额度最先重置的排第一;失败后在休息的排最后。</td></tr>
|
||||
<tr><td>智能 <code>smart</code></td><td>默认。把成员的账号和 Key 放在一起排(组里的路由组算一个,见下文):还有余量的订阅里,额度最先重置的排第一。看周额度的重置时间;只有 5 小时额度、没有周额度的账号(如 Claude Enterprise)看它的 5 小时重置,所以排在所有更晚重置的周额度前面。失败后在休息的排最后。</td></tr>
|
||||
<tr><td>按顺序 <code>order</code></td><td>先用第一个模型,它答不了再换下一个。</td></tr>
|
||||
<tr><td>轮流 <code>rotate</code></td><td>会话的每一轮交给下一个成员的账号或 Key,均摊负载。</td></tr>
|
||||
<tr><td>最少使用 <code>usage</code></td><td>剩余额度最多的账号或 Key 排第一。</td></tr>
|
||||
<tr><td>重置前用完 <code>pace</code></td><td>每周剩余额度除以距重置的小时数,最高的账号排第一——重置时损失最多的那个——少让成员的周额度到重置时作废;用到 90% 以上的等其他账号都答不了时才用。Key 按最近发给它的 token 数排。</td></tr>
|
||||
<tr><td>重置前用完 <code>pace</code></td><td>每周剩余额度除以距重置的小时数,最高的账号排第一——重置时损失最多的那个——少让成员的周额度到重置时作废;只有 5 小时额度、没有周额度的账号(如 Claude Enterprise)按 5 小时窗口的剩余额度除以距它重置的小时数算,所以几乎总排第一。用到 90% 以上的等其他账号都答不了时才用。Key 按最近发给它的 token 数排。</td></tr>
|
||||
</table></div>
|
||||
<div class="table-wrap"><table>
|
||||
<tr><th>会话保持</th><th>会话在回答过它的账号或 Key 上留多久</th></tr>
|
||||
@@ -182,6 +182,7 @@
|
||||
<p>路由组的成员也可以是另一个路由组:在 <span class="ui">添加模型</span> 的 <em>路由组</em> 分类里选它,或者在终端里用 <code>models+=group/<id></code>。子组占据它在顺序里的位置,在自己的模型之间按自己的路由方式和规则分配。比如 Coder 可以是 <code>deepseek-v4-pro</code>,然后是 <em>Fast</em>,而 Fast 是几个便宜模型的顺序。Coder 的规则可以把一轮对话交给 <code>group/fast</code>,再由 Fast 自己的规则决定它里面哪个模型排第一。</p>
|
||||
<ul>
|
||||
<li><b>不会无限递归。</b>一个组不能包含它自己,无论是直接包含,还是经由它里面的组间接包含。Coder 已经包含 Fast 时,再把 Coder 放进 Fast 会被拒绝,选择器里也不会列出这样的组。最多嵌套 8 层。一个组被别的组包含时不能删除,要先从那个组里移除它。</li>
|
||||
<li><b>每一层按自己的路由。</b>外层组不管用什么路由,都不会伸进它里面的组:智能、轮流、最少使用、重置前用完都把子组当作一个成员,拿子组自己会先用的那个账号或 Key 来比;排到它时,子组按自己的顺序整体尝试。Coder 设为智能、里面放 Claude(按顺序)和 GPT(按顺序)两个组时,比较的是 Claude 组的第一个和 GPT 组的第一个,不会把两组的账号混在一起排。</li>
|
||||
<li><b>故障转移照常。</b>子组里的模型全部失败时,由外层组的下一个成员接手,和其他成员一样。</li>
|
||||
<li>同一个模型出现两次(单独一次,子组里一次)时只问一次,按它第一次出现的位置。</li>
|
||||
</ul>
|
||||
|
||||
Reference in new issue
Block a user