Files
magpie/internal/dimagent/usage.go
T
Ethan Xuandyetone 2f00db65e6 feat: add DimAgent subscription support (#226)
* feat: add DimAgent subscription support

Add OAuth PKCE login with automatic and pasted callbacks, account management, model discovery, quota reporting and gateway integration. Safely rotate credentials during concurrent requests and preserve fresh tokens when updating model caches. Cover protocol, provider, GUI and callback behavior with regression tests.

* dimagent: a refresh hiccup inside the day's lead keeps the token that still runs

The fallback checked !dimagentNearExpiry, which is false whenever a refresh
runs at all, so a 5xx/timeout on /oauth/token failed every request for the
last 24h of a still-valid token. A 401/403 still lapses the account.

* dimagent: review tests (sign forwarding, parallel refresh once, hiccup in lead, LoginUsage)

* dimagent: signing in again takes the callback port from the sign-in it replaces

StartSignIn begins the new flow before it cancels the old one, so the new
flow's listen on 54321 failed (ignored) and the old listener then shut
down a second later: the browser's callback was refused and only pasting
could finish. The older DimAgent flow is now ended and its server closed
first, and the listen retried briefly while the port is let go.

* dimagent: the fake-upstream tests get a name of their own

---------

Co-authored-by: yetone <yetoneful@gmail.com>
2026-09-30 01:11:29 +08:00

427 lines
15 KiB
Go

package dimagent
// The account's allowance. /api/me/usage answers what DimAgent's own console
// draws: the subscription's credit buckets and, on a plan that runs its calls
// through a window, that window's cap and how much of it is spent.
//
// {"success":true,"data":{
// "account_id":7,
// "subscription":{"product":{"name":…},"subscription":{"status":…,
// "cancel_at_period_end":…},"current_term":{"start_at":…,"end_at":…},
// "last_term":{"end_at":…}},
// "credits":{"subscription_bucket":{"total_units":700,"used_units":210,
// "remaining_units":490,"unlimited":…,"status":…,"bucket_kind":…,
// "expires_at":…,"hard_deadline_at":…,
// "window_states":[{"window_duration_hours":5,"window_token_cap":…,
// "window_token_used":…,"window_started_at":…,"window_expires_at":…}]},
// "addon_buckets":[…],
// "total_units":…, "used_units":…, "remaining_units":…},
// "resets":{"window":{"available_count":…,"nearest_expires_at":…},
// "monthly_full":{…}},
// "feature_meters":[{"feature_key":…,"unit":…,"total_remaining":…,
// "total_allowance":…,"total_used":…,"unlimited":…,"period_end":…}],
// "credits_display":{"enabled":…,"credit_name":"Credits"}}}
//
// Which fields a reply leaves out varies by the account, so the reader takes
// each as it arrives rather than failing over one shape:
//
// - account_id is a number, though a console prints the same digits;
// - a plan that has run out names no current_term, and its paid time ended
// with the last_term the record still carries;
// - an account on no plan has no subscription_bucket and no credits of the
// plan's own, so its total_units is its add-ons added up — the same
// credits shown twice;
// - what the units count is stated by credits_display.credit_name, not by a
// bucket's bucket_kind, which only says which pile a bucket is.
//
// The plan's name is product.name, falling back to the subscription's status
// when there is no product. A bucket that names no total says nothing about an
// allowance; one that is unlimited is shown as unlimited rather than as
// nothing spent.
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
// Bucket is one allowance: units spent of units granted, and the windows the
// upstream counts them in. Which pile it is — the plan's own or an add-on —
// the reader already knows from where it found it; a bucket_kind says no more
// than that, so it isn't kept.
type Bucket struct {
Units string // what its units are counted in, as the vendor names them
Total float64
Used float64
Remaining float64
Unlimited bool
ExpiresAt time.Time
HardDeadline time.Time
Status string
Windows []Window
}
// Window is a rate-limit window inside a bucket: how long it runs, what it
// allows, and when it starts again. The upstream counts hours as a number, so
// a window may be half one.
type Window struct {
Hours float64
Cap float64
Used float64
Started time.Time
Expires time.Time
}
// empty is a window that says nothing: all zero, as an older reply leaves its
// flat fields when a bucket has no window at all.
func (w Window) empty() bool {
return w.Hours == 0 && w.Cap == 0 && w.Used == 0 && w.Expires.IsZero()
}
// Plan is the subscription as the record describes it: what the account is
// on, whether it renews itself, and when the paid term ends.
type Plan struct {
Name string
Status string
Renew bool // cancel_at_period_end is false, so the plan renews
RenewKnown bool // the record said one way or the other
EndsAt time.Time // current_term end, its last_term's once the plan is over: renewal when Renew, the end when not
}
// Usage is what one account was told about itself.
type Usage struct {
AccountID string
Plan Plan
Subscription Bucket
AddOns []Bucket
Total Bucket // the account's flat tally, kept only in place of buckets
Resets Resets
Meters []FeatureMeter
}
// Resets is when the allowance comes back: the app reads it out of two
// counters, one for the window and one for the month.
type Resets struct {
Window Reset
Monthly Reset
}
// Reset is one of those counters: how many resets are still owed, and when
// the next one lands.
type Reset struct {
Available int
NextAt time.Time
}
// FeatureMeter is an allowance counted by something other than credits —
// images made, seconds of audio — which the console lists beside them.
type FeatureMeter struct {
Feature string
Unit string
Remaining float64
Allowance float64
Used float64
Unlimited bool
PeriodEnd time.Time
}
// window is the shape a rate window is sent in, both inside window_states and
// laid flat on the bucket, which is how the older replies spell it.
type window struct {
WindowDurationHrs float64 `json:"window_duration_hours"`
WindowTokenCap float64 `json:"window_token_cap"`
WindowTokenUsed float64 `json:"window_token_used"`
WindowStartedAt any `json:"window_started_at"`
WindowExpiresAt any `json:"window_expires_at"`
}
func (w window) asWindow() Window {
return Window{Hours: w.WindowDurationHrs, Cap: w.WindowTokenCap, Used: w.WindowTokenUsed,
Started: parseTime(w.WindowStartedAt), Expires: parseTime(w.WindowExpiresAt)}
}
// ParseUsage reads the answer. A reply that carries no allowance at all is an
// error, so a caller says it couldn't read the quota rather than showing
// nothing spent.
func ParseUsage(body []byte) (Usage, error) {
var env struct {
Success *bool `json:"success"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
return Usage{}, fmt.Errorf("dimagent usage: unreadable reply: %w", err)
}
if env.Success != nil && !*env.Success {
return Usage{}, fmt.Errorf("dimagent usage: %s", nonEmpty(env.Message, "the upstream refused"))
}
payload := env.Data
if len(payload) == 0 || string(payload) == "null" {
payload = body // a reply that is the object bare
}
var raw struct {
// The upstream answers an account's own id as a number; a console
// prints the same figure, so its shape isn't one to fail a whole
// reading over.
AccountID any `json:"account_id"`
Subscription struct {
Product struct {
Name string `json:"name"`
} `json:"product"`
Subscription struct {
Status string `json:"status"`
CancelAtPeriodEnd *bool `json:"cancel_at_period_end"`
} `json:"subscription"`
CurrentTerm struct {
EndAt any `json:"end_at"`
} `json:"current_term"`
// a plan that has run out names no current term at all: its
// paid time ended with the last term it had
LastTerm struct {
EndAt any `json:"end_at"`
} `json:"last_term"`
} `json:"subscription"`
Credits struct {
SubscriptionBucket json.RawMessage `json:"subscription_bucket"`
AddonBuckets []json.RawMessage `json:"addon_buckets"`
TotalUnits *float64 `json:"total_units"`
UsedUnits *float64 `json:"used_units"`
RemainingUnits *float64 `json:"remaining_units"`
} `json:"credits"`
// what the vendor calls its currency, and whether it shows it at all
CreditsDisplay struct {
Enabled *bool `json:"enabled"`
CreditName string `json:"credit_name"`
} `json:"credits_display"`
Resets struct {
Window resetRaw `json:"window"`
MonthlyFull resetRaw `json:"monthly_full"`
} `json:"resets"`
FeatureMeters []struct {
FeatureKey string `json:"feature_key"`
Unit string `json:"unit"`
TotalRemaining *float64 `json:"total_remaining"`
TotalAllowance *float64 `json:"total_allowance"`
TotalUsed *float64 `json:"total_used"`
Unlimited bool `json:"unlimited"`
PeriodEnd any `json:"period_end"`
} `json:"feature_meters"`
}
if err := json.Unmarshal(payload, &raw); err != nil {
return Usage{}, fmt.Errorf("dimagent usage: unreadable reply: %w", err)
}
u := Usage{AccountID: accountIDOf(raw.AccountID)}
// the plan's name as the vendor's console reads it: product name, or the
// subscription's status when there is no product
u.Plan = Plan{Name: strings.TrimSpace(raw.Subscription.Product.Name),
Status: strings.TrimSpace(raw.Subscription.Subscription.Status),
EndsAt: parseTime(raw.Subscription.CurrentTerm.EndAt)}
// a plan that ended names no current term; its paid time ended with the
// last term it had, which the record still carries
if u.Plan.EndsAt.IsZero() {
u.Plan.EndsAt = parseTime(raw.Subscription.LastTerm.EndAt)
}
if u.Plan.Name == "" {
u.Plan.Name = u.Plan.Status
}
if c := raw.Subscription.Subscription.CancelAtPeriodEnd; c != nil {
u.Plan.Renew, u.Plan.RenewKnown = !*c, true
}
units := strings.TrimSpace(raw.CreditsDisplay.CreditName)
if raw.CreditsDisplay.Enabled != nil && !*raw.CreditsDisplay.Enabled {
units = "" // the vendor shows no currency of its own
}
if b, ok := parseBucket(raw.Credits.SubscriptionBucket, units); ok {
u.Subscription = b
}
for _, a := range raw.Credits.AddonBuckets {
if b, ok := parseBucket(a, units); ok {
u.AddOns = append(u.AddOns, b)
}
}
// An account's own tally is an aggregate over its buckets, so it says
// something of its own only when the reply names none: kept beside them
// it would be the same credits counted twice over.
if raw.Credits.TotalUnits != nil && *raw.Credits.TotalUnits > 0 &&
!u.Subscription.holds() && len(u.AddOns) == 0 {
t := Bucket{Units: units, Total: *raw.Credits.TotalUnits}
if raw.Credits.UsedUnits != nil {
t.Used = *raw.Credits.UsedUnits
}
if raw.Credits.RemainingUnits != nil {
t.Remaining = *raw.Credits.RemainingUnits
}
t.Total, t.Used, t.Remaining = threeOf(t.Total, t.Used, t.Remaining)
u.Total = t
}
u.Resets.Window = raw.Resets.Window.asReset()
u.Resets.Monthly = raw.Resets.MonthlyFull.asReset()
for _, f := range raw.FeatureMeters {
m := FeatureMeter{Feature: f.FeatureKey, Unit: f.Unit, Unlimited: f.Unlimited, PeriodEnd: parseTime(f.PeriodEnd)}
if f.TotalAllowance != nil {
m.Allowance = *f.TotalAllowance
}
if f.TotalUsed != nil {
m.Used = *f.TotalUsed
}
if f.TotalRemaining != nil {
m.Remaining = *f.TotalRemaining
}
m.Allowance, m.Used, m.Remaining = threeOf(m.Allowance, m.Used, m.Remaining)
u.Meters = append(u.Meters, m)
}
if !u.hasAllowance() {
return Usage{}, fmt.Errorf("dimagent usage: the reply has no allowance")
}
return u, nil
}
// threeOf fills in the one of total / used / remaining a reply leaves out from
// the other two, which is how it spells a bucket it hasn't worked out itself.
// Units may be counted in fractions, so they stay floats throughout.
func threeOf(total, used, remaining float64) (float64, float64, float64) {
switch {
case total > 0 && used == 0 && remaining > 0:
used = total - remaining
case total > 0 && remaining == 0 && used > 0:
remaining = total - used
case total == 0 && used > 0 && remaining > 0:
total = used + remaining
}
return total, used, remaining
}
// holds is whether a bucket says an allowance of its own. An account with none
// of them carries only the flat total_units, which is an aggregate over the
// buckets rather than a bucket itself.
func (b Bucket) holds() bool {
return b.Total > 0 || b.Unlimited || len(b.Windows) > 0
}
// hasAllowance says the account has anything to show: a bucket of its own, a
// tally in place of them, or a meter counting something else.
func (u Usage) hasAllowance() bool {
return u.Subscription.holds() || u.Total.holds() ||
len(u.AddOns) > 0 || len(u.Meters) > 0
}
// accountIDOf is the account's id as written: the upstream sends a number
// where its console prints digits, and either arrives.
func accountIDOf(v any) string {
switch t := v.(type) {
case nil:
return ""
case float64:
return strconv.FormatInt(int64(t), 10)
case string:
return strings.TrimSpace(t)
}
return ""
}
// resetRaw is one of the two reset counters, whose count may be said as a
// number or as a numeric string.
type resetRaw struct {
AvailableCount any `json:"available_count"`
NearestExpires any `json:"nearest_expires_at"`
}
func (r resetRaw) asReset() Reset {
out := Reset{NextAt: parseTime(r.NearestExpires)}
switch n := r.AvailableCount.(type) {
case float64:
out.Available = int(n)
case string:
var i int
if _, err := fmt.Sscanf(strings.TrimSpace(n), "%d", &i); err == nil {
out.Available = i
}
}
return out
}
// parseBucket reads one allowance. Its units are named total_units /
// used_units / remaining_units, and its expiry either as a number or an
// RFC3339 string; a bucket that says no total and no window says nothing and
// is reported as such.
func parseBucket(raw json.RawMessage, units string) (Bucket, bool) {
if len(raw) == 0 || string(raw) == "null" {
return Bucket{}, false
}
var b struct {
TotalUnits float64 `json:"total_units"`
UsedUnits float64 `json:"used_units"`
RemainingUnits float64 `json:"remaining_units"`
Unlimited bool `json:"unlimited"`
Status string `json:"status"`
ExpiresAt any `json:"expires_at"`
HardDeadlineAt any `json:"hard_deadline_at"`
WindowStates []window `json:"window_states"`
// an older reply lays one window out flat on the bucket instead
window
}
if json.Unmarshal(raw, &b) != nil {
return Bucket{}, false
}
out := Bucket{Units: units, Unlimited: b.Unlimited, Status: b.Status,
Total: b.TotalUnits, Used: b.UsedUnits, Remaining: b.RemainingUnits,
ExpiresAt: parseTime(b.ExpiresAt), HardDeadline: parseTime(b.HardDeadlineAt)}
out.Total, out.Used, out.Remaining = threeOf(out.Total, out.Used, out.Remaining)
for _, w := range b.WindowStates {
win := w.asWindow()
if win.empty() {
continue // a placeholder window, which says nothing
}
out.Windows = append(out.Windows, win)
}
// the older shape: one window laid flat on the bucket itself
if len(b.WindowStates) == 0 {
if win := b.window.asWindow(); !win.empty() {
out.Windows = append(out.Windows, win)
}
}
if out.Total <= 0 && !out.Unlimited && len(out.Windows) == 0 {
return Bucket{}, false
}
return out, true
}
// parseTime reads a stamp the upstream may send as unix seconds, unix
// milliseconds, an RFC3339 string, or nothing at all.
func parseTime(v any) time.Time {
switch t := v.(type) {
case nil:
return time.Time{}
case float64:
if t <= 0 {
return time.Time{}
}
if t > 1e12 { // milliseconds
return time.UnixMilli(int64(t))
}
return time.Unix(int64(t), 0)
case string:
s := strings.TrimSpace(t)
if s == "" {
return time.Time{}
}
for _, f := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05", "2006-01-02 15:04:05", "2006-01-02"} {
if when, err := time.Parse(f, s); err == nil {
return when
}
}
}
return time.Time{}
}
func nonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}