gateway, gui: a provider's keys can be routed by weight, a key weighing 3 taking three requests for every one a key weighing 1 takes (dengtongcai, #841) — Routing already spread requests over a provider's keys in order, in turn, least used first or by pace, but never in a set proportion: two team keys meant to split 75/25 could only go evenly or one after the other. Routing now has By weight (routing "weight") for a provider with keys: each key that's on shows its weight and its share beside it in the editor (Weight 3 · 75%), a click edits it in place and posts keys/weight (provider.SetKeyWeight, 1 to 1000; none or 0 counts as 1, kept out of the file), and providers.json takes it as "weight" on each of keys (keyWeight for the first). The gateway picks as NGINX's smooth weighted round robin does (weighted.go): 3, 1 and 4 go round in every 8 requests in that ratio, never bunched; the others follow in their order for failover. A key resting after a failure is left out of the round while it rests, the others taking its share by their weights, and goes last. A conversation still stays with its key as Stays says (cache-warm across turns by default, within a turn always), and a request kept so is counted against the key that took it, not the one the round picked, so new conversations make up the share; Stays Off gives the ratio request by request. The weight goes with its key through Make first, the editor's Save, backups and WebDAV sync. The Routing page offers By weight for key pools and explains a pick by it; sink (Goes to the back) is off for it as for In turn. zh, ja, de strings. TestWeightedKeys (3:1:4 exact in each of 50 rounds of 8 through perKey and route; with one resting the other two 75/25 and the resting one last; a kept request made up by the next picks) and TestSetKeyWeight fail on the old code (no Weighted); key-weight.test.cjs (WebKit, en and zh: By weight shows the weights and shares, a click sets one and the shares redraw, Smart hides them, no scroll) fails on the old code (no By weight). key-import, panel-routing, routing-save, rate-limited-sink, routing-reset-deadline, account-arrange, gui-ja, gui-de and click-scroll pass on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-05 08:50:57 +08:00
1 parent 456f77f15e
commit 3ec6b4da27
19 files changed
+508 -24

No files matched your search

+1 -1
View File
@@ -230,7 +230,7 @@ var secretHeader = regexp.MustCompile(`(?i)auth|key|token|secret|cookie|session|
func Secret(name string) bool { return secretHeader.MatchString(name) }
func withoutKeys(p provider.Provider) provider.Provider {
p.Key, p.KeyName, p.Keys, p.KeyProtocol = "", "", nil, ""
p.Key, p.KeyName, p.Keys, p.KeyProtocol, p.KeyWeight = "", "", nil, "", 0
p.BalanceToken = ""
if len(p.Headers) > 0 {
h := map[string]string{}
+1 -1
View File
@@ -644,7 +644,7 @@ func take(to *backup.Bundle, from backup.Bundle, part string) {
ps = slices.Clone(ps)
for i, p := range ps {
if k, ok := keys[p.ID]; ok && p.Key == "" && len(p.Keys) == 0 {
ps[i].Key, ps[i].KeyName, ps[i].Keys, ps[i].KeyProtocol = k.Key, k.KeyName, k.Keys, k.KeyProtocol
ps[i].Key, ps[i].KeyName, ps[i].Keys, ps[i].KeyProtocol, ps[i].KeyWeight = k.Key, k.KeyName, k.Keys, k.KeyProtocol, k.KeyWeight
if p.BalanceToken == "" {
ps[i].BalanceToken = k.BalanceToken
}
+1
View File
@@ -281,6 +281,7 @@ func affine(scope, mode string, rotate bool, in http.Header, from provider.Proto
case "session", "turn", "cache":
a.Kept = true
if at > 0 {
weightedKept(cs[0], cs[at])
cs = append(append([]candidate{cs[at]}, cs[:at]...), cs[at+1:]...)
order := append(append([]Weighed{pl.order[at]}, pl.order[:at]...), pl.order[at+1:]...)
pl.order = order
+2 -2
View File
@@ -302,7 +302,7 @@ func (s *Server) plan(p provider.Provider, model string, from provider.Protocol)
cs, wg := weigh(q, cs, m, from)
for i, c := range cs {
w := weighed(c, q, wg, fallback, from)
w.Turn = i == 0 && q.Routing == provider.Rotate && len(cs) > 1
w.Turn = i == 0 && (q.Routing == provider.Rotate || q.Routing == provider.Weighted) && len(cs) > 1
pl.order = append(pl.order, w)
}
pl.order = append(pl.order, asideOf(aside, q, fallback, from)...)
@@ -464,7 +464,7 @@ func planLevel(g provider.Group, ms []provider.Member, depth int, from provider.
cs, wg := weigh(m.Provider, keys(m), m.Model, from)
for k, c := range cs {
w := weighed(c, m.Provider, wg, false, from)
w.Turn = k == 0 && m.Provider.Routing == provider.Rotate && len(cs) > 1
w.Turn = k == 0 && (m.Provider.Routing == provider.Rotate || m.Provider.Routing == provider.Weighted) && len(cs) > 1
w.Via = m.Groups()
pl.order = append(pl.order, w)
}
+2
View File
@@ -705,6 +705,8 @@ func weighRouted(p provider.Provider, cs []candidate, model string, from provide
routed.turn[p.ID]++
routed.Unlock()
cs = append(append([]candidate{}, cs[n:]...), cs[:n]...)
case provider.Weighted:
cs = weightedFirst(p.ID, cs)
case provider.LeastUsed:
// a subscription by the share of its allowance used, as the vendor
// says; then, and for keys, by what magpie sent it lately
+3 -2
View File
@@ -54,9 +54,10 @@ func sunkAt(c candidate) (sinking, bool) {
}
// sinks says whether an order routed so sends the rate limited back:
// in turn goes round already, and a manual group has one member.
// in turn and by weight go round already, and a manual group has one
// member.
func sinks(on bool, routing string) bool {
return on && routing != provider.Rotate && routing != provider.Manual
return on && routing != provider.Rotate && routing != provider.Weighted && routing != provider.Manual
}
// sinkOrder is the order of n candidates with those that sank moved
+108
View File
@@ -0,0 +1,108 @@
package gateway
// Weighted routing (#841): a provider's requests spread over its keys by
// each key's weight, as NGINX's smooth weighted round robin does — keys
// weighing 3 and 1 go a, a, b, a, a, a, b, a…, the ratio held over a
// handful of requests rather than only on average. A key resting after a
// failure is left out of the round while it rests, so the others share
// its requests by their own weights and it takes up its share when it is
// back. A conversation still stays with the key that answered it while
// its affinity says (affinity.go); a request kept so counts against the
// key that took it, not the one the round picked (weightedKept), so new
// conversations make up the share.
import (
"sync"
"github.com/yetone/magpie/internal/provider"
)
// wrr is each weighted provider's round: every key's current weight, and
// the total the last pick was charged.
var wrr = struct {
sync.Mutex
m map[string]*wrrRound // provider id → its round
}{m: map[string]*wrrRound{}}
type wrrRound struct {
cur map[string]int // candidate rest → current weight
total int
}
// keyWeight is a candidate's weight: its key's, 1 when none.
func keyWeight(c candidate) int { return max(c.p.KeyWeight, 1) }
// resting says whether c rests now, as restLast will find.
func resting(c candidate) bool {
if _, ok := restOf(c.restKey()); ok {
return true
}
if c.restID() != c.restKey() {
_, ok := restOf(c.restID())
return ok
}
return false
}
// weightedFirst puts first the key the provider id's round picks now, the
// others after it in their order, for failover.
func weightedFirst(id string, cs []candidate) []candidate {
wrr.Lock()
defer wrr.Unlock()
r := wrr.m[id]
if r == nil {
r = &wrrRound{cur: map[string]int{}}
wrr.m[id] = r
}
// keys no longer on are dropped from the round
on := make(map[string]bool, len(cs))
for _, c := range cs {
on[c.rest] = true
}
for k := range r.cur {
if !on[k] {
delete(r.cur, k)
}
}
best, total := -1, 0
for i, c := range cs {
if resting(c) {
continue
}
w := keyWeight(c)
r.cur[c.rest] += w
total += w
if best < 0 || r.cur[c.rest] > r.cur[cs[best].rest] {
best = i
}
}
if best < 0 {
return cs // all resting: restLast orders them
}
r.cur[cs[best].rest] -= total
r.total = total
if best == 0 {
return cs
}
return append(append([]candidate{cs[best]}, cs[:best]...), cs[best+1:]...)
}
// weightedKept moves the pick of picked's round to kept, when affinity
// keeps a conversation with kept, another key of the same weighted
// provider: the round counts the requests each key really took.
func weightedKept(picked, kept candidate) {
if picked.p.Routing != provider.Weighted || picked.p.ID != kept.p.ID || picked.rest == kept.rest {
return
}
wrr.Lock()
defer wrr.Unlock()
r := wrr.m[picked.p.ID]
if r == nil {
return
}
if _, ok := r.cur[kept.rest]; !ok {
return
}
r.cur[picked.rest] += r.total
r.cur[kept.rest] -= r.total
}
+148
View File
@@ -0,0 +1,148 @@
package gateway
import (
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
)
// Weighted routing (#841): a provider's keys weighing 3 and 1 take three
// requests to one, held over every few requests rather than only on
// average, and a key resting is left out of the round, the others taking
// its share by their weights.
func TestWeightedKeys(t *testing.T) {
p := provider.Provider{ID: "wk", Chat: "https://wk.example/v1", Key: "ka", KeyName: "team-a", KeyWeight: 3,
Keys: []provider.KeyAccount{{Name: "team-b", Key: "kb"}, {Name: "team-c", Key: "kc", Weight: 4}, {Name: "spare", Key: "kd", Off: true, Weight: 9}},
Routing: provider.Weighted}
a, b, c := "wk#"+provider.KeyID("ka"), "wk#"+provider.KeyID("kb"), "wk#"+provider.KeyID("kc")
t.Cleanup(func() {
wrr.Lock()
delete(wrr.m, p.ID)
wrr.Unlock()
restingUntil.Lock()
delete(restingUntil.m, c)
restingUntil.Unlock()
})
pick := func() string {
cs := route(p, perKey(p, "m", provider.Chat), "m", provider.Chat)
if len(cs) != 3 {
t.Fatalf("%d candidates: %s", len(cs), restsOf(cs))
}
return cs[0].rest
}
// 3 : 1 : 4 over every round of 8, smoothly: never the same key more
// than twice running when another weighs as much
got := map[string]int{}
var seq []string
for range 8 * 50 {
k := pick()
got[k]++
seq = append(seq, k)
if len(seq)%8 == 0 {
round := map[string]int{}
for _, s := range seq[len(seq)-8:] {
round[s]++
}
if round[a] != 3 || round[b] != 1 || round[c] != 4 {
t.Fatalf("round %d: a %d b %d c %d, want 3 1 4", len(seq)/8, round[a], round[b], round[c])
}
}
}
if got[a] != 150 || got[b] != 50 || got[c] != 200 {
t.Fatalf("over 400: a %d b %d c %d", got[a], got[b], got[c])
}
if run := strings.Count(strings.Join(seq[:8], " "), c+" "+c+" "+c); run > 0 {
t.Fatalf("not smooth: %v", seq[:8])
}
// c resting: a and b share its requests 3 : 1, and c is tried last
restingUntil.Lock()
restingUntil.m[c] = time.Now().Add(time.Hour)
restingUntil.Unlock()
got = map[string]int{}
for range 4 * 25 {
cs := route(p, perKey(p, "m", provider.Chat), "m", provider.Chat)
cs, _ = restLast(cs, planned{order: make([]Weighed, len(cs))})
if cs[len(cs)-1].rest != c {
t.Fatalf("the resting key isn't last: %s", restsOf(cs))
}
got[cs[0].rest]++
}
if got[a] != 75 || got[b] != 25 || got[c] != 0 {
t.Fatalf("with c resting: a %d b %d c %d", got[a], got[b], got[c])
}
// a conversation kept on b counts against b: the next picks make it up
restingUntil.Lock()
delete(restingUntil.m, c)
restingUntil.Unlock()
wrr.Lock()
delete(wrr.m, p.ID)
wrr.Unlock()
got = map[string]int{}
for i := range 8 * 10 {
cs := route(p, perKey(p, "m", provider.Chat), "m", provider.Chat)
k := cs[0]
if i%8 == 0 { // every round, one request stays with b
for _, x := range cs {
if x.rest == b {
weightedKept(k, x)
k = x
}
}
}
got[k.rest]++
}
if got[a] != 30 || got[b] != 10 || got[c] != 40 {
t.Fatalf("with b kept: a %d b %d c %d", got[a], got[b], got[c])
}
}
// A key's weight is saved with it, the first key's too, and 1 is the
// default left out of the file.
func TestSetKeyWeight(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", home+"/.config")
if err := provider.Save(provider.Provider{ID: "wk", Name: "WK", Chat: "https://wk.example/v1", Key: "ka", Keys: []provider.KeyAccount{{Key: "kb"}}, Routing: provider.Weighted}); err != nil {
t.Fatal(err)
}
if err := provider.SetKeyWeight("wk", provider.KeyID("ka"), 3); err != nil {
t.Fatal(err)
}
if err := provider.SetKeyWeight("wk", provider.KeyID("kb"), 5); err != nil {
t.Fatal(err)
}
if err := provider.SetKeyWeight("wk", provider.KeyID("kb"), -1); err == nil {
t.Fatal("a weight below 0 was taken")
}
p, err := provider.Find("wk")
if err != nil {
t.Fatal(err)
}
if p.Routing != provider.Weighted {
t.Fatalf("routing %q", p.Routing)
}
ks := p.KeyList()
if ks[0].Weight != 3 || ks[1].Weight != 5 {
t.Fatalf("weights %d %d", ks[0].Weight, ks[1].Weight)
}
// the weight goes with its key when another goes first
if err := provider.UseKey("wk", provider.KeyID("kb")); err != nil {
t.Fatal(err)
}
p, _ = provider.Find("wk")
if p.KeyWeight != 5 || p.Keys[0].Weight != 3 {
t.Fatalf("after Make first: first %d, other %d", p.KeyWeight, p.Keys[0].Weight)
}
if err := provider.SetKeyWeight("wk", provider.KeyID("kb"), 1); err != nil {
t.Fatal(err)
}
p, _ = provider.Find("wk")
if p.KeyWeight != 0 {
t.Fatalf("1 kept as %d", p.KeyWeight)
}
}
+3
View File
@@ -1783,6 +1783,9 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
@media (max-width: 520px) { .acc.adding .kf { grid-template-columns: 1fr; } }
.acc.adding .bulk-keys { width: 100%; box-sizing: border-box; resize: vertical; min-height: 84px; font-family: var(--code); font-size: 11.5px; line-height: 1.5; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--card); color: var(--fg); }
.acc.adding .bulk-count { margin-left: 6px; white-space: nowrap; }
.acc .key-weight { display: none; flex: none; font-size: 11px; padding: 1px 6px; border-radius: 5px; color: var(--fg-2); background: var(--pill); border: 0; cursor: pointer; white-space: nowrap; font-variant-numeric: tabular-nums; }
.accts.weighted .acc .key-weight { display: inline-block; }
.acc .key-weight-in { width: 64px; flex: none; font-size: 12px; padding: 1px 6px; }
.acc .key-rest { flex: none; font-size: 11px; padding: 1px 6px; border-radius: 5px; color: var(--amber); background: var(--amber-soft); white-space: nowrap; }
.acc .proto { flex: none; display: inline-flex; align-items: center; gap: 3px; height: 20px; padding: 0 5px 0 7px; font: inherit; font-size: 11px; color: var(--muted); border: 0; border-radius: 5px; background: var(--pill); white-space: nowrap; cursor: default; transition: opacity .15s, background .12s, color .12s; }
.acc .proto svg { opacity: .7; transition: transform .15s var(--ease); }
+43 -4
View File
@@ -8261,7 +8261,10 @@ const ROUTINGS = [
["rotate", "In turn", "Each turn of a conversation goes to the next one, spreading the load evenly; the requests within a turn stay where it began, so the prompt cache holds, and one that fails is passed over while it rests."],
["usage", "Least used first", "Each request goes to the one used least: a subscription by the share of its allowance used, a key by the tokens it served in the last hours."],
["pace", "Weekly pace", "Each request goes to the subscription 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 week is lost at its reset; one at 90% or more waits until the others can't answer. A key goes by the tokens it served in the last hours."],
["weight", "By weight", "Requests spread over the keys by the weight set beside each: a key weighing 3 takes three requests for every one a key weighing 1 takes, evenly over a few requests. One that fails is passed over while it rests, and the others share its requests; a conversation stays with its key as Stays says."],
];
// weight is a key's share: offered for a provider with keys only
const routingsOf = (p) => p.account ? ROUTINGS.filter(([id]) => id !== "weight") : ROUTINGS;
// as on the Routing page's list of these (routing.js AFF_OPTS)
const STAYS = [
["", "Auto", "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."],
@@ -8315,10 +8318,12 @@ 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";
// in turn and by weight go round already
kLabel.hidden = kWrap.hidden = routingNow() === "rotate" || routingNow() === "weight";
// each key's weight shows beside it while the routing is by weight
for (const l of document.querySelectorAll(".accts.keys")) if (l.dataset.provider === p.id) l.classList.toggle("weighted", routingNow() === "weight");
};
const rPick = segs(ROUTINGS.map(([id, name]) => [id, t(name)]), routingNow(), (routing) => { draft.routing = routing; drawRouting(); });
const rPick = segs(routingsOf(p).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");
@@ -10013,12 +10018,45 @@ function accountModels(p, ref, isKey, name) {
return [pill, box];
}
// keyWeight: a key's weight under By weight routing, and its share of the
// requests; a click edits it in place, Enter or leaving it saves it.
function keyWeight(p, k, w, total) {
const b = el("button", "key-weight", t("Weight {n} · {share}%", { n: w, share: Math.round(100 * w / total) }));
b.title = t("Its share of the requests: a key weighing 3 takes three for every one a key weighing 1 takes");
b.onclick = () => {
const i = input(String(w), "1");
i.type = "number";
i.min = "1";
i.max = "1000";
i.className = "key-weight-in";
let done = false;
const save = (keep) => {
if (done) return;
done = true;
const n = Math.round(Number(i.value));
if (keep && n >= 1 && n <= 1000 && n !== w) accountAction("keys/weight", { id: p.id, ref: k.id, weight: n }, t("{key} weighs {n}", { key: k.name || k.masked, n }));
else renderProviders();
};
i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") save(true); else if (e.key === "Escape") save(false); };
i.onblur = () => save(true);
b.replaceWith(i);
i.focus({ preventScroll: true });
i.select();
};
return b;
}
// renderKeyAccounts: a key provider's accounts, one per key, the same list
// a subscription has. addingKey holds the half-typed new one.
let addingKey = null;
function renderKeyAccounts(p) {
const list = el("div", "accts");
const list = el("div", "accts keys");
list.dataset.provider = p.id;
list.classList.toggle("weighted", (draft?.routing ?? (p.routing || "")) === "weight");
const several = p.keyList.filter((k) => k.on).length > 1;
// the weights of those on, for each one's share (#841)
const weightOf = (k) => Math.max(k.weight || 0, 1);
const weights = p.keyList.filter((k) => k.on).reduce((n, k) => n + weightOf(k), 0);
for (const k of p.keyList) {
const row = el("div", "acc" + (k.on ? " in-use" : " off") + (k.id === justAdded ? " new" : ""));
row.dataset.accountId = k.id;
@@ -10049,6 +10087,7 @@ function renderKeyAccounts(p) {
r.title = t("The gateway passes this key over until then, and tries the next one");
row.append(r);
}
if (k.on && several) row.append(keyWeight(p, k, weightOf(k), weights));
const proto = protoPicker(p, k.protocol, (v) => accountAction("keys/protocol", { id: p.id, ref: k.id, protocol: v }));
if (proto) row.append(proto);
// its own models (#474), when there is another key to send the rest to
+18
View File
@@ -790,6 +790,12 @@ const I18N = {
"In order": "按顺序",
"In turn": "轮流",
"Least used first": "用量少的优先",
"By weight": "按权重",
"By weight: it's {who}'s share \u2014 each key takes requests as its weight says.": "按权重:轮到 {who} 的份额——每个密钥按自己的权重接请求。",
"Requests spread over the keys by the weight set beside each: a key weighing 3 takes three requests for every one a key weighing 1 takes, evenly over a few requests. One that fails is passed over while it rests, and the others share its requests; a conversation stays with its key as Stays says.": "请求按每个密钥旁设置的权重分配:权重 3 的密钥每接 3 个请求,权重 1 的接 1 个,几个请求之内就均匀分开。出错的在休息期间被跳过,由其他密钥按各自权重分担;会话按「会话保持」的设置留在原来的密钥上。",
"Weight {n} · {share}%": "权重 {n} · {share}%",
"Its share of the requests: a key weighing 3 takes three for every one a key weighing 1 takes": "它在请求中的份额:权重 3 的密钥每接 3 个请求,权重 1 的接 1 个",
"{key} weighs {n}": "{key} 的权重已设为 {n}",
"Requests go to the first; the next takes over when the one before runs out of quota, hits a rate limit or fails.": "请求先发给第一个;它额度用完、被限流或出错时,由下一个接手。",
"Each turn of a conversation goes to the next one, spreading the load evenly; the requests within a turn stay where it began, so the prompt cache holds, and one that fails is passed over while it rests.": "会话的每一轮轮流交给下一个,均摊负载;一轮之内的请求留在这一轮开始的地方,好让提示词缓存生效;出错的在休息期间被跳过。",
"Each request goes to the one used least: a subscription by the share of its allowance used, a key by the tokens it served in the last hours.": "每个请求发给用量最少的:订阅看已用额度比例,密钥看最近几小时经它的 token 数。",
@@ -3840,6 +3846,12 @@ const I18N = {
"In order": "順番",
"In turn": "交互",
"Least used first": "使用量が最少のものを優先",
"By weight": "重み付け",
"By weight: it's {who}'s share \u2014 each key takes requests as its weight says.": "重み付け:{who} の取り分です——各キーは重みどおりにリクエストを受けます。",
"Requests spread over the keys by the weight set beside each: a key weighing 3 takes three requests for every one a key weighing 1 takes, evenly over a few requests. One that fails is passed over while it rests, and the others share its requests; a conversation stays with its key as Stays says.": "各キーの横で設定した重みでリクエストを振り分けます。重み 3 のキーは、重み 1 のキーが 1 件受けるごとに 3 件受け、数件のうちに均等に分かれます。失敗したキーは休止中は飛ばされ、他のキーがそれぞれの重みで分担します。会話は「維持」の設定どおりそのキーにとどまります。",
"Weight {n} · {share}%": "重み {n} · {share}%",
"Its share of the requests: a key weighing 3 takes three for every one a key weighing 1 takes": "リクエストの取り分:重み 3 のキーは、重み 1 のキーが 1 件受けるごとに 3 件受けます",
"{key} weighs {n}": "{key} の重みを {n} にしました",
"Requests go to the first; the next takes over when the one before runs out of quota, hits a rate limit or fails.": "リクエストは最初のものに送られます。クォータ切れ、レート制限、エラーのときは次のものが引き継ぎます。",
"Each turn of a conversation goes to the next one, spreading the load evenly; the requests within a turn stay where it began, so the prompt cache holds, and one that fails is passed over while it rests.": "会話のターンごとに次のものへ送り、負荷を均等に分散します。ターン内のリクエストは開始した場所に留まるのでプロンプトキャッシュが効きます。エラーになったものは休止中スキップされます。",
"Each request goes to the one used least: a subscription by the share of its allowance used, a key by the tokens it served in the last hours.": "各リクエストは使用量が最も少ないものに送られます。サブスクリプションは利用枠の使用割合、キーは直近数時間に処理したトークン数で判断します。",
@@ -6882,6 +6894,12 @@ const I18N = {
"In order": "Der Reihe nach",
"In turn": "Abwechselnd",
"Least used first": "Am wenigsten genutzte zuerst",
"By weight": "Nach Gewicht",
"By weight: it's {who}'s share \u2014 each key takes requests as its weight says.": "Nach Gewicht: {who} ist mit seinem Anteil dran — jeder Schlüssel bekommt Anfragen nach seinem Gewicht.",
"Requests spread over the keys by the weight set beside each: a key weighing 3 takes three requests for every one a key weighing 1 takes, evenly over a few requests. One that fails is passed over while it rests, and the others share its requests; a conversation stays with its key as Stays says.": "Anfragen verteilen sich nach dem Gewicht neben jedem Schlüssel: Einer mit Gewicht 3 bekommt drei Anfragen für jede, die einer mit Gewicht 1 bekommt, schon über wenige Anfragen gleichmäßig. Ein fehlschlagender wird übersprungen, solange er pausiert, und die anderen teilen sich seine Anfragen; ein Gespräch bleibt bei seinem Schlüssel, wie „Bleibt“ es sagt.",
"Weight {n} · {share}%": "Gewicht {n} · {share}%",
"Its share of the requests: a key weighing 3 takes three for every one a key weighing 1 takes": "Sein Anteil an den Anfragen: Ein Schlüssel mit Gewicht 3 bekommt drei für jede, die einer mit Gewicht 1 bekommt",
"{key} weighs {n}": "{key} hat jetzt Gewicht {n}",
"Requests go to the first; the next takes over when the one before runs out of quota, hits a rate limit or fails.": "Anfragen gehen an das erste; das nächste übernimmt, wenn das vorherige kein Kontingent mehr hat, ein Rate-Limit erreicht oder fehlschlägt.",
"Each turn of a conversation goes to the next one, spreading the load evenly; the requests within a turn stay where it began, so the prompt cache holds, and one that fails is passed over while it rests.": "Jede Runde eines Gesprächs geht an das nächste, damit sich die Last gleichmäßig verteilt; die Anfragen einer Runde bleiben dort, wo sie begann, damit der Prompt-Cache greift, und ein fehlgeschlagenes wird übersprungen, solange es pausiert.",
"Each request goes to the one used least: a subscription by the share of its allowance used, a key by the tokens it served in the last hours.": "Jede Anfrage geht an das am wenigsten genutzte: ein Abo nach dem verbrauchten Anteil seines Kontingents, ein Schlüssel nach den Token, die er in den letzten Stunden geliefert hat.",
+5 -2
View File
@@ -297,6 +297,7 @@
? t("In order: with {rested} resting after a failure, {who} is the first that can answer.", { rested: rested.join(", "), who: w })
: t("In order: {who} is first, and answers everything while it can.", { who: w });
case "rotate": return t("In turn: it's {who}'s turn — each request starts one further along.", { who: w });
case "weight": return t("By weight: it's {who}'s share — each key takes requests as its weight says.", { who: w });
case "usage":
if (f.kind === "account" && f.known) return t("Least used first: {who} has the most of its allowance left — {n} used.", { who: w, n: pct(f.used) });
if (f.kind === "key") return t("Least used first: {who} served the fewest tokens lately — {n}.", { who: w, n: tokens(f.tokens || 0) });
@@ -2291,7 +2292,7 @@
// in turn goes round already, and a manual group sends to one member
// how long a group's member may take to its first token (provider.Group.FirstToken)
const FIRST_OPTS = [[0, "Wait"], [30, "30 s"], [60, "1 min"], [120, "2 min"]];
const sinkable = (routing) => routing !== "rotate" && routing !== "manual";
const sinkable = (routing) => routing !== "rotate" && routing !== "weight" && 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.",
@@ -3286,8 +3287,10 @@
try { await api("provider/" + what, body); status(ok, "ok"); groups = await api("groups"); load(); } catch (e) { status(e.message, "err"); }
};
const lab = (s, c) => { const w = el("span", "lab"); w.append(el("small", "", s), c); return w; };
// by weight is a key's share (#841), set beside each key in the provider's editor
const opts = p.kind === "account" ? ROUTE_OPTS : [...ROUTE_OPTS, ["weight", "By weight"]];
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("Routing"), segs(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(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]) }))));
+5 -2
View File
@@ -987,7 +987,7 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
in.Contexts = old.Contexts // a save that doesn't say
}
if in.Key == old.Key {
in.KeyName, in.KeyProtocol = old.KeyName, old.KeyProtocol
in.KeyName, in.KeyProtocol, in.KeyWeight = old.KeyName, old.KeyProtocol, old.KeyWeight
}
}
if in.Icon == "" && old != nil && in.Preset == "" {
@@ -1358,6 +1358,7 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
var in struct {
ID, Key, Name, Ref string
Protocol provider.Protocol
Weight int
}
var added, had int
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
@@ -1374,6 +1375,8 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
added, had, err = provider.AddKeys(in.ID, provider.SplitKeys(in.Key), in.Protocol)
case "protocol":
err = provider.SetKeyProtocol(in.ID, in.Ref, in.Protocol)
case "weight":
err = provider.SetKeyWeight(in.ID, in.Ref, in.Weight)
case "use":
err = provider.UseKey(in.ID, in.Ref)
case "remove":
@@ -1543,7 +1546,7 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
// all) is the saved one's; the provider's other keys are kept.
func typed(p, in provider.Provider, proxy *string) provider.Provider {
if k := strings.TrimSpace(in.Key); k != "" && k != p.Key {
p.Key, p.KeyName, p.KeyProtocol = k, "", ""
p.Key, p.KeyName, p.KeyProtocol, p.KeyWeight = k, "", "", 0
}
for _, f := range []struct {
to *string
+114
View File
@@ -0,0 +1,114 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// Weighted keys (#841): a key provider's Routing has By weight, and with it
// picked each key that's on shows its weight and its share of the
// requests beside it; a click edits the weight in place and posts
// keys/weight. Hidden under the other routings, and offered to key
// providers only. The page doesn't scroll. In 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 relay = () => ({
id: "relay", name: "Relay", icon: "generic", chat: "https://relay.example.com/v1", responses: "", anthropic: "", catalog: "",
models: [{ id: "big", name: "", on: true }], agents: [], fallback: [], headers: {}, routing: "",
key: { set: true, masked: "sk-…one" }, balanceToken: { takes: false, set: false }, proxy: "",
keyList: [
{ id: "aaaaaaaaaa", name: "team-a", masked: "sk-…one", on: true, active: true, weight: 3 },
{ id: "bbbbbbbbbb", name: "team-b", masked: "sk-…two", on: true },
{ id: "cccccccccc", name: "", masked: "sk-…off", on: false },
],
});
function serve(lang, posts) {
const p = relay();
const providers = { providers: [p], presets: [], excluded: [], gateway: { running: true, window: true } };
const state = { agents: [], profiles: [], settings: { lang, theme: "light" } };
return async (route) => {
const url = new URL(route.request().url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json(state);
if (url.pathname === "/api/providers") return json(providers);
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/keys/weight" && route.request().method() === "POST") {
const body = route.request().postDataJSON();
posts.push(body);
p.keyList = p.keyList.map((k) => k.id === body.ref ? { ...k, weight: body.weight } : k);
return json(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)];
await route.fulfill({ body: await fs.readFile(file), contentType });
};
}
const words = {
en: { by: "By weight", a: "Weight 3 · 75%", b: "Weight 1 · 25%", b2: "Weight 2 · 40%", a2: "Weight 3 · 60%", note: "team-b weighs 2" },
zh: { by: "按权重", a: "权重 3 · 75%", b: "权重 1 · 25%", b2: "权重 2 · 40%", a2: "权重 3 · 60%", note: "team-b 的权重已设为 2" },
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
const w = words[lang];
test(`${engine} ${lang}: By weight shows each key's weight and share, and a click sets it`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const page = await (await browser.newContext({ viewport: { width: 900, height: 760 }, reducedMotion: "reduce" })).newPage();
t.after(async () => {
if (process.env.ARTIFACT_DIR) {
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-key-weight.png`) });
}
await browser.close();
});
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
const posts = [];
await page.route("**/*", serve(lang, posts));
await page.goto("http://magpie.test/?view=providers");
await page.locator(".row.provider", { hasText: "Relay" }).first().click();
await page.locator(".editor .accts .acc.add").waitFor();
const scrolled = () => page.evaluate(() => [window.scrollY, ...[...document.querySelectorAll("*")].filter((e) => e.scrollTop > 0).map((e) => `${e.className}:${e.scrollTop}`)].join(" "));
const chip = (id) => page.locator(`.acc[data-account-id="${id}"] .key-weight`);
// smart: no weights shown
assert.equal(await chip("aaaaaaaaaa").isVisible(), false);
const by = page.locator(".editor").getByRole("button", { name: w.by, exact: true });
await by.scrollIntoViewIfNeeded();
const sc = await scrolled();
await by.click();
assert.equal(await scrolled(), sc, "a click scrolled");
assert.equal((await chip("aaaaaaaaaa").textContent()).trim(), w.a);
assert.equal((await chip("bbbbbbbbbb").textContent()).trim(), w.b);
assert.equal(await chip("aaaaaaaaaa").isVisible(), true);
assert.equal(await chip("cccccccccc").count(), 0, "a key that's off has no share");
// a click edits it in place, Enter saves it
await chip("bbbbbbbbbb").click();
const box = page.locator('.acc[data-account-id="bbbbbbbbbb"] .key-weight-in');
await box.waitFor();
assert.equal(await scrolled(), sc, "a click scrolled");
await box.fill("2");
await box.press("Enter");
for (let i = 0; i < 60 && !posts.length; i++) await new Promise((r) => setTimeout(r, 50));
assert.deepEqual(posts.at(-1), { id: "relay", ref: "bbbbbbbbbb", weight: 2 });
await page.locator(`.acc[data-account-id="bbbbbbbbbb"] .key-weight`, { hasText: w.b2 }).waitFor();
assert.equal((await chip("aaaaaaaaaa").textContent()).trim(), w.a2);
// still shown: the routing picked stays in the editor's draft
assert.equal(await chip("bbbbbbbbbb").isVisible(), true);
assert.equal((await page.locator("#status").textContent()).trim(), w.note);
if (process.env.ARTIFACT_DIR) await page.locator(".editor .accts").screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-key-weight-rows.png`) });
// back to Smart hides them
await page.locator(".editor").getByRole("button", { name: lang === "en" ? "Smart" : "智能", exact: true }).first().click();
assert.equal(await chip("aaaaaaaaaa").isVisible(), false);
assert.deepEqual(errors, []);
});
}
}
+2 -2
View File
@@ -40,7 +40,7 @@ func Restore(ps []Provider) (added, replaced int, err error) {
continue
}
if p.Key == "" && len(p.Keys) == 0 {
p.Key, p.KeyName, p.Keys, p.KeyProtocol = f.Providers[i].Key, f.Providers[i].KeyName, f.Providers[i].Keys, f.Providers[i].KeyProtocol
p.Key, p.KeyName, p.Keys, p.KeyProtocol, p.KeyWeight = f.Providers[i].Key, f.Providers[i].KeyName, f.Providers[i].Keys, f.Providers[i].KeyProtocol, f.Providers[i].KeyWeight
if p.BalanceToken == "" {
p.BalanceToken = f.Providers[i].BalanceToken
}
@@ -104,7 +104,7 @@ func Mirror(ps []Provider, gs []Group) error {
continue
}
if h, ok := here[p.ID]; ok && p.Key == "" && len(p.Keys) == 0 {
p.Key, p.KeyName, p.Keys, p.KeyProtocol = h.Key, h.KeyName, h.Keys, h.KeyProtocol
p.Key, p.KeyName, p.Keys, p.KeyProtocol, p.KeyWeight = h.Key, h.KeyName, h.Keys, h.KeyProtocol, h.KeyWeight
if p.BalanceToken == "" {
p.BalanceToken = h.BalanceToken
}
+41 -5
View File
@@ -24,6 +24,10 @@ type KeyAccount struct {
// Protocol, when set, is the only one the key works with: some relays
// give out one key for Anthropic and another for OpenAI. Empty is any.
Protocol Protocol `json:"protocol,omitempty"`
// Weight is the key's share of the requests when the provider's
// routing is Weighted (#841): one with 3 takes three for every one a
// key with 1 takes. None, or 0, counts as 1.
Weight int `json:"weight,omitempty"`
}
// KeyInfo describes one of a provider's keys without giving it away.
@@ -35,6 +39,7 @@ type KeyInfo struct {
On bool `json:"on"` // in use: the first, or next in line
Protocol Protocol `json:"protocol,omitempty"` // the only one it works with
Weight int `json:"weight,omitempty"` // its share when routing is Weighted
// Rest is why the gateway passes it over now, after a failure, and
// until when; nil while it takes requests.
@@ -71,10 +76,10 @@ func KeyID(key string) string { return keyID(key) }
func (p Provider) KeyList() []KeyInfo {
var out []KeyInfo
if p.Key != "" {
out = append(out, KeyInfo{ID: keyID(p.Key), Name: p.KeyName, Masked: Mask(p.Key), Active: true, On: true, Protocol: p.KeyProtocol})
out = append(out, KeyInfo{ID: keyID(p.Key), Name: p.KeyName, Masked: Mask(p.Key), Active: true, On: true, Protocol: p.KeyProtocol, Weight: p.KeyWeight})
}
for _, k := range p.Keys {
out = append(out, KeyInfo{ID: keyID(k.Key), Name: k.Name, Masked: Mask(k.Key), On: !k.Off, Protocol: k.Protocol})
out = append(out, KeyInfo{ID: keyID(k.Key), Name: k.Name, Masked: Mask(k.Key), On: !k.Off, Protocol: k.Protocol, Weight: k.Weight})
}
return out
}
@@ -187,11 +192,11 @@ func AddKeys(id string, keys []string, proto Protocol) (added, had int, err erro
// first is the first key as a KeyAccount, and setFirst makes k the first.
func (p *Provider) first() KeyAccount {
return KeyAccount{Name: p.KeyName, Key: p.Key, Protocol: p.KeyProtocol}
return KeyAccount{Name: p.KeyName, Key: p.Key, Protocol: p.KeyProtocol, Weight: p.KeyWeight}
}
func (p *Provider) setFirst(k KeyAccount) {
p.Key, p.KeyName, p.KeyProtocol = k.Key, k.Name, k.Protocol
p.Key, p.KeyName, p.KeyProtocol, p.KeyWeight = k.Key, k.Name, k.Protocol, k.Weight
}
func keyProtocolOK(proto Protocol) error {
@@ -223,10 +228,41 @@ func SetKeyProtocol(id, keyRef string, proto Protocol) error {
return Save(*p)
}
// MaxKeyWeight is the most a key's weight can be.
const MaxKeyWeight = 1000
// SetKeyWeight sets a key's share of the requests under Weighted routing;
// 0 takes it back to the default, 1.
func SetKeyWeight(id, keyRef string, weight int) error {
if weight < 0 || weight > MaxKeyWeight {
return fmt.Errorf("a key's weight is 0 to %d", MaxKeyWeight)
}
p, err := Find(id)
if err != nil {
return err
}
i, ok := findKey(p, keyRef)
if !ok {
return fmt.Errorf("%s has no such key", p.Name)
}
if weight == 1 {
weight = 0 // the default: kept out of the file
}
if i < 0 {
p.KeyWeight = weight
} else {
p.Keys[i].Weight = weight
}
return Save(*p)
}
// WeightOf is the key's weight as Weighted routing counts it: 1 when none.
func (k KeyAccount) WeightOf() int { return max(k.Weight, 1) }
// WithKey is p using key k: its endpoints narrowed to k's protocol when k
// has one. It has none left when p doesn't serve that protocol.
func (p Provider) WithKey(k KeyAccount) Provider {
p.Key, p.KeyName, p.KeyProtocol = k.Key, k.Name, k.Protocol
p.Key, p.KeyName, p.KeyProtocol, p.KeyWeight = k.Key, k.Name, k.Protocol, k.Weight
if k.Protocol != "" {
for _, pr := range Protocols {
if pr != k.Protocol {
+6 -3
View File
@@ -59,6 +59,8 @@ type Provider struct {
// for: a relay that hands out one key for Anthropic and another for
// OpenAI (see KeyAccount.Protocol).
KeyProtocol Protocol `json:"keyProtocol,omitempty"`
// KeyWeight is the first key's weight (see KeyAccount.Weight).
KeyWeight int `json:"keyWeight,omitempty"`
// Base URLs, one per protocol the vendor serves natively. magpie appends
// the usual paths: chat/responses bases end in /v1 (OpenAI style),
@@ -83,7 +85,8 @@ type Provider struct {
// can't take it; "rotate" each in turn; "usage" the least used first;
// "pace" the one with the most remaining allowance per hour until
// its window resets first, so less allowance is lost at reset.
// The window can be shorter than a week.
// The window can be shorter than a week. "weight" by each key's
// weight, a key with 3 taking three requests to one with 1's.
// Whichever it is, one out of credit, out of quota, rate limited or
// failing is passed over for as long as that lasts.
Routing string `json:"routing,omitempty"`
@@ -583,7 +586,7 @@ func AddCopy(p Provider, from string) (string, error) {
return "", fmt.Errorf("%s is a signed-in account, which can't be copied", src.Name)
}
if p.Key == "" {
p.Key, p.KeyName, p.KeyProtocol = src.Key, src.KeyName, src.KeyProtocol
p.Key, p.KeyName, p.KeyProtocol, p.KeyWeight = src.Key, src.KeyName, src.KeyProtocol, src.KeyWeight
p.Keys = slices.Clone(src.Keys)
p.Routing, p.Sink, p.Affinity = src.Routing, src.Sink, src.Affinity
}
@@ -835,7 +838,7 @@ func normalize(p Provider) Provider {
p.Decide = pr.Decide
}
}
if p.Routing != Ordered && p.Routing != Rotate && p.Routing != LeastUsed && p.Routing != Pace {
if p.Routing != Ordered && p.Routing != Rotate && p.Routing != LeastUsed && p.Routing != Pace && p.Routing != Weighted {
p.Routing = ""
}
if !slices.Contains(Affinities, p.Affinity) {
+3
View File
@@ -24,6 +24,9 @@ const (
Rotate = "rotate"
LeastUsed = "usage"
Pace = "pace"
// Weighted spreads a provider's requests over its keys by each key's
// weight (KeyAccount.Weight, #841), smoothly: 3 and 1 go a, a, b, a…
Weighted = "weight"
)
// SetRouting changes how a provider's requests spread over its keys or
+2
View File
@@ -46,6 +46,8 @@ func routingName(v string) string {
return "least used"
case provider.Pace:
return "weekly pace"
case provider.Weighted:
return "by weight"
case provider.Manual:
return "manual"
}