Moody-Sin: the Routing page showed a WorkBuddy account as "已用 71%" only, which for a plan counted in credits says little. A quota window now carries its own count when the vendor counts in amounts — amount of limit, in unit (QuotaWindow, QuotaSpan for quota --json and /v1/magpie/quotas, and the routing trace's Weighed, from the window its share is of: Allowance.Count). WorkBuddy's built-in fills it from the resource summary, and @magpie-community/opencode-workbuddy-auth 0.1.8 tells it from auth.usage (amount / limit / unit, passed through host.js); the workbuddy mover's min is 0.1.8. The Routing page's stage and its list of what each account did, the Usage page's meter, the menu bar's tooltip and the TUI say "3,550 / 5,000 credits · 71% used", or "1,450 / 5,000 credits · 29% left" with Left on (积分 in Chinese); a window counted in shares alone reads as before. quota-credits.test.cjs (WebKit, en/zh, used/left, Routing and Usage pages) fails on the old assets and passes; TestQuotaWindowCount, TestAllowanceCount, TestTraceTellsTheCount, TestPluginUsage (the count through the host) and TestWorkBuddyAccounts cover the Go side. Tried with the real WorkBuddy and WorkBuddy AI accounts in a sandbox magpie: moved onto the local plugin, /api/usage/quotas gives 33.29 / 3900 and 13.47 / 2250 credits and the Usage page reads "33.29 / 3,900 积分 · 已用 0.9%", "3,866.71 / 3,900 积分 · 剩余 99.1%" after a click on the count; not moved, the built-in gives the same for WorkBuddy. The Routing page with two real accounts of one provider wasn't tried (one account each). go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
212 lines
6.6 KiB
Go
212 lines
6.6 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()
|
|
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
|
|
switch {
|
|
case u.SignIn == "expired":
|
|
notePluginLapse(pp, key, http.StatusUnauthorized)
|
|
case u.SignIn == "renewed":
|
|
notePluginLapse(pp, key, http.StatusOK)
|
|
case u.SignIn == "kept":
|
|
case signInGone.MatchString(u.Error):
|
|
notePluginLapse(pp, key, http.StatusUnauthorized)
|
|
case u.Error == "":
|
|
notePluginLapse(pp, key, http.StatusOK)
|
|
}
|
|
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
|
|
}
|