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:
7 files changed
+313
No files matched your search
+2
@@ -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-")) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")...)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user