From 181eefdc319d643ab577b27d004fd01ec61be134 Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 1 Oct 2026 12:24:45 +0800 Subject: [PATCH] =?UTF-8?q?plugins:=20built-in=20Devin=20shows=20its=20usa?= =?UTF-8?q?ge=20as=20the=20Devin=20plugin=20does,=20and=20a=20Grok=20accou?= =?UTF-8?q?nt=20whose=20sign-in=20has=20expired=20no=20longer=20stops=20Gr?= =?UTF-8?q?ok's=20move=20(goal:=20=E6=8F=92=E4=BB=B6=E8=AE=A2=E9=98=85=20p?= =?UTF-8?q?rovider=20=E8=B7=9F=E5=86=85=E7=BD=AE=E7=9A=84=E6=89=80?= =?UTF-8?q?=E6=9C=89=E9=80=BB=E8=BE=91=E9=83=BD=E8=A6=81=E4=B8=80=E6=A8=A1?= =?UTF-8?q?=E4=B8=80=E6=A0=B7)=20=E2=80=94=20the=20plugin=20showed=20a=20D?= =?UTF-8?q?evin=20account's=20day=20and=20week=20quotas,=20its=20ACUs=20an?= =?UTF-8?q?d=20extra=20balance=20from=20GetUserStatus=20while=20the=20buil?= =?UTF-8?q?t-in=20showed=20no=20usage=20at=20all,=20so=20routing=20had=20n?= =?UTF-8?q?o=20allowance=20to=20fail=20over=20on=20until=20the=20move;=20t?= =?UTF-8?q?he=20built-in=20now=20reads=20the=20same=20and=20fills=20the=20?= =?UTF-8?q?same=20windows=20(1=20day,=207=20days,=20ACUs=20aside),=20plan?= =?UTF-8?q?=20end=20and=20balance,=20and=20a=20refused=20key=20says=20to?= =?UTF-8?q?=20sign=20in=20again.=20A=20move=20checks=20each=20account=20th?= =?UTF-8?q?rough=20the=20plugin=20and=20stopped=20when=20one=20failed;=20G?= =?UTF-8?q?rok's=20plugin=20says=20an=20expired=20sign-in=20only=20in=20wo?= =?UTF-8?q?rds=20("Grok's=20sign-in=20has=20expired"),=20so=20one=20dead?= =?UTF-8?q?=20account=20kept=20every=20Grok=20account=20on=20the=20built-i?= =?UTF-8?q?n.=20Such=20an=20account=20now=20moves=20along=20untried=20and?= =?UTF-8?q?=20unmarked,=20as=20a=20built-in=20Grok=20account=20was=20never?= =?UTF-8?q?=20marked;=20any=20other=20failure=20still=20stops=20the=20move?= =?UTF-8?q?.=20TestDevinUsage=20and=20TestMoveExpiredInWords=20fail=20with?= =?UTF-8?q?out=20the=20change.=20go=20vet,=20go=20build=20-tags=20nogui=20?= =?UTF-8?q?(darwin,=20GOOS=3Dlinux),=20GOOS=3Dwindows=20go=20build,=20go?= =?UTF-8?q?=20test=20-tags=20nogui=20./...=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/plugin/testdata/fake/index.js | 2 + internal/provider/devin_usage.go | 167 ++++++++++++++++++++++++ internal/provider/devin_usage_test.go | 85 ++++++++++++ internal/provider/login_usage.go | 5 + internal/provider/migrate.go | 6 + internal/provider/migrate_lapse_test.go | 45 +++++++ internal/provider/subscription_usage.go | 3 + 7 files changed, 313 insertions(+) create mode 100644 internal/provider/devin_usage.go create mode 100644 internal/provider/devin_usage_test.go diff --git a/internal/plugin/testdata/fake/index.js b/internal/plugin/testdata/fake/index.js index 72fc48df..a21556ff 100644 --- a/internal/plugin/testdata/fake/index.js +++ b/internal/plugin/testdata/fake/index.js @@ -101,6 +101,8 @@ export const FakePlugin = async ({ client }) => ({ models: async (p, { auth }) => { if (auth?.refresh === "r-dead" || auth?.key === "dead") throw new Error("the vendor refused the sign-in") if (auth?.refresh === "r-models-gone") throw Object.assign(new Error("the vendor refused the sign-in"), { signIn: "expired" }) + // a sign-in past its time, said in words only, as Grok's plugin says it + if (auth?.refresh === "r-models-expired") throw new Error("FakeCo's sign-in has expired; run `fake login`") // the vendor unreachable: the list kept, as Zed's plugin keeps it if (auth?.refresh === "r-unreachable") await fetch("http://127.0.0.1:9/models").catch(() => {}) if (auth?.type === "oauth" && auth.refresh?.startsWith("rot-")) { diff --git a/internal/provider/devin_usage.go b/internal/provider/devin_usage.go new file mode 100644 index 00000000..b48b4108 --- /dev/null +++ b/internal/provider/devin_usage.go @@ -0,0 +1,167 @@ +package provider + +// How much of a Devin plan an account has used, as GetUserStatus (what the +// CLI's /usage reads) tells it: the plan's end, the daily and weekly quotas +// a quota-billed plan has (the share left of each, and when it comes back), +// the ACUs a plan with a limit has used this cycle, and the extra usage +// balance. The Devin plugin reads the same. + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "runtime" + "strconv" + "strings" + "time" +) + +var devinUsageClient = &http.Client{Timeout: 20 * time.Second} + +// devinUsageVersion is the CLI version GetUserStatus is asked as, the one +// the gateway's chats name. +const devinUsageVersion = "3000.11.3" + +// devinNum reads a number JSON gives as a number or a string (int64s are +// strings in proto JSON); ok is false when it is left out. +func devinNum(raw json.RawMessage) (float64, bool) { + if len(raw) == 0 || string(raw) == "null" { + return 0, false + } + var s string + if json.Unmarshal(raw, &s) == nil { + f, err := strconv.ParseFloat(strings.TrimSpace(s), 64) + return f, err == nil + } + var f float64 + return f, json.Unmarshal(raw, &f) == nil +} + +type devinPlanStatus struct { + PlanInfo struct { + HideDaily bool `json:"hideDailyQuota"` + HideWeekly bool `json:"hideWeeklyQuota"` + } `json:"planInfo"` + PlanEnd string `json:"planEnd"` + DailyLeft json.RawMessage `json:"dailyQuotaRemainingPercent"` + DailyReset json.RawMessage `json:"dailyQuotaResetAtUnix"` + WeeklyLeft json.RawMessage `json:"weeklyQuotaRemainingPercent"` + WeeklyReset json.RawMessage `json:"weeklyQuotaResetAtUnix"` + ACULimit json.RawMessage `json:"acuLimit"` + ACUUsed json.RawMessage `json:"acuConsumed"` + Overage json.RawMessage `json:"overageBalanceMicros"` +} + +// devinQuota fills q from a plan's status. A quota Devin leaves out is not +// taken for used up. +func devinQuota(q *SubscriptionQuota, st devinPlanStatus) { + var end *time.Time + if t, err := time.Parse(time.RFC3339Nano, st.PlanEnd); err == nil { + end = &t + q.Until = &t + } + for _, w := range []struct { + name string + span time.Duration + left, reset json.RawMessage + hide bool + }{ + {"1 day", 24 * time.Hour, st.DailyLeft, st.DailyReset, st.PlanInfo.HideDaily}, + {"7 days", 7 * 24 * time.Hour, st.WeeklyLeft, st.WeeklyReset, st.PlanInfo.HideWeekly}, + } { + left, ok := devinNum(w.left) + if w.hide || !ok { + continue + } + x := QuotaWindow{Name: w.name, Used: min(100, max(0, 100-left)), Span: w.span} + if at, _ := devinNum(w.reset); at > 0 { + t := time.Unix(int64(at), 0).UTC() + x.ResetsAt = &t + } + q.Windows = append(q.Windows, x) + } + if limit, _ := devinNum(st.ACULimit); limit > 0 { + used, _ := devinNum(st.ACUUsed) + q.Windows = append(q.Windows, QuotaWindow{Name: "ACUs", Used: min(100, 100*used/limit), + Display: devinACUs(used) + " / " + devinACUs(limit) + " ACUs", ResetsAt: end, Aside: true}) + } + if extra, _ := devinNum(st.Overage); extra > 0 { + q.Balance = fmt.Sprintf("$%.2f", extra/1e6) + } +} + +// devinACUs writes an ACU count as the plugin does: to two decimals, none +// trailing. +func devinACUs(f float64) string { + return strconv.FormatFloat(math.Round(f*100)/100, 'f', -1, 64) +} + +func devinLoginQuota(ctx context.Context, l Login) SubscriptionQuota { + q := SubscriptionQuota{Provider: "devin", Name: "Devin", Icon: "devin", Plan: l.Plan, User: l.User, Windows: []QuotaWindow{}} + home, found := "", false + for _, d := range devinLogins() { + if d.User == l.User { + home, found = d.Home, true + break + } + } + if !found { + q.Error = "no such Devin account" + return q + } + key, server, err := DevinAuthAt(home) + if err != nil { + q.Error = err.Error() + return q + } + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + st, err := devinUserStatus(ctx, key, server) + if err != nil { + q.Error = err.Error() + return q + } + devinQuota(&q, st) + return q +} + +var errDevinUsageSignIn = errors.New("Devin's sign-in has expired — sign in again") + +func devinUserStatus(ctx context.Context, key, server string) (devinPlanStatus, error) { + body, _ := json.Marshal(map[string]any{"metadata": map[string]any{ + "ideName": "devin-cli", "ideVersion": devinUsageVersion, "extensionName": "devin-cli", + "extensionVersion": devinUsageVersion, "apiKey": key, "locale": "en", "os": runtime.GOOS, + }}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, server+"/exa.seat_management_pb.SeatManagementService/GetUserStatus", bytes.NewReader(body)) + if err != nil { + return devinPlanStatus{}, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Connect-Protocol-Version", "1") + res, err := devinUsageClient.Do(req) + if err != nil { + return devinPlanStatus{}, err + } + defer res.Body.Close() + b, _ := io.ReadAll(io.LimitReader(res.Body, 4<<20)) + var env struct { + Code string `json:"code"` + Message string `json:"message"` + UserStatus struct { + PlanStatus devinPlanStatus `json:"planStatus"` + } `json:"userStatus"` + } + _ = json.Unmarshal(b, &env) + if res.StatusCode == http.StatusUnauthorized || env.Code == "unauthenticated" { + return devinPlanStatus{}, errDevinUsageSignIn + } + if res.StatusCode != http.StatusOK { + return devinPlanStatus{}, fmt.Errorf("Devin: %s", APIError(b, res.Status)) + } + return env.UserStatus.PlanStatus, nil +} diff --git a/internal/provider/devin_usage_test.go b/internal/provider/devin_usage_test.go new file mode 100644 index 00000000..1f182ff4 --- /dev/null +++ b/internal/provider/devin_usage_test.go @@ -0,0 +1,85 @@ +package provider + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestDevinUsage: magpie's built-in Devin account showed no usage while the +// Devin plugin showed its day, week and ACU windows; both read +// GetUserStatus now, and a key Devin refuses says to sign in again. +func TestDevinUsage(t *testing.T) { + home := claudeHome(t) + data := filepath.Join(home, "data") + t.Setenv("XDG_DATA_HOME", data) + os.MkdirAll(filepath.Join(data, "devin"), 0o700) + os.WriteFile(filepath.Join(data, "devin", "credentials.toml"), devinCredentials("devin-session-token$good", "", "", ""), 0o600) + exe := filepath.Join(home, "devin") + os.WriteFile(exe, []byte(`#!/bin/sh +printf 'Logged in (via Devin).\n\nUser:\n Email: dev@example.com\n\nAccount:\n Tier: Devin Pro\n' +`), 0o755) + fakeDevin(t, exe) + forgetDevinStatus() + + var asked map[string]any + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/exa.seat_management_pb.SeatManagementService/GetUserStatus" || r.Header.Get("Connect-Protocol-Version") != "1" { + http.NotFound(w, r) + return + } + b, _ := io.ReadAll(r.Body) + json.Unmarshal(b, &asked) + if strings.Contains(string(b), "refused") { + w.WriteHeader(http.StatusUnauthorized) + io.WriteString(w, `{"code":"unauthenticated","message":"bad key"}`) + return + } + io.WriteString(w, `{"userStatus":{"planStatus":{"planInfo":{"planName":"Pro"},"planEnd":"2026-11-01T00:00:00Z", + "dailyQuotaRemainingPercent":75,"dailyQuotaResetAtUnix":"1790000000", + "weeklyQuotaRemainingPercent":"40","weeklyQuotaResetAtUnix":"1790500000", + "acuLimit":"250","acuConsumed":12.345,"overageBalanceMicros":"5000000"}}}`) + })) + defer fake.Close() + t.Setenv("WINDSURF_API_SERVER_URL", fake.URL) + + ls, ok := builtinLogins("devin") + if !ok || len(ls) != 1 || ls[0].User != "dev@example.com" { + t.Fatalf("logins %+v %v", ls, ok) + } + q := loginQuota(context.Background(), ls[0]) + if q.Error != "" { + t.Fatal(q.Error) + } + if md, _ := asked["metadata"].(map[string]any); md["apiKey"] != "devin-session-token$good" || md["ideName"] != "devin-cli" { + t.Fatalf("asked %v", asked) + } + if len(q.Windows) != 3 { + t.Fatalf("windows %+v", q.Windows) + } + day, week, acu := q.Windows[0], q.Windows[1], q.Windows[2] + if day.Name != "1 day" || day.Used != 25 || day.ResetsAt == nil || day.ResetsAt.Unix() != 1790000000 { + t.Fatalf("day %+v", day) + } + if week.Name != "7 days" || week.Used != 60 || week.Span != 7*24*time.Hour { + t.Fatalf("week %+v", week) + } + if acu.Name != "ACUs" || !acu.Aside || acu.Display != "12.35 / 250 ACUs" || acu.ResetsAt == nil { + t.Fatalf("acu %+v", acu) + } + if q.Until == nil || q.Until.Format("2006-01-02") != "2026-11-01" || q.Balance != "$5.00" { + t.Fatalf("plan %+v", q) + } + + os.WriteFile(filepath.Join(data, "devin", "credentials.toml"), devinCredentials("devin-session-token$refused", "", "", ""), 0o600) + if q := loginQuota(context.Background(), ls[0]); q.Error != "Devin's sign-in has expired — sign in again" { + t.Fatalf("refused %+v", q) + } +} diff --git a/internal/provider/login_usage.go b/internal/provider/login_usage.go index 445f18b5..57134cb7 100644 --- a/internal/provider/login_usage.go +++ b/internal/provider/login_usage.go @@ -92,6 +92,8 @@ func builtinLogins(agent string) (logins []Login, ok bool) { logins = loginsOf(qoderLoginsOf(agent)) case "zed": logins = zedLoginList() + case "devin": + logins = devinLoginList() case "factory": logins = factoryLoginList() case MiMoID: @@ -129,6 +131,9 @@ func loginQuota(ctx context.Context, l Login) SubscriptionQuota { if l.Agent == "zed" { return zedLoginQuota(ctx, l) } + if l.Agent == "devin" { + return devinLoginQuota(ctx, l) + } if l.Agent == "factory" { return factoryLoginQuota(ctx, l) } diff --git a/internal/provider/migrate.go b/internal/provider/migrate.go index 623461e3..478bc8e1 100644 --- a/internal/provider/migrate.go +++ b/internal/provider/migrate.go @@ -527,6 +527,12 @@ func move(ctx context.Context, id string, mv *mover) (err error) { notePluginLapse(pp, moved[i].Key, http.StatusUnauthorized) continue } + if err != nil && signInGone.MatchString(err.Error()) { + // the plugin says in words the sign-in is gone (Grok's past + // its time): it moves along untried, as a lapsed one does, and + // unmarked, as the plugin's own reads leave its accounts + continue + } if err != nil { return &moveError{MoveWhy{Code: "account", Args: map[string]string{"user": a.User, "error": err.Error()}}, fmt.Sprintf("%s doesn't work through the plugin: %s", a.User, err), err} } diff --git a/internal/provider/migrate_lapse_test.go b/internal/provider/migrate_lapse_test.go index 49cc8600..24640943 100644 --- a/internal/provider/migrate_lapse_test.go +++ b/internal/provider/migrate_lapse_test.go @@ -117,6 +117,51 @@ func TestMoveRefusedWhileListing(t *testing.T) { } } +// An account whose plugin says only in words that its sign-in has expired +// (Grok's, past its time with no CLI to renew it) moves along untried and +// unmarked, as a built-in Grok account never was marked; it used to stop +// the whole move. A plugin failing an account for any other reason still +// does (TestMove's r-dead). +func TestMoveExpiredInWords(t *testing.T) { + bun, err := exec.LookPath("bun") + if err != nil { + t.Skip("no bun on PATH") + } + claudeHome(t) + t.Setenv("MAGPIE_BUN", bun) + t.Cleanup(plugin.Settle) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + abs, _ := filepath.Abs("../plugin/testdata/fake/index.js") + if _, err := plugin.Add(ctx, abs); err != nil { + t.Fatal(err) + } + if _, err := plugin.Providers(ctx); err != nil { + t.Fatal(err) + } + inUse := []string{"fake-1"} + fakeMover(t, &inUse) + movers["fakeco"].pkg = abs + loginsMu.Lock() + if err := writeLogins([]savedLogin{fakeLogin("a@fake", "r-a", true, true), fakeLogin("x@fake", "r-models-expired", false, true)}); err != nil { + t.Fatal(err) + } + loginsMu.Unlock() + if err := Move(ctx, "fakeco"); err != nil { + t.Fatalf("an expired sign-in stopped the move: %v", err) + } + users := map[string]bool{} + for _, l := range pluginLogins(mustPlugin(t)) { + users[l.User] = true + if l.Lapsed != "" { + t.Fatalf("%s marked lapsed: %q", l.User, l.Lapsed) + } + } + if !users["a@fake"] || !users["x@fake"] { + t.Fatalf("moved %v", users) + } +} + // A move's lock left by a magpie killed mid-move (a hung move stopped by // hand) holds nothing: the next move takes it, where it used to be // refused as "another magpie is moving" for a quarter of an hour. diff --git a/internal/provider/subscription_usage.go b/internal/provider/subscription_usage.go index 2f0b727f..9c7b21d3 100644 --- a/internal/provider/subscription_usage.go +++ b/internal/provider/subscription_usage.go @@ -283,6 +283,9 @@ func fetchSubscriptionUsage() []SubscriptionQuota { if !moved("zed") && !hidden["zed"] { fetches = append(fetches, perLogin(via("zed"), zedLoginList(), "Zed", "zed")...) } + if !moved("devin") && !hidden["devin"] { + fetches = append(fetches, perLogin(via("devin"), devinLoginList(), "Devin", "devin")...) + } if !moved("factory") && !hidden["factory"] { fetches = append(fetches, perLogin(via("factory"), factoryLoginList(), "Factory", "factory")...) }