From 1a0b1f9feafbcf6004770d32ca94f9fee0804e87 Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 24 Sep 2026 19:41:34 +0800 Subject: [PATCH] =?UTF-8?q?providers:=20smart=20routing,=20a=20Copilot=20s?= =?UTF-8?q?ubscription=20signed=20in=20from=20magpie,=20and=20import=20by?= =?UTF-8?q?=20app=20in=20tabs=20=E2=80=94=20Smart,=20now=20the=20default,?= =?UTF-8?q?=20keeps=20the=20first=20key=20or=20account=20while=20it=20has?= =?UTF-8?q?=20quota=20to=20spare=20and=20moves=20to=20the=20one=20with=20t?= =?UTF-8?q?he=20most=20left=20as=20it=20runs=20low;=20one=20out=20of=20cre?= =?UTF-8?q?dit=20sits=20out=20half=20an=20hour,=20one=20out=20of=20quota?= =?UTF-8?q?=20until=20it=20says=20it=20resets,=20one=20rate=20limited=20as?= =?UTF-8?q?=20long=20as=20the=20vendor=20asks,=20and=20one=20failing=20a?= =?UTF-8?q?=20minute,=20longer=20each=20time;=20Copilot=20gets=20a=20tile?= =?UTF-8?q?=20of=20its=20own=20that=20signs=20in=20with=20GitHub's=20devic?= =?UTF-8?q?e=20code=20(the=20code=20shown=20in=20magpie),=20several=20acco?= =?UTF-8?q?unts=20beside=20the=20editors'=20own;=20Import=20from=20other?= =?UTF-8?q?=20apps=20shows=20a=20tab=20per=20app=20it=20reads,=20each=20wi?= =?UTF-8?q?th=20how=20many=20providers=20it=20has=20to=20bring=20over?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gateway/gateway.go | 2 +- internal/gateway/routing.go | 169 +++++++++++++++++-- internal/gateway/routing_test.go | 78 ++++++++- internal/gui/assets/app.css | 10 ++ internal/gui/assets/app.js | 45 ++++- internal/gui/assets/i18n.js | 6 + internal/provider/account.go | 11 +- internal/provider/copilot_accounts.go | 170 +++++++++++++++++++ internal/provider/copilot_accounts_test.go | 117 +++++++++++++ internal/provider/copilot_signin.go | 124 ++++++++++++++ internal/provider/grok_accounts.go | 155 +++--------------- internal/provider/logins.go | 22 ++- internal/provider/logins_on.go | 8 +- internal/provider/provider.go | 10 +- internal/provider/routing.go | 3 +- internal/provider/side_logins.go | 181 +++++++++++++++++++++ internal/provider/signin.go | 8 +- 17 files changed, 947 insertions(+), 172 deletions(-) create mode 100644 internal/provider/copilot_accounts.go create mode 100644 internal/provider/copilot_accounts_test.go create mode 100644 internal/provider/copilot_signin.go create mode 100644 internal/provider/side_logins.go diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index a7be3406..c4c82095 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -371,7 +371,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro call.Provider, call.To, call.Usage = c.p.ID, "", Usage{} call.Status, call.Error = s.attempt(hw, r, from, c.p, c.model, body, &call) if !last && hw.failed() { - s.rest(c.rest) + s.restAfter(c.rest, hw.status, hw.header, hw.held.Bytes()) skipped = append(skipped, c.label()+": "+call.Error) continue } diff --git a/internal/gateway/routing.go b/internal/gateway/routing.go index d58dee38..10bc47ce 100644 --- a/internal/gateway/routing.go +++ b/internal/gateway/routing.go @@ -1,13 +1,21 @@ package gateway // Routing spreads a provider's requests over the keys or accounts it has -// on (see provider.Provider.Routing): in turn, or the least used first. -// Whichever goes first, a key that suits the request still goes before one -// that doesn't, and one resting after a failure still waits at the back. +// on (see provider.Provider.Routing): smartly, in order, in turn, or the +// least used first. Whichever goes first, a key that suits the request +// still goes before one that doesn't, and one resting after a failure +// waits at the back for as long as its failure says: out of credit for +// half an hour, out of quota until it says it resets, rate limited until +// it says to try again, and otherwise a minute, longer each time it +// fails again. import ( "math" + "net/http" + "regexp" "sort" + "strconv" + "strings" "sync" "time" @@ -20,9 +28,10 @@ const usageHalfLife = time.Hour var routed = struct { sync.Mutex - turn map[string]int // provider → requests routed in turn - used map[string]tokenUse // candidate rest id → tokens it served -}{turn: map[string]int{}, used: map[string]tokenUse{}} + turn map[string]int // provider → requests routed in turn + used map[string]tokenUse // candidate rest id → tokens it served + failures map[string]int // candidate rest id → failures since it last answered +}{turn: map[string]int{}, used: map[string]tokenUse{}, failures: map[string]int{}} type tokenUse struct { n float64 @@ -41,15 +50,153 @@ func served(rest string, tokens int) { now := time.Now() routed.Lock() routed.used[rest] = tokenUse{routed.used[rest].now(now) + float64(tokens), now} + delete(routed.failures, rest) routed.Unlock() } +// How long a candidate sits out, by why it failed. +const ( + creditRest = 30 * time.Minute // out of credit: until someone tops it up + quotaRest = 15 * time.Minute // out of quota, with no word of when it resets + longestWait = time.Hour // the most a vendor's own "try again at" is trusted + longestRetry = 10 * time.Minute // failing again and again +) + +var ( + // creditWords: the account or key has no money left. + creditWords = regexp.MustCompile(`(?i)insufficient.?(balance|credit|fund)|balance|credit|billing|payment|arrear|overdue|suspended|余额|欠费|充值|账户.*(不足|停)`) + // quotaWords: it has used up what its plan allows for now. + usedUpWords = regexp.MustCompile(`(?i)quota|usage.?limit|limit.?reached|exceeded.*(plan|limit)|额度|用量|套餐|上限`) +) + +// Why a candidate failed, as rest tells it. +const ( + failCredit = "credit" + failQuota = "quota" + failRate = "rate" + failOther = "other" +) + +// failure says why a reply failed. +func failure(status int, body []byte) string { + switch { + case status == 402, creditWords.Match(body) && status != 429 || strings.Contains(string(body), "insufficient_quota"): + return failCredit + case usedUpWords.Match(body): + return failQuota + case status == 429: + return failRate + } + return failOther +} + +// restAfter sets a failed candidate aside for as long as its failure says. +func (s *Server) restAfter(rest string, status int, header http.Header, body []byte) { + now := time.Now() + d := fallbackCooldown + switch failure(status, body) { + case failCredit: + d = creditRest + case failQuota: + d = quotaRest + if w := retryAfter(header, now); w > 0 { + d = w + } + case failRate: + if w := retryAfter(header, now); w > 0 { + d = w + } + default: + routed.Lock() + routed.failures[rest]++ + n := routed.failures[rest] + routed.Unlock() + d = min(fallbackCooldown< 0 || len(vs) == 0 || !strings.Contains(k, "reset") || !strings.Contains(k, "ratelimit") { + continue + } + v := vs[0] + if t, err := time.Parse(time.RFC3339, v); err == nil { + d = max(d, t.Sub(now)) + } else if w, err := time.ParseDuration(v); err == nil { + d = max(d, w) + } else if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 1e9 { + d = max(d, time.Unix(n, 0).Sub(now)) + } + } + if d <= 0 { + return 0 + } + return min(d, longestWait) +} + +var allowanceUsed = provider.AllowanceUsed + +// Shares of an allowance past which an account is kept for when the others +// can't take a request: low, and all but used up. +const ( + lowShare = 90 + usedShare = 98 +) + // route orders one provider's candidates as its routing says. func route(p provider.Provider, cs []candidate, model string, from provider.Protocol) []candidate { if len(cs) < 2 { return cs } + var share map[string]float64 + if p.Account != nil { + share = allowanceUsed(p.Account.Agent) + } + shareOf := func(c candidate) float64 { // 0 when not known + if c.p.Account == nil { + return 0 + } + return share[c.p.Account.User] + } switch p.Routing { + case "": + // the first while it has quota to spare, 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 + var fine, low, spent []candidate + for _, c := range cs { + switch v := shareOf(c); { + case v >= usedShare: + spent = append(spent, c) + case v >= lowShare: + low = append(low, c) + default: + fine = append(fine, c) + } + } + for _, l := range [][]candidate{low, spent} { + sort.SliceStable(l, func(i, j int) bool { + return shareOf(l[i]) < shareOf(l[j]) + }) + } + cs = append(append(fine, low...), spent...) + case provider.Ordered: + return cs case provider.Rotate: routed.Lock() n := routed.turn[p.ID] % len(cs) @@ -59,10 +206,6 @@ func route(p provider.Provider, cs []candidate, model string, from provider.Prot 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 - var share map[string]float64 - if p.Account != nil { - share = provider.AllowanceUsed(p.Account.Agent) - } now := time.Now() routed.Lock() tokens := make([]float64, len(cs)) @@ -76,10 +219,8 @@ func route(p provider.Provider, cs []candidate, model string, from provider.Prot } sort.SliceStable(idx, func(a, b int) bool { ca, cb := cs[idx[a]], cs[idx[b]] - if ca.p.Account != nil && cb.p.Account != nil { - if sa, sb := share[ca.p.Account.User], share[cb.p.Account.User]; sa != sb { - return sa < sb - } + if sa, sb := shareOf(ca), shareOf(cb); sa != sb { + return sa < sb } return tokens[idx[a]] < tokens[idx[b]] }) diff --git a/internal/gateway/routing_test.go b/internal/gateway/routing_test.go index 7264d54b..a5c9e67e 100644 --- a/internal/gateway/routing_test.go +++ b/internal/gateway/routing_test.go @@ -1,11 +1,17 @@ package gateway import ( + "net/http" "testing" + "time" "github.com/yetone/magpie/internal/provider" ) +// Tests don't ask vendors how much of an allowance is used: the gateway +// would, behind a request, of whatever upstream a test serves. +func init() { allowanceUsed = func(string) map[string]float64 { return nil } } + func restsOf(cs []candidate) string { s := "" for _, c := range cs { @@ -18,7 +24,7 @@ func TestRouting(t *testing.T) { cs := []candidate{{rest: "r#a"}, {rest: "r#b"}, {rest: "r#c"}} p := provider.Provider{ID: "r"} if got := restsOf(route(p, cs, "m", provider.Chat)); got != "r#a r#b r#c " { - t.Fatalf("in order: %s", got) + t.Fatalf("smart: %s", got) } p.Routing = provider.Rotate @@ -33,6 +39,11 @@ func TestRouting(t *testing.T) { t.Fatal("rotating changed the list it was given") } + p.Routing = provider.Ordered + if got := restsOf(route(p, cs, "m", provider.Chat)); got != "r#a r#b r#c " { + t.Fatalf("in order: %s", got) + } + p.ID, p.Routing = "u", provider.LeastUsed cs = []candidate{{rest: "u#a"}, {rest: "u#b"}, {rest: "u#c"}} served("u#a", 5000) @@ -41,3 +52,68 @@ func TestRouting(t *testing.T) { t.Fatalf("least used: %s", got) } } + +// Smart routing keeps the first while it has quota to spare, then goes to +// whichever has the most; a failure rests as long as it says. +func TestSmartRouting(t *testing.T) { + old := allowanceUsed + defer func() { allowanceUsed = old }() + share := map[string]float64{"a": 10, "b": 50, "c": 20} + allowanceUsed = func(string) map[string]float64 { return share } + acct := func(user string) candidate { + return candidate{p: provider.Provider{Account: &provider.Account{Agent: "x", User: user}}, rest: "s#" + user} + } + p := provider.Provider{ID: "s", Account: &provider.Account{Agent: "x"}} + cs := []candidate{acct("a"), acct("b"), acct("c")} + if got := restsOf(route(p, cs, "m", provider.Chat)); got != "s#a s#b s#c " { + t.Fatalf("all fine: %s", got) + } + share["a"], share["b"] = 99, 93 + if got := restsOf(route(p, cs, "m", provider.Chat)); got != "s#c s#b s#a " { + t.Fatalf("a used up, b low: %s", got) + } + + for _, c := range []struct { + status int + body string + want string + }{ + {402, `{}`, failCredit}, + {400, `{"error":{"message":"Your credit balance is too low"}}`, failCredit}, + {429, `{"error":{"code":"insufficient_quota"}}`, failCredit}, + {403, `账户余额不足`, failCredit}, + {429, `{"error":{"message":"usage limit reached for your plan"}}`, failQuota}, + {429, `{"error":{"message":"Too many requests"}}`, failRate}, + {503, `overloaded`, failOther}, + } { + if got := failure(c.status, []byte(c.body)); got != c.want { + t.Errorf("%d %s: %s, want %s", c.status, c.body, got, c.want) + } + } + + s := &Server{} + until := func(id string) time.Duration { + restingUntil.Lock() + defer restingUntil.Unlock() + return time.Until(restingUntil.m[id]).Round(time.Minute) + } + s.restAfter("t#credit", 402, http.Header{}, nil) + h := http.Header{} + h.Set("Retry-After", "300") + s.restAfter("t#rate", 429, h, []byte("slow down")) + s.restAfter("t#fail", 500, http.Header{}, nil) + s.restAfter("t#fail", 500, http.Header{}, nil) + if until("t#credit") != creditRest || until("t#rate") != 5*time.Minute || until("t#fail") != 2*time.Minute { + t.Fatalf("rests: %v %v %v", until("t#credit"), until("t#rate"), until("t#fail")) + } + served("t#fail", 1) + s.restAfter("t#fail", 500, http.Header{}, nil) + if until("t#fail") != time.Minute { + t.Fatalf("answering again starts over: %v", until("t#fail")) + } + h = http.Header{} + h.Set("X-Ratelimit-Reset-Tokens", "6m0s") + if d := retryAfter(h, time.Now()); d != 6*time.Minute { + t.Fatalf("reset header: %v", d) + } +} diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index a210f3c0..2ea40250 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -605,6 +605,8 @@ body.window .foot { padding-left: 18px; } .signing > .text { margin: -3px 0; } .signing .acts { display: flex; gap: 2px; margin: 2px 0 0 -4px; } .signing .acts .link { color: var(--fg-2); } +.signing .devcode { display: flex; align-items: center; gap: 6px; margin: 6px 0 2px; } +.signing .devcode code { font: 600 17px/1.2 var(--mono, ui-monospace, monospace); letter-spacing: .12em; padding: 4px 9px; border: 1px solid var(--line); border-radius: 7px; background: var(--card); user-select: all; } .signing .spinner { width: 16px; height: 16px; flex: none; margin-top: 1px; border-radius: 50%; border: 1.75px solid var(--line); border-top-color: var(--fg); animation: spin .8s linear infinite; } .signing .mark { width: 16px; height: 16px; flex: none; margin-top: 1px; border-radius: 50%; display: grid; place-items: center; background: var(--fg); color: var(--card); font-size: 11px; font-weight: 700; } .fallback { display: grid; gap: 6px; } @@ -845,6 +847,14 @@ body.panel .modal { padding: 16px 12px; } .editor.importapps { grid-template-columns: minmax(0, 1fr); } .editor.importapps .appnote { font-size: 12px; color: var(--muted); line-height: 1.5; } .editor.importapps .applist { display: flex; flex-direction: column; gap: 14px; max-height: min(56vh, 460px); overflow-y: auto; margin: 0 -4px; padding: 0 4px; } +.editor.importapps .apptabs { display: flex; gap: 4px; padding: 3px; border-radius: 10px; background: var(--pill); overflow-x: auto; scrollbar-width: none; } +.editor.importapps .apptab { flex: 1 1 0; min-width: max-content; display: flex; align-items: center; justify-content: center; gap: 7px; padding: 6px 12px; border-radius: 7px; font-size: 12.5px; font-weight: 500; color: var(--muted); white-space: nowrap; } +.editor.importapps .apptab:hover { color: var(--fg); } +.editor.importapps .apptab.on { background: var(--card); color: var(--fg); box-shadow: 0 1px 2px rgba(0,0,0,.18); } +.editor.importapps .apptab.missing .applogo { filter: grayscale(1); opacity: .5; } +.editor.importapps .apptab .count { min-width: 16px; padding: 0 5px; border-radius: 8px; font-size: 10.5px; line-height: 16px; text-align: center; color: var(--fg-2); background: var(--line); } +.editor.importapps .apptab.missing .count { background: none; color: var(--faint); } +.editor.importapps .apphead > .applogo, .editor.importapps .apphead > b { display: none; } .editor.importapps .apphead { display: flex; align-items: baseline; gap: 8px; padding-bottom: 4px; } .editor.importapps .apphead b { font-weight: 600; font-size: 13px; flex: none; } .editor.importapps .apphead { align-items: center; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 4ccc8190..351e74b8 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -1712,9 +1712,39 @@ function renderImportApps(ia) { return ed; } ed.append(el("div", "appnote", t("magpie reads these apps' settings and changes nothing in them. Pick the providers to bring over."))); + // one tab per app magpie can import from, so which ones it can is plain + // at a glance; each shows how many providers it has to bring over + const tabs = el("div", "apptabs"); + tabs.setAttribute("role", "tablist"); const list = el("div", "applist"); + const secs = {}; + const pickable = (s) => s.items.some((it) => ia.picks[s.id + "\n" + it.ref]); + if (!ia.sources.some((s) => s.id === ia.tab)) { + ia.tab = (ia.sources.find(pickable) || ia.sources.find((s) => s.found) || ia.sources[0])?.id; + } + const showTab = (id) => { + ia.tab = id; + for (const [sid, [tab, sec]] of Object.entries(secs)) { + tab.classList.toggle("on", sid === id); + tab.setAttribute("aria-selected", String(sid === id)); + sec.hidden = sid !== id; + } + list.scrollTop = 0; + }; for (const s of ia.sources) { const sec = el("div", "appsrc"); + const tab = el("button", "apptab" + (s.found && !s.error ? "" : " missing")); + tab.setAttribute("role", "tab"); + const tlogo = el("img", "applogo"); + tlogo.src = `icons/app-${s.id}.png`; + tlogo.alt = ""; + tlogo.draggable = false; + const n = s.items.filter((it) => ia.picks[s.id + "\n" + it.ref]).length; + tab.append(tlogo, el("span", "", s.name), el("span", "count", s.found && !s.error ? String(n) : "–")); + tab.title = s.found ? (s.error || t(n === 1 ? "1 provider to bring over" : "{n} providers to bring over", { n })) : t("Not found on this computer"); + tab.onclick = () => showTab(s.id); + tabs.append(tab); + secs[s.id] = [tab, sec]; const sh = el("div", "apphead"); const logo = el("img", "applogo"); logo.src = `icons/app-${s.id}.png`; @@ -1750,7 +1780,8 @@ function renderImportApps(ia) { } list.append(sec); } - ed.append(list); + ed.append(tabs, list); + showTab(ia.tab); go.onclick = async () => { const picks = []; for (const [k, v] of Object.entries(ia.picks)) { @@ -1992,7 +2023,8 @@ function renderModels(p) { // renderRouting: how the gateway spreads requests over the keys or // accounts a provider has on. It takes effect at once, like ticking one. const ROUTINGS = [ - ["", "In order", "Requests go to the first; the next takes over when the one before runs out of quota, hits a rate limit or fails."], + ["", "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."], + ["order", "In order", "Requests go to the first; the next takes over when the one before runs out of quota, hits a rate limit or fails."], ["rotate", "In turn", "Each request goes to the next one, spreading the load evenly; one that fails is passed over for a minute."], ["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."], ]; @@ -2080,6 +2112,8 @@ const SUBS = [ { agent: "cursor", name: "Cursor", icon: "cursor", plans: "Pro · Ultra · Teams", single: true }, // so does the Grok CLI { agent: "grok", name: "Grok", icon: "xai", plans: "SuperGrok · X Premium+", own: true }, + // signed in with GitHub's device code; the editors' own sign-in stays theirs + { agent: "copilot", name: "Copilot", icon: "githubcopilot", plans: "Pro · Pro+ · Business", own: true }, ]; const subOf = (agent) => SUBS.find((x) => x.agent === agent); let signing = null; // the sign-in under way: { id, agent, url, state, error } @@ -2147,7 +2181,12 @@ function renderSigning(sub) { } box.append(el("span", "spinner")); tt.append(el("span", "n", t("Finish signing in to {name} in your browser", { name: sub.name })), - el("span", "s", t("magpie opened the sign-in page. The account shows up here as soon as you're done."))); + el("span", "s", signing.code ? t("magpie opened GitHub's device page. Enter this code there; the account shows up here as soon as you're done.") : t("magpie opened the sign-in page. The account shows up here as soon as you're done."))); + if (signing.code) { + const code = el("span", "devcode"); + code.append(el("code", "", signing.code), copyBtn(signing.code, t("Code"))); + tt.append(code); + } box.append(tt); if (signing.url) { const acts = el("span", "acts"); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 2ae987de..35f3560e 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -136,6 +136,8 @@ const I18N = { "Routing": "路由", "Tick every key to use; Routing says how requests spread over them.": "勾选的密钥会同时启用;请求怎么分给它们,由下面的「路由」决定。", "Tick every account to use; Routing says how requests spread over them.": "勾选的账号会同时启用;请求怎么分给它们,由下面的「路由」决定。", + "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": "按顺序", "In turn": "轮流", "Least used first": "用量少的优先", @@ -188,6 +190,10 @@ const I18N = { "Open again": "重新打开", "Copy link": "复制链接", "Sign-in link": "登录链接", + "1 provider to bring over": "有 1 个供应商可导入", + "{n} providers to bring over": "有 {n} 个供应商可导入", + "magpie opened GitHub's device page. Enter this code there; the account shows up here as soon as you're done.": "magpie 已打开 GitHub 的设备登录页,请在那里输入下面的验证码;完成后账号会立即出现在这里。", + "Code": "验证码", "Sign-in didn't finish": "登录未完成", "Try again": "重试", "Signed in as {user}": "已登录 {user}", diff --git a/internal/provider/account.go b/internal/provider/account.go index 6855692e..be160dfd 100644 --- a/internal/provider/account.go +++ b/internal/provider/account.go @@ -836,12 +836,9 @@ func (a copilotApp) session(ctx context.Context) (copilotSession, error) { return copilotToken(ctx, a.Token) } -func copilotAccount(cfg string) (Provider, bool) { - app, ok := copilotLogin(cfg) - if !ok { - return Provider{}, false - } - acct := &Account{Agent: "copilot", User: app.User} +// copilotProvider is Copilot as one GitHub account serves it. +func copilotProvider(app copilotApp, plan string) Provider { + acct := &Account{Agent: "copilot", User: app.User, Plan: plan} if acct.User == "" { acct.User = "GitHub" } @@ -877,7 +874,7 @@ func copilotAccount(cfg string) (Provider, bool) { } return ms, catalog.SaveLive("copilot", copilotBase, ms) } - return Provider{ID: "copilot", Name: "Copilot", Icon: "githubcopilot", Chat: copilotBase, Website: "https://github.com/features/copilot", Account: acct}, true + return Provider{ID: "copilot", Name: "Copilot", Icon: "githubcopilot", Chat: copilotBase, Website: "https://github.com/features/copilot", Account: acct} } // lastRole is the role of the last message in a chat request. diff --git a/internal/provider/copilot_accounts.go b/internal/provider/copilot_accounts.go new file mode 100644 index 00000000..ecadfac2 --- /dev/null +++ b/internal/provider/copilot_accounts.go @@ -0,0 +1,170 @@ +package provider + +// Several Copilot subscriptions. Copilot's editors and its CLI keep one +// GitHub account, which magpie only ever reads. Each further account is +// signed in by magpie itself, with GitHub's device code under the Copilot +// editors' own OAuth app, and its GitHub token kept in logins.json; the +// editors' own account is remembered there too, with nothing of its token, +// so it can stand behind another or be turned off. + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +// copilotConfigDir is where the Copilot editors keep their sign-in. +func copilotConfigDir() string { + if cfg := os.Getenv("XDG_CONFIG_HOME"); cfg != "" { + return cfg + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config") +} + +// copilotSaved is the sign-in of an account magpie keeps. +func copilotSaved(l savedLogin) (copilotApp, bool) { + var app copilotApp + if l.own() || json.Unmarshal(l.Auth, &app) != nil || app.Token == "" { + return copilotApp{}, false + } + app.User = l.User + return app, true +} + +type copilotLoginApp struct { + Login + app copilotApp +} + +// copilotLogins lists the Copilot accounts, the first in use first, then +// the rest as they were added. +func copilotLogins(cfg string) []copilotLoginApp { + own, hasOwn := copilotLogin(cfg) + ownUser := "" + if hasOwn { + ownUser = own.User + if ownUser == "" { + ownUser = "GitHub" + } + } + var out []copilotLoginApp + for _, l := range sideLogins("copilot", ownUser, func(l savedLogin) bool { + _, ok := copilotSaved(l) + return ok + }) { + app := own + if !l.saved.own() { + app, _ = copilotSaved(l.saved) + } + app.User = l.User + out = append(out, copilotLoginApp{l.Login, app}) + } + return out +} + +func copilotSide() []sideLogin { + var out []sideLogin + for _, c := range copilotLogins(copilotConfigDir()) { + out = append(out, sideLogin{Login: c.Login}) + } + return out +} + +func copilotLoginList() []Login { return loginsOf(copilotSide()) } + +func switchCopilotLogin(user string) error { + return switchSideLogin("copilot", user, copilotSide()) +} + +func setCopilotLoginOn(user string, on bool) error { + return setSideLoginOn("copilot", user, on, copilotSide()) +} + +func forgetCopilotLogin(user string) error { + return forgetSideLogin("copilot", user, "the Copilot sign-in of your editor or the Copilot CLI; sign out there", copilotSide(), nil) +} + +// addCopilotLogin keeps an account magpie just signed in. +func addCopilotLogin(user, plan, token string) error { + auth, _ := json.Marshal(map[string]string{"oauth_token": token}) + return addSideLogin(savedLogin{Agent: "copilot", User: user, Plan: plan, Auth: auth}, func(savedLogin) {}) +} + +// copilotAccount is the Copilot account in use first. +func copilotAccount(cfg string) (Provider, bool) { + ls := copilotLogins(cfg) + if len(ls) == 0 { + return Provider{}, false + } + return copilotProvider(ls[0].app, ls[0].Plan), true +} + +// copilotAlsoOn is the Copilot accounts in use behind the first. +func copilotAlsoOn() []Provider { + var out []Provider + for _, c := range copilotLogins(copilotConfigDir()) { + if !c.Active && c.On { + out = append(out, copilotProvider(c.app, c.Plan)) + } + } + return out +} + +// copilotPlans names Copilot's plans as GitHub sells them. +var copilotPlans = map[string]string{ + "free": "Free", "individual": "Pro", "individual_pro": "Pro+", + "business": "Business", "enterprise": "Enterprise", +} + +// copilotUser is who a GitHub token belongs to and which Copilot plan it +// has; no plan, no Copilot. +func copilotUser(ctx context.Context, token string) (user, plan string, err error) { + get := func(u string, v any) (int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return 0, err + } + req.Header.Set("Authorization", "token "+token) + req.Header.Set("Accept", "application/json") + res, err := http.DefaultClient.Do(req) + if err != nil { + return 0, err + } + defer res.Body.Close() + b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + if res.StatusCode != 200 { + return res.StatusCode, errors.New(APIError(b, res.Status)) + } + return 200, json.Unmarshal(b, v) + } + var gh struct { + Login string `json:"login"` + } + if _, err := get(GitHubUserURL, &gh); err != nil || gh.Login == "" { + return "", "", errors.New("GitHub didn't say whose account this is") + } + var cp struct { + Plan string `json:"copilot_plan"` + } + if code, err := get(CopilotUserURL, &cp); err != nil { + if code == 401 || code == 403 || code == 404 { + return gh.Login, "", errors.New(gh.Login + " has no Copilot subscription") + } + return gh.Login, "", errors.New("Copilot: " + err.Error()) + } + plan = copilotPlans[strings.ToLower(cp.Plan)] + if plan == "" && cp.Plan != "" { + plan = strings.ToUpper(cp.Plan[:1]) + cp.Plan[1:] + } + return gh.Login, plan, nil +} + +// GitHubUserURL says whose a GitHub token is; a var so tests can point it +// elsewhere. +var GitHubUserURL = "https://api.github.com/user" diff --git a/internal/provider/copilot_accounts_test.go b/internal/provider/copilot_accounts_test.go new file mode 100644 index 00000000..83840672 --- /dev/null +++ b/internal/provider/copilot_accounts_test.go @@ -0,0 +1,117 @@ +package provider + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// A second Copilot account, signed in from magpie with GitHub's device +// code, is kept beside the editors' own and can go first. +func TestCopilotAccounts(t *testing.T) { + signIn(t) // the editors' own: octocat, gho_x + var polls atomic.Int32 + gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + switch r.URL.Path { + case "/device": + if r.Form.Get("client_id") != copilotClientID { + w.WriteHeader(400) + return + } + w.Write([]byte(`{"device_code":"dev","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","interval":0}`)) + case "/token": + if r.Form.Get("device_code") != "dev" { + w.WriteHeader(400) + return + } + if polls.Add(1) < 2 { + w.Write([]byte(`{"error":"authorization_pending"}`)) + return + } + w.Write([]byte(`{"access_token":"gho_hubot","token_type":"bearer"}`)) + case "/user": + if r.Header.Get("Authorization") != "token gho_hubot" { + w.WriteHeader(401) + return + } + w.Write([]byte(`{"login":"hubot"}`)) + case "/copilot_user": + w.Write([]byte(`{"copilot_plan":"individual_pro"}`)) + case "/copilot_token": + tok := strings.TrimPrefix(r.Header.Get("Authorization"), "token ") + json.NewEncoder(w).Encode(map[string]any{"token": "session-" + tok, "expires_at": time.Now().Add(time.Hour).Unix()}) + } + })) + defer gh.Close() + oldDev, oldTok, oldUser, oldCU, oldCT := gitHubDeviceURL, gitHubTokenURL, GitHubUserURL, CopilotUserURL, CopilotTokenURL + gitHubDeviceURL, gitHubTokenURL, GitHubUserURL, CopilotUserURL, CopilotTokenURL = gh.URL+"/device", gh.URL+"/token", gh.URL+"/user", gh.URL+"/copilot_user", gh.URL+"/copilot_token" + defer func() { + gitHubDeviceURL, gitHubTokenURL, GitHubUserURL, CopilotUserURL, CopilotTokenURL = oldDev, oldTok, oldUser, oldCU, oldCT + }() + copilotSessions = map[string]copilotSession{} + + st, err := StartSignIn("copilot") + if err != nil || st.Code != "ABCD-1234" || st.URL != "https://github.com/login/device" { + t.Fatalf("start: %+v %v", st, err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + st, _ = WaitSignIn(ctx, st.ID) + if st.State != "done" || st.User != "hubot" || st.Plan != "Pro+" || st.Using || st.Code != "ABCD-1234" { + t.Fatalf("done: %+v", st) + } + + users := func() string { + var s []string + for _, l := range Logins("copilot") { + s = append(s, l.User+map[bool]string{true: "*", false: ""}[l.Active]+map[bool]string{true: "+", false: ""}[l.On]) + } + return strings.Join(s, " ") + } + if got := users(); got != "octocat*+ hubot+" { + t.Fatalf("logins: %s", got) + } + p, ok := find(All(), "copilot") + if !ok || p.Account.User != "octocat" { + t.Fatalf("first: %+v", p) + } + also := p.AlsoOn() + if len(also) != 1 || also[0].Account.User != "hubot" || also[0].Account.Plan != "Pro+" { + t.Fatalf("also on: %+v", also) + } + req, _ := http.NewRequest("POST", also[0].Chat+"/chat/completions", nil) + if err := also[0].Sign(context.Background(), req, Chat, []byte(`{}`)); err != nil || req.Header.Get("Authorization") != "Bearer session-gho_hubot" { + t.Fatalf("hubot signs with its own: %v %s", err, req.Header.Get("Authorization")) + } + + if err := ForgetLogin("copilot", "octocat"); err == nil { + t.Fatal("forgot the one in use") + } + if err := SwitchLogin("copilot", "hubot"); err != nil { + t.Fatal(err) + } + if got := users(); got != "hubot*+ octocat+" { + t.Fatalf("after switch: %s", got) + } + if err := SetLoginOn("copilot", "hubot", false); err == nil { + t.Fatal("turned off the first") + } + if err := SwitchLogin("copilot", "octocat"); err != nil { + t.Fatal(err) + } + if err := ForgetLogin("copilot", "octocat"); err == nil || !strings.Contains(err.Error(), "first") { + t.Fatalf("forgot the first: %v", err) + } + if err := ForgetLogin("copilot", "hubot"); err != nil { + t.Fatal(err) + } + if got := users(); got != "octocat*+" { + t.Fatalf("after forget: %s", got) + } +} diff --git a/internal/provider/copilot_signin.go b/internal/provider/copilot_signin.go new file mode 100644 index 00000000..a1bb906a --- /dev/null +++ b/internal/provider/copilot_signin.go @@ -0,0 +1,124 @@ +package provider + +// Signing in to a Copilot account from magpie: GitHub's device flow, with +// the OAuth app Copilot's editors sign in with. magpie shows the code, the +// user types it at github.com/login/device, and the token GitHub then +// hands over is kept for magpie alone (see copilot_accounts.go). + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// copilotClientID is the OAuth app of Copilot's editors. +const copilotClientID = "Iv1.b507a08c87ecfe98" + +// GitHub's device flow; vars so tests can point them elsewhere. +var ( + gitHubDeviceURL = "https://github.com/login/device/code" + gitHubTokenURL = "https://github.com/login/oauth/access_token" +) + +func gitHubForm(ctx context.Context, u string, form url.Values, v any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + if res.StatusCode != 200 { + return errors.New("GitHub: " + APIError(b, res.Status)) + } + return json.Unmarshal(b, v) +} + +func startCopilotSignIn(s *signInFlow) error { + ctx, cancel := context.WithCancel(context.Background()) + var dc struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + URI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` + } + if err := gitHubForm(ctx, gitHubDeviceURL, url.Values{"client_id": {copilotClientID}, "scope": {"read:user"}}, &dc); err != nil { + cancel() + return err + } + if dc.DeviceCode == "" || dc.UserCode == "" { + cancel() + return errors.New("GitHub gave no device code") + } + s.st.URL, s.st.Code = dc.URI, dc.UserCode + if s.st.URL == "" { + s.st.URL = "https://github.com/login/device" + } + s.stop = cancel + interval := time.Duration(max(dc.Interval, 1)) * time.Second + go func() { + fail := func(msg string) { s.finish(SignInState{State: "failed", Error: msg}) } + for { + select { + case <-ctx.Done(): + return + case <-time.After(interval): + } + var tok struct { + Token string `json:"access_token"` + Error string `json:"error"` + Desc string `json:"error_description"` + } + err := gitHubForm(ctx, gitHubTokenURL, url.Values{"client_id": {copilotClientID}, "device_code": {dc.DeviceCode}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}}, &tok) + switch { + case ctx.Err() != nil: + return + case err != nil: + continue // a hiccup: ask again + case tok.Error == "authorization_pending": + continue + case tok.Error == "slow_down": + interval += 5 * time.Second + continue + case tok.Error == "expired_token": + fail("the code expired; start again") + return + case tok.Error == "access_denied": + fail("the sign-in was declined on GitHub") + return + case tok.Error != "" || tok.Token == "": + fail("GitHub: " + strings.TrimSpace(tok.Error+" "+tok.Desc)) + return + } + user, plan, err := copilotUser(ctx, tok.Token) + if err != nil { + fail(err.Error()) + return + } + if err := addCopilotLogin(user, plan, tok.Token); err != nil { + fail(err.Error()) + return + } + // the one Copilot's editors are signed in to, if it is this + using := false + if own, ok := copilotLogin(copilotConfigDir()); ok && strings.EqualFold(own.User, user) { + using = true + } + s.finish(SignInState{State: "done", User: user, Plan: plan, Using: using}) + return + } + }() + return nil +} diff --git a/internal/provider/grok_accounts.go b/internal/provider/grok_accounts.go index bac0b4d7..ecf49289 100644 --- a/internal/provider/grok_accounts.go +++ b/internal/provider/grok_accounts.go @@ -10,7 +10,6 @@ package provider import ( "context" "errors" - "fmt" "os" "path/filepath" "strings" @@ -30,140 +29,48 @@ type grokLogin struct { // grokLogins lists the Grok accounts that are signed in, the first in use // first, then the rest as they were added. func grokLogins() []grokLogin { - loginsMu.Lock() - defer loginsMu.Unlock() - ls := readLogins() - // the CLI's own account, as it is now - if c, ok := readGrokCredential(GrokHome()); ok && c.Email != "" { - found := false - for i := range ls { - if ls[i].Agent == "grok" && ls[i].Home == "" { - found = true - if !strings.EqualFold(ls[i].User, c.Email) { - ls[i].User, ls[i].Seen = c.Email, time.Now().UTC().Truncate(time.Second) - _ = writeLogins(ls) - } - } - } - if !found { - ls = append(ls, savedLogin{Agent: "grok", User: c.Email, Seen: time.Now().UTC().Truncate(time.Second)}) - _ = writeLogins(ls) - } + own := "" + if c, ok := readGrokCredential(GrokHome()); ok { + own = c.Email } var out []grokLogin - first := -1 - for _, l := range ls { - if l.Agent != "grok" { - continue - } - home := l.Home + for _, l := range sideLogins("grok", own, func(l savedLogin) bool { + _, ok := readGrokCredential(l.Home) + return ok + }) { + home := l.saved.Home if home == "" { home = GrokHome() } - if _, ok := readGrokCredential(home); !ok { - continue // signed out there - } - g := grokLogin{Login: Login{Agent: "grok", User: l.User, Plan: l.Plan, Seen: l.Seen, On: l.On}, Home: home} - if l.First || (first < 0 && l.Home == "") { - first = len(out) - } - out = append(out, g) - } - if len(out) == 0 { - return nil - } - if first < 0 { - first = 0 - } - out[first].Active, out[first].On = true, true - return append([]grokLogin{out[first]}, append(out[:first:first], out[first+1:]...)...) -} - -// grokLoginList is the Grok accounts as Logins lists them. -func grokLoginList() []Login { - var out []Login - for _, g := range grokLogins() { - out = append(out, g.Login) + out = append(out, grokLogin{l.Login, home}) } return out } -// editGrokLogin changes the saved Grok account of user. -func editGrokLogin(user string, f func(ls []savedLogin, i int) ([]savedLogin, error)) error { - loginsMu.Lock() - defer loginsMu.Unlock() - ls := readLogins() - for i := range ls { - if ls[i].Agent == "grok" && strings.EqualFold(ls[i].User, user) { - ls, err := f(ls, i) - if err != nil { - return err - } - return writeLogins(ls) - } +func grokSide() []sideLogin { + var out []sideLogin + for _, g := range grokLogins() { + out = append(out, sideLogin{Login: g.Login}) } - return fmt.Errorf("no Grok account %q", user) + return out } -func grokActive(user string) bool { - for _, g := range grokLogins() { - if g.Active { - return strings.EqualFold(g.User, user) - } - } - return false -} +// grokLoginList is the Grok accounts as Logins lists them. +func grokLoginList() []Login { return loginsOf(grokSide()) } // switchGrokLogin puts a Grok account first. The CLI's own sign-in stays // as it is: magpie only changes which account its gateway uses first. -func switchGrokLogin(user string) error { - var was string - for _, g := range grokLogins() { - if g.Active { - was = g.User - } - } - return editGrokLogin(user, func(ls []savedLogin, i int) ([]savedLogin, error) { - for j := range ls { - if ls[j].Agent == "grok" { - if j != i && strings.EqualFold(ls[j].User, was) { - ls[j].On = true // the one it replaces is next in line - } - ls[j].First = j == i - } - } - return ls, nil - }) -} +func switchGrokLogin(user string) error { return switchSideLogin("grok", user, grokSide()) } func setGrokLoginOn(user string, on bool) error { - if !on && grokActive(user) { - return fmt.Errorf("magpie uses %s first; put another Grok account first to stop using it", user) - } - return editGrokLogin(user, func(ls []savedLogin, i int) ([]savedLogin, error) { - ls[i].On = on - return ls, nil - }) + return setSideLoginOn("grok", user, on, grokSide()) } // forgetGrokLogin drops an account magpie signed in, with its home. The // CLI's own is signed out in the CLI. func forgetGrokLogin(user string) error { - if grokActive(user) { - return fmt.Errorf("magpie uses %s first; put another Grok account first", user) - } - var home string - err := editGrokLogin(user, func(ls []savedLogin, i int) ([]savedLogin, error) { - if ls[i].Home == "" { - return nil, errors.New("that is the Grok CLI's own sign-in; run `grok logout` to sign it out") - } - home = ls[i].Home - return append(ls[:i], ls[i+1:]...), nil - }) - if err == nil { - removeGrokHome(home) - } - return err + return forgetSideLogin("grok", user, "the Grok CLI's own sign-in; run `grok logout` to sign it out", grokSide(), + func(l savedLogin) { removeGrokHome(l.Home) }) } // removeGrokHome deletes a home magpie made, and nothing else. @@ -180,31 +87,15 @@ func newGrokHome() (string, error) { } // addGrokLogin keeps the account just signed in in home, in use beside -// the others. Signed in again, an account keeps the home it had. +// the others. Signed in again, an account keeps the newer home. func addGrokLogin(home string) (string, error) { c, ok := readGrokCredential(home) if !ok || c.Email == "" { removeGrokHome(home) return "", errors.New("grok login finished without an account") } - loginsMu.Lock() - defer loginsMu.Unlock() - ls := readLogins() - for i := range ls { - if ls[i].Agent == "grok" && strings.EqualFold(ls[i].User, c.Email) { - if ls[i].Home != "" { - // the fresh sign-in replaces the old one - removeGrokHome(ls[i].Home) - ls[i].Home = home - ls[i].Seen = time.Now().UTC().Truncate(time.Second) - return c.Email, writeLogins(ls) - } - removeGrokHome(home) // the CLI's own already - return c.Email, nil - } - } - ls = append(ls, savedLogin{Agent: "grok", User: c.Email, Home: home, On: true, Seen: time.Now().UTC().Truncate(time.Second)}) - return c.Email, writeLogins(ls) + return c.Email, addSideLogin(savedLogin{Agent: "grok", User: c.Email, Home: home}, + func(l savedLogin) { removeGrokHome(l.Home) }) } // grokAlsoOn is the Grok accounts in use behind the first. diff --git a/internal/provider/logins.go b/internal/provider/logins.go index b263a7e8..35b59de2 100644 --- a/internal/provider/logins.go +++ b/internal/provider/logins.go @@ -47,7 +47,7 @@ type savedLogin struct { Profile json.RawMessage `json:"profile,omitempty"` // Home is where a Grok account magpie signed in keeps its sign-in; the // Grok CLI's own account has none (see grok_accounts.go). First puts a - // Grok account ahead of the CLI's own. + // Grok or Copilot account ahead of the agent's own (side_logins.go). Home string `json:"home,omitempty"` First bool `json:"first,omitempty"` } @@ -232,12 +232,14 @@ func rememberLogins(force bool) { // Logins lists the remembered accounts of an agent ("" for every one), // the active one flagged. func Logins(agent string) []Login { - var grok []Login + var side []Login switch agent { case "grok": return grokLoginList() + case "copilot": + return copilotLoginList() case "": - grok = grokLoginList() + side = append(grokLoginList(), copilotLoginList()...) } rememberLogins(false) loginsMu.Lock() @@ -250,22 +252,25 @@ func Logins(agent string) []Login { } var out []Login for _, l := range readLogins() { - if (agent != "" && l.Agent != agent) || l.Agent == "grok" { + if (agent != "" && l.Agent != agent) || l.Agent == "grok" || l.Agent == "copilot" { continue } using := strings.EqualFold(active[l.Agent], l.User) out = append(out, Login{Agent: l.Agent, User: l.User, Plan: l.Plan, Seen: l.Seen, Active: using, On: using || l.On}) } - return append(out, grok...) + return append(out, side...) } // SwitchLogin signs an agent in to a remembered account. Sessions of the // agent that are already running keep the account they started with until // they restart. func SwitchLogin(agent, user string) error { - if agent == "grok" { + switch agent { + case "grok": return switchGrokLogin(user) + case "copilot": + return switchCopilotLogin(user) } loginsMu.Lock() defer loginsMu.Unlock() @@ -362,8 +367,11 @@ func putClaudeLogin(l savedLogin) error { // ForgetLogin drops a remembered account. The one an agent is signed in to // now can't be forgotten; it would only be remembered again. func ForgetLogin(agent, user string) error { - if agent == "grok" { + switch agent { + case "grok": return forgetGrokLogin(user) + case "copilot": + return forgetCopilotLogin(user) } loginsMu.Lock() defer loginsMu.Unlock() diff --git a/internal/provider/logins_on.go b/internal/provider/logins_on.go index ecf776e0..2589f281 100644 --- a/internal/provider/logins_on.go +++ b/internal/provider/logins_on.go @@ -24,8 +24,11 @@ var savedTokenMu sync.Mutex // SetLoginOn puts a saved account in use beside the agent's own, or takes // it out. The account the agent is signed in to is always in use. func SetLoginOn(agent, user string, on bool) error { - if agent == "grok" { + switch agent { + case "grok": return setGrokLoginOn(user, on) + case "copilot": + return setCopilotLoginOn(user, on) } loginsMu.Lock() defer loginsMu.Unlock() @@ -51,6 +54,9 @@ func (p Provider) AlsoOn() []Provider { if p.Account != nil && p.Account.Agent == "grok" && p.Account.token == nil { return grokAlsoOn(p) } + if p.Account != nil && p.Account.Agent == "copilot" { + return copilotAlsoOn() + } if p.Account == nil || p.Account.token != nil || (p.Account.Agent != "claude" && p.Account.Agent != "codex") { return nil } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 825f1602..163f0ca0 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -60,9 +60,11 @@ type Provider struct { Fallback []string `json:"fallback,omitempty"` // Routing is how requests spread over the keys or accounts it has on: - // "" in order, the next one only when the one before can't take it; - // "rotate" each in turn; "usage" the least used first. A failing one - // is passed over the same way whichever it is. + // "" smart, the first while it has quota to spare, then whichever has + // the most; "order" in order, the next one only when the one before + // can't take it; "rotate" each in turn; "usage" the least used first. + // 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"` // Headers are extra HTTP request headers sent to the vendor, exactly as @@ -288,7 +290,7 @@ func normalize(p Provider) Provider { } p.Models = cleanList(p.Models) p.Fallback = cleanList(p.Fallback) - if p.Routing != Rotate && p.Routing != LeastUsed { + if p.Routing != Ordered && p.Routing != Rotate && p.Routing != LeastUsed { p.Routing = "" } p.Catalog = strings.Join(p.Catalogs(), ", ") diff --git a/internal/provider/routing.go b/internal/provider/routing.go index c3df7000..e8f4f742 100644 --- a/internal/provider/routing.go +++ b/internal/provider/routing.go @@ -12,8 +12,9 @@ import ( "time" ) -// The routings besides the default, in order. +// The routings besides the default, smart, in order. const ( + Ordered = "order" Rotate = "rotate" LeastUsed = "usage" ) diff --git a/internal/provider/side_logins.go b/internal/provider/side_logins.go new file mode 100644 index 00000000..111a9bf6 --- /dev/null +++ b/internal/provider/side_logins.go @@ -0,0 +1,181 @@ +package provider + +// Accounts magpie signs in beside an agent's own. Grok's CLI and Copilot's +// editors and CLI each keep one account, which magpie only ever reads; a +// further account is signed in by magpie and kept by magpie alone (a home +// of its own for Grok, a token for Copilot). logins.json lists them, and +// the agent's own account too, with nothing of its secrets, so it can go +// behind another or be turned off like the rest. + +import ( + "fmt" + "strings" + "time" +) + +// sideLogin is one of those accounts, with what magpie saved of it. +type sideLogin struct { + Login + saved savedLogin +} + +// own says a saved account is the agent's own sign-in, not one of magpie's. +func (l savedLogin) own() bool { + return l.Home == "" && (len(l.Auth) == 0 || string(l.Auth) == "null") +} + +// sideLogins lists an agent's accounts that are signed in, the first in +// use first, then the rest as they were added. ownUser is who the agent +// itself is signed in to, "" for no one; usable says a saved one of +// magpie's is still signed in. +func sideLogins(agent, ownUser string, usable func(savedLogin) bool) []sideLogin { + loginsMu.Lock() + defer loginsMu.Unlock() + ls := readLogins() + if ownUser != "" { + found := false + for i := range ls { + if ls[i].Agent == agent && ls[i].own() { + found = true + if !strings.EqualFold(ls[i].User, ownUser) { + ls[i].User, ls[i].Seen = ownUser, time.Now().UTC().Truncate(time.Second) + _ = writeLogins(ls) + } + } + } + if !found { + ls = append(ls, savedLogin{Agent: agent, User: ownUser, Seen: time.Now().UTC().Truncate(time.Second)}) + _ = writeLogins(ls) + } + } + var out []sideLogin + first := -1 + for _, l := range ls { + if l.Agent != agent { + continue + } + if l.own() && ownUser == "" || !l.own() && !usable(l) { + continue // signed out there + } + if l.First || (first < 0 && l.own()) { + first = len(out) + } + out = append(out, sideLogin{Login{Agent: agent, User: l.User, Plan: l.Plan, Seen: l.Seen, On: l.On}, l}) + } + if len(out) == 0 { + return nil + } + if first < 0 { + first = 0 + } + out[first].Active, out[first].On = true, true + return append([]sideLogin{out[first]}, append(out[:first:first], out[first+1:]...)...) +} + +func loginsOf(ls []sideLogin) []Login { + var out []Login + for _, l := range ls { + out = append(out, l.Login) + } + return out +} + +// editSideLogin changes the saved account of user. +func editSideLogin(agent, user string, f func(ls []savedLogin, i int) ([]savedLogin, error)) error { + loginsMu.Lock() + defer loginsMu.Unlock() + ls := readLogins() + for i := range ls { + if ls[i].Agent == agent && strings.EqualFold(ls[i].User, user) { + ls, err := f(ls, i) + if err != nil { + return err + } + return writeLogins(ls) + } + } + return fmt.Errorf("no %s account %q", agent, user) +} + +func activeOf(ls []sideLogin) string { + for _, l := range ls { + if l.Active { + return l.User + } + } + return "" +} + +// switchSideLogin puts an account first. The agent's own sign-in stays as +// it is: magpie only changes which account its gateway uses first. +func switchSideLogin(agent, user string, ls []sideLogin) error { + was := activeOf(ls) + return editSideLogin(agent, user, func(saved []savedLogin, i int) ([]savedLogin, error) { + for j := range saved { + if saved[j].Agent == agent { + if j != i && strings.EqualFold(saved[j].User, was) { + saved[j].On = true // the one it replaces is next in line + } + saved[j].First = j == i + } + } + return saved, nil + }) +} + +func setSideLoginOn(agent, user string, on bool, ls []sideLogin) error { + if !on && strings.EqualFold(activeOf(ls), user) { + return fmt.Errorf("magpie uses %s first; put another account first to stop using it", user) + } + return editSideLogin(agent, user, func(saved []savedLogin, i int) ([]savedLogin, error) { + saved[i].On = on + return saved, nil + }) +} + +// forgetSideLogin drops an account magpie signed in; gone is told what it +// kept. The agent's own is signed out in the agent (ownHow says how). +func forgetSideLogin(agent, user, ownHow string, ls []sideLogin, gone func(savedLogin)) error { + if strings.EqualFold(activeOf(ls), user) { + return fmt.Errorf("magpie uses %s first; put another account first", user) + } + var old savedLogin + err := editSideLogin(agent, user, func(saved []savedLogin, i int) ([]savedLogin, error) { + if saved[i].own() { + return nil, fmt.Errorf("that is %s", ownHow) + } + old = saved[i] + return append(saved[:i], saved[i+1:]...), nil + }) + if err == nil && gone != nil { + gone(old) + } + return err +} + +// addSideLogin keeps an account magpie just signed in, in use beside the +// others. Signed in again, an account keeps the newer sign-in; one that is +// the agent's own already is not kept twice (dup is told of what is let +// go either way). +func addSideLogin(l savedLogin, dup func(savedLogin)) error { + loginsMu.Lock() + defer loginsMu.Unlock() + ls := readLogins() + l.Seen = time.Now().UTC().Truncate(time.Second) + for i := range ls { + if ls[i].Agent == l.Agent && strings.EqualFold(ls[i].User, l.User) { + if ls[i].own() { + dup(l) + return nil + } + old := ls[i] + ls[i].Auth, ls[i].Home, ls[i].Plan, ls[i].Seen = l.Auth, l.Home, l.Plan, l.Seen + if old.Home != l.Home { + dup(old) + } + return writeLogins(ls) + } + } + l.On = true + return writeLogins(append(ls, l)) +} diff --git a/internal/provider/signin.go b/internal/provider/signin.go index 01ba734b..e2a7caf0 100644 --- a/internal/provider/signin.go +++ b/internal/provider/signin.go @@ -48,6 +48,7 @@ type SignInState struct { ID string `json:"id"` Agent string `json:"agent"` URL string `json:"url"` // the vendor's page, to open or copy + Code string `json:"code,omitempty"` // what to type there, for a device code State string `json:"state"` // waiting, done, failed or canceled User string `json:"user,omitempty"` // the account, once done Plan string `json:"plan,omitempty"` // @@ -130,6 +131,11 @@ func StartSignIn(agent string) (SignInState, error) { if err := startGrokSignIn(s); err != nil { return SignInState{}, err } + case "copilot": + // GitHub's device code, as Copilot's editors sign in + if err := startCopilotSignIn(s); err != nil { + return SignInState{}, err + } default: return SignInState{}, fmt.Errorf("magpie can't sign in to %s accounts", agent) } @@ -231,7 +237,7 @@ func (s *signInFlow) finish(out SignInState) bool { s.mu.Unlock() return false } - out.ID, out.Agent, out.URL = s.st.ID, s.st.Agent, s.st.URL + out.ID, out.Agent, out.URL, out.Code = s.st.ID, s.st.Agent, s.st.URL, s.st.Code s.st = out s.mu.Unlock() close(s.done)