Files
magpie/internal/provider/factory_usage.go
T
yetone 1cdc349145 factory: X-Factory-Org-Id carries Factory's own id for the org, as droid sends it, not WorkOS's, and a refused org is mended once (#242 tasselx: "403 permission_error: Factory: Requested active organization is not accessible by this user") — read again from droid 0.229.0 (still the latest @factory/cli-darwin-arm64): its header builder (Ad) sends X-Factory-Org-Id only from the stored active_organization_id, left off when null; that field is only ever filled with Factory's (Firestore) org ids, whoami's orgId or /api/app/auth/me's memberships, never WorkOS's org_… ids, which go only to WorkOS's refresh; moving a token that names no org into the first of /api/cli/org's workosOrgIds (gt) saves active_organization_id null; routine refresh names no org. magpie sent the WorkOS org id as the header, which Factory looks up as one of its own and refuses. Now factoryCreds keeps Active (activeOrganizationId) from whoami's orgId, asked without an org header, and only it is sent; Org, the WorkOS id, is kept but never sent; logins saved by v0.1.432 have no Active and so send none; refresh names no org. The sign-in checks the JWT's external_org_id then org_id, carries on as droid does when /api/cli/org fails or lists none, and when whoami fails but the token says whose it is. On that 403 an account mends itself once (Account.retry, Provider.Retries/Retry, asked by gateway.forward and by the usage read): an active org sent is dropped and the request goes again without the header; with none sent, the token is put in the first org /api/cli/org lists (droid does this at sign-in, not on a 403). Other 403s and 401s are passed on as they were. Tested in factory_test.go against fake WorkOS and Factory servers that answer droid's way: TestFactoryActiveOrg (a token WorkOS put in org_A keeps fac_A and gets a 200; a v0.1.432-format login sends no header and gets a 200) and TestFactoryOrgRefused (a left active org dropped and retried with no refresh; a left token org refreshed into org_A and retried; the usage read the same; other 403s and 401s not retried) fail on main with the reporter's 403; the existing tests now check whoami and /api/cli/org get no org header and refresh sends no organization_id. go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried against a real Factory account: that the server reads the header as a Factory org id is from droid's code, not seen on the wire; the gateway's retry is covered only by a provider-level test; accounts with several orgs take the first, with no picker.
2026-09-30 09:34:21 +08:00

126 lines
3.6 KiB
Go

package provider
// How much of a Factory plan an account has used, as droid's /status asks
// it: GET /api/billing/limits. Standard usage runs in rolling 5-hour, weekly
// and monthly windows, each a percent used and when it ends; Droid Core, the
// open models Factory hosts, has windows of its own; extra usage is a
// balance in cents.
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
)
type factoryWindow struct {
Used float64 `json:"usedPercent"`
End json.RawMessage `json:"windowEnd"`
}
type factoryPool struct {
FiveHour *factoryWindow `json:"fiveHour"`
Weekly *factoryWindow `json:"weekly"`
Monthly *factoryWindow `json:"monthly"`
}
type factoryLimits struct {
Limits struct {
Standard *factoryPool `json:"standard"`
Core *factoryPool `json:"core"`
} `json:"limits"`
Extra *float64 `json:"extraUsageBalanceCents"`
ExtraAllowed bool `json:"extraUsageAllowed"`
}
// factoryWhen reads a window's end: an ISO time or epoch milliseconds, as
// droid hands either to new Date.
func factoryWhen(raw json.RawMessage) *time.Time {
var s string
if json.Unmarshal(raw, &s) == nil && s != "" {
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return &t
}
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
t := time.UnixMilli(n).UTC()
return &t
}
return nil
}
var n float64
if json.Unmarshal(raw, &n) == nil && n > 0 {
t := time.UnixMilli(int64(n)).UTC()
return &t
}
return nil
}
// factoryQuota turns the limits into windows: standard's by their span,
// then Droid Core's, then the extra usage left. For routing, standard counts
// the vendors' models and Core the open ones Factory hosts; with extra usage
// to spend, a window used up stops neither.
func factoryQuota(l factoryLimits) []QuotaWindow {
var out []QuotaWindow
extra := l.ExtraAllowed && l.Extra != nil && *l.Extra > 0
add := func(p *factoryPool, prefix string, core bool) {
if p == nil {
return
}
for _, w := range []struct {
win *factoryWindow
name string
span time.Duration
}{
{p.FiveHour, "5 hours", 5 * time.Hour},
{p.Weekly, "7 days", 7 * 24 * time.Hour},
{p.Monthly, "30 days", 30 * 24 * time.Hour},
} {
if w.win == nil {
continue
}
out = append(out, QuotaWindow{Name: prefix + w.name, Used: min(max(w.win.Used, 0), 100),
Span: w.span, ResetsAt: factoryWhen(w.win.End), Aside: extra,
matches: func(model string) bool { return factoryCore(model) == core }})
}
}
add(l.Limits.Standard, "", false)
add(l.Limits.Core, "Droid Core · ", true)
if l.Extra != nil && *l.Extra > 0 {
out = append(out, QuotaWindow{Name: "Extra usage", Display: fmt.Sprintf("$%.2f", *l.Extra/100), Aside: true})
}
return out
}
func factoryLoginQuota(ctx context.Context, l Login) SubscriptionQuota {
q := SubscriptionQuota{Provider: "factory", Name: "Factory", Icon: "factory",
Plan: l.Plan, User: l.User, Windows: []QuotaWindow{}}
c, err := factoryFresh(ctx, l.User)
if err != nil {
q.Error = err.Error()
return q
}
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
var lim factoryLimits
err = factoryGet(ctx, c, "/api/billing/limits", nil, &lim)
var st *factoryStatus
if errors.As(err, &st) && factoryMendOrg(ctx, l.User, st.Code, []byte(st.Msg)) {
// the org it named was put right: ask once more
if c, err = factoryFresh(ctx, l.User); err == nil {
err = factoryGet(ctx, c, "/api/billing/limits", nil, &lim)
}
}
if err != nil {
q.Error = err.Error()
return q
}
if lim.Limits.Standard == nil {
q.Error = "Factory: the account reported no limits"
return q
}
q.Windows = factoryQuota(lim)
return q
}