Skip to content

Commit f5e878e

Browse files
committed
feat(probe): zero-cost wham usage probe with /responses fallback
ChatGPT exposes GET /backend-api/wham/usage which returns structured JSON (plan_type, 5h/7d window used_percent + reset_at, credits, spend_control) without consuming any tokens. Previously ProbeUsageSnapshot sent a minimal /responses request that did cost ~13 input + 5 output tokens per probe. Changes: - New proxy/usage_wham.go: QueryWhamUsage() + ApplyWhamUsage() that map the structured response onto existing Account/Store helpers (SetUsageSnapshot5h, PersistUsageSnapshot, MarkPremium5hRateLimited, UpdateAccountPlanType). Reuses newCodexStandardTransport so per-account proxies still work. - admin/usage_probe.go: ProbeUsageSnapshot tries wham first; falls back to the original /responses probe on any failure so we never regress if /wham/usage changes. - Unit tests cover header construction, JSON parsing, plan_type sync to DB, 5h/7d snapshot persistence, and premium 5h rate-limit detection at 100%.
1 parent b22b5a1 commit f5e878e

3 files changed

Lines changed: 402 additions & 1 deletion

File tree

admin/usage_probe.go

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"io"
7+
"log"
78
"net/http"
89
"time"
910

@@ -12,7 +13,11 @@ import (
1213
"github.com/gin-gonic/gin"
1314
)
1415

15-
// ProbeUsageSnapshot 主动发送最小探针请求刷新账号用量
16+
// ProbeUsageSnapshot 主动刷新账号用量。
17+
//
18+
// 优先尝试 /backend-api/wham/usage(零额度成本的结构化端点);
19+
// 失败时(4xx/5xx/网络)回退到给 /backend-api/codex/responses 发一个最小请求
20+
// (会真实计入用量但保证向下兼容)。
1621
func (h *Handler) ProbeUsageSnapshot(ctx context.Context, account *auth.Account) error {
1722
if account == nil {
1823
return nil
@@ -25,6 +30,52 @@ func (h *Handler) ProbeUsageSnapshot(ctx context.Context, account *auth.Account)
2530
return nil
2631
}
2732

33+
// 1) 优先用 wham(零成本)
34+
if err := h.probeUsageViaWham(ctx, account); err == nil {
35+
return nil
36+
} else {
37+
log.Printf("[账号 %d] wham 用量探测失败,回退到 /responses 探针: %v", account.DBID, err)
38+
}
39+
40+
// 2) Fallback: 原有的 /responses 最小探针
41+
return h.probeUsageViaResponses(ctx, account)
42+
}
43+
44+
// probeUsageViaWham 通过 /backend-api/wham/usage 拉取用量,
45+
// 不消耗任何 token 额度。
46+
func (h *Handler) probeUsageViaWham(ctx context.Context, account *auth.Account) error {
47+
usage, resp, err := proxy.QueryWhamUsage(ctx, account, h.store.ResolveProxyForAccount(account))
48+
if resp != nil {
49+
// QueryWhamUsage 在非 200 时不会读 body;这里关闭,并按状态码做冷却
50+
_, _ = io.Copy(io.Discard, resp.Body)
51+
_ = resp.Body.Close()
52+
switch resp.StatusCode {
53+
case http.StatusUnauthorized:
54+
h.store.ReportRequestFailure(account, "client", 0)
55+
h.store.MarkCooldown(account, 24*time.Hour, "unauthorized")
56+
case http.StatusTooManyRequests:
57+
h.store.ReportRequestFailure(account, "client", 0)
58+
}
59+
}
60+
if err != nil {
61+
return err
62+
}
63+
if usage == nil {
64+
return fmt.Errorf("wham returned empty body")
65+
}
66+
67+
state := proxy.ApplyWhamUsage(h.store, account, usage)
68+
h.store.ReportRequestSuccess(account, 0)
69+
// 用量未耗尽时重置冷却
70+
if !state.Premium5hRateLimited && (!state.HasUsage7d || state.UsagePct7d < 100) {
71+
h.store.ClearCooldown(account)
72+
}
73+
return nil
74+
}
75+
76+
// probeUsageViaResponses 原有探针:发送最小 /responses 请求,
77+
// 通过响应头同步 Codex 用量状态。会真实消耗少量 token。
78+
func (h *Handler) probeUsageViaResponses(ctx context.Context, account *auth.Account) error {
2879
payload := buildTestPayload(h.store.GetTestModel())
2980
resp, err := proxy.ExecuteRequest(ctx, account, payload, "", h.store.ResolveProxyForAccount(account), "", nil, nil)
3081
if err != nil {

proxy/usage_wham.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package proxy
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"strings"
10+
"time"
11+
12+
"github.com/codex2api/auth"
13+
)
14+
15+
// WhamUsageURL 是 ChatGPT 后端用量查询端点。
16+
// 该端点返回结构化 JSON(不消耗任何额度),可用于零成本获取账号 5h/7d 用量。
17+
const WhamUsageURL = "https://chatgpt.com/backend-api/wham/usage"
18+
19+
// whamURLForTest 允许测试替换默认 URL。生产代码不要赋值。
20+
var whamURLForTest = ""
21+
22+
// WhamUsage 是 /backend-api/wham/usage 的响应结构。
23+
type WhamUsage struct {
24+
UserID string `json:"user_id"`
25+
AccountID string `json:"account_id"`
26+
Email string `json:"email"`
27+
PlanType string `json:"plan_type"`
28+
29+
RateLimit struct {
30+
Allowed bool `json:"allowed"`
31+
LimitReached bool `json:"limit_reached"`
32+
PrimaryWindow *WhamUsageWindow `json:"primary_window"`
33+
SecondaryWindow *WhamUsageWindow `json:"secondary_window"`
34+
} `json:"rate_limit"`
35+
36+
Credits *struct {
37+
HasCredits bool `json:"has_credits"`
38+
Unlimited bool `json:"unlimited"`
39+
OverageLimitReached bool `json:"overage_limit_reached"`
40+
Balance string `json:"balance"`
41+
ApproxLocalMessages []int `json:"approx_local_messages"`
42+
ApproxCloudMessages []int `json:"approx_cloud_messages"`
43+
} `json:"credits,omitempty"`
44+
45+
SpendControl *struct {
46+
Reached bool `json:"reached"`
47+
IndividualLimit interface{} `json:"individual_limit"`
48+
} `json:"spend_control,omitempty"`
49+
}
50+
51+
// WhamUsageWindow 是单个限流窗口(primary=5h,secondary=7d)。
52+
type WhamUsageWindow struct {
53+
UsedPercent float64 `json:"used_percent"`
54+
LimitWindowSeconds int64 `json:"limit_window_seconds"`
55+
ResetAfterSeconds int64 `json:"reset_after_seconds"`
56+
ResetAt int64 `json:"reset_at"`
57+
}
58+
59+
// QueryWhamUsage 调用 /backend-api/wham/usage 获取账号当前用量。
60+
// 该调用不消耗任何 token 额度——比发送最小 /responses 请求更便宜。
61+
func QueryWhamUsage(ctx context.Context, account *auth.Account, proxyURL string) (*WhamUsage, *http.Response, error) {
62+
url := WhamUsageURL
63+
if whamURLForTest != "" {
64+
url = whamURLForTest
65+
}
66+
return queryWhamUsageWithURL(ctx, account, proxyURL, url)
67+
}
68+
69+
func queryWhamUsageWithURL(ctx context.Context, account *auth.Account, proxyURL, url string) (*WhamUsage, *http.Response, error) {
70+
if account == nil {
71+
return nil, nil, fmt.Errorf("account is nil")
72+
}
73+
accessToken := account.GetAccessToken()
74+
if accessToken == "" {
75+
return nil, nil, fmt.Errorf("account has no access token")
76+
}
77+
78+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
79+
if err != nil {
80+
return nil, nil, fmt.Errorf("build wham request: %w", err)
81+
}
82+
req.Header.Set("Authorization", "Bearer "+accessToken)
83+
req.Header.Set("Accept", "application/json")
84+
req.Header.Set("User-Agent", latestCodexCLIUserAgentPrefix)
85+
req.Header.Set("Originator", Originator)
86+
if accountID := strings.TrimSpace(account.AccountID); accountID != "" {
87+
req.Header.Set("chatgpt-account-id", accountID)
88+
}
89+
90+
client := &http.Client{Transport: newCodexStandardTransport(proxyURL)}
91+
92+
resp, err := client.Do(req)
93+
if err != nil {
94+
return nil, nil, fmt.Errorf("wham request: %w", err)
95+
}
96+
if resp.StatusCode != http.StatusOK {
97+
// 调用方需要根据状态码触发刷新 / 冷却;返回 resp 让上层处理 body。
98+
return nil, resp, fmt.Errorf("wham returned status %d", resp.StatusCode)
99+
}
100+
101+
body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
102+
_ = resp.Body.Close()
103+
if err != nil {
104+
return nil, resp, fmt.Errorf("read wham response: %w", err)
105+
}
106+
107+
var usage WhamUsage
108+
if err := json.Unmarshal(body, &usage); err != nil {
109+
return nil, resp, fmt.Errorf("parse wham response: %w", err)
110+
}
111+
return &usage, resp, nil
112+
}
113+
114+
// ApplyWhamUsage 将 /wham/usage 返回的数据写入账号 state + 持久化。
115+
// 行为与 SyncCodexUsageState(处理 /responses 响应头时)保持一致:
116+
// - plan_type 同步到内存 + DB
117+
// - 5h 窗口写入 SetUsageSnapshot5h
118+
// - 7d 窗口走 PersistUsageSnapshot
119+
// - premium 5h 用尽时走 MarkPremium5hRateLimited
120+
func ApplyWhamUsage(store *auth.Store, account *auth.Account, usage *WhamUsage) CodexUsageSyncResult {
121+
result := CodexUsageSyncResult{}
122+
if account == nil || usage == nil {
123+
return result
124+
}
125+
126+
if store != nil && usage.PlanType != "" {
127+
store.UpdateAccountPlanType(account, usage.PlanType)
128+
}
129+
130+
now := time.Now()
131+
132+
// 5h 主窗口
133+
if w := usage.RateLimit.PrimaryWindow; w != nil && w.LimitWindowSeconds > 0 {
134+
resetAt := whamWindowResetAt(w, now)
135+
account.SetUsageSnapshot5h(w.UsedPercent, resetAt)
136+
result.UsagePct5h = w.UsedPercent
137+
result.Reset5hAt = resetAt
138+
result.HasUsage5h = true
139+
result.Used5hHeaders = true
140+
}
141+
142+
// 7d 次窗口
143+
if w := usage.RateLimit.SecondaryWindow; w != nil && w.LimitWindowSeconds > 0 {
144+
resetAt := whamWindowResetAt(w, now)
145+
account.SetReset7dAt(resetAt)
146+
result.UsagePct7d = w.UsedPercent
147+
result.HasUsage7d = true
148+
if store != nil {
149+
store.PersistUsageSnapshot(account, w.UsedPercent)
150+
}
151+
} else if result.Used5hHeaders && store != nil {
152+
// 只有 5h 数据时,单独持久化 5h 快照
153+
store.PersistUsageSnapshot5hOnly(account)
154+
result.Persisted5hOnly = true
155+
}
156+
157+
// premium 5h 限流标记
158+
if result.Used5hHeaders && account.IsPremium5hPlan() && result.HasUsage5h && result.UsagePct5h >= 100 {
159+
if store != nil {
160+
store.MarkPremium5hRateLimited(account, result.Reset5hAt)
161+
}
162+
result.Premium5hRateLimited = true
163+
}
164+
165+
return result
166+
}
167+
168+
func whamWindowResetAt(w *WhamUsageWindow, now time.Time) time.Time {
169+
if w == nil {
170+
return time.Time{}
171+
}
172+
// reset_at 是 unix 时间戳(秒),优先使用;缺失时 fallback 到 reset_after_seconds
173+
if w.ResetAt > 0 {
174+
return time.Unix(w.ResetAt, 0)
175+
}
176+
if w.ResetAfterSeconds > 0 {
177+
return now.Add(time.Duration(w.ResetAfterSeconds) * time.Second)
178+
}
179+
return time.Time{}
180+
}

0 commit comments

Comments
 (0)