routing: In order keeps sending to the first account until it is used up or refused, and a tick keeps Codex or Claude Code signed in to the first account (#530: an account In order at 98% of its week, its five hours untouched, stopped getting requests; #524: "不允许切换首选账号的登录状态", the agent's own sign-in must stay on the account made first while the gateway still spreads requests) — since #209 magpie signs Codex and Claude Code in to the next ticked account with room once the one they are on reaches 98% (provider.SpentShare, the share Smart counts an account spent at), and since #408 back once the first is under 90%. The account the agent is signed in to is the one every routing tries first (the others stand behind it, AlsoOn), so In order the 98% switch put the second account ahead of the first, which is what #530 saw as the first being dropped; and the gateway counted an account at 98% as full whatever the routing, so a passing failure on it (a 502) rested it until its week renewed (39 hours there), not for the usual backoff. Now SpentShareOf gives 100 In order and 98 otherwise: the login switch moves the agent off an account In order only once a window of it is used up, the gateway's full (the rest after a failure, the quota rest's window) and affinity's "spent" go by the same share, and a vendor's 429 or usage-limit refusal still rests the account until it says it's back, as Smart does; a window used up is out, as before. Smart, In turn and Least used are unchanged. For #524, the subscription keeps KeepLogin (providers.json, beside Routing, kept across an editor Save; POST provider/keeplogin, only for Codex and Claude Code): with it on, magpie never moves the agent off the account it is signed in to, however used up, while the gateway still routes requests through magpie to the other ticked accounts; a pending return to the first (#408) still happens, that being the first. The tick sits at the foot of the Accounts list, under the rows where Make first is, shown only for Codex and Claude Code with more than one account on: "Keep {agent} signed in to the first account" / "{agent} 始终登录首选账号", its tooltip saying requests through magpie still go to the other ticked accounts. The Routing note follows it: kept, it says the agent stays signed in to the first whatever it has left; In order, that magpie moves it on once it is used up; otherwise the 98% wording as before. TestCodexLoginOrderedAndKept (In order stays at 98% and moves at 100%, back once renewed; Smart moves at 98%; kept, it stays at 100% and the setting survives a SetRouting; let go, it moves) and TestOrderedKeepsAnAccountAt98 (In order an account at 98% isn't full and a 502 rests it by backoff, Smart rests it to its week, a refusal rests it either way) fail without the change. keep-login.test.cjs (en and zh, Chromium and WebKit: the tick, its tooltip and Chinese strings, the post, the note when kept and In order, no tick with one account, the page doesn't move) fails without the UI and passes with it (12/12, Playwright 1.61); account-return, routing-account-order and account-arrange pass; click-scroll fails in Chromium here on origin/main too and passes in WebKit. No request was made to any vendor and no live agent config was touched. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestClaudeRunEndsWhenTheClientLeaves, TestClaudeAccountNamedAsItsLogin and TestClaudeIdentityServedWhileAsked failed once under the full run and pass alone). Turned on after magpie has moved the agent off the first, it goes back to it at once rather than once that has room again.
This commit is contained in:
1 parent
d5322cfbf1
commit
817a5f50d5
12 files changed
+419
-22
No files matched your search
@@ -262,7 +262,7 @@ func affine(scope, mode string, rotate bool, in http.Header, from provider.Proto
|
||||
a.Why = "gone"
|
||||
case pl.order[at].Rest != nil:
|
||||
a.Why = "resting"
|
||||
case pl.order[at].Known && pl.order[at].Used >= usedShare:
|
||||
case pl.order[at].Known && pl.order[at].Used >= provider.SpentShareOf(cs[at].p.Routing):
|
||||
a.Why = "spent"
|
||||
case mode == provider.AffinitySession:
|
||||
a.Why = "session"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// In order, an account at 98% of its week is still tried in its turn, and
|
||||
// a failure that isn't the vendor saying it's out rests it as any failure,
|
||||
// not until the week renews; Smart keeps it for when nothing else can take
|
||||
// a request, as before. A week used up is out either way (#530).
|
||||
func TestOrderedKeepsAnAccountAt98(t *testing.T) {
|
||||
old := allowances
|
||||
defer func() { allowances = old }()
|
||||
now := time.Now()
|
||||
week := now.Add(39 * time.Hour)
|
||||
allowances = func(string) map[string]provider.Allowance {
|
||||
return map[string]provider.Allowance{
|
||||
"low@x.com": {{Used: 98, Resets: week, Span: 7 * 24 * time.Hour}, {Used: 0, Resets: now.Add(5 * time.Hour), Span: 5 * time.Hour}},
|
||||
"full@x.com": {{Used: 100, Resets: week, Span: 7 * 24 * time.Hour}},
|
||||
}
|
||||
}
|
||||
acct := func(routing, user string) candidate {
|
||||
return candidate{p: provider.Provider{ID: "codex", Routing: routing, Account: &provider.Account{Agent: "codex", User: user}}, model: "gpt-5.6-sol", rest: "codex#" + routing + user}
|
||||
}
|
||||
s := &Server{}
|
||||
near := func(d, want time.Duration) bool { return d > want-time.Minute && d <= want }
|
||||
|
||||
if !acct(provider.Ordered, "low@x.com").full(now).IsZero() {
|
||||
t.Fatal("in order, an account at 98% counts as full")
|
||||
}
|
||||
if !acct("", "low@x.com").full(now).Equal(week) {
|
||||
t.Fatal("smart no longer counts an account at 98% as full")
|
||||
}
|
||||
if !acct(provider.Ordered, "full@x.com").full(now).Equal(week) {
|
||||
t.Fatal("in order, a week used up isn't full")
|
||||
}
|
||||
|
||||
r := s.restAfter(acct(provider.Ordered, "low@x.com"), 502, http.Header{}, []byte("bad gateway"))
|
||||
if r.By != "backoff" || !near(time.Until(r.Until), fallbackCooldown) {
|
||||
t.Fatalf("in order, a failure at 98%% rests %v by %s", time.Until(r.Until), r.By)
|
||||
}
|
||||
r = s.restAfter(acct("", "low@x.com"), 502, http.Header{}, []byte("bad gateway"))
|
||||
if r.By != "window" || !near(time.Until(r.Until), 39*time.Hour) {
|
||||
t.Fatalf("smart, a failure at 98%% rests %v by %s", time.Until(r.Until), r.By)
|
||||
}
|
||||
// the vendor saying it's out rests it until then, in order too
|
||||
r = s.restAfter(acct(provider.Ordered, "full@x.com"), 429, http.Header{}, []byte(`{"error":{"message":"You've hit your usage limit"}}`))
|
||||
if r.Why != failQuota || r.By != "window" || !near(time.Until(r.Until), 39*time.Hour) {
|
||||
t.Fatalf("in order, used up rests %v by %s (%s)", time.Until(r.Until), r.By, r.Why)
|
||||
}
|
||||
// and with no window known full, until the quota rest
|
||||
r = s.restAfter(acct(provider.Ordered, "low@x.com"), 429, http.Header{}, []byte(`{"error":{"message":"You've hit your usage limit"}}`))
|
||||
if r.Why != failQuota || r.By != "quota" {
|
||||
t.Fatalf("in order, refused at 98%%: rests %v by %s (%s)", time.Until(r.Until), r.By, r.Why)
|
||||
}
|
||||
}
|
||||
@@ -365,12 +365,15 @@ func (s *Server) restAfterMarked(c candidate, status int, header http.Header, bo
|
||||
}
|
||||
|
||||
// full is when a subscription whose allowance, as last known, has a window
|
||||
// used up for the candidate's model renews; zero otherwise.
|
||||
// used up for the candidate's model renews; zero otherwise. Used up is
|
||||
// all but (usedShare), except In order: there an account at 98% is still
|
||||
// tried in its turn, and one that fails then isn't benched until its week
|
||||
// renews unless the vendor said it was out (#530).
|
||||
func (c candidate) full(now time.Time) time.Time {
|
||||
if c.p.Account == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return allowances(c.p.Account.UsageAgent())[c.p.Account.User].Full(c.model, usedShare, now)
|
||||
return allowances(c.p.Account.UsageAgent())[c.p.Account.User].Full(c.model, provider.SpentShareOf(c.p.Routing), now)
|
||||
}
|
||||
|
||||
// keepRetry passes on, with a vendor's error, what it said about when to
|
||||
|
||||
@@ -1575,6 +1575,7 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
|
||||
.accts { display: grid; border: 1px solid var(--line); border-radius: 10px; background: var(--card); overflow: hidden; }
|
||||
.acc { display: flex; align-items: center; gap: 9px; min-height: 38px; padding: 5px 8px 5px 11px; font-size: 12.5px; text-align: left; }
|
||||
.acc + .acc, .accts .signing { border-top: 1px solid var(--line-2); }
|
||||
.editor .accts label.keep-login { min-height: 34px; padding: 6px 11px; font-size: 12px; border-top: 1px solid var(--line-2); }
|
||||
.acc .n { font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.acc .plan { color: var(--muted); font-size: 11px; padding: 1px 6px; border-radius: 5px; background: var(--pill); white-space: nowrap; }
|
||||
.acc .grow { flex: 1; }
|
||||
|
||||
@@ -6079,9 +6079,13 @@ function renderRouting(p) {
|
||||
// and back once the first has room (provider.KeepOnAnAccountWithRoom,
|
||||
// #209, #408)
|
||||
const a = p.account;
|
||||
const own = a && (a.agent === "codex" || a.agent === "claude")
|
||||
? " " + t("Routing picks the account for each request through magpie; {agent} on its own uses the one it is signed in to, which magpie moves to the next ticked account with room once it is 98% used, and back to the first once that has room again.", { agent: a.agentName })
|
||||
: "";
|
||||
// — at 98%, In order once used up (#530), never when kept on the first (#524)
|
||||
const own = !a || (a.agent !== "codex" && a.agent !== "claude") ? ""
|
||||
: " " + (p.keepLogin
|
||||
? t("Routing picks the account for each request through magpie; {agent} on its own stays signed in to the first account, whatever it has left.", { agent: a.agentName })
|
||||
: cur[0] === "order"
|
||||
? t("Routing picks the account for each request through magpie; {agent} on its own uses the one it is signed in to, which magpie moves to the next ticked account with room once it is used up, and back to the first once that has room again.", { agent: a.agentName })
|
||||
: t("Routing picks the account for each request through magpie; {agent} on its own uses the one it is signed in to, which magpie moves to the next ticked account with room once it is 98% used, and back to the first once that has room again.", { agent: a.agentName }));
|
||||
return field(t("Routing"), pick, t(cur[2]) + own);
|
||||
}
|
||||
|
||||
@@ -6868,6 +6872,17 @@ function renderAccounts(a, p) {
|
||||
if (amBox) row.append(amBox);
|
||||
list.append(row);
|
||||
}
|
||||
// magpie signs Codex or Claude Code in to the next account when the
|
||||
// first runs low (#209, #408); this keeps it on the first instead, the
|
||||
// gateway still spreading requests over the ticked ones (#524)
|
||||
if ((a.agent === "codex" || a.agent === "claude") && several && p) {
|
||||
const [keep, cb] = tick(t("Keep {agent} signed in to the first account", { agent: a.agentName }), !!p.keepLogin);
|
||||
keep.classList.add("keep-login");
|
||||
keep.title = t("magpie won't sign {agent} in to another account when the first runs low; requests through magpie still go to the other ticked accounts as Routing says", { agent: a.agentName });
|
||||
cb.onchange = () => accountAction("provider/keeplogin", { id: p.id, keepLogin: cb.checked },
|
||||
cb.checked ? t("{agent} stays signed in to the first account", { agent: a.agentName }) : t("magpie moves {agent} to an account with room again", { agent: a.agentName }));
|
||||
list.append(keep);
|
||||
}
|
||||
if (a.agent === "codex" && providers?.codexDaemon) list.append(renderCodexDaemon(providers.codexDaemon));
|
||||
if (signing?.agent === a.agent) list.append(renderSigning(sub));
|
||||
else {
|
||||
|
||||
@@ -502,6 +502,12 @@ const I18N = {
|
||||
"Tick every key to use; Routing says how requests spread over them.": "勾选的密钥会同时启用;请求怎么分给它们,由下面的「路由」决定。",
|
||||
"Tick every account to use; Routing says how requests spread over them.": "勾选的账号会同时启用;请求怎么分给它们,由下面的「路由」决定。",
|
||||
"Routing picks the account for each request through magpie; {agent} on its own uses the one it is signed in to, which magpie moves to the next ticked account with room once it is 98% used, and back to the first once that has room again.": "经过 magpie 的请求由路由挑选账号;{agent} 自己直连时用的是它登录的账号——这个账号用到 98% 时,magpie 会把 {agent} 登录到下一个已勾选且还有余量的账号,等首选账号额度恢复后再切回去。",
|
||||
"Routing picks the account for each request through magpie; {agent} on its own uses the one it is signed in to, which magpie moves to the next ticked account with room once it is used up, and back to the first once that has room again.": "经过 magpie 的请求由路由挑选账号;{agent} 自己直连时用的是它登录的账号——这个账号额度用完时,magpie 会把 {agent} 登录到下一个已勾选且还有余量的账号,等首选账号额度恢复后再切回去。",
|
||||
"Routing picks the account for each request through magpie; {agent} on its own stays signed in to the first account, whatever it has left.": "经过 magpie 的请求由路由挑选账号;{agent} 自己直连时始终登录首选账号,不管它还剩多少额度。",
|
||||
"Keep {agent} signed in to the first account": "{agent} 始终登录首选账号",
|
||||
"magpie won't sign {agent} in to another account when the first runs low; requests through magpie still go to the other ticked accounts as Routing says": "首选账号额度快用完时,magpie 不会把 {agent} 登录到别的账号;经过 magpie 的请求仍按「路由」分给其他已勾选的账号",
|
||||
"{agent} stays signed in to the first account": "{agent} 将始终登录首选账号",
|
||||
"magpie moves {agent} to an account with room again": "首选账号额度不足时,magpie 会再次把 {agent} 切换到有余量的账号",
|
||||
"Smart": "智能",
|
||||
"The first takes requests while it has quota to spare; when it runs low, the one with the most left takes over. One out of credit sits out half an hour, one out of quota until it resets, one rate limited as long as the vendor asks, and one that fails a minute, longer each time it fails again.": "额度充足时请求都给第一个(保持缓存命中);额度快用完时,自动换到剩余额度最多的那个。欠费的暂停半小时,额度用完的暂停到额度重置,被限流的按厂商要求的时间暂停,出错的暂停一分钟、连续出错则越停越久——期间请求自动转给其他可用的。",
|
||||
"In order": "按顺序",
|
||||
|
||||
@@ -94,6 +94,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
|
||||
// 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"`
|
||||
// how many requests each of its keys or accounts has out at once, the
|
||||
// rest queued: the user's (null: not set), and what its plugin says
|
||||
// when the user set none (provider.Concurrency)
|
||||
@@ -268,7 +271,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, Unlisted: p.Unlisted, Off: p.Off, Contexts: p.Contexts,
|
||||
Fallback: p.Fallback, Routing: p.Routing, Affinity: p.Affinity, KeepLogin: p.KeepLogin, Unlisted: p.Unlisted, Off: p.Off, Contexts: p.Contexts,
|
||||
MaxConcurrency: p.MaxConcurrency, PluginConcurrency: p.PluginConcurrency(),
|
||||
}
|
||||
if out.Fallback == nil {
|
||||
@@ -750,8 +753,9 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
|
||||
if old != nil {
|
||||
// the other keys are kept apart, in the Accounts list
|
||||
in.Keys = old.Keys
|
||||
in.Routing = old.Routing // set on its own, with route
|
||||
in.Off = old.Off // and this with off and on
|
||||
in.Routing = old.Routing // set on its own, with route
|
||||
in.KeepLogin = old.KeepLogin // and this with keeplogin
|
||||
in.Off = old.Off // and this with off and on
|
||||
if in.Contexts == nil {
|
||||
in.Contexts = old.Contexts // a save that doesn't say
|
||||
}
|
||||
@@ -840,6 +844,12 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
|
||||
return
|
||||
}
|
||||
provider.ForgetBalances()
|
||||
case "keeplogin":
|
||||
// Codex or Claude Code stays signed in to the first account (#524)
|
||||
if err := provider.SetKeepLogin(in.ID, in.KeepLogin); err != nil {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
case "affinity":
|
||||
if err := provider.SetAffinity(in.ID, in.Affinity); err != nil {
|
||||
fail(rw, err)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// #524: under Codex's accounts, with more than one on, a tick keeps Codex
|
||||
// signed in to the first account; ticking it posts provider/keeplogin, and
|
||||
// the Routing note then says Codex stays on the first. #530: In order, the
|
||||
// note says magpie moves Codex on once the account is used up, not at 98%.
|
||||
// With one account there is nothing to keep, so no tick. The page doesn't
|
||||
// move. In English and Chinese, Chromium and WebKit.
|
||||
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 codex = ({ keepLogin = false, routing = "", spare = true } = {}) => ({
|
||||
id: "codex", name: "Codex", icon: "codex-color", chat: "", responses: "", anthropic: "", catalog: "", routing, keepLogin,
|
||||
models: [{ id: "gpt-6", name: "GPT-6", on: true }], agents: [], fallback: [], headers: {}, keyList: [],
|
||||
account: {
|
||||
agent: "codex", agentName: "Codex", user: "work@example.com", plan: "PLUS",
|
||||
logins: [
|
||||
{ user: "work@example.com", plan: "PLUS", active: true, on: true },
|
||||
{ user: "spare@example.com", plan: "PRO", on: spare },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
function serve(lang, first, posts) {
|
||||
let list = [first];
|
||||
const state = { agents: [], profiles: [], settings: { lang, theme: "light" } };
|
||||
const providers = () => ({ providers: list, presets: [], excluded: [], gateway: { running: true, window: true } });
|
||||
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/provider/keeplogin") {
|
||||
const body = route.request().postDataJSON();
|
||||
posts.push(body);
|
||||
list = [{ ...list[0], keepLogin: body.keepLogin }];
|
||||
return json(providers());
|
||||
}
|
||||
if (url.pathname === "/api/groups") return json({ groups: [] });
|
||||
if (url.pathname === "/api/plugins") return json({ plugins: [] });
|
||||
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: { keep: "Keep Codex signed in to the first account", kept: /Codex on its own stays signed in to the first account/, moves: /once it is 98% used/, usedUp: /once it is used up/ },
|
||||
zh: { keep: "Codex 始终登录首选账号", kept: /Codex 自己直连时始终登录首选账号/, moves: /用到 98% 时/, usedUp: /额度用完时/ },
|
||||
};
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
for (const lang of ["en", "zh"]) {
|
||||
const w = words[lang];
|
||||
const open = async (t, opts) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(() => browser.close());
|
||||
const page = await (await browser.newContext({ viewport: { width: 900, height: 760 }, reducedMotion: "reduce" })).newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [], posts = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", serve(lang, codex(opts), posts));
|
||||
await page.goto("http://magpie.test/?view=providers");
|
||||
await page.locator(".row.provider", { hasText: "Codex" }).first().click();
|
||||
await page.locator(".editor .acc").first().waitFor();
|
||||
return { page, errors, posts };
|
||||
};
|
||||
|
||||
test(`${engine} ${lang}: a tick keeps Codex signed in to the first account`, async (t) => {
|
||||
const { page, errors, posts } = await open(t, {});
|
||||
const keep = page.locator(".editor .accts label.keep-login");
|
||||
assert.equal((await keep.textContent()).trim(), w.keep);
|
||||
assert.ok(await keep.getAttribute("title"), "it says what it does");
|
||||
assert.equal(await keep.locator("input").isChecked(), false);
|
||||
assert.match(await page.locator(".editor").textContent(), w.moves);
|
||||
const missing = await page.evaluate(() => [
|
||||
"Keep {agent} signed in to the first account",
|
||||
"magpie won't sign {agent} in to another account when the first runs low; requests through magpie still go to the other ticked accounts as Routing says",
|
||||
"{agent} stays signed in to the first account",
|
||||
"magpie moves {agent} to an account with room again",
|
||||
"Routing picks the account for each request through magpie; {agent} on its own stays signed in to the first account, whatever it has left.",
|
||||
"Routing picks the account for each request through magpie; {agent} on its own uses the one it is signed in to, which magpie moves to the next ticked account with room once it is used up, and back to the first once that has room again.",
|
||||
].filter((k) => !I18N.zh[k]));
|
||||
assert.deepEqual(missing, [], "every string has its Chinese");
|
||||
const top = await page.evaluate(() => document.scrollingElement.scrollTop);
|
||||
await keep.locator("input").click();
|
||||
// the answer drawn: the note says so, and the box is ticked
|
||||
const says = (re) => page.waitForFunction((src) => new RegExp(src).test(document.querySelector(".editor")?.textContent || ""), re.source);
|
||||
await says(w.kept);
|
||||
assert.equal(await page.locator(".editor .accts label.keep-login input").isChecked(), true);
|
||||
assert.deepEqual(posts, [{ id: "codex", keepLogin: true }]);
|
||||
assert.match(await page.locator(".editor").textContent(), w.kept);
|
||||
assert.doesNotMatch(await page.locator(".editor").textContent(), w.moves);
|
||||
assert.equal(await page.evaluate(() => document.scrollingElement.scrollTop), top, "the page doesn't move");
|
||||
await page.locator(".editor .accts label.keep-login input").click();
|
||||
await says(w.moves);
|
||||
assert.equal(await page.locator(".editor .accts label.keep-login input").isChecked(), false);
|
||||
assert.deepEqual(posts[1], { id: "codex", keepLogin: false });
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test(`${engine} ${lang}: in order, Codex is moved on once the account is used up`, async (t) => {
|
||||
const { page, errors } = await open(t, { routing: "order" });
|
||||
const text = await page.locator(".editor").textContent();
|
||||
assert.match(text, w.usedUp);
|
||||
assert.doesNotMatch(text, w.moves);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test(`${engine} ${lang}: with one account on, there is nothing to keep`, async (t) => {
|
||||
const { page, errors } = await open(t, { spare: false });
|
||||
assert.equal(await page.locator(".editor .accts label.keep-login").count(), 0);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,14 @@ package provider
|
||||
// It moves at the share Smart routing counts an account spent at, so the
|
||||
// account the agent is signed in to and the one the gateway goes to agree
|
||||
// on which is out (#209): the sign-in follows Smart; Smart doesn't follow
|
||||
// the sign-in.
|
||||
// the sign-in. In order, the gateway goes to the first until it is used
|
||||
// up or refused, so the sign-in moves only once it is used up (#530).
|
||||
//
|
||||
// The account it moved from is the one the user made first, and it stays
|
||||
// that: once it is no longer low (backShare) the agent is signed back in
|
||||
// to it, as Smart gives it requests again then (#408). A switch the user
|
||||
// makes meanwhile ends that.
|
||||
// makes meanwhile ends that. With KeepLogin on the subscription, the agent
|
||||
// stays signed in to the first, whatever it has left (#524).
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -45,6 +47,29 @@ const loginSwitchEvery = 5 * time.Minute
|
||||
// request, and the agent signed in to it is signed in to another.
|
||||
const SpentShare = 98
|
||||
|
||||
// SpentShareOf is the share past which routing counts an account spent:
|
||||
// SpentShare, but In order sends requests to the first until a window of it
|
||||
// is used up or the vendor refuses it, so there an account at 98% is still
|
||||
// tried in its turn (#530).
|
||||
func SpentShareOf(routing string) float64 {
|
||||
if routing == Ordered {
|
||||
return 100
|
||||
}
|
||||
return SpentShare
|
||||
}
|
||||
|
||||
// loginSwitching is how magpie moves agent's sign-in, as its subscription
|
||||
// says: the share the account it is on is moved off at, and whether it is
|
||||
// kept on the first instead (KeepLogin).
|
||||
func loginSwitching(agent string) (share float64, keep bool) {
|
||||
for _, p := range load().Providers {
|
||||
if p.ID == agent {
|
||||
return SpentShareOf(p.Routing), p.KeepLogin
|
||||
}
|
||||
}
|
||||
return SpentShare, false
|
||||
}
|
||||
|
||||
// backShare is the share below which the account magpie moved the agent
|
||||
// off is signed back in to: no longer low, as Smart counts it (the
|
||||
// gateway's lowShare), so it doesn't go back and forth at the edge.
|
||||
@@ -59,12 +84,6 @@ func usedUp(q SubscriptionQuota) bool {
|
||||
return usedPast(q, 100)
|
||||
}
|
||||
|
||||
// spent reports whether an account's allowance is all but used up, as
|
||||
// Smart routing counts it: a window for every model at SpentShare.
|
||||
func spent(q SubscriptionQuota) bool {
|
||||
return usedPast(q, SpentShare)
|
||||
}
|
||||
|
||||
func usedPast(q SubscriptionQuota, share float64) bool {
|
||||
for _, w := range q.Windows {
|
||||
if !w.Aside && w.Model == "" && w.Used >= share {
|
||||
@@ -100,6 +119,12 @@ func NextLogin(ctx context.Context, agent string) (from, to string, back, ok boo
|
||||
if from == "" || len(spares) == 0 {
|
||||
return "", "", false, false
|
||||
}
|
||||
// kept on the first, the agent goes back to it at once, however little
|
||||
// that has left (#524)
|
||||
share, keep := loginSwitching(agent)
|
||||
if keep && first != nil && first.On && first.Lapsed == "" {
|
||||
return from, first.User, true, true
|
||||
}
|
||||
u := LoginUsage(ctx, agent)
|
||||
if first != nil && first.On && first.Lapsed == "" {
|
||||
if q, known := u[first.User]; known && q.Error == "" && !usedPast(q, backShare) {
|
||||
@@ -115,11 +140,13 @@ func NextLogin(ctx context.Context, agent string) (from, to string, back, ok boo
|
||||
return w.ResetsAt != nil && !w.ResetsAt.After(now)
|
||||
})
|
||||
}
|
||||
if !known || q.Error != "" || !spent(q) {
|
||||
// kept on the first, it stays there however little that has left; and
|
||||
// it moves on at the share its routing counts the account spent at
|
||||
if keep || !known || q.Error != "" || !usedPast(q, share) {
|
||||
return "", "", false, false
|
||||
}
|
||||
for _, l := range spares {
|
||||
if q, known := u[l.User]; known && q.Error == "" && !spent(q) {
|
||||
if q, known := u[l.User]; known && q.Error == "" && !usedPast(q, share) {
|
||||
return from, l.User, false, true
|
||||
}
|
||||
}
|
||||
@@ -149,7 +176,8 @@ func SwitchWhenSpent(ctx context.Context, agent string) (string, error) {
|
||||
}
|
||||
r.To = to
|
||||
setLoginReturn(agent, r)
|
||||
log.Printf("%s: %s has used %d%% or more of its allowance; signed it in to %s", agent, from, SpentShare, to)
|
||||
share, _ := loginSwitching(agent)
|
||||
log.Printf("%s: %s has used %g%% or more of its allowance; signed it in to %s", agent, from, share, to)
|
||||
}
|
||||
// the models the agent is offered are the new account's plan's
|
||||
catalog.Touched()
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// In order, Codex stays signed in to the account it is on at 98% of its
|
||||
// allowance, as the gateway still sends that one requests first, and moves
|
||||
// on once it is used up (#530); Smart moves it at 98%, as before. Kept on
|
||||
// the first (KeepLogin), it stays however used up that is, and moves on
|
||||
// again once that is off (#524).
|
||||
func TestCodexLoginOrderedAndKept(t *testing.T) {
|
||||
home := signIn(t) // me@example.com, acct-1
|
||||
rememberLogins(true)
|
||||
codexSignIn(t, home, "spare@example.com", "r-spare")
|
||||
rememberLogins(true)
|
||||
codexSignIn(t, home, "work@example.com", "r-work")
|
||||
rememberLogins(true)
|
||||
if err := SetLoginOn("codex", "spare@example.com", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
used := map[string]float64{"acct-work@example.com": 98, "acct-1": 0, "acct-spare@example.com": 20}
|
||||
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{"plan_type": "plus", "rate_limit": map[string]any{
|
||||
"primary_window": map[string]any{"used_percent": 0, "limit_window_seconds": 18000},
|
||||
"secondary_window": map[string]any{"used_percent": used[r.Header.Get("chatgpt-account-id")], "limit_window_seconds": 604800}}})
|
||||
}))
|
||||
defer fake.Close()
|
||||
old := CodexBase
|
||||
CodexBase = fake.URL + "/backend-api/codex"
|
||||
t.Cleanup(func() { CodexBase = old })
|
||||
switched := func() string {
|
||||
t.Helper()
|
||||
loginUsageCache.Lock()
|
||||
loginUsageCache.m = nil
|
||||
loginUsageCache.Unlock()
|
||||
to, err := SwitchWhenSpent(context.Background(), "codex")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return to
|
||||
}
|
||||
active := func() string {
|
||||
t.Helper()
|
||||
for _, l := range Logins("codex") {
|
||||
if l.Active {
|
||||
return l.User
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if active() != "work@example.com" {
|
||||
t.Fatalf("signed in to %s", active())
|
||||
}
|
||||
|
||||
// in order: at 98% it stays, used up it moves on
|
||||
if err := SetRouting("codex", Ordered); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if to := switched(); to != "" {
|
||||
t.Fatalf("in order, moved off an account at 98%%, to %s", to)
|
||||
}
|
||||
used["acct-work@example.com"] = 100
|
||||
if to := switched(); to != "spare@example.com" {
|
||||
t.Fatalf("in order, used up: moved to %q", to)
|
||||
}
|
||||
// renewed, back on the first
|
||||
used["acct-work@example.com"] = 0
|
||||
if to := switched(); to != "work@example.com" {
|
||||
t.Fatalf("back to %q", to)
|
||||
}
|
||||
|
||||
// smart: at 98% it moves on, as before
|
||||
if err := SetRouting("codex", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
used["acct-work@example.com"] = 98
|
||||
if to := switched(); to != "spare@example.com" {
|
||||
t.Fatalf("smart at 98%%: moved to %q", to)
|
||||
}
|
||||
used["acct-work@example.com"] = 0
|
||||
if to := switched(); to != "work@example.com" {
|
||||
t.Fatalf("back to %q", to)
|
||||
}
|
||||
|
||||
// kept on the first: it stays, used up or not; the setting outlives a
|
||||
// save of the provider's picks
|
||||
if err := SetKeepLogin("codex", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetRouting("codex", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p, err := Find("codex"); err != nil || !p.KeepLogin {
|
||||
t.Fatalf("kept on the first: %+v %v", p, err)
|
||||
}
|
||||
used["acct-work@example.com"] = 100
|
||||
if to := switched(); to != "" {
|
||||
t.Fatalf("kept on the first, moved to %s", to)
|
||||
}
|
||||
if active() != "work@example.com" {
|
||||
t.Fatalf("kept on the first, signed in to %s", active())
|
||||
}
|
||||
// let go: it moves on again
|
||||
if err := SetKeepLogin("codex", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if to := switched(); to != "spare@example.com" {
|
||||
t.Fatalf("let go: moved to %q", to)
|
||||
}
|
||||
// kept on the first again: back on it at once, used up as it is
|
||||
if err := SetKeepLogin("codex", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if to := switched(); to != "work@example.com" {
|
||||
t.Fatalf("kept on the first again: moved to %q", to)
|
||||
}
|
||||
if to := switched(); to != "" {
|
||||
t.Fatalf("kept on the first again, then moved to %s", to)
|
||||
}
|
||||
|
||||
// only Codex's and Claude Code's sign-ins are moved
|
||||
if err := SetKeepLogin("nonesuch", true); err == nil {
|
||||
t.Fatal("kept a provider that isn't there")
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,12 @@ type Provider struct {
|
||||
// "turn", "off".
|
||||
Affinity string `json:"affinity,omitempty"`
|
||||
|
||||
// KeepLogin, on Codex's or Claude Code's subscription, keeps the agent
|
||||
// signed in to the account the user made first: magpie doesn't sign it
|
||||
// in to another when that one runs low or out (#524). The gateway still
|
||||
// spreads its requests over the accounts that are on, as Routing says.
|
||||
KeepLogin bool `json:"keepLogin,omitempty"`
|
||||
|
||||
// MaxConcurrency is how many requests may be out at the vendor at once
|
||||
// on each of its keys or accounts (Discord, Lemon: a Codex account is
|
||||
// risk-controlled past five or six at once); the rest wait their turn,
|
||||
@@ -299,7 +305,7 @@ func All() []Provider {
|
||||
continue
|
||||
}
|
||||
pk := picks[a.ID]
|
||||
a.Models, a.Unlisted, a.Off, a.Fallback, a.Routing, a.Affinity, a.Contexts, a.Family = pk.Models, pk.Unlisted, pk.Off, pk.Fallback, pk.Routing, pk.Affinity, pk.Contexts, pk.Family
|
||||
a.Models, a.Unlisted, a.Off, a.Fallback, a.Routing, a.Affinity, a.KeepLogin, a.Contexts, a.Family = pk.Models, pk.Unlisted, pk.Off, pk.Fallback, pk.Routing, pk.Affinity, pk.KeepLogin, pk.Contexts, pk.Family
|
||||
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
|
||||
@@ -400,7 +406,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, 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)}
|
||||
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, 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)}
|
||||
} else {
|
||||
p.AccountProxies = nil // a provider of a key has no accounts to proxy apart
|
||||
if subscriptionID(p.ID) && !stored(p.ID) {
|
||||
|
||||
@@ -8,6 +8,8 @@ package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -32,6 +34,20 @@ func SetRouting(id, routing string) error {
|
||||
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.
|
||||
func SetKeepLogin(id string, keep bool) error {
|
||||
p, err := Find(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Account == nil || p.Account.Agent != p.ID || !slices.Contains(switchedAgents, p.ID) {
|
||||
return fmt.Errorf("magpie doesn't sign %s in to another of its accounts", p.Name)
|
||||
}
|
||||
p.KeepLogin = keep
|
||||
return Save(*p)
|
||||
}
|
||||
|
||||
// SetAffinity changes how long a provider's conversations stay with the key
|
||||
// or account that answered them.
|
||||
func SetAffinity(id, affinity string) error {
|
||||
|
||||
Reference in new issue
Block a user