From a24458ca4bdf022f4aa4ab630c7e559f3b4ec312 Mon Sep 17 00:00:00 2001 From: yetone Date: Wed, 7 Oct 2026 05:52:09 +0800 Subject: [PATCH] =?UTF-8?q?providers:=20a=20Copilot=20Business=20seat=20th?= =?UTF-8?q?at=20has=20used=20up=20its=20premium=20requests=20reads=20as=20?= =?UTF-8?q?used=20up,=20not=20unlimited=20(#1063,=20n1majne3:=20Github=20c?= =?UTF-8?q?opilot=20Bussiness=E9=A2=9D=E5=BA=A6=E7=94=A8=E5=B0=BD=E6=97=B6?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E4=B8=BA=E6=97=A0=E9=99=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit magpie counted a quota_snapshots entry only when has_quota was true. GitHub sends has_quota false for an allowance that is used up, and under token-based billing for every snapshot, so an exhausted seat's premium_interactions was dropped and the card showed only Chat and Completions, both unlimited. The snapshot is now read as VS Code's chatEntitlementService (parseQuotas, getQuotaUsage, isChatQuotaExceeded) reads it: - an entitlement of 0 on a limited snapshot is no allowance (left out); otherwise used = entitlement - quota_remaining, or 100 - percent_remaining when quota_remaining is absent; neither known is unknown and left out, never unlimited; - entitlement, quota_remaining and quota_reset_at come as numbers or as strings ("3900"); a string used to fail the whole reading; - an organization's pooled premium allowance (unlimited true, has_quota false) is used up, not unlimited; chat and completions keep reading unlimited, as VS Code reads them; - quota_reset_at, when set, is the snapshot's own reset; - premium past its allowance with overage_permitted no longer stops a Pro account (Aside), while a Business or Enterprise seat stops whatever it says, as VS Code pauses it ("Contact your administrator"). GUI card, tray, TUI and CLI all render these windows, so none of them changes. TestCopilotUsageReadsEverySeat (a real Pro+ response from api.github.com, Business left / used up as numbers and as strings, Business with overage, Enterprise pool spent / left, routing over each), TestCopilotUsageFreeUnderTokenBilling and TestCopilotUsageUnknownIsNotUnlimited fail on the old code (no Premium requests window; "cannot unmarshal string into ... entitlement of type float64"). Not tried with a real Business seat: its shapes are VS Code's test fixtures and the Pro+ account's live bytes. --- internal/provider/copilot_usage_test.go | 143 ++++++++++++++++++++++++ internal/provider/subscription_usage.go | 80 +++++++++++-- 2 files changed, 213 insertions(+), 10 deletions(-) create mode 100644 internal/provider/copilot_usage_test.go diff --git a/internal/provider/copilot_usage_test.go b/internal/provider/copilot_usage_test.go new file mode 100644 index 00000000..cb8440bb --- /dev/null +++ b/internal/provider/copilot_usage_test.go @@ -0,0 +1,143 @@ +package provider + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// copilotUsageOf is the card magpie makes of one /copilot_internal/user body. +func copilotUsageOf(t *testing.T, body string) SubscriptionQuota { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + old := CopilotUserURL + CopilotUserURL = srv.URL + t.Cleanup(func() { CopilotUserURL = old }) + q := copilotSubscriptionUsage(t.Context(), "tok", "") + if q.Error != "" { + t.Fatalf("error: %s", q.Error) + } + return q +} + +func copilotWindow(q SubscriptionQuota, name string) *QuotaWindow { + for i := range q.Windows { + if q.Windows[i].Name == name { + return &q.Windows[i] + } + } + return nil +} + +// The snapshots of a token-based-billing account's quota_snapshots, the way +// GitHub sends them: a Pro+ (individual_max) account's, read from the real +// endpoint on 2026-10-07 with its ids and lists left out, and the +// Business/Enterprise shapes VS Code's own parser is tested with +// (chatEntitlementService.test.ts): entitlement as a string, has_quota +// false on every snapshot, an organization's pool as unlimited. +const copilotUnlimitedSnaps = `"chat":{"overage_count":0,"overage_permitted":false,"percent_remaining":100.0,"quota_id":"chat","quota_remaining":0.0,"unlimited":true,"timestamp_utc":"2026-10-06T14:40:30.429-07:00","has_quota":true,"quota_reset_at":0,"token_based_billing":true,"credits_used":0,"overage_entitlement":0,"remaining":0,"entitlement":0}, +"completions":{"overage_count":0,"overage_permitted":false,"percent_remaining":100.0,"quota_id":"completions","quota_remaining":0.0,"unlimited":true,"timestamp_utc":"2026-10-06T14:40:30.429-07:00","has_quota":true,"quota_reset_at":0,"token_based_billing":true,"credits_used":0,"overage_entitlement":0,"remaining":0,"entitlement":0}` + +const copilotBusinessUnlimited = `"chat":{"overage_count":0,"overage_entitlement":0,"overage_permitted":false,"percent_remaining":100,"unlimited":true,"entitlement":"0","has_quota":false}, +"completions":{"overage_count":0,"overage_entitlement":0,"overage_permitted":false,"percent_remaining":100,"unlimited":true,"entitlement":"0","has_quota":false}` + +func TestCopilotUsageReadsEverySeat(t *testing.T) { + type want struct { + used float64 + display string + aside bool + } + for _, c := range []struct { + name, body string + premium *want // nil: the card must say Premium requests are unlimited + }{ + {"Pro+ with allowance left, the real response", `{"access_type_sku":"free_github_star_quota","copilot_plan":"individual_max","quota_reset_date":"2026-11-01","quota_snapshots":{` + copilotUnlimitedSnaps + `, +"premium_interactions":{"overage_count":0,"overage_permitted":false,"percent_remaining":99.9,"quota_id":"premium_interactions","quota_remaining":19999.9,"unlimited":false,"timestamp_utc":"2026-10-06T14:40:30.429-07:00","has_quota":true,"quota_reset_at":0,"token_based_billing":true,"credits_used":0,"overage_entitlement":0,"remaining":19999,"entitlement":20000}}, +"quota_reset_date_utc":"2026-11-01T00:00:00.000Z","token_based_billing":true}`, + &want{0.0005, "0.1 / 20000", false}}, + {"Pro used up, overage permitted: spending goes on", `{"access_type_sku":"monthly_subscriber_quota","copilot_plan":"individual","quota_reset_date_utc":"2026-11-01T00:00:00.000Z","quota_snapshots":{` + copilotUnlimitedSnaps + `, +"premium_interactions":{"overage_count":12,"overage_permitted":true,"percent_remaining":0.0,"quota_id":"premium_interactions","quota_remaining":0.0,"unlimited":false,"has_quota":false,"quota_reset_at":0,"overage_entitlement":0,"remaining":0,"entitlement":300}},"token_based_billing":true}`, + &want{100, "300 / 300", true}}, + {"Business with allowance left (entitlement as a string, has_quota false)", `{"access_type_sku":"copilot_for_business_seat","copilot_plan":"business","quota_reset_date_utc":"2026-11-01T00:00:00.000Z","token_based_billing":true,"quota_snapshots":{` + copilotBusinessUnlimited + `, +"premium_interactions":{"overage_count":0,"overage_entitlement":0,"overage_permitted":false,"percent_remaining":5.5,"unlimited":false,"entitlement":"1000","has_quota":false}}}`, + &want{94.5, "", false}}, + {"Business used up (#1063)", `{"access_type_sku":"copilot_for_business_seat","copilot_plan":"business","quota_reset_date_utc":"2026-11-01T00:00:00.000Z","token_based_billing":true,"quota_snapshots":{` + copilotBusinessUnlimited + `, +"premium_interactions":{"overage_count":0,"overage_entitlement":0,"overage_permitted":false,"percent_remaining":0,"unlimited":false,"entitlement":"300","quota_remaining":"0","has_quota":false}}}`, + &want{100, "300 / 300", false}}, + {"Business used up, numbers as github.com sent the Pro+ one (#1063's card: Chat and Completions unlimited, nothing else)", `{"access_type_sku":"copilot_for_business_seat","copilot_plan":"business","quota_reset_date":"2026-11-01","quota_snapshots":{` + copilotUnlimitedSnaps + `, +"premium_interactions":{"overage_count":0,"overage_permitted":false,"percent_remaining":0.0,"quota_id":"premium_interactions","quota_remaining":0.0,"unlimited":false,"has_quota":false,"quota_reset_at":0,"token_based_billing":true,"credits_used":0,"overage_entitlement":0,"remaining":0,"entitlement":300}}, +"quota_reset_date_utc":"2026-11-01T00:00:00.000Z","token_based_billing":true}`, + &want{100, "300 / 300", false}}, + {"Business used up, overage permitted: the organization still pauses it", `{"access_type_sku":"copilot_for_business_seat","copilot_plan":"business","quota_reset_date_utc":"2026-11-01T00:00:00.000Z","token_based_billing":true,"quota_snapshots":{` + copilotBusinessUnlimited + `, +"premium_interactions":{"overage_count":0,"overage_entitlement":0,"overage_permitted":true,"percent_remaining":0,"unlimited":false,"entitlement":"300","has_quota":false}}}`, + &want{100, "", false}}, + {"Enterprise's pooled allowance spent", `{"access_type_sku":"copilot_enterprise_seat_multi_quota","copilot_plan":"enterprise","token_based_billing":true,"quota_reset_date_utc":"2026-11-01T00:00:00.000Z","quota_snapshots":{` + copilotBusinessUnlimited + `, +"premium_interactions":{"overage_count":0,"overage_entitlement":0,"overage_permitted":false,"percent_remaining":0,"unlimited":true,"entitlement":"0","has_quota":false}}}`, + &want{100, "", false}}, + {"Enterprise's pooled allowance with some left", `{"access_type_sku":"copilot_enterprise_seat_multi_quota","copilot_plan":"enterprise","token_based_billing":true,"quota_snapshots":{"premium_interactions":{"overage_count":0,"overage_entitlement":0,"overage_permitted":false,"percent_remaining":50,"unlimited":true,"entitlement":"0","has_quota":true}}}`, + nil}, + } { + t.Run(c.name, func(t *testing.T) { + q := copilotUsageOf(t, c.body) + w := copilotWindow(q, "Premium requests") + if w == nil { + t.Fatalf("no Premium requests window: %+v", q.Windows) + } + if c.premium == nil { + if !w.Unlimited { + t.Fatalf("pool with some left read as %+v", *w) + } + return + } + if w.Unlimited || w.Display != c.premium.display || w.Aside != c.premium.aside || + w.Used < c.premium.used-1e-9 || w.Used > c.premium.used+1e-9 { + t.Fatalf("Premium requests = %+v, want %+v", *w, *c.premium) + } + if w.ResetsAt == nil && c.premium.used > 0 { + t.Fatalf("no reset: %+v", *w) + } + for _, n := range []string{"Chat requests", "Completions"} { + if o := copilotWindow(q, n); o != nil && !o.Unlimited { + t.Fatalf("%s = %+v", n, *o) + } + } + // a used-up seat that can't go on rests until the month renews + now := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC) + used, _ := allowanceOf(q.Windows, now).For("claude-sonnet-4.5", now) + if stops := c.premium.used >= 100 && !c.premium.aside; stops != (used >= 100) { + t.Fatalf("routing sees %v used", used) + } + }) + } +} + +// A Free account's premium_interactions with no allowance at all is left +// out, as VS Code leaves it; its limited chat and completions are counted +// though has_quota is false on them. +func TestCopilotUsageFreeUnderTokenBilling(t *testing.T) { + q := copilotUsageOf(t, `{"access_type_sku":"free_limited_copilot","copilot_plan":"free","token_based_billing":true,"quota_snapshots":{ +"chat":{"overage_count":0,"overage_entitlement":0,"overage_permitted":false,"percent_remaining":98.7,"unlimited":false,"entitlement":"200","has_quota":false}, +"completions":{"overage_count":0,"overage_entitlement":0,"overage_permitted":false,"percent_remaining":100,"unlimited":false,"entitlement":"4000","has_quota":false}, +"premium_interactions":{"overage_count":0,"overage_entitlement":0,"overage_permitted":false,"percent_remaining":0,"unlimited":false,"entitlement":"0","has_quota":false}}}`) + if len(q.Windows) != 2 || q.Windows[0].Name != "Chat requests" || q.Windows[1].Name != "Completions" { + t.Fatalf("windows = %+v", q.Windows) + } + if u := q.Windows[0].Used; u < 1.29 || u > 1.31 { + t.Fatalf("chat used = %v", u) + } +} + +// A snapshot that doesn't say how much is used is unknown: it is left out, +// never shown as unlimited. +func TestCopilotUsageUnknownIsNotUnlimited(t *testing.T) { + q := copilotUsageOf(t, `{"copilot_plan":"business","quota_snapshots":{"premium_interactions":{"unlimited":false,"entitlement":"many"}}}`) + for _, w := range q.Windows { + if w.Unlimited { + t.Fatalf("unknown read as unlimited: %+v", q.Windows) + } + } +} diff --git a/internal/provider/subscription_usage.go b/internal/provider/subscription_usage.go index 675c53fb..5f487acb 100644 --- a/internal/provider/subscription_usage.go +++ b/internal/provider/subscription_usage.go @@ -1002,31 +1002,91 @@ func copilotSubscriptionUsage(ctx context.Context, githubToken, host string) Sub } else if t, err := time.Parse("2006-01-02", data.ResetDay); err == nil { resets = &t } + // A Business or Enterprise seat's organization decides what happens + // past its allowance, so using it up pauses the seat whatever + // overage_permitted says, as VS Code's isChatQuotaExceeded has it. + managed := strings.EqualFold(data.Plan, "business") || strings.EqualFold(data.Plan, "enterprise") for _, x := range []struct{ id, name string }{{"chat", "Chat requests"}, {"completions", "Completions"}, {"premium_interactions", "Premium requests"}} { w, ok := data.Snapshots[x.id] - if ok && w.Unlimited { + if !ok { + continue + } + at := resets + if sec, ok := w.ResetAt.value(); ok && sec > 0 { + t := time.Unix(int64(sec), 0) + at = &t + } + if w.Unlimited { + // An organization's pooled premium allowance reads unlimited, + // and has_quota false when the pool is spent (#1063): VS Code + // pauses the seat then, so it is used up, not unlimited. Chat + // and completions say has_quota false under token-based + // billing whatever is left, and VS Code reads them unlimited. + if x.id == "premium_interactions" && w.HasQuota != nil && !*w.HasQuota { + q.Windows = append(q.Windows, QuotaWindow{Name: x.name, Used: 100, ResetsAt: at, + Span: 30 * 24 * time.Hour, Aside: w.Overage && !managed}) + continue + } unlimited = append(unlimited, QuotaWindow{Name: x.name, Unlimited: true, Display: "Unlimited", Aside: true}) continue } - if !ok || !w.HasQuota || w.Entitlement <= 0 { + // has_quota isn't whether there is an allowance: GitHub says false + // for one used up, and under token-based billing for every one + // (#1063). The entitlement says it; 0 is none, as VS Code reads it. + ent, entOK := w.Entitlement.value() + if entOK && ent <= 0 { continue } - used := w.Entitlement - w.Remaining - q.Windows = append(q.Windows, QuotaWindow{Name: x.name, Used: 100 * used / w.Entitlement, ResetsAt: resets, - Display: fmt.Sprintf("%s / %s", compactNumber(used), compactNumber(w.Entitlement)), - Span: 30 * 24 * time.Hour, Aside: x.id == "completions"}) + var used float64 + display := "" + if rem, ok := w.Remaining.value(); entOK && ok { + n := max(0, ent-rem) + used = min(100, 100*n/ent) + display = fmt.Sprintf("%s / %s", compactNumber(n), compactNumber(ent)) + } else if w.Percent != nil { + used = min(100, max(0, 100-*w.Percent)) + } else { + // nothing says how much is used: unknown, never unlimited + continue + } + q.Windows = append(q.Windows, QuotaWindow{Name: x.name, Used: used, ResetsAt: at, Display: display, + Span: 30 * 24 * time.Hour, + Aside: x.id == "completions" || x.id == "premium_interactions" && w.Overage && !managed}) } q.Windows = append(q.Windows, unlimited...) return q } +// copilotQuotaWire is one of /copilot_internal/user's quota_snapshots, as +// VS Code's IQuotaSnapshotData has it: entitlement and quota_remaining +// come as numbers or, under token-based billing, as strings ("3900"). type copilotQuotaWire struct { - Unlimited bool `json:"unlimited"` - HasQuota bool `json:"has_quota"` - Entitlement float64 `json:"entitlement"` - Remaining float64 `json:"quota_remaining"` + Unlimited bool `json:"unlimited"` + HasQuota *bool `json:"has_quota"` + Entitlement copilotNumber `json:"entitlement"` + Remaining copilotNumber `json:"quota_remaining"` + Percent *float64 `json:"percent_remaining"` + Overage bool `json:"overage_permitted"` + ResetAt copilotNumber `json:"quota_reset_at"` } +// copilotNumber is a JSON number or a string of one; absent, null or +// unreadable is unknown. +type copilotNumber struct { + n float64 + ok bool +} + +func (c *copilotNumber) UnmarshalJSON(b []byte) error { + s := strings.Trim(strings.TrimSpace(string(b)), `"`) + if n, err := strconv.ParseFloat(s, 64); err == nil && !math.IsNaN(n) && !math.IsInf(n, 0) { + *c = copilotNumber{n, true} + } + return nil +} + +func (c copilotNumber) value() (float64, bool) { return c.n, c.ok } + func compactNumber(n float64) string { if n == float64(int64(n)) { return fmt.Sprintf("%d", int64(n))