| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458 |
- package xai
- import (
- "encoding/json"
- "fmt"
- "math"
- "net/http"
- "strconv"
- "strings"
- "time"
- )
- const (
- // CLI client identity required by cli-chat-proxy billing endpoints.
- CLITokenAuthHeader = "x-xai-token-auth"
- CLITokenAuthValue = "xai-grok-cli"
- CLIClientVersionHeader = "x-grok-client-version"
- // CLIClientVersion is the one place the pinned Grok CLI version lives. The
- // repository and service layers build their own client identity from it, so
- // one bump here covers OAuth traffic and billing probes together.
- // Keep in sync with https://x.ai/cli/stable.
- CLIClientVersion = "0.2.120"
- // billingCLIUserAgent is the legacy pager/shell UA used by billing probes.
- // Distinct from CLIUserAgent() in cli_identity.go (workspace-style UA).
- billingCLIUserAgent = "grok-pager/" + CLIClientVersion + " grok-shell/" + CLIClientVersion + " (macos; aarch64)"
- BillingWeeklyPath = "/billing?format=credits"
- BillingMonthlyPath = "/billing"
- SuperGrokLimitCents = 15_000 // $150.00
- SuperGrokHeavyLimitCents = 150_000 // $1,500.00
- )
- // BillingPeriod describes the current weekly/monthly window.
- type BillingPeriod struct {
- Type string `json:"type,omitempty"`
- Start string `json:"start,omitempty"`
- End string `json:"end,omitempty"`
- }
- // BillingProductUsage is per-product usage inside the weekly credits window.
- type BillingProductUsage struct {
- Product string `json:"product,omitempty"`
- UsagePercent *float64 `json:"usagePercent,omitempty"`
- }
- // BillingConfig is the nested config object from /v1/billing responses.
- // Weekly (`?format=credits`) and monthly (`/billing`) share this shape; absolute
- // money fields typically appear on the credits (prepaid/on-demand) or monthly
- // (limit/used) responses.
- type BillingConfig struct {
- CurrentPeriod *BillingPeriod `json:"currentPeriod,omitempty"`
- CreditUsagePercent *float64 `json:"creditUsagePercent,omitempty"`
- ProductUsage []BillingProductUsage `json:"productUsage,omitempty"`
- MonthlyLimit json.RawMessage `json:"monthlyLimit,omitempty"`
- Used json.RawMessage `json:"used,omitempty"`
- OnDemandCap json.RawMessage `json:"onDemandCap,omitempty"`
- OnDemandUsed json.RawMessage `json:"onDemandUsed,omitempty"`
- PrepaidBalance json.RawMessage `json:"prepaidBalance,omitempty"`
- IsUnifiedBillingUser bool `json:"isUnifiedBillingUser,omitempty"`
- TopUpMethod string `json:"topUpMethod,omitempty"`
- BillingPeriodStart string `json:"billingPeriodStart,omitempty"`
- BillingPeriodEnd string `json:"billingPeriodEnd,omitempty"`
- }
- // BillingPayload is the top-level body from /v1/billing.
- type BillingPayload struct {
- Config *BillingConfig `json:"config,omitempty"`
- }
- // BillingProductSummary is a normalized product usage row for UI.
- type BillingProductSummary struct {
- Product string `json:"product"`
- UsagePercent *float64 `json:"usage_percent,omitempty"`
- }
- // BillingSummary is the merged weekly + monthly billing view.
- // Cents fields remain the authoritative monthly numbers; dollar fields are the
- // operator-facing absolute money view (prepaid / on-demand / monthly $).
- type BillingSummary struct {
- PeriodType string `json:"period_type,omitempty"` // weekly | monthly | unknown
- UsagePercent *float64 `json:"usage_percent,omitempty"`
- PeriodStart string `json:"period_start,omitempty"`
- PeriodEnd string `json:"period_end,omitempty"`
- ProductUsage []BillingProductSummary `json:"product_usage,omitempty"`
- MonthlyLimitCents *float64 `json:"monthly_limit_cents,omitempty"`
- UsedCents *float64 `json:"used_cents,omitempty"`
- IncludedUsedCents *float64 `json:"included_used_cents,omitempty"`
- BillingPeriodStart string `json:"billing_period_start,omitempty"`
- BillingPeriodEnd string `json:"billing_period_end,omitempty"`
- UsedPercent *float64 `json:"used_percent,omitempty"`
- // Absolute money (USD). Prepaid/on-demand come from credits probe as dollars.
- // MonthlyLimit/MonthlyUsed are cents/100 for consistent $ display.
- PrepaidBalance *float64 `json:"prepaid_balance,omitempty"`
- MonthlyLimit *float64 `json:"monthly_limit,omitempty"`
- MonthlyUsed *float64 `json:"monthly_used,omitempty"`
- OnDemandCap *float64 `json:"on_demand_cap,omitempty"`
- OnDemandUsed *float64 `json:"on_demand_used,omitempty"`
- TopUpMethod string `json:"top_up_method,omitempty"`
- IsUnifiedBillingUser bool `json:"is_unified_billing_user,omitempty"`
- Plan string `json:"plan,omitempty"` // SuperGrok | SuperGrok Heavy | ""
- StatusCode int `json:"status_code,omitempty"`
- WeeklyStatusCode int `json:"weekly_status_code,omitempty"`
- MonthlyStatusCode int `json:"monthly_status_code,omitempty"`
- Source string `json:"source,omitempty"`
- FetchedAt string `json:"fetched_at,omitempty"`
- UpdatedAt string `json:"updated_at,omitempty"`
- WeeklyUpdatedAt string `json:"weekly_updated_at,omitempty"`
- MonthlyUpdatedAt string `json:"monthly_updated_at,omitempty"`
- Partial bool `json:"partial,omitempty"`
- FailedWindows []string `json:"failed_windows,omitempty"`
- }
- // BuildBillingURL builds weekly or monthly billing URL against the CLI chat proxy.
- func BuildBillingURL(formatCredits bool) string {
- base := strings.TrimRight(DefaultCLIBaseURL, "/")
- if formatCredits {
- return base + BillingWeeklyPath
- }
- return base + BillingMonthlyPath
- }
- // BuildBillingURLWithValidator builds the weekly or monthly billing URL against
- // the caller-resolved base URL, applying the caller's outbound URL trust policy
- // first. Accounts forwarding through a custom upstream keep their billing
- // probes on the same upstream.
- func BuildBillingURLWithValidator(baseURL string, formatCredits bool, validator BaseURLValidator) (string, error) {
- validatedBaseURL, err := validatedBaseURLWithValidator(baseURL, validator)
- if err != nil {
- return "", fmt.Errorf("invalid base url: %w", err)
- }
- if formatCredits {
- return validatedBaseURL + BillingWeeklyPath, nil
- }
- return validatedBaseURL + BillingMonthlyPath, nil
- }
- // ApplyCLIBillingHeaders sets Authorization + CLI identity headers for billing GETs.
- func ApplyCLIBillingHeaders(req *http.Request, accessToken string) {
- if req == nil {
- return
- }
- token := strings.TrimSpace(accessToken)
- if token != "" {
- req.Header.Set("Authorization", "Bearer "+token)
- }
- req.Header.Set("Accept", "application/json")
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set(CLITokenAuthHeader, CLITokenAuthValue)
- req.Header.Set(CLIClientVersionHeader, CLIClientVersion)
- req.Header.Set("User-Agent", billingCLIUserAgent)
- }
- // ParseBillingPayload unmarshals a billing API response body.
- func ParseBillingPayload(body []byte) (*BillingPayload, error) {
- if len(body) == 0 {
- return nil, fmt.Errorf("empty billing body")
- }
- var payload BillingPayload
- if err := json.Unmarshal(body, &payload); err != nil {
- return nil, err
- }
- return &payload, nil
- }
- // BuildBillingSummary normalizes a billing config into a UI-friendly summary.
- func BuildBillingSummary(config *BillingConfig) *BillingSummary {
- if config == nil {
- return nil
- }
- summary := &BillingSummary{}
- period := config.CurrentPeriod
- periodType := resolvePeriodType(period)
- creditUsage := cloneFloat(config.CreditUsagePercent)
- // Weekly period bounds must not fall back to monthly billing period ends —
- // that would park accounts on a multi-week horizon when weekly UsagePercent
- // is high (scheduler seven_day uses PeriodEnd).
- periodStart := ""
- periodEnd := ""
- if period != nil {
- periodStart = strings.TrimSpace(period.Start)
- periodEnd = strings.TrimSpace(period.End)
- }
- products := make([]BillingProductSummary, 0, len(config.ProductUsage))
- for _, item := range config.ProductUsage {
- product := strings.TrimSpace(item.Product)
- if product == "" {
- continue
- }
- products = append(products, BillingProductSummary{
- Product: product,
- UsagePercent: cloneFloat(item.UsagePercent),
- })
- }
- monthlyLimit := parseCentValue(config.MonthlyLimit)
- used := parseCentValue(config.Used)
- // Absolute money on credits responses is dollar-denominated ({"val": 12}).
- // Monthly limit/used are cents (same as MonthlyLimitCents / UsedCents).
- prepaid := parseCentValue(config.PrepaidBalance)
- onDemandCap := parseCentValue(config.OnDemandCap)
- onDemandUsed := parseCentValue(config.OnDemandUsed)
- billingStart := strings.TrimSpace(config.BillingPeriodStart)
- billingEnd := strings.TrimSpace(config.BillingPeriodEnd)
- var includedUsed *float64
- if used != nil {
- if monthlyLimit != nil && *monthlyLimit > 0 {
- v := math.Min(*used, *monthlyLimit)
- includedUsed = &v
- } else {
- includedUsed = cloneFloat(used)
- }
- }
- var usedPercent *float64
- if monthlyLimit != nil && *monthlyLimit > 0 && includedUsed != nil {
- v := (*includedUsed / *monthlyLimit) * 100
- usedPercent = &v
- }
- hasWeekly := creditUsage != nil || periodType == "weekly" || len(products) > 0 || prepaid != nil || onDemandCap != nil || onDemandUsed != nil
- hasMonthly := monthlyLimit != nil || used != nil || (!hasWeekly && billingEnd != "")
- if !hasWeekly && !hasMonthly {
- return nil
- }
- if hasWeekly {
- if periodType == "unknown" {
- periodType = "weekly"
- }
- summary.PeriodType = periodType
- summary.UsagePercent = creditUsage
- summary.PeriodStart = periodStart
- summary.PeriodEnd = periodEnd
- } else {
- // Monthly-only: do not put monthly % into UsagePercent (weekly bar field).
- // Frontend weekly bar only renders when PeriodType == weekly.
- summary.PeriodType = "monthly"
- summary.PeriodStart = billingStart
- summary.PeriodEnd = billingEnd
- }
- summary.ProductUsage = products
- summary.MonthlyLimitCents = monthlyLimit
- summary.UsedCents = used
- summary.IncludedUsedCents = includedUsed
- if hasMonthly {
- summary.BillingPeriodStart = billingStart
- summary.BillingPeriodEnd = billingEnd
- }
- summary.UsedPercent = usedPercent
- summary.PrepaidBalance = prepaid
- if onDemandCap != nil {
- summary.OnDemandCap = onDemandCap
- }
- if onDemandUsed != nil {
- summary.OnDemandUsed = onDemandUsed
- }
- // Expose monthly cents as dollars for UI absolute rows.
- if monthlyLimit != nil {
- v := *monthlyLimit / 100
- summary.MonthlyLimit = &v
- }
- if used != nil {
- v := *used / 100
- summary.MonthlyUsed = &v
- }
- summary.TopUpMethod = strings.TrimSpace(config.TopUpMethod)
- summary.IsUnifiedBillingUser = config.IsUnifiedBillingUser
- summary.Plan = resolvePlan(monthlyLimit)
- return summary
- }
- // MergeBillingProbeResult updates successful billing domains while retaining
- // the previous value for any domain that could not be refreshed.
- func MergeBillingProbeResult(previous, weekly, monthly *BillingSummary, weeklyOK, monthlyOK bool) *BillingSummary {
- var out BillingSummary
- if previous != nil {
- out = *previous
- previousUpdatedAt := previous.UpdatedAt
- if previousUpdatedAt == "" {
- previousUpdatedAt = previous.FetchedAt
- }
- if out.WeeklyUpdatedAt == "" && (out.UsagePercent != nil || len(out.ProductUsage) > 0) {
- out.WeeklyUpdatedAt = previousUpdatedAt
- }
- if out.MonthlyUpdatedAt == "" && (out.MonthlyLimitCents != nil || out.UsedPercent != nil) {
- out.MonthlyUpdatedAt = previousUpdatedAt
- }
- }
- now := time.Now().UTC().Format(time.RFC3339)
- if weeklyOK && weekly != nil {
- out.PeriodType = weekly.PeriodType
- out.UsagePercent = weekly.UsagePercent
- out.PeriodStart = weekly.PeriodStart
- out.PeriodEnd = weekly.PeriodEnd
- out.ProductUsage = weekly.ProductUsage
- // Absolute prepaid / on-demand usually ride the credits (weekly) response.
- if weekly.PrepaidBalance != nil {
- out.PrepaidBalance = weekly.PrepaidBalance
- }
- if weekly.OnDemandCap != nil {
- out.OnDemandCap = weekly.OnDemandCap
- }
- if weekly.OnDemandUsed != nil {
- out.OnDemandUsed = weekly.OnDemandUsed
- }
- if weekly.TopUpMethod != "" {
- out.TopUpMethod = weekly.TopUpMethod
- }
- if weekly.IsUnifiedBillingUser {
- out.IsUnifiedBillingUser = true
- }
- out.WeeklyUpdatedAt = now
- }
- if monthlyOK && monthly != nil {
- if out.PeriodType == "" {
- out.PeriodType = "monthly"
- }
- out.MonthlyLimitCents = monthly.MonthlyLimitCents
- out.UsedCents = monthly.UsedCents
- out.IncludedUsedCents = monthly.IncludedUsedCents
- out.BillingPeriodStart = monthly.BillingPeriodStart
- out.BillingPeriodEnd = monthly.BillingPeriodEnd
- out.UsedPercent = monthly.UsedPercent
- out.MonthlyLimit = monthly.MonthlyLimit
- out.MonthlyUsed = monthly.MonthlyUsed
- // Monthly probe may also carry on-demand cap when credits omitted it.
- if monthly.OnDemandCap != nil && out.OnDemandCap == nil {
- out.OnDemandCap = monthly.OnDemandCap
- }
- if monthly.OnDemandUsed != nil && out.OnDemandUsed == nil {
- out.OnDemandUsed = monthly.OnDemandUsed
- }
- out.Plan = monthly.Plan
- out.MonthlyUpdatedAt = now
- }
- out.Partial = !weeklyOK || !monthlyOK
- out.FailedWindows = nil
- if !weeklyOK {
- out.FailedWindows = append(out.FailedWindows, "weekly")
- }
- if !monthlyOK {
- out.FailedWindows = append(out.FailedWindows, "monthly")
- }
- if !weeklyOK && !monthlyOK && previous == nil {
- return nil
- }
- return &out
- }
- // StampBillingSummary sets fetch metadata.
- func StampBillingSummary(summary *BillingSummary, statusCode int, source string) *BillingSummary {
- if summary == nil {
- return nil
- }
- now := time.Now().UTC().Format(time.RFC3339)
- summary.StatusCode = statusCode
- summary.Source = source
- summary.FetchedAt = now
- summary.UpdatedAt = now
- return summary
- }
- func resolvePeriodType(period *BillingPeriod) string {
- if period == nil {
- return "unknown"
- }
- raw := strings.ToLower(strings.TrimSpace(period.Type))
- if strings.Contains(raw, "weekly") {
- return "weekly"
- }
- if strings.Contains(raw, "monthly") {
- return "monthly"
- }
- return "unknown"
- }
- func resolvePlan(monthlyLimitCents *float64) string {
- if monthlyLimitCents == nil {
- return ""
- }
- // Allow small float noise.
- limit := math.Round(*monthlyLimitCents)
- switch limit {
- case SuperGrokLimitCents:
- return "SuperGrok"
- case SuperGrokHeavyLimitCents:
- return "SuperGrok Heavy"
- default:
- return ""
- }
- }
- func parseCentValue(raw json.RawMessage) *float64 {
- if len(raw) == 0 || string(raw) == "null" {
- return nil
- }
- // Object form: {"val": 123}
- var obj struct {
- Val any `json:"val"`
- }
- if err := json.Unmarshal(raw, &obj); err == nil && obj.Val != nil {
- return anyToFloat(obj.Val)
- }
- // Bare number / string
- var n any
- if err := json.Unmarshal(raw, &n); err != nil {
- return nil
- }
- return anyToFloat(n)
- }
- func anyToFloat(v any) *float64 {
- switch n := v.(type) {
- case float64:
- return &n
- case float32:
- f := float64(n)
- return &f
- case int:
- f := float64(n)
- return &f
- case int64:
- f := float64(n)
- return &f
- case json.Number:
- f, err := n.Float64()
- if err != nil {
- return nil
- }
- return &f
- case string:
- s := strings.TrimSpace(n)
- if s == "" {
- return nil
- }
- f, err := strconv.ParseFloat(s, 64)
- if err != nil {
- return nil
- }
- return &f
- default:
- return nil
- }
- }
- func cloneFloat(v *float64) *float64 {
- if v == nil {
- return nil
- }
- f := *v
- return &f
- }
|