This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user