plugins: built-in Devin shows its usage as the Devin plugin does, and a Grok account whose sign-in has expired no longer stops Grok's move (goal: 插件订阅 provider 跟内置的所有逻辑都要一模一样) — the plugin showed a Devin account's day and week quotas, its ACUs and extra balance from GetUserStatus while the built-in showed no usage at all, so routing had no allowance to fail over on until the move; the built-in now reads the same and fills the same windows (1 day, 7 days, ACUs aside), plan end and balance, and a refused key says to sign in again. A move checks each account through the plugin and stopped when one failed; Grok's plugin says an expired sign-in only in words ("Grok's sign-in has expired"), so one dead account kept every Grok account on the built-in. Such an account now moves along untried and unmarked, as a built-in Grok account was never marked; any other failure still stops the move. TestDevinUsage and TestMoveExpiredInWords fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.

This commit is contained in:
yetone committed 2026-10-01 12:24:45 +08:00
1 parent b69fd168c2
commit 181eefdc31
7 files changed
+313

No files matched your search

+2
View File
@@ -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-")) {
+167
View File
@@ -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
}
+85
View File
@@ -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)
}
}
+5
View File
@@ -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)
}
+6
View File
@@ -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}
}
+45
View File
@@ -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.
+3
View File
@@ -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")...)
}