providers: a Copilot Business seat that has used up its premium requests reads as used up, not unlimited (#1063, n1majne3: Github copilot Bussiness额度用尽时显示为无限)

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.
This commit is contained in:
yetone committed 2026-10-07 05:52:14 +08:00
1 parent d2aa2e1f4e
commit a24458ca4b
2 files changed
+213 -10

No files matched your search

+143
View File
@@ -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)
}
}
}
+70 -10
View File
@@ -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))