billing.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. package xai
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "math"
  6. "net/http"
  7. "strconv"
  8. "strings"
  9. "time"
  10. )
  11. const (
  12. // CLI client identity required by cli-chat-proxy billing endpoints.
  13. CLITokenAuthHeader = "x-xai-token-auth"
  14. CLITokenAuthValue = "xai-grok-cli"
  15. CLIClientVersionHeader = "x-grok-client-version"
  16. // CLIClientVersion is the one place the pinned Grok CLI version lives. The
  17. // repository and service layers build their own client identity from it, so
  18. // one bump here covers OAuth traffic and billing probes together.
  19. // Keep in sync with https://x.ai/cli/stable.
  20. CLIClientVersion = "0.2.120"
  21. // billingCLIUserAgent is the legacy pager/shell UA used by billing probes.
  22. // Distinct from CLIUserAgent() in cli_identity.go (workspace-style UA).
  23. billingCLIUserAgent = "grok-pager/" + CLIClientVersion + " grok-shell/" + CLIClientVersion + " (macos; aarch64)"
  24. BillingWeeklyPath = "/billing?format=credits"
  25. BillingMonthlyPath = "/billing"
  26. SuperGrokLimitCents = 15_000 // $150.00
  27. SuperGrokHeavyLimitCents = 150_000 // $1,500.00
  28. )
  29. // BillingPeriod describes the current weekly/monthly window.
  30. type BillingPeriod struct {
  31. Type string `json:"type,omitempty"`
  32. Start string `json:"start,omitempty"`
  33. End string `json:"end,omitempty"`
  34. }
  35. // BillingProductUsage is per-product usage inside the weekly credits window.
  36. type BillingProductUsage struct {
  37. Product string `json:"product,omitempty"`
  38. UsagePercent *float64 `json:"usagePercent,omitempty"`
  39. }
  40. // BillingConfig is the nested config object from /v1/billing responses.
  41. // Weekly (`?format=credits`) and monthly (`/billing`) share this shape; absolute
  42. // money fields typically appear on the credits (prepaid/on-demand) or monthly
  43. // (limit/used) responses.
  44. type BillingConfig struct {
  45. CurrentPeriod *BillingPeriod `json:"currentPeriod,omitempty"`
  46. CreditUsagePercent *float64 `json:"creditUsagePercent,omitempty"`
  47. ProductUsage []BillingProductUsage `json:"productUsage,omitempty"`
  48. MonthlyLimit json.RawMessage `json:"monthlyLimit,omitempty"`
  49. Used json.RawMessage `json:"used,omitempty"`
  50. OnDemandCap json.RawMessage `json:"onDemandCap,omitempty"`
  51. OnDemandUsed json.RawMessage `json:"onDemandUsed,omitempty"`
  52. PrepaidBalance json.RawMessage `json:"prepaidBalance,omitempty"`
  53. IsUnifiedBillingUser bool `json:"isUnifiedBillingUser,omitempty"`
  54. TopUpMethod string `json:"topUpMethod,omitempty"`
  55. BillingPeriodStart string `json:"billingPeriodStart,omitempty"`
  56. BillingPeriodEnd string `json:"billingPeriodEnd,omitempty"`
  57. }
  58. // BillingPayload is the top-level body from /v1/billing.
  59. type BillingPayload struct {
  60. Config *BillingConfig `json:"config,omitempty"`
  61. }
  62. // BillingProductSummary is a normalized product usage row for UI.
  63. type BillingProductSummary struct {
  64. Product string `json:"product"`
  65. UsagePercent *float64 `json:"usage_percent,omitempty"`
  66. }
  67. // BillingSummary is the merged weekly + monthly billing view.
  68. // Cents fields remain the authoritative monthly numbers; dollar fields are the
  69. // operator-facing absolute money view (prepaid / on-demand / monthly $).
  70. type BillingSummary struct {
  71. PeriodType string `json:"period_type,omitempty"` // weekly | monthly | unknown
  72. UsagePercent *float64 `json:"usage_percent,omitempty"`
  73. PeriodStart string `json:"period_start,omitempty"`
  74. PeriodEnd string `json:"period_end,omitempty"`
  75. ProductUsage []BillingProductSummary `json:"product_usage,omitempty"`
  76. MonthlyLimitCents *float64 `json:"monthly_limit_cents,omitempty"`
  77. UsedCents *float64 `json:"used_cents,omitempty"`
  78. IncludedUsedCents *float64 `json:"included_used_cents,omitempty"`
  79. BillingPeriodStart string `json:"billing_period_start,omitempty"`
  80. BillingPeriodEnd string `json:"billing_period_end,omitempty"`
  81. UsedPercent *float64 `json:"used_percent,omitempty"`
  82. // Absolute money (USD). Prepaid/on-demand come from credits probe as dollars.
  83. // MonthlyLimit/MonthlyUsed are cents/100 for consistent $ display.
  84. PrepaidBalance *float64 `json:"prepaid_balance,omitempty"`
  85. MonthlyLimit *float64 `json:"monthly_limit,omitempty"`
  86. MonthlyUsed *float64 `json:"monthly_used,omitempty"`
  87. OnDemandCap *float64 `json:"on_demand_cap,omitempty"`
  88. OnDemandUsed *float64 `json:"on_demand_used,omitempty"`
  89. TopUpMethod string `json:"top_up_method,omitempty"`
  90. IsUnifiedBillingUser bool `json:"is_unified_billing_user,omitempty"`
  91. Plan string `json:"plan,omitempty"` // SuperGrok | SuperGrok Heavy | ""
  92. StatusCode int `json:"status_code,omitempty"`
  93. WeeklyStatusCode int `json:"weekly_status_code,omitempty"`
  94. MonthlyStatusCode int `json:"monthly_status_code,omitempty"`
  95. Source string `json:"source,omitempty"`
  96. FetchedAt string `json:"fetched_at,omitempty"`
  97. UpdatedAt string `json:"updated_at,omitempty"`
  98. WeeklyUpdatedAt string `json:"weekly_updated_at,omitempty"`
  99. MonthlyUpdatedAt string `json:"monthly_updated_at,omitempty"`
  100. Partial bool `json:"partial,omitempty"`
  101. FailedWindows []string `json:"failed_windows,omitempty"`
  102. }
  103. // BuildBillingURL builds weekly or monthly billing URL against the CLI chat proxy.
  104. func BuildBillingURL(formatCredits bool) string {
  105. base := strings.TrimRight(DefaultCLIBaseURL, "/")
  106. if formatCredits {
  107. return base + BillingWeeklyPath
  108. }
  109. return base + BillingMonthlyPath
  110. }
  111. // BuildBillingURLWithValidator builds the weekly or monthly billing URL against
  112. // the caller-resolved base URL, applying the caller's outbound URL trust policy
  113. // first. Accounts forwarding through a custom upstream keep their billing
  114. // probes on the same upstream.
  115. func BuildBillingURLWithValidator(baseURL string, formatCredits bool, validator BaseURLValidator) (string, error) {
  116. validatedBaseURL, err := validatedBaseURLWithValidator(baseURL, validator)
  117. if err != nil {
  118. return "", fmt.Errorf("invalid base url: %w", err)
  119. }
  120. if formatCredits {
  121. return validatedBaseURL + BillingWeeklyPath, nil
  122. }
  123. return validatedBaseURL + BillingMonthlyPath, nil
  124. }
  125. // ApplyCLIBillingHeaders sets Authorization + CLI identity headers for billing GETs.
  126. func ApplyCLIBillingHeaders(req *http.Request, accessToken string) {
  127. if req == nil {
  128. return
  129. }
  130. token := strings.TrimSpace(accessToken)
  131. if token != "" {
  132. req.Header.Set("Authorization", "Bearer "+token)
  133. }
  134. req.Header.Set("Accept", "application/json")
  135. req.Header.Set("Content-Type", "application/json")
  136. req.Header.Set(CLITokenAuthHeader, CLITokenAuthValue)
  137. req.Header.Set(CLIClientVersionHeader, CLIClientVersion)
  138. req.Header.Set("User-Agent", billingCLIUserAgent)
  139. }
  140. // ParseBillingPayload unmarshals a billing API response body.
  141. func ParseBillingPayload(body []byte) (*BillingPayload, error) {
  142. if len(body) == 0 {
  143. return nil, fmt.Errorf("empty billing body")
  144. }
  145. var payload BillingPayload
  146. if err := json.Unmarshal(body, &payload); err != nil {
  147. return nil, err
  148. }
  149. return &payload, nil
  150. }
  151. // BuildBillingSummary normalizes a billing config into a UI-friendly summary.
  152. func BuildBillingSummary(config *BillingConfig) *BillingSummary {
  153. if config == nil {
  154. return nil
  155. }
  156. summary := &BillingSummary{}
  157. period := config.CurrentPeriod
  158. periodType := resolvePeriodType(period)
  159. creditUsage := cloneFloat(config.CreditUsagePercent)
  160. // Weekly period bounds must not fall back to monthly billing period ends —
  161. // that would park accounts on a multi-week horizon when weekly UsagePercent
  162. // is high (scheduler seven_day uses PeriodEnd).
  163. periodStart := ""
  164. periodEnd := ""
  165. if period != nil {
  166. periodStart = strings.TrimSpace(period.Start)
  167. periodEnd = strings.TrimSpace(period.End)
  168. }
  169. products := make([]BillingProductSummary, 0, len(config.ProductUsage))
  170. for _, item := range config.ProductUsage {
  171. product := strings.TrimSpace(item.Product)
  172. if product == "" {
  173. continue
  174. }
  175. products = append(products, BillingProductSummary{
  176. Product: product,
  177. UsagePercent: cloneFloat(item.UsagePercent),
  178. })
  179. }
  180. monthlyLimit := parseCentValue(config.MonthlyLimit)
  181. used := parseCentValue(config.Used)
  182. // Absolute money on credits responses is dollar-denominated ({"val": 12}).
  183. // Monthly limit/used are cents (same as MonthlyLimitCents / UsedCents).
  184. prepaid := parseCentValue(config.PrepaidBalance)
  185. onDemandCap := parseCentValue(config.OnDemandCap)
  186. onDemandUsed := parseCentValue(config.OnDemandUsed)
  187. billingStart := strings.TrimSpace(config.BillingPeriodStart)
  188. billingEnd := strings.TrimSpace(config.BillingPeriodEnd)
  189. var includedUsed *float64
  190. if used != nil {
  191. if monthlyLimit != nil && *monthlyLimit > 0 {
  192. v := math.Min(*used, *monthlyLimit)
  193. includedUsed = &v
  194. } else {
  195. includedUsed = cloneFloat(used)
  196. }
  197. }
  198. var usedPercent *float64
  199. if monthlyLimit != nil && *monthlyLimit > 0 && includedUsed != nil {
  200. v := (*includedUsed / *monthlyLimit) * 100
  201. usedPercent = &v
  202. }
  203. hasWeekly := creditUsage != nil || periodType == "weekly" || len(products) > 0 || prepaid != nil || onDemandCap != nil || onDemandUsed != nil
  204. hasMonthly := monthlyLimit != nil || used != nil || (!hasWeekly && billingEnd != "")
  205. if !hasWeekly && !hasMonthly {
  206. return nil
  207. }
  208. if hasWeekly {
  209. if periodType == "unknown" {
  210. periodType = "weekly"
  211. }
  212. summary.PeriodType = periodType
  213. summary.UsagePercent = creditUsage
  214. summary.PeriodStart = periodStart
  215. summary.PeriodEnd = periodEnd
  216. } else {
  217. // Monthly-only: do not put monthly % into UsagePercent (weekly bar field).
  218. // Frontend weekly bar only renders when PeriodType == weekly.
  219. summary.PeriodType = "monthly"
  220. summary.PeriodStart = billingStart
  221. summary.PeriodEnd = billingEnd
  222. }
  223. summary.ProductUsage = products
  224. summary.MonthlyLimitCents = monthlyLimit
  225. summary.UsedCents = used
  226. summary.IncludedUsedCents = includedUsed
  227. if hasMonthly {
  228. summary.BillingPeriodStart = billingStart
  229. summary.BillingPeriodEnd = billingEnd
  230. }
  231. summary.UsedPercent = usedPercent
  232. summary.PrepaidBalance = prepaid
  233. if onDemandCap != nil {
  234. summary.OnDemandCap = onDemandCap
  235. }
  236. if onDemandUsed != nil {
  237. summary.OnDemandUsed = onDemandUsed
  238. }
  239. // Expose monthly cents as dollars for UI absolute rows.
  240. if monthlyLimit != nil {
  241. v := *monthlyLimit / 100
  242. summary.MonthlyLimit = &v
  243. }
  244. if used != nil {
  245. v := *used / 100
  246. summary.MonthlyUsed = &v
  247. }
  248. summary.TopUpMethod = strings.TrimSpace(config.TopUpMethod)
  249. summary.IsUnifiedBillingUser = config.IsUnifiedBillingUser
  250. summary.Plan = resolvePlan(monthlyLimit)
  251. return summary
  252. }
  253. // MergeBillingProbeResult updates successful billing domains while retaining
  254. // the previous value for any domain that could not be refreshed.
  255. func MergeBillingProbeResult(previous, weekly, monthly *BillingSummary, weeklyOK, monthlyOK bool) *BillingSummary {
  256. var out BillingSummary
  257. if previous != nil {
  258. out = *previous
  259. previousUpdatedAt := previous.UpdatedAt
  260. if previousUpdatedAt == "" {
  261. previousUpdatedAt = previous.FetchedAt
  262. }
  263. if out.WeeklyUpdatedAt == "" && (out.UsagePercent != nil || len(out.ProductUsage) > 0) {
  264. out.WeeklyUpdatedAt = previousUpdatedAt
  265. }
  266. if out.MonthlyUpdatedAt == "" && (out.MonthlyLimitCents != nil || out.UsedPercent != nil) {
  267. out.MonthlyUpdatedAt = previousUpdatedAt
  268. }
  269. }
  270. now := time.Now().UTC().Format(time.RFC3339)
  271. if weeklyOK && weekly != nil {
  272. out.PeriodType = weekly.PeriodType
  273. out.UsagePercent = weekly.UsagePercent
  274. out.PeriodStart = weekly.PeriodStart
  275. out.PeriodEnd = weekly.PeriodEnd
  276. out.ProductUsage = weekly.ProductUsage
  277. // Absolute prepaid / on-demand usually ride the credits (weekly) response.
  278. if weekly.PrepaidBalance != nil {
  279. out.PrepaidBalance = weekly.PrepaidBalance
  280. }
  281. if weekly.OnDemandCap != nil {
  282. out.OnDemandCap = weekly.OnDemandCap
  283. }
  284. if weekly.OnDemandUsed != nil {
  285. out.OnDemandUsed = weekly.OnDemandUsed
  286. }
  287. if weekly.TopUpMethod != "" {
  288. out.TopUpMethod = weekly.TopUpMethod
  289. }
  290. if weekly.IsUnifiedBillingUser {
  291. out.IsUnifiedBillingUser = true
  292. }
  293. out.WeeklyUpdatedAt = now
  294. }
  295. if monthlyOK && monthly != nil {
  296. if out.PeriodType == "" {
  297. out.PeriodType = "monthly"
  298. }
  299. out.MonthlyLimitCents = monthly.MonthlyLimitCents
  300. out.UsedCents = monthly.UsedCents
  301. out.IncludedUsedCents = monthly.IncludedUsedCents
  302. out.BillingPeriodStart = monthly.BillingPeriodStart
  303. out.BillingPeriodEnd = monthly.BillingPeriodEnd
  304. out.UsedPercent = monthly.UsedPercent
  305. out.MonthlyLimit = monthly.MonthlyLimit
  306. out.MonthlyUsed = monthly.MonthlyUsed
  307. // Monthly probe may also carry on-demand cap when credits omitted it.
  308. if monthly.OnDemandCap != nil && out.OnDemandCap == nil {
  309. out.OnDemandCap = monthly.OnDemandCap
  310. }
  311. if monthly.OnDemandUsed != nil && out.OnDemandUsed == nil {
  312. out.OnDemandUsed = monthly.OnDemandUsed
  313. }
  314. out.Plan = monthly.Plan
  315. out.MonthlyUpdatedAt = now
  316. }
  317. out.Partial = !weeklyOK || !monthlyOK
  318. out.FailedWindows = nil
  319. if !weeklyOK {
  320. out.FailedWindows = append(out.FailedWindows, "weekly")
  321. }
  322. if !monthlyOK {
  323. out.FailedWindows = append(out.FailedWindows, "monthly")
  324. }
  325. if !weeklyOK && !monthlyOK && previous == nil {
  326. return nil
  327. }
  328. return &out
  329. }
  330. // StampBillingSummary sets fetch metadata.
  331. func StampBillingSummary(summary *BillingSummary, statusCode int, source string) *BillingSummary {
  332. if summary == nil {
  333. return nil
  334. }
  335. now := time.Now().UTC().Format(time.RFC3339)
  336. summary.StatusCode = statusCode
  337. summary.Source = source
  338. summary.FetchedAt = now
  339. summary.UpdatedAt = now
  340. return summary
  341. }
  342. func resolvePeriodType(period *BillingPeriod) string {
  343. if period == nil {
  344. return "unknown"
  345. }
  346. raw := strings.ToLower(strings.TrimSpace(period.Type))
  347. if strings.Contains(raw, "weekly") {
  348. return "weekly"
  349. }
  350. if strings.Contains(raw, "monthly") {
  351. return "monthly"
  352. }
  353. return "unknown"
  354. }
  355. func resolvePlan(monthlyLimitCents *float64) string {
  356. if monthlyLimitCents == nil {
  357. return ""
  358. }
  359. // Allow small float noise.
  360. limit := math.Round(*monthlyLimitCents)
  361. switch limit {
  362. case SuperGrokLimitCents:
  363. return "SuperGrok"
  364. case SuperGrokHeavyLimitCents:
  365. return "SuperGrok Heavy"
  366. default:
  367. return ""
  368. }
  369. }
  370. func parseCentValue(raw json.RawMessage) *float64 {
  371. if len(raw) == 0 || string(raw) == "null" {
  372. return nil
  373. }
  374. // Object form: {"val": 123}
  375. var obj struct {
  376. Val any `json:"val"`
  377. }
  378. if err := json.Unmarshal(raw, &obj); err == nil && obj.Val != nil {
  379. return anyToFloat(obj.Val)
  380. }
  381. // Bare number / string
  382. var n any
  383. if err := json.Unmarshal(raw, &n); err != nil {
  384. return nil
  385. }
  386. return anyToFloat(n)
  387. }
  388. func anyToFloat(v any) *float64 {
  389. switch n := v.(type) {
  390. case float64:
  391. return &n
  392. case float32:
  393. f := float64(n)
  394. return &f
  395. case int:
  396. f := float64(n)
  397. return &f
  398. case int64:
  399. f := float64(n)
  400. return &f
  401. case json.Number:
  402. f, err := n.Float64()
  403. if err != nil {
  404. return nil
  405. }
  406. return &f
  407. case string:
  408. s := strings.TrimSpace(n)
  409. if s == "" {
  410. return nil
  411. }
  412. f, err := strconv.ParseFloat(s, 64)
  413. if err != nil {
  414. return nil
  415. }
  416. return &f
  417. default:
  418. return nil
  419. }
  420. }
  421. func cloneFloat(v *float64) *float64 {
  422. if v == nil {
  423. return nil
  424. }
  425. f := *v
  426. return &f
  427. }