1package provider
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "time"
10)
11
12const codexUsageURL = "https://chatgpt.com/backend-api/wham/usage"
13
14// CodexWeeklyUsage is the account-wide seven-day allowance, not session usage.
15// A nil result means the backend did not report a weekly window.
16type CodexWeeklyUsage struct {
17 RemainingPercent float64
18 ResetsAt time.Time
19}
20
21// FetchCodexWeeklyUsage reads ChatGPT subscription usage without making a model
22// request. Credentials must belong to the selected ChatGPT account. The caller
23// owns credential refresh and must provide a bounded context.
24func FetchCodexWeeklyUsage(ctx context.Context, client *http.Client, token, accountID string) (*CodexWeeklyUsage, error) {
25 return fetchCodexWeeklyUsage(ctx, client, codexUsageURL, token, accountID)
26}
27
28func fetchCodexWeeklyUsage(ctx context.Context, client *http.Client, endpoint, token, accountID string) (*CodexWeeklyUsage, error) {
29 if token == "" || accountID == "" {
30 return nil, fmt.Errorf("codex usage: missing subscription credentials")
31 }
32 req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
33 if err != nil {
34 return nil, fmt.Errorf("codex usage: invalid endpoint")
35 }
36 req.Header.Set("Authorization", "Bearer "+token)
37 req.Header.Set("ChatGPT-Account-Id", accountID)
38 req.Header.Set("User-Agent", "codex-cli")
39 req.Header.Set("Accept", "application/json")
40 if client == nil {
41 client = http.DefaultClient
42 }
43 // The account header is sensitive too: never forward it on a redirect.
44 bounded := *client
45 bounded.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
46 resp, err := bounded.Do(req)
47 if err != nil {
48 return nil, fmt.Errorf("codex usage: request failed")
49 }
50 defer resp.Body.Close()
51 if resp.StatusCode != http.StatusOK {
52 return nil, fmt.Errorf("codex usage: HTTP %d", resp.StatusCode)
53 }
54 type window struct {
55 UsedPercent *float64 `json:"used_percent"`
56 Seconds int `json:"limit_window_seconds"`
57 ResetAt int64 `json:"reset_at"`
58 }
59 var payload struct {
60 RateLimit *struct {
61 Primary *window `json:"primary_window"`
62 Secondary *window `json:"secondary_window"`
63 } `json:"rate_limit"`
64 }
65 body, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1))
66 if err != nil || len(body) > 1<<20 {
67 return nil, fmt.Errorf("codex usage: unreadable or oversized response")
68 }
69 if err := json.Unmarshal(body, &payload); err != nil {
70 // Do not include response text or JSON errors containing account data.
71 return nil, fmt.Errorf("codex usage: invalid response")
72 }
73 if payload.RateLimit == nil {
74 return nil, nil
75 }
76 for _, w := range []*window{payload.RateLimit.Primary, payload.RateLimit.Secondary} {
77 if w == nil || w.Seconds != 7*24*60*60 {
78 continue
79 }
80 if w.UsedPercent == nil || *w.UsedPercent < 0 || *w.UsedPercent > 100 {
81 return nil, fmt.Errorf("codex usage: invalid weekly percentage")
82 }
83 usage := &CodexWeeklyUsage{RemainingPercent: 100 - *w.UsedPercent}
84 if w.ResetAt > 0 {
85 usage.ResetsAt = time.Unix(w.ResetAt, 0)
86 }
87 return usage, nil
88 }
89 return nil, nil
90}