feat: migrate account usage to CLIProxyAPI
Frontend / quality (push) Canceled after 0s

This commit is contained in:
2026-09-08 09:56:08 +08:00
parent 0b2f969c6d
commit 8997886eef
35 changed files with 2778 additions and 1651 deletions
+745
View File
@@ -0,0 +1,745 @@
package cliproxy
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
usageURL = "https://chatgpt.com/backend-api/wham/usage"
profileURL = "https://chatgpt.com/backend-api/wham/profiles/me"
resetCreditsURL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"
codexUserAgent = "codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal"
maxResponseBody = 4 << 20
)
var ErrNotConfigured = errors.New("CLIProxyAPI Management API 未配置")
type Client struct {
baseURL string
managementKey string
httpClient *http.Client
now func() time.Time
}
type AuthFile struct {
AuthIndex string
Label string
Name string
Email *string
Status string
AccountID string
PlanType *string
}
type Limit struct {
LimitID string
LimitName *string
WindowType string
UsedPercent float64
WindowDurationMinutes int
ResetsAt int64
PlanType *string
}
type UsageSummary struct {
LifetimeTokens *int64
PeakDailyTokens *int64
LongestRunningTurnSec *int64
CurrentStreakDays *int
LongestStreakDays *int
}
type UsagePoint struct {
Date string
TotalTokens int64
}
type ResetCredits struct {
AvailableCount int
ExpiresAt []int64
}
type Snapshot struct {
Auth AuthFile
Limits []Limit
Summary UsageSummary
Usage []UsagePoint
ProfileAvailable bool
UsageAvailable bool
ResetCredits *ResetCredits
FetchedAt time.Time
}
func New(baseURL, managementKey string) *Client {
return NewWithHTTPClient(baseURL, managementKey, &http.Client{Timeout: 15 * time.Second})
}
func NewWithHTTPClient(baseURL, managementKey string, httpClient *http.Client) *Client {
if httpClient == nil {
httpClient = &http.Client{Timeout: 15 * time.Second}
}
return &Client{
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
managementKey: managementKey,
httpClient: httpClient,
now: time.Now,
}
}
func (c *Client) Configured() bool {
return c.baseURL != "" && c.managementKey != ""
}
func (c *Client) Auth(ctx context.Context, authIndex string) (AuthFile, error) {
if !c.Configured() {
return AuthFile{}, ErrNotConfigured
}
authIndex = strings.TrimSpace(authIndex)
if authIndex == "" {
return AuthFile{}, errors.New("authIndex 不能为空")
}
u, err := url.Parse(c.baseURL + "/v0/management/auth-files")
if err != nil {
return AuthFile{}, errors.New("CLIProxyAPI 地址无效")
}
q := u.Query()
q.Set("auth_index", authIndex)
u.RawQuery = q.Encode()
body, err := c.managementRequest(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return AuthFile{}, err
}
var raw any
if err := json.Unmarshal(body, &raw); err != nil {
return AuthFile{}, errors.New("CLIProxyAPI auth-files 响应格式错误")
}
items := authItems(raw)
matches := make([]AuthFile, 0, 1)
for _, item := range items {
auth, ok := parseAuthFile(item, authIndex)
if ok {
matches = append(matches, auth)
}
}
if len(matches) == 0 {
return AuthFile{}, errors.New("未找到可用的 Codex auth")
}
if len(matches) != 1 {
return AuthFile{}, errors.New("Codex auth 匹配结果不唯一")
}
return matches[0], nil
}
func (c *Client) Snapshot(ctx context.Context, authIndex string) (Snapshot, error) {
auth, err := c.Auth(ctx, authIndex)
if err != nil {
return Snapshot{}, err
}
receivedAt := c.now().UTC()
usageBody, err := c.apiCall(ctx, auth, usageURL)
if err != nil {
return Snapshot{}, fmt.Errorf("获取用量限额失败: %w", err)
}
limits, availableFallback, effectivePlan, err := parseUsage(usageBody, auth.PlanType, receivedAt)
if err != nil {
return Snapshot{}, err
}
auth.PlanType = effectivePlan
var summary UsageSummary
usage := []UsagePoint{}
profileAvailable := false
usageAvailable := false
if profileBody, profileErr := c.apiCall(ctx, auth, profileURL); profileErr == nil {
if parsedSummary, parsedUsage, parsedUsageAvailable, parseErr := parseProfile(profileBody); parseErr == nil {
summary = parsedSummary
usage = parsedUsage
usageAvailable = parsedUsageAvailable
profileAvailable = usageAvailable || usageSummaryAvailable(summary)
}
}
var resetCredits *ResetCredits
resetCtx, cancelReset := context.WithTimeout(ctx, 5*time.Second)
resetBody, resetErr := c.apiCall(resetCtx, auth, resetCreditsURL)
cancelReset()
if resetErr == nil {
resetCredits = parseResetCredits(resetBody, availableFallback)
} else if availableFallback > 0 {
resetCredits = &ResetCredits{AvailableCount: availableFallback, ExpiresAt: []int64{}}
}
return Snapshot{
Auth: auth,
Limits: limits,
Summary: summary,
Usage: usage,
ProfileAvailable: profileAvailable,
UsageAvailable: usageAvailable,
ResetCredits: resetCredits,
FetchedAt: receivedAt,
}, nil
}
func (c *Client) managementRequest(ctx context.Context, method, endpoint string, body io.Reader) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return nil, errors.New("创建 CLIProxyAPI 请求失败")
}
req.Header.Set("Authorization", "Bearer "+c.managementKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
httpClient := *c.httpClient
httpClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, errors.New("CLIProxyAPI 请求失败")
}
defer resp.Body.Close()
payload, err := readLimited(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("CLIProxyAPI Management API 返回状态码 %d", resp.StatusCode)
}
return payload, nil
}
func (c *Client) apiCall(ctx context.Context, auth AuthFile, upstreamURL string) ([]byte, error) {
requestBody := struct {
AuthIndex string `json:"auth_index"`
Method string `json:"method"`
URL string `json:"url"`
Header map[string]string `json:"header"`
}{
AuthIndex: auth.AuthIndex,
Method: http.MethodGet,
URL: upstreamURL,
Header: map[string]string{
"Authorization": "Bearer $TOKEN$",
"Chatgpt-Account-Id": auth.AccountID,
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": codexUserAgent,
},
}
encoded, err := json.Marshal(requestBody)
if err != nil {
return nil, errors.New("创建 CLIProxyAPI api-call 请求失败")
}
payload, err := c.managementRequest(ctx, http.MethodPost, c.baseURL+"/v0/management/api-call", strings.NewReader(string(encoded)))
if err != nil {
return nil, err
}
var envelope struct {
StatusCode int `json:"status_code"`
Body json.RawMessage `json:"body"`
}
if err := json.Unmarshal(payload, &envelope); err != nil || envelope.StatusCode == 0 || len(envelope.Body) == 0 {
return nil, errors.New("CLIProxyAPI api-call 响应格式错误")
}
if envelope.StatusCode < 200 || envelope.StatusCode >= 300 {
if errorType := safeUpstreamErrorType(envelope.Body); errorType != "" {
return nil, fmt.Errorf("CLIProxyAPI 上游请求返回状态码 %d (%s)", envelope.StatusCode, errorType)
}
return nil, fmt.Errorf("CLIProxyAPI 上游请求返回状态码 %d", envelope.StatusCode)
}
var bodyString string
if len(envelope.Body) > 0 && envelope.Body[0] == '"' {
if err := json.Unmarshal(envelope.Body, &bodyString); err != nil {
return nil, errors.New("CLIProxyAPI api-call body 格式错误")
}
if len(bodyString) > maxResponseBody {
return nil, errors.New("CLIProxyAPI 响应体过大")
}
return []byte(bodyString), nil
}
if len(envelope.Body) > maxResponseBody {
return nil, errors.New("CLIProxyAPI 响应体过大")
}
return envelope.Body, nil
}
func safeUpstreamErrorType(raw json.RawMessage) string {
body := []byte(raw)
if len(body) > 0 && body[0] == '"' {
var text string
if json.Unmarshal(body, &text) != nil {
return ""
}
body = []byte(text)
}
var payload map[string]any
if json.Unmarshal(body, &payload) != nil {
return ""
}
candidates := []string{stringValue(payload, "type", "code")}
if nested, ok := objectValue(payload, "error"); ok {
candidates = append([]string{stringValue(nested, "type", "code")}, candidates...)
}
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" || len(candidate) > 80 {
continue
}
safe := true
for _, r := range candidate {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' || r == '.') {
safe = false
break
}
}
if safe {
return candidate
}
}
return ""
}
func readLimited(r io.Reader) ([]byte, error) {
payload, err := io.ReadAll(io.LimitReader(r, maxResponseBody+1))
if err != nil {
return nil, errors.New("读取 CLIProxyAPI 响应失败")
}
if len(payload) > maxResponseBody {
return nil, errors.New("CLIProxyAPI 响应体过大")
}
return payload, nil
}
func authItems(raw any) []map[string]any {
switch value := raw.(type) {
case []any:
out := make([]map[string]any, 0, len(value))
for _, item := range value {
if object, ok := item.(map[string]any); ok {
out = append(out, object)
}
}
return out
case map[string]any:
for _, key := range []string{"auth_files", "authFiles", "files", "data", "items"} {
if nested, ok := value[key]; ok {
if out := authItems(nested); len(out) > 0 {
return out
}
}
}
if stringValue(value, "auth_index", "authIndex") != "" {
return []map[string]any{value}
}
}
return []map[string]any{}
}
func parseAuthFile(item map[string]any, expectedIndex string) (AuthFile, bool) {
index := strings.TrimSpace(stringValue(item, "auth_index", "authIndex"))
if index != expectedIndex {
return AuthFile{}, false
}
provider := strings.ToLower(strings.TrimSpace(stringValue(item, "provider")))
typ := strings.ToLower(strings.TrimSpace(stringValue(item, "type")))
if provider != "" && provider != "codex" || typ != "" && typ != "codex" || provider == "" && typ == "" {
return AuthFile{}, false
}
status := strings.TrimSpace(stringValue(item, "status"))
statusLower := strings.ToLower(status)
// Do not reject CPA's transient unavailable/error state: quota exhaustion
// itself can set it, and this dashboard still needs to read the reset time.
if boolValue(item, "disabled") || statusLower == "disabled" {
return AuthFile{}, false
}
claims := claimsFrom(item)
authClaims, _ := objectValue(claims, "https://api.openai.com/auth")
accountID := strings.TrimSpace(stringValue(claims, "chatgpt_account_id", "chatgptAccountId"))
if accountID == "" {
accountID = strings.TrimSpace(stringValue(authClaims, "chatgpt_account_id", "chatgptAccountId"))
}
if accountID == "" {
return AuthFile{}, false
}
var email *string
if value := strings.TrimSpace(stringValue(item, "email")); value != "" {
email = &value
} else if value := strings.TrimSpace(stringValue(claims, "email")); value != "" {
email = &value
}
var planType *string
value := strings.TrimSpace(stringValue(claims, "plan_type", "planType"))
if value == "" {
value = strings.TrimSpace(stringValue(authClaims, "chatgpt_plan_type", "plan_type", "planType"))
}
if value != "" {
planType = &value
}
return AuthFile{
AuthIndex: index,
Label: strings.TrimSpace(stringValue(item, "label")),
Name: strings.TrimSpace(stringValue(item, "name")),
Email: email,
Status: status,
AccountID: accountID,
PlanType: planType,
}, true
}
func claimsFrom(item map[string]any) map[string]any {
for _, key := range []string{"id_token_claims", "idTokenClaims", "id_token", "idToken"} {
value, ok := item[key]
if !ok {
continue
}
if object, ok := value.(map[string]any); ok {
if nested, ok := object["claims"].(map[string]any); ok {
return nested
}
return object
}
if encoded, ok := value.(string); ok {
if claims := decodeClaimsString(encoded); claims != nil {
return claims
}
}
}
return map[string]any{}
}
func decodeClaimsString(value string) map[string]any {
var claims map[string]any
if json.Unmarshal([]byte(value), &claims) == nil {
return claims
}
parts := strings.Split(value, ".")
if len(parts) < 2 {
return nil
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || json.Unmarshal(payload, &claims) != nil {
return nil
}
return claims
}
func parseUsage(body []byte, fallbackPlan *string, receivedAt time.Time) ([]Limit, int, *string, error) {
var root map[string]any
if err := json.Unmarshal(body, &root); err != nil {
return nil, 0, nil, errors.New("wham usage 响应格式错误")
}
if data, ok := objectValue(root, "data"); ok {
root = data
}
planType := fallbackPlan
if value := strings.TrimSpace(stringValue(root, "plan_type", "planType")); value != "" {
planType = &value
}
limits := make([]Limit, 0)
if raw, ok := objectValue(root, "rate_limit", "rateLimit"); ok {
limits = append(limits, parseLimit("codex", nil, raw, planType, receivedAt)...)
}
if raw, ok := objectValue(root, "code_review_rate_limit", "codeReviewRateLimit"); ok {
limits = append(limits, parseLimit("code_review", nil, raw, planType, receivedAt)...)
}
for _, raw := range arrayValue(root, "additional_rate_limits", "additionalRateLimits") {
object, ok := raw.(map[string]any)
if !ok {
continue
}
limitID := strings.TrimSpace(stringValue(object, "metered_feature", "meteredFeature"))
if limitID == "" {
continue
}
var name *string
if value := strings.TrimSpace(stringValue(object, "limit_name", "limitName")); value != "" {
name = &value
}
rateLimit := object
if nested, ok := objectValue(object, "rate_limit", "rateLimit"); ok {
rateLimit = nested
}
limits = append(limits, parseLimit(limitID, name, rateLimit, planType, receivedAt)...)
}
availableCount := 0
if credits, ok := objectValue(root, "rate_limit_reset_credits", "rateLimitResetCredits"); ok {
availableCount = int(numberValue(credits, "available_count", "availableCount"))
}
return limits, availableCount, planType, nil
}
func parseLimit(limitID string, name *string, raw map[string]any, planType *string, receivedAt time.Time) []Limit {
out := make([]Limit, 0, 2)
for _, window := range []struct {
kind string
keys []string
}{{"primary", []string{"primary_window", "primaryWindow"}}, {"secondary", []string{"secondary_window", "secondaryWindow"}}} {
object, ok := objectValue(raw, window.keys...)
if !ok {
continue
}
used, usedOK := numericValue(object, "used_percent", "usedPercent")
if !usedOK {
continue
}
seconds := int64(numberValue(object, "limit_window_seconds", "limitWindowSeconds"))
resetsAt := unixTimeValue(object, "reset_at", "resetAt")
if resetsAt == 0 {
resetAfter := int64(numberValue(object, "reset_after_seconds", "resetAfterSeconds"))
if resetAfter > 0 {
resetsAt = receivedAt.Unix() + resetAfter
}
}
used = math.Max(0, math.Min(100, used))
out = append(out, Limit{
LimitID: limitID,
LimitName: name,
WindowType: window.kind,
UsedPercent: used,
WindowDurationMinutes: int((seconds + 59) / 60),
ResetsAt: resetsAt,
PlanType: planType,
})
}
return out
}
func parseProfile(body []byte) (UsageSummary, []UsagePoint, bool, error) {
var root map[string]any
if err := json.Unmarshal(body, &root); err != nil {
return UsageSummary{}, nil, false, errors.New("wham profile 响应格式错误")
}
stats := root
nestedStats := false
if nested, ok := objectValue(root, "stats", "usage_stats", "usageStats"); ok {
stats = nested
nestedStats = true
}
summary := UsageSummary{
LifetimeTokens: nonNegativeInt64Pointer(stats, "lifetime_tokens", "lifetimeTokens"),
PeakDailyTokens: nonNegativeInt64Pointer(stats, "peak_daily_tokens", "peakDailyTokens"),
LongestRunningTurnSec: nonNegativeInt64Pointer(stats, "longest_running_turn_sec", "longestRunningTurnSec"),
CurrentStreakDays: nonNegativeIntPointer(stats, "current_streak_days", "currentStreakDays"),
LongestStreakDays: nonNegativeIntPointer(stats, "longest_streak_days", "longestStreakDays"),
}
buckets, usageAvailable := arrayField(root, "daily_usage_buckets", "dailyUsageBuckets")
if !usageAvailable && nestedStats {
buckets, usageAvailable = arrayField(stats, "daily_usage_buckets", "dailyUsageBuckets")
}
usage := make([]UsagePoint, 0, len(buckets))
for _, raw := range buckets {
bucket, ok := raw.(map[string]any)
if !ok {
continue
}
date := strings.TrimSpace(stringValue(bucket, "date", "start_date", "startDate"))
if len(date) >= 10 {
date = date[:10]
}
if _, err := time.Parse("2006-01-02", date); err != nil {
continue
}
tokens, ok := numericValue(bucket, "total_tokens", "totalTokens", "tokens")
if !ok || tokens < 0 {
continue
}
usage = append(usage, UsagePoint{Date: date, TotalTokens: int64(tokens)})
}
return summary, usage, usageAvailable, nil
}
func usageSummaryAvailable(summary UsageSummary) bool {
return summary.LifetimeTokens != nil || summary.PeakDailyTokens != nil || summary.LongestRunningTurnSec != nil || summary.CurrentStreakDays != nil || summary.LongestStreakDays != nil
}
func parseResetCredits(body []byte, fallback int) *ResetCredits {
var root map[string]any
if json.Unmarshal(body, &root) != nil {
if fallback > 0 {
return &ResetCredits{AvailableCount: fallback, ExpiresAt: []int64{}}
}
return nil
}
if data, ok := objectValue(root, "data"); ok {
root = data
}
countPresent := hasValue(root, "available_count", "availableCount")
count := int(numberValue(root, "available_count", "availableCount"))
if countPresent && count <= 0 {
return nil
}
if !countPresent {
count = fallback
}
if count <= 0 {
return nil
}
expiresAt := make([]int64, 0)
for _, raw := range arrayValue(root, "credits", "available_credits", "availableCredits") {
credit, ok := raw.(map[string]any)
if !ok {
continue
}
if status := strings.ToLower(strings.TrimSpace(stringValue(credit, "status"))); status != "" && status != "available" {
continue
}
if value := unixTimeValue(credit, "expires_at", "expiresAt"); value > 0 {
expiresAt = append(expiresAt, value)
}
}
return &ResetCredits{AvailableCount: count, ExpiresAt: uniqueSorted(expiresAt)}
}
func uniqueSorted(values []int64) []int64 {
for i := 0; i < len(values); i++ {
for j := i + 1; j < len(values); j++ {
if values[j] < values[i] {
values[i], values[j] = values[j], values[i]
}
}
}
out := values[:0]
for _, value := range values {
if len(out) == 0 || out[len(out)-1] != value {
out = append(out, value)
}
}
return out
}
func objectValue(object map[string]any, keys ...string) (map[string]any, bool) {
for _, key := range keys {
if value, ok := object[key].(map[string]any); ok {
return value, true
}
}
return nil, false
}
func arrayValue(object map[string]any, keys ...string) []any {
value, _ := arrayField(object, keys...)
return value
}
func arrayField(object map[string]any, keys ...string) ([]any, bool) {
for _, key := range keys {
if value, ok := object[key].([]any); ok {
return value, true
}
}
return []any{}, false
}
func stringValue(object map[string]any, keys ...string) string {
for _, key := range keys {
value, ok := object[key]
if !ok || value == nil {
continue
}
switch typed := value.(type) {
case string:
return typed
case json.Number:
return typed.String()
case float64:
if typed == math.Trunc(typed) {
return strconv.FormatInt(int64(typed), 10)
}
return strconv.FormatFloat(typed, 'f', -1, 64)
}
}
return ""
}
func hasValue(object map[string]any, keys ...string) bool {
for _, key := range keys {
if value, ok := object[key]; ok && value != nil {
return true
}
}
return false
}
func boolValue(object map[string]any, keys ...string) bool {
for _, key := range keys {
if value, ok := object[key].(bool); ok {
return value
}
}
return false
}
func numberValue(object map[string]any, keys ...string) float64 {
value, _ := numericValue(object, keys...)
return value
}
func numericValue(object map[string]any, keys ...string) (float64, bool) {
for _, key := range keys {
value, ok := object[key]
if !ok || value == nil {
continue
}
var parsed float64
var err error
switch typed := value.(type) {
case float64:
parsed = typed
case string:
parsed, err = strconv.ParseFloat(strings.TrimSpace(typed), 64)
case json.Number:
parsed, err = typed.Float64()
default:
continue
}
if err == nil && !math.IsNaN(parsed) && !math.IsInf(parsed, 0) {
return parsed, true
}
}
return 0, false
}
func nonNegativeInt64Pointer(object map[string]any, keys ...string) *int64 {
if value, ok := numericValue(object, keys...); ok && value >= 0 {
parsed := int64(value)
return &parsed
}
return nil
}
func nonNegativeIntPointer(object map[string]any, keys ...string) *int {
value := nonNegativeInt64Pointer(object, keys...)
if value == nil {
return nil
}
parsed := int(*value)
return &parsed
}
func unixTimeValue(object map[string]any, keys ...string) int64 {
for _, key := range keys {
value, ok := object[key]
if !ok || value == nil {
continue
}
if text, ok := value.(string); ok {
if parsed, err := time.Parse(time.RFC3339, text); err == nil {
return parsed.Unix()
}
if parsed, err := strconv.ParseInt(text, 10, 64); err == nil {
return parsed
}
}
return int64(numberValue(object, key))
}
return 0
}
+319
View File
@@ -0,0 +1,319 @@
package cliproxy
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
func authResponse(items string) string {
return `{"auth_files":` + items + `}`
}
func validAuth(index string) string {
return `{"auth_index":"` + index + `","provider":"codex","type":"codex","label":"Main","email":"user@example.com","status":"ready","id_token":{"chatgpt_account_id":"acct-123","plan_type":"new_unknown_plan"}}`
}
func TestAuthFiltersProviderStatusAndRequiresUniqueMatch(t *testing.T) {
tests := []struct {
name string
body string
want string
}{
{name: "valid", body: authResponse(`[` + validAuth("auth/one") + `]`), want: ""},
{name: "wrong provider", body: authResponse(`[{"auth_index":"auth/one","provider":"gemini","type":"gemini","id_token":{"chatgpt_account_id":"acct"}}]`), want: "未找到"},
{name: "disabled", body: authResponse(`[{"auth_index":"auth/one","provider":"codex","disabled":true,"id_token":{"chatgpt_account_id":"acct"}}]`), want: "未找到"},
{name: "quota unavailable remains readable", body: authResponse(`[{"auth_index":"auth/one","type":"codex","status":"error","unavailable":true,"id_token":{"chatgpt_account_id":"acct-123","plan_type":"new_unknown_plan"}}]`), want: ""},
{name: "duplicate", body: authResponse(`[` + validAuth("auth/one") + `,` + validAuth("auth/one") + `]`), want: "不唯一"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("auth_index"); got != "auth/one" {
t.Fatalf("auth_index = %q", got)
}
_, _ = w.Write([]byte(tt.body))
}))
defer server.Close()
client := New(server.URL, "management-secret")
auth, err := client.Auth(context.Background(), "auth/one")
if tt.want == "" {
if err != nil {
t.Fatal(err)
}
if auth.AuthIndex != "auth/one" || auth.AccountID != "acct-123" || auth.PlanType == nil || *auth.PlanType != "new_unknown_plan" {
t.Fatalf("auth = %#v", auth)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("error = %v; want %q", err, tt.want)
}
})
}
}
func TestSnapshotUsesManagementAuthorizationAndAPICallRequestStructure(t *testing.T) {
var mu sync.Mutex
var upstreamURLs []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer management-secret" {
t.Fatalf("Authorization = %q", got)
}
switch r.URL.Path {
case "/v0/management/auth-files":
_, _ = w.Write([]byte(authResponse(`[` + validAuth("auth 1") + `]`)))
case "/v0/management/api-call":
var request struct {
AuthIndex string `json:"auth_index"`
Method string `json:"method"`
URL string `json:"url"`
Header map[string]string `json:"header"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
if request.AuthIndex != "auth 1" || request.Method != http.MethodGet || request.Header["Authorization"] != "Bearer $TOKEN$" || request.Header["Chatgpt-Account-Id"] != "acct-123" || request.Header["Accept"] != "application/json" || request.Header["Content-Type"] != "application/json" || request.Header["User-Agent"] != codexUserAgent {
t.Fatalf("api-call request = %#v", request)
}
mu.Lock()
upstreamURLs = append(upstreamURLs, request.URL)
mu.Unlock()
var body string
switch request.URL {
case usageURL:
body = `{"rate_limit":{"primary_window":{"used_percent":12.75,"limit_window_seconds":301,"reset_after_seconds":60}}}`
case profileURL:
body = `{"lifetime_tokens":1000,"peak_daily_tokens":250,"longest_running_turn_sec":90,"current_streak_days":3,"longest_streak_days":8,"daily_usage_buckets":[{"start_date":"2026-08-14","tokens":77}]}`
case resetCreditsURL:
body = `{"available_count":1,"credits":[{"expires_at":"2026-08-20T00:00:00Z"}]}`
default:
t.Fatalf("unexpected upstream URL %q", request.URL)
}
_ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": body})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := New(server.URL, "management-secret")
fixedNow := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
client.now = func() time.Time { return fixedNow }
snapshot, err := client.Snapshot(context.Background(), "auth 1")
if err != nil {
t.Fatal(err)
}
if len(snapshot.Limits) != 1 || snapshot.Limits[0].UsedPercent != 12.75 || snapshot.Limits[0].WindowDurationMinutes != 6 || snapshot.Limits[0].ResetsAt != fixedNow.Unix()+60 {
t.Fatalf("limits = %#v", snapshot.Limits)
}
if snapshot.Limits[0].PlanType == nil || *snapshot.Limits[0].PlanType != "new_unknown_plan" {
t.Fatalf("plan type was not preserved: %#v", snapshot.Limits[0].PlanType)
}
if snapshot.Summary.LifetimeTokens == nil || *snapshot.Summary.LifetimeTokens != 1000 || snapshot.Summary.PeakDailyTokens == nil || *snapshot.Summary.PeakDailyTokens != 250 || len(snapshot.Usage) != 1 || snapshot.Usage[0].TotalTokens != 77 {
t.Fatalf("profile = %#v usage = %#v", snapshot.Summary, snapshot.Usage)
}
if snapshot.ResetCredits == nil || snapshot.ResetCredits.AvailableCount != 1 || len(snapshot.ResetCredits.ExpiresAt) != 1 || snapshot.ResetCredits.ExpiresAt[0] != time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC).Unix() {
t.Fatalf("reset credits = %#v", snapshot.ResetCredits)
}
if len(upstreamURLs) != 3 {
t.Fatalf("upstream URLs = %#v", upstreamURLs)
}
}
func TestUsageParsesAllLimitKindsAndClampsPercentages(t *testing.T) {
plan := "enterprise-new"
body := []byte(`{
"rate_limit":{"primary_window":{"used_percent":-1.25,"limit_window_seconds":300,"reset_at":1800000000}},
"code_review_rate_limit":{"secondary_window":{"used_percent":101.5,"limit_window_seconds":604801,"reset_at":1800000100}},
"additional_rate_limits":[{"metered_feature":"spark","limit_name":"Spark usage","primary_window":{"used_percent":45.5,"limit_window_seconds":61,"reset_at":1800000200}}],
"rate_limit_reset_credits":{"available_count":2}
}`)
limits, fallback, effectivePlan, err := parseUsage(body, &plan, time.Now())
if err != nil {
t.Fatal(err)
}
if len(limits) != 3 || limits[0].LimitID != "codex" || limits[0].UsedPercent != 0 || limits[1].LimitID != "code_review" || limits[1].UsedPercent != 100 || limits[1].WindowDurationMinutes != 10081 || limits[2].LimitID != "spark" || limits[2].LimitName == nil || *limits[2].LimitName != "Spark usage" || limits[2].WindowDurationMinutes != 2 || fallback != 2 || effectivePlan == nil || *effectivePlan != plan {
t.Fatalf("limits = %#v fallback = %d", limits, fallback)
}
}
func TestUsageSkipsWindowsWithoutUsedPercentage(t *testing.T) {
limits, _, _, err := parseUsage([]byte(`{"rate_limit":{"primary_window":{"limit_window_seconds":18000,"reset_at":1800000000}}}`), nil, time.Now())
if err != nil {
t.Fatal(err)
}
if len(limits) != 0 {
t.Fatalf("limits = %#v; missing used_percent must not become zero usage", limits)
}
}
func TestUsageReadsNestedAdditionalLimitsAndPlanFromPayload(t *testing.T) {
body := []byte(`{"data":{"plan_type":"prolite","additional_rate_limits":[{"metered_feature":"codex_bengalfox","limit_name":"GPT-5.3-Codex-Spark","rate_limit":{"primary_window":{"used_percent":9.5,"limit_window_seconds":18000,"reset_at":1800000000}}}]}}`)
limits, _, plan, err := parseUsage(body, nil, time.Now())
if err != nil {
t.Fatal(err)
}
if len(limits) != 1 || limits[0].LimitID != "codex_bengalfox" || limits[0].UsedPercent != 9.5 || plan == nil || *plan != "prolite" || limits[0].PlanType == nil || *limits[0].PlanType != "prolite" {
t.Fatalf("limits = %#v plan = %v", limits, plan)
}
}
func TestProfileMissingOrNullBucketsAreNotMarkedAvailable(t *testing.T) {
for _, body := range []string{`{}`, `{"stats":{}}`, `{"stats":{"daily_usage_buckets":null}}`} {
summary, usage, usageAvailable, err := parseProfile([]byte(body))
if err != nil {
t.Fatal(err)
}
if usageAvailable || usage == nil || len(usage) != 0 || usageSummaryAvailable(summary) {
t.Fatalf("body=%s summary=%#v usage=%#v available=%v", body, summary, usage, usageAvailable)
}
}
}
func TestProfileInvalidOptionalMetricsRemainUnavailable(t *testing.T) {
summary, usage, usageAvailable, err := parseProfile([]byte(`{"stats":{"lifetime_tokens":"unknown","daily_usage_buckets":[{"start_date":"2026-08-14","tokens":"bad"}]}}`))
if err != nil {
t.Fatal(err)
}
if summary.LifetimeTokens != nil || !usageAvailable || len(usage) != 0 {
t.Fatalf("summary = %#v usage = %#v", summary, usage)
}
}
func TestResetCreditsIgnoreNonAvailableDetails(t *testing.T) {
credits := parseResetCredits([]byte(`{"available_count":2,"credits":[{"status":"redeemed","expires_at":"2026-08-19T00:00:00Z"},{"status":"available","expires_at":"2026-08-20T00:00:00Z"}]}`), 0)
if credits == nil || credits.AvailableCount != 2 || len(credits.ExpiresAt) != 1 || credits.ExpiresAt[0] != time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC).Unix() {
t.Fatalf("credits = %#v", credits)
}
}
func TestProfileFailureDoesNotHideRateLimits(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v0/management/auth-files" {
_, _ = w.Write([]byte(authResponse(`[` + validAuth("auth-1") + `]`)))
return
}
var request struct {
URL string `json:"url"`
}
_ = json.NewDecoder(r.Body).Decode(&request)
switch request.URL {
case usageURL:
_ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": `{"rate_limit":{"primary_window":{"used_percent":50,"limit_window_seconds":18000,"reset_at":1800000000}}}`})
case profileURL:
_ = json.NewEncoder(w).Encode(map[string]any{"status_code": 503, "body": `{"error":"profile unavailable"}`})
case resetCreditsURL:
_ = json.NewEncoder(w).Encode(map[string]any{"status_code": 404, "body": `{}`})
}
}))
defer server.Close()
snapshot, err := New(server.URL, "management-secret").Snapshot(context.Background(), "auth-1")
if err != nil {
t.Fatal(err)
}
if len(snapshot.Limits) != 1 || snapshot.ProfileAvailable || snapshot.Usage == nil || len(snapshot.Usage) != 0 || snapshot.Summary.LifetimeTokens != nil {
t.Fatalf("snapshot = %#v", snapshot)
}
}
func TestResetCreditsSuccessfulZeroOverridesUsageFallback(t *testing.T) {
if credits := parseResetCredits([]byte(`{"available_count":0,"credits":[]}`), 3); credits != nil {
t.Fatalf("credits = %#v; successful detail response is authoritative", credits)
}
}
func TestOptionalResetFailureFallsBackToUsageCount(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v0/management/auth-files" {
_, _ = w.Write([]byte(authResponse(`[` + validAuth("auth-1") + `]`)))
return
}
var request struct {
URL string `json:"url"`
}
_ = json.NewDecoder(r.Body).Decode(&request)
switch request.URL {
case usageURL:
_ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": `{"rate_limit_reset_credits":{"available_count":3}}`})
case profileURL:
_ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": `{}`})
case resetCreditsURL:
_ = json.NewEncoder(w).Encode(map[string]any{"status_code": 503, "body": `{"secret":"upstream detail"}`})
}
}))
defer server.Close()
client := New(server.URL, "management-secret")
snapshot, err := client.Snapshot(context.Background(), "auth-1")
if err != nil {
t.Fatal(err)
}
if snapshot.ResetCredits == nil || snapshot.ResetCredits.AvailableCount != 3 || snapshot.ResetCredits.ExpiresAt == nil {
t.Fatalf("reset credits = %#v", snapshot.ResetCredits)
}
}
func TestManagementRequestDoesNotFollowRedirectsWithSecret(t *testing.T) {
reachedRedirect := false
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reachedRedirect = true
if r.Header.Get("Authorization") != "" {
t.Fatal("management Authorization header reached redirect target")
}
w.WriteHeader(http.StatusOK)
}))
defer target.Close()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusFound)
}))
defer server.Close()
client := New(server.URL, "management-secret")
_, err := client.Auth(context.Background(), "auth-1")
if err == nil || !strings.Contains(err.Error(), "302") {
t.Fatalf("error = %v", err)
}
if reachedRedirect {
t.Fatal("redirect was unexpectedly followed")
}
}
func TestSafeUpstreamErrorTypeOnlyReturnsBoundedIdentifiers(t *testing.T) {
if got := safeUpstreamErrorType(json.RawMessage(`"{\"error\":{\"type\":\"token_expired\"}}"`)); got != "token_expired" {
t.Fatalf("type = %q", got)
}
if got := safeUpstreamErrorType(json.RawMessage(`{"error":{"type":"secret bearer value"}}`)); got != "" {
t.Fatalf("unsafe type = %q", got)
}
}
func TestUpstreamErrorDoesNotLeakBodyOrManagementKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v0/management/auth-files" {
_, _ = w.Write([]byte(authResponse(`[` + validAuth("auth-1") + `]`)))
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"status_code": 401, "body": `{"access_token":"raw-token","detail":"private detail"}`})
}))
defer server.Close()
client := New(server.URL, "management-secret")
_, err := client.Snapshot(context.Background(), "auth-1")
if err == nil {
t.Fatal("snapshot unexpectedly succeeded")
}
message := err.Error()
for _, secret := range []string{"raw-token", "private detail", "management-secret"} {
if strings.Contains(message, secret) {
t.Fatalf("error leaked %q: %s", secret, message)
}
}
if !strings.Contains(message, "401") {
t.Fatalf("error = %q", message)
}
}