Files
magpie/internal/provider/plugin_usage.go
T
forever 470d1e71a5 provider: a plugin account's usage reading begun before a request was answered no longer overrules the answer, so a 401's lapse mark isn't taken off by a clean reading already out (TestPluginFailover, about 1 run in 90) (waitingsnow) (#1176)
TestPluginFailover failed about 1 run in 90 at "a refused isn't marked".
With notePluginLapse logged, a failing run read: a background reading of
a's allowance (loginReading, after the "stream" round's StaleAllowance)
began at 31.985; the upstream answered a's request 401 at 32.001 and
notePluginSaid marked a lapsed at 32.003; the reading ended clean at
32.011 and notePluginLapse(200) took the mark off; the test read the
logins at 32.042. Users meet the same: a reading of the Usage page or of
routing's allowances that is out when a request is refused clears the
mark the refusal left, and routing goes on trying the refused account;
one that ends refused marks an account a request has just gone through
on.

What plugin accounts' sign-ins are told of is now counted under
loginsMu (pluginSignInTold, pluginSignInSaid). A request's answer, a
sign-in and the plugin's own signIn events are told now and apply as
before. pluginLoginQuota takes the count before plugin.AccountUsage, and
what its reading found applies only when nothing was told of that
account since (notePluginLapseSince), as Allowances leaves out an
account renewed while it read. A count rather than a time: Windows'
clock moves in ticks of up to 15.6ms, and a reading begun just after an
answer would read as begun with it.

TestPluginUsageReadOlderThanAnswer holds the fake plugin's usage on an
httptest server ($FAKE_USAGE, whose 401 the fake now reads as an expired
sign-in) while a request's answer is noted, both ways, and checks that
readings begun afterwards still mark and clear. Each direction fails
without the fix. TestPluginFailover now reads the allowances the
"stream" round left stale before the "gone" round: the fake's usage
doesn't ask the upstream that refuses, so a reading that asks after the
401 finds the sign-in good and rightly takes the mark off. A review
reproduced that with a delay injected before the count is taken.

Left as it is: a reading whose own renewal of the token tells "renewed"
before it asks skips the "expired" it then finds; the next 401 marks the
account. Not tried with a real plugin account.

Under a temp HOME with Go's caches pinned and bun 1.4.2: gofmt, go vet
-tags nogui ./..., builds for darwin, GOOS=linux -tags nogui and
GOOS=windows (and windows vet of provider and gateway) pass; go test
-tags nogui ./... passes; the changed provider tests and
TestPluginSaysSignIn/TestPluginFailover pass with -race -count=20; the
report's `go test -tags nogui -race -cpu 1,2,4 -count=30 -run
'^TestPluginFailover$' ./internal/gateway/` passed 3 rounds (270 runs),
where it failed 3 of about 54 runs before the fix on the same loaded
machine.
2026-10-08 06:48:41 +08:00

214 lines
6.8 KiB
Go

package provider
// A plugin's accounts show their allowance as the built-ins' do: on the
// usage page, in the menu bar, beside each account, and to the gateway,
// which passes over one used up. The plugin tells it (auth.usage, see
// internal/plugin/host.js); a built-in moved onto its plugin keeps its
// card's name and icon.
import (
"context"
"net/http"
"regexp"
"strings"
"time"
"github.com/yetone/magpie/internal/plugin"
)
// movedCards are the names and icons the built-ins' usage cards have,
// and the sites their providers link to.
var movedCards = map[string]struct{ name, icon, site string }{
"zed": {"Zed", "zed", "https://zed.dev"},
"qoder": {"Qoder", "qoder", "https://qoder.com"},
"factory": {"Factory", "factory", "https://factory.ai"},
MiMoID: {"Xiaomi MiMo", "mimocode", "https://mimo-ai.xiaomimimo.com"},
CommandCodePlanID: {"Command Code", "commandcode", cmdStudio},
"kiro": {"Kiro", "kiro-color", "https://kiro.dev"},
"zcode": {"ZCode", "zcode", "https://zcode.z.ai"},
"workbuddy": {"WorkBuddy", "workbuddy-color", "https://www.codebuddy.cn"},
WorkBuddyAIID: {"WorkBuddy AI", "workbuddy-color", "https://www.workbuddy.ai"},
"cursor": {"Cursor", "cursor", "https://cursor.com"},
"grok": {"Grok (SuperGrok)", "xai", "https://x.ai/cli"},
"devin": {"Devin", "devin", "https://devin.ai"},
}
// builtinHost is the API host the built-in id shows when it served itself,
// "" for those that show none (their requests go through their own code).
func builtinHost(id string) string {
switch id {
case "grok":
return HostOf(GrokBase)
case CommandCodePlanID:
return HostOf(cmdAPI)
case "factory":
return HostOf(factoryAPI)
case "zcode":
return HostOf(ZCodeZaiBase)
case "workbuddy":
return HostOf(wbCN.api())
case WorkBuddyAIID:
return HostOf(wbAI.api())
}
return ""
}
// movedNames are the built-ins' provider names that aren't their card's.
var movedNames = map[string]string{CommandCodePlanID: "Command Code Plan"}
// pluginCard is the name and icon pp's usage cards show.
func pluginCard(pp plugin.Provider) (string, string) {
if c, ok := movedCards[pp.ID]; ok && Moved(pp.ID) {
return c.name, c.icon
}
name := pp.Name
if name == "" {
name = pp.ID
}
return name, PluginIcon(pp)
}
// pluginUsageLogins are the accounts of pp whose allowance can be asked.
func pluginUsageLogins(pp plugin.Provider) []Login {
if !pp.Usage || !pp.SignedIn || movingNow(pp.ID) {
return nil
}
return pluginLoginList(pp)
}
// signInGone is a plugin's usage saying the account's sign-in is gone.
var signInGone = regexp.MustCompile(`(?i)sign-in has (expired|lapsed)|sign in again`)
// pluginLoginQuota is the allowance of one of a plugin provider's
// accounts, l.Agent being "plugin:" and its id.
func pluginLoginQuota(ctx context.Context, l Login) SubscriptionQuota {
pp, ok := pluginOfAgent(l.Agent)
name, icon := pluginCard(pp)
q := SubscriptionQuota{Provider: PluginID(pp.ID), Name: name, Icon: icon, Plan: l.Plan, User: l.User, Windows: []QuotaWindow{}}
if !ok {
q.Error = "no such plugin provider"
return q
}
key := ""
for _, x := range pluginLogins(pp) {
if strings.EqualFold(x.User, l.User) {
key = x.acct.Key
}
}
if key == "" {
q.Error = "not signed in"
return q
}
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
began := pluginSignInAt()
u, err := plugin.AccountUsage(ctx, pp.ID, key)
if err != nil {
q.Error = err.Error()
return q
}
// the plugin says what the read means for the sign-in, as a built-in's
// usage read marked the account or left it; one that doesn't say has
// an error to sign in again mark it and a clean read clear it. What a
// request was answered while it read is newer, and stands.
switch {
case u.SignIn == "expired":
notePluginLapseSince(pp, key, http.StatusUnauthorized, began)
case u.SignIn == "renewed":
notePluginLapseSince(pp, key, http.StatusOK, began)
case u.SignIn == "kept":
case signInGone.MatchString(u.Error):
notePluginLapseSince(pp, key, http.StatusUnauthorized, began)
case u.Error == "":
notePluginLapseSince(pp, key, http.StatusOK, began)
}
keepPluginPlan(pp, key, u.Plan)
return quotaOfPlugin(q, u)
}
// quotaOfPlugin fills q with what the plugin told.
func quotaOfPlugin(q SubscriptionQuota, u plugin.Usage) SubscriptionQuota {
if u.Plan != "" {
q.Plan = u.Plan
}
if u.User != "" {
q.User = u.User
}
q.Balance, q.Renew, q.Error = u.Balance, u.Renew, u.Error
if t, err := time.Parse(time.RFC3339, u.Until); err == nil {
q.Until = &t
}
if r := u.Resets; r != nil {
q.Resets = &ResetCredits{Count: r.Count, ByWindow: r.ByWindow, FiveHour: r.FiveHour, Weekly: r.Weekly}
if t, err := time.Parse(time.RFC3339, r.Until); err == nil {
q.Resets.Until = &t
}
}
for _, x := range u.Windows {
w := QuotaWindow{Name: x.Name, Used: x.Used, ResetSecs: x.ResetSecs, Display: x.Display,
Amount: x.Amount, Limit: x.Limit, Unit: x.Unit,
Span: time.Duration(x.Span * float64(time.Second)), Model: strings.ToLower(x.Model), Aside: x.Aside}
if t, err := time.Parse(time.RFC3339, x.ResetsAt); err == nil {
w.ResetsAt = &t
}
if set := lowerSet(x.Models); set != nil {
w.matches = func(model string) bool { return set[strings.ToLower(model)] }
} else if set := lowerSet(x.NotModels); set != nil {
w.matches = func(model string) bool { return !set[strings.ToLower(model)] }
}
q.Windows = append(q.Windows, w)
}
return q
}
func lowerSet(ids []string) map[string]bool {
if len(ids) == 0 {
return nil
}
m := map[string]bool{}
for _, id := range ids {
m[strings.ToLower(id)] = true
}
return m
}
// pluginUsageFetches are a card's fetch for each plugin account that
// tells its allowance, but those placed already where their built-in's
// cards were.
func pluginUsageFetches(via func(string) context.Context, hidden, placed map[string]bool) []func() SubscriptionQuota {
var out []func() SubscriptionQuota
for _, pp := range plugin.Cached() {
id := PluginID(pp.ID)
if hidden[id] || placed[pp.ID] {
continue
}
ls := pluginUsageLogins(pp)
if len(ls) == 0 {
continue
}
name, icon := pluginCard(pp)
out = append(out, perLogin(via(id), ls, name, icon)...)
}
return out
}
// pluginUsageFetchesOf are the cards of the plugin provider id.
func pluginUsageFetchesOf(via func(string) context.Context, id string) []func() SubscriptionQuota {
for _, pp := range plugin.Cached() {
if pp.ID == id {
name, icon := pluginCard(pp)
return perLogin(via(id), pluginUsageLogins(pp), name, icon)
}
}
return nil
}
// UsageAgent is the agent an account's allowance is asked for by
// (LoginUsage, Allowances): a plugin's accounts by their provider's.
func (a *Account) UsageAgent() string {
if a.plugin != nil {
return "plugin:" + a.plugin.ID
}
return a.Agent
}