Files
magpie/internal/provider/devin_usage.go
T
yetone fa9daa6945 docs: every built-in subscription a plugin serves once moved says so in its code, and AGENTS.md says where a fix for it goes (user: 对内置的订阅 provider 在代码层面和文档层面加上注释,防止又有别的 agent 在改内置订阅 provider 的代码而不知道生效的其实是对应的插件代码) — a moved Cursor, Devin, Grok, Command Code plan, Kiro, Zed, Qoder, Factory, Xiaomi MiMo, ZCode or WorkBuddy (provider.Moved; since v0.1.642 also the default for a new sign-in, provider.Adopt) is signed in, listed, requested and metered by its @magpie-community plugin, and none of the built-in's code in internal/provider/<name>*.go, internal/gateway/<name>.go, internal/zed or internal/qoder serves it; a fix made there alone never reaches those users. Each of those files now opens with a PLUGIN-SERVED notice naming the subscription's id, its plugin package, where the plugin lives (magpie-community/plugins, packages/<name>) and the mover whose min to raise so moved users get the fixed plugin (keepMovedCurrent); Qoder's adds that Qoder CN isn't moved and still runs there. AGENTS.md (CLAUDE.md imports it) lists the moved built-ins with their packages and movers, how to tell whether a user's is moved (move.state "plugin", onPlugins), the order a fix goes in (the plugin, publish, raise min, the built-in only for those not moved), the magpie code that does run for a moved one (internal/plugin, plugins.go, plugin_usage.go, pluginsignin.go, migrate*.go, app.js's subOf/pluginSubs/startPluginSignIn), and the parity tests. movers in migrate.go and SUBS in app.js point there too. TestMovedBuiltinsSayTheirPlugin fails for a mover whose code carries no PLUGIN-SERVED notice naming its id and package (it failed while a wrapped package name was split). Comments and docs only, no behaviour changed. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestProvidersAnswerWhileListsComeIn and TestClaudeSignedOut failed once on their timing under load, pass alone; untouched).
2026-10-02 19:37:02 +08:00

177 lines
5.7 KiB
Go

package provider
// PLUGIN-SERVED (see AGENTS.md): Devin ("devin") is a deprecated built-in
// subscription served by its plugin, @magpie-community/opencode-devin-auth,
// once moved onto it (provider.Moved; the default for a new sign-in). A
// moved one's sign-ins, models, requests and usage are all the plugin's,
// never this code's (only the move, in migrate*.go, still reads its
// accounts). A fix here alone doesn't reach those users; fix the plugin
// (github.com/magpie-community/plugins, packages/devin) and raise the
// mover's min in internal/provider/migrate_side.go.
// 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
}