// Package grok implements the Grok (grok.com / xAI) provider client. Auth is the
// website "sso" session cookie (a JWT whose only claim is a session_id — no exp,
// no refresh: when the session dies upstream the account is simply dead, never
// renewed). grok.com gates requests with an x-statsig-id header; its value
// is a 70-byte anti-bot record — header[49] | counter_le32 |
// sha256("METHOD!path!counter"+suffix)[:16] | trailer — XOR-masked with one
// random byte and base64-encoded; we reproduce it exactly in statsigID (the
// build-specific header/suffix/trailer are env-overridable when grok rotates
// them). Uses tls-client so the JA3/JA4 fingerprint matches Chrome.
package grok
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math"
"math/rand/v2"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/bogdanfinn/tls-client/profiles"
"github.com/google/uuid"
)
const (
apiBase = "https://grok.com"
origin = "https://grok.com"
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
// fullCredits is the weekly grant — UI shows "100 满额".
fullCredits = 100
)
var (
ErrAuth = errors.New("grok auth failed")
ErrQuotaExhausted = errors.New("grok quota exhausted")
ErrTemporaryUpstream = errors.New("grok upstream temporary error")
)
type Client struct {
proxy string
}
func NewClient(proxy string) *Client {
return &Client{proxy: strings.TrimSpace(proxy)}
}
func (c *Client) SetProxy(proxy string) {
c.proxy = strings.TrimSpace(proxy)
}
// IsGrokToken reports whether a JWT looks like a Grok website "sso" cookie: a
// payload whose ONLY claim is "session_id". That disambiguates it from a runway
// token (id + sso claims) or a chatgpt token (openai.com claims).
func IsGrokToken(token string) bool {
claims := decodeJWTPayload(token)
if len(claims) == 0 {
return false
}
if _, ok := claims["session_id"]; !ok {
return false
}
// Reject tokens that ALSO carry other-provider markers.
for k := range claims {
if k == "session_id" {
continue
}
if k == "sso" || k == "id" || strings.HasPrefix(k, "https://api.openai.com/") {
return false
}
}
return true
}
// SessionIDFromToken returns the sso session id (for dedup / display).
func SessionIDFromToken(token string) string {
return strings.TrimSpace(stringValue(decodeJWTPayload(token)["session_id"]))
}
// ExtractAccountInfo returns the free (no-network) account view. grok sso has no
// email/exp claim, so identity falls back to the session id.
func ExtractAccountInfo(token string) map[string]any {
sid := SessionIDFromToken(token)
return map[string]any{
"email": emptyStringNil(sid),
"session_id": emptyStringNil(sid),
"expires_at": nil,
}
}
// FetchCreditsBalance reads the account's live credit balance via the billing
// gRPC-web endpoint GetGrokCreditsConfig (empty request). The response carries
// the remaining credits (field 1, a float32) and the weekly reset timestamp
// (field 5). A 401/403 maps to ErrAuth (the session is dead). Returns the
// normalized map the TokenService quota plumbing expects.
func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[string]any, error) {
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
if token == "" {
return unknownBalance("empty token"), nil
}
client, err := c.newDirectTLSClient()
if err != nil {
return nil, err
}
c.ensureChallenge(ctx, client, token)
// gRPC-web empty message frame: 1-byte flag + 4-byte length (both zero).
body := []byte{0, 0, 0, 0, 0}
req, err := http.NewRequest(http.MethodPost, apiBase+"/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig", strings.NewReader(string(body)))
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
c.applyHeaders(req, token, map[string]string{
"content-type": "application/grpc-web+proto",
"x-grpc-web": "1",
"accept": "application/grpc-web+proto",
})
resp, err := client.Do(req)
if err != nil {
return unknownBalance("network: " + err.Error()), nil
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return nil, ErrAuth
}
if resp.StatusCode != 200 {
return unknownBalance(fmt.Sprintf("http %d: %s", resp.StatusCode, clip(raw, 160))), nil
}
// GetGrokCreditsConfig field #1 is the credits USED this period (not remaining):
// an exhausted account reads 100, a fresh one reads ~0. Remaining = 100 - used.
// Field #5 carries the credits' own reset timestamp (weekly grant refill) —
// this is the 恢复时间 we surface, NOT the subscription's billing-period end.
used, resetUnix, ok := parseCreditsConfig(raw)
if !ok {
return unknownBalance("unparsable credits config"), nil
}
if used < 0 {
used = 0
}
if used > fullCredits {
used = fullCredits
}
remaining := fullCredits - used
// 恢复时间: the credits-config weekly reset (when the free grant refills).
reset := strings.TrimSpace(resetUnix)
return map[string]any{
"remaining": remaining,
"used": used,
"total": fullCredits,
"reset_after": emptyStringNil(reset),
"unknown": false,
"error": nil,
}, nil
}
// Subscription is the membership view parsed from GET /rest/subscriptions.
type Subscription struct {
Member bool // a subscription with status ACTIVE exists (INACTIVE entries don't count)
Tier string // e.g. SUBSCRIPTION_TIER_GROK_PRO ("" for free)
Status string // e.g. SUBSCRIPTION_STATUS_ACTIVE
BillingPeriodEnd string // RFC3339; when the plan renews / credits reset
FreeTrial bool // currently in a free-trial offer
}
// FetchSubscription reads GET /rest/subscriptions and reports the account's
// membership. Member is true only when an entry with SUBSCRIPTION_STATUS_ACTIVE
// exists: a lapsed membership keeps its entry but flips to
// SUBSCRIPTION_STATUS_INACTIVE, and an empty array means never subscribed —
// both read as Member=false (the entry's tier/status are still surfaced).
// A 401/403 maps to ErrAuth; other transport/HTTP errors are returned so callers
// can treat them as best-effort (they already have the credit balance).
func (c *Client) FetchSubscription(ctx context.Context, token string) (*Subscription, error) {
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
if token == "" {
return nil, ErrAuth
}
client, err := c.newDirectTLSClient()
if err != nil {
return nil, err
}
c.ensureChallenge(ctx, client, token)
req, err := http.NewRequest(http.MethodGet, apiBase+"/rest/subscriptions", nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
c.applyHeaders(req, token, nil)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return nil, ErrAuth
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("%w: subscriptions http %d", ErrTemporaryUpstream, resp.StatusCode)
}
var body struct {
Subscriptions []struct {
Tier string `json:"tier"`
Status string `json:"status"`
BillingPeriodEnd string `json:"billingPeriodEnd"`
ActiveOffer struct {
FreeTrial *struct {
TrialDays int `json:"trialDays"`
} `json:"freeTrial"`
} `json:"activeOffer"`
} `json:"subscriptions"`
}
if err := json.Unmarshal(raw, &body); err != nil {
return nil, fmt.Errorf("%w: subscriptions non-json", ErrTemporaryUpstream)
}
out := &Subscription{}
// Surface the ACTIVE subscription if any (falling back to the first entry
// for tier/status info), but only an ACTIVE one sets Member.
for i, s := range body.Subscriptions {
active := strings.EqualFold(s.Status, "SUBSCRIPTION_STATUS_ACTIVE")
if i == 0 || active {
out.Member = active
out.Tier = strings.TrimSpace(s.Tier)
out.Status = strings.TrimSpace(s.Status)
out.BillingPeriodEnd = strings.TrimSpace(s.BillingPeriodEnd)
out.FreeTrial = s.ActiveOffer.FreeTrial != nil
if active {
break
}
}
}
return out, nil
}
// FetchSession reads the account profile via GET /api/auth/session and returns
// (email, userID). A 401/403 means the sso session is dead → ErrAuth.
func (c *Client) FetchSession(ctx context.Context, token string) (email, userID string, err error) {
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
if token == "" {
return "", "", ErrAuth
}
client, err := c.newDirectTLSClient()
if err != nil {
return "", "", err
}
c.ensureChallenge(ctx, client, token)
req, err := http.NewRequest(http.MethodGet, apiBase+"/api/auth/session", nil)
if err != nil {
return "", "", err
}
req = req.WithContext(ctx)
c.applyHeaders(req, token, nil)
resp, err := client.Do(req)
if err != nil {
return "", "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return "", "", ErrAuth
}
if resp.StatusCode != 200 {
return "", "", fmt.Errorf("%w: session http %d", ErrTemporaryUpstream, resp.StatusCode)
}
var body struct {
Status string `json:"status"`
Session struct {
Email string `json:"email"`
UserID string `json:"userId"`
} `json:"session"`
}
if err := json.Unmarshal(raw, &body); err != nil {
return "", "", fmt.Errorf("%w: session non-json", ErrTemporaryUpstream)
}
// A dead sso cookie answers 200 {"status":"unauthenticated"} rather than 401.
if !strings.EqualFold(body.Status, "authenticated") {
return "", "", ErrAuth
}
return strings.TrimSpace(body.Session.Email), strings.TrimSpace(body.Session.UserID), nil
}
// grok's x-statsig-id is a per-session anti-bot token. Its 3-byte "F" tail is a
// browser fingerprint the server recomputes from the homepage seed + curve set,
// and the byte-indexing that derives it ROTATES on every grok web reship — so any
// hand-ported algorithm goes stale within a day (403 anti-bot). The durable path
// therefore runs grok's OWN signer in goja (statsig_engine.go): we fetch the seed
// + curves from the homepage and let grok's code do all the (rotating) indexing.
// statsigID prefers that engine; the hand-ported computeStatsigTail below and the
// static env-overridable defaults are only a last-resort fallback. See the package
// doc and statsig_engine.go for the full picture.
// statsigEpoch is the challenge epoch (2023-05-01 00:00 UTC).
const (
statsigEpoch = 1682924400
defaultStatsigHeader = "00a1adb5012bd32f844f4426c62680d91c6129361eb9459a759710e179a888a99b21678e1f0b1e8952de6a6b3ca019f74b"
defaultStatsigSuffix = "obfiowerehiringd244100f5c28f5c28f5c047ae147ae147b047ae147ae147b0f5c28f5c28f5c00"
defaultStatsigTrailer = 3
statsigSaltPrefix = "obfiowerehiring"
statsigAnimDuration = 4096
statsigTTL = 5 * time.Minute
)
var (
statsigHeader = resolveStatsigHeader()
statsigSuffix = envOr("GROK_STATSIG_SUFFIX", defaultStatsigSuffix)
statsigTrailer = resolveStatsigTrailer()
statsigMetaRe = regexp.MustCompile(`name="grok[^"]*verification"[^>]*content="([^"]+)"`)
statsigMu sync.Mutex
statsigCache = map[string]statsigChallenge{} // keyed by sso token
)
// statsigChallenge is a resolved, self-consistent (header, salt) pair for one
// grok session, derived from the homepage seed + curves.
type statsigChallenge struct {
header []byte
suffix string
trailer byte
fetchedAt time.Time
// Inputs for the durable goja signer (statsig_js.go): the raw seed
// content and the curves JSON. Empty when parsing failed (engine then skipped).
seedB64 string
curvesJSON string
}
// statsigCurve is one entry of the per-load curve set injected via the Next.js
// RSC stream; the server uses it (with the seed) to recompute F.
type statsigCurve struct {
Color []int `json:"color"`
Deg int `json:"deg"`
Bezier []int `json:"bezier"`
}
func resolveStatsigHeader() []byte {
h := envOr("GROK_STATSIG_HEADER_HEX", defaultStatsigHeader)
b, err := hex.DecodeString(h)
if err != nil || len(b) != 49 {
b, _ = hex.DecodeString(defaultStatsigHeader)
}
return b
}
func resolveStatsigTrailer() byte {
if v := strings.TrimSpace(os.Getenv("GROK_STATSIG_TRAILER")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 && n <= 255 {
return byte(n)
}
}
return defaultStatsigTrailer
}
func envOr(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
// ensureChallenge refreshes the cached (header, salt) for the session if missing
// or stale. Any failure is non-fatal: statsigID then falls back to the static
// defaults. An explicit env override disables dynamic fetching entirely.
func (c *Client) ensureChallenge(ctx context.Context, client tlsclient.HttpClient, token string) {
if token == "" || client == nil {
return
}
if os.Getenv("GROK_STATSIG_HEADER_HEX") != "" || os.Getenv("GROK_STATSIG_SUFFIX") != "" {
return
}
statsigMu.Lock()
cur, ok := statsigCache[token]
fresh := ok && time.Since(cur.fetchedAt) < statsigTTL
statsigMu.Unlock()
if fresh {
return
}
ch, err := fetchStatsigChallenge(ctx, client)
if err != nil {
// Silent fallback to static defaults is the #1 cause of a recurring
// "403 anti-bot": the homepage structure changed and we never notice.
// Surface it so the failure mode (fetch/parse broke vs. offsets rotated)
// is diagnosable from logs instead of guessing.
log.Printf("grok statsig: self-heal failed, using stale static defaults (403 likely): %v", err)
return
}
log.Printf("grok statsig: self-heal ok header[:6]=%x suffix=%s", ch.header[:6], ch.suffix)
statsigMu.Lock()
statsigCache[token] = ch
statsigMu.Unlock()
}
// fetchStatsigChallenge does a browser-free homepage GET and derives a
// self-consistent (header, salt) pair: header = 0x00 + seed, salt = prefix + F.
//
// The request is ANONYMOUS (no sso cookie): grok redirects a request carrying an
// sso cookie to the sign-in page, whose HTML omits the anti-bot seed and
// the curves array — so a cookied fetch silently loses the challenge inputs and
// statsigID falls back to the stale static recipe (403 anti-bot). The seed/curves
// are per-build and account-independent (the seed is transmitted inside the
// x-statsig-id header the server re-verifies), so the anonymous landing page is
// the reliable source for every session.
func fetchStatsigChallenge(ctx context.Context, client tlsclient.HttpClient) (statsigChallenge, error) {
req, err := http.NewRequest(http.MethodGet, apiBase+"/", nil)
if err != nil {
return statsigChallenge{}, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
"accept-language": {"en-US,en;q=0.9"},
"user-agent": {userAgent},
http.HeaderOrderKey: {"accept", "accept-language", "user-agent"},
}
resp, err := client.Do(req)
if err != nil {
return statsigChallenge{}, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return statsigChallenge{}, err
}
html := string(raw)
mm := statsigMetaRe.FindStringSubmatch(html)
if mm == nil {
return statsigChallenge{}, errors.New("statsig: seed meta not found")
}
curves, err := parseStatsigCurves(html)
if err != nil {
return statsigChallenge{}, err
}
curvesJSON, err := json.Marshal(curves)
if err != nil {
return statsigChallenge{}, err
}
// Primary path is the goja signer (statsig_js.go); set it up / refresh it for
// this build. Static (header, salt) below is only a last-resort fallback.
ensureEngine(ctx, client, html)
ch := statsigChallenge{
header: statsigHeader,
suffix: statsigSuffix,
trailer: statsigTrailer,
fetchedAt: time.Now(),
seedB64: mm[1],
curvesJSON: string(curvesJSON),
}
// NOTE: the old hand-ported per-session derivation (0x00+seed header +
// computeStatsigTail salt) is intentionally NOT applied here. Verified
// 2026-07-13 against /rest/app-chat/conversations/new: that dynamic token is
// rejected ("Request rejected by anti-bot rules.", 403), while the static
// (header, salt) defaults are accepted (200) — so we keep the static values
// as the fallback. When the goja signer (ensureEngine above) locates and
// verifies grok's own chunk it still takes over via seedB64/curvesJSON in
// statsigID; computeStatsigTail is retained only for reference/tests.
return ch, nil
}
func decodeStatsigSeed(s string) ([]byte, error) {
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 48 {
return b, nil
}
b, err := base64.RawStdEncoding.DecodeString(strings.TrimRight(s, "="))
if err != nil {
return nil, fmt.Errorf("statsig: seed decode: %w", err)
}
if len(b) != 48 {
return nil, fmt.Errorf("statsig: seed len %d", len(b))
}
return b, nil
}
// parseStatsigCurves extracts the [[{color,deg,bezier}...]...] array from the
// RSC-escaped homepage HTML (the one immediately followed by color/bezier keys).
func parseStatsigCurves(html string) ([][]statsigCurve, error) {
marker := -1
for from := 0; ; {
i := strings.Index(html[from:], "curves")
if i < 0 {
break
}
i += from
end := i + 160
if end > len(html) {
end = len(html)
}
w := html[i:end]
if strings.Contains(w, "color") && strings.Contains(w, "bezier") {
marker = i
break
}
from = i + 6
}
if marker < 0 {
return nil, errors.New("statsig: curves not found")
}
rel := strings.IndexByte(html[marker:], '[')
if rel < 0 {
return nil, errors.New("statsig: curves array start not found")
}
start := marker + rel
depth, stop := 0, -1
for k := start; k < len(html); k++ {
switch html[k] {
case '[':
depth++
case ']':
depth--
if depth == 0 {
stop = k + 1
}
}
if stop > 0 {
break
}
}
if stop < 0 {
return nil, errors.New("statsig: curves array end not found")
}
sub := strings.ReplaceAll(html[start:stop], `\`, "")
var out [][]statsigCurve
if err := json.Unmarshal([]byte(sub), &out); err != nil {
return nil, fmt.Errorf("statsig: curves json: %w", err)
}
return out, nil
}
// computeStatsigTail reproduces the browser's 3-byte color + 6-number transform
// matrix "F" tail: it selects a curve by the seed, samples the curve's keyframe
// animation (color lerp + rotate) at a seed-derived paused currentTime, and
// serializes getComputedStyle(color)+getComputedStyle(transform) exactly as the
// signer does (each number -> Number(v.toFixed(2)).toString(16), '.'/'-' stripped).
func computeStatsigTail(seed []byte, curves [][]statsigCurve) (string, error) {
if len(seed) < 46 {
return "", errors.New("statsig: short seed")
}
if len(curves) == 0 {
return "", errors.New("statsig: no curves")
}
group := int(seed[5]) % len(curves)
if len(curves[group]) == 0 {
return "", errors.New("statsig: empty curve group")
}
idx := int(seed[45]) % len(curves[group])
cv := curves[group][idx]
if len(cv.Color) < 6 || len(cv.Bezier) < 4 {
return "", errors.New("statsig: malformed curve")
}
n := (int(seed[1]) % 16) * (int(seed[6]) % 16) * (int(seed[13]) % 16)
currentTime := jsRound(float64(n)/10) * 10
progress := float64(currentTime) / statsigAnimDuration
x1 := toFixed2(float64(cv.Bezier[0]) / 255)
y1 := toFixed2(float64(cv.Bezier[1])*2/255 - 1)
x2 := toFixed2(float64(cv.Bezier[2]) / 255)
y2 := toFixed2(float64(cv.Bezier[3])*2/255 - 1)
eased := cubicBezierEase(x1, y1, x2, y2, progress)
nums := make([]float64, 0, 9)
for k := 0; k < 3; k++ {
v := jsRound(float64(cv.Color[k]) + (float64(cv.Color[k+3])-float64(cv.Color[k]))*eased)
if v < 0 {
v = 0
}
if v > 255 {
v = 255
}
nums = append(nums, float64(v))
}
theta := int(math.Floor(float64(cv.Deg)*300/255 + 60))
rad := float64(theta) * eased * math.Pi / 180
cos, sin := math.Cos(rad), math.Sin(rad)
nums = append(nums, cos, sin, -sin, cos, 0, 0)
var b strings.Builder
for _, v := range nums {
b.WriteString(jsHex(v))
}
out := strings.NewReplacer(".", "", "-", "").Replace(b.String())
return out, nil
}
// jsRound matches JavaScript Math.round (round half up toward +Inf).
func jsRound(x float64) int {
return int(math.Floor(x + 0.5))
}
// toFixed2 matches JavaScript Number(v.toFixed(2)).
func toFixed2(v float64) float64 {
f, _ := strconv.ParseFloat(strconv.FormatFloat(v, 'f', 2, 64), 64)
return f
}
// jsHex matches JavaScript Number(v.toFixed(2)).toString(16).
func jsHex(v float64) string {
v = toFixed2(v)
neg := ""
if v < 0 {
neg = "-"
v = -v
}
ip := int64(math.Floor(v))
frac := v - float64(ip)
s := neg + strconv.FormatInt(ip, 16)
if frac == 0 {
return s
}
const digits = "0123456789abcdef"
var b strings.Builder
b.WriteString(s)
b.WriteByte('.')
for i := 0; i < 20 && frac != 0; i++ {
frac *= 16
d := int(frac)
b.WriteByte(digits[d])
frac -= float64(d)
}
return b.String()
}
// cubicBezierEase evaluates a CSS cubic-bezier(x1,y1,x2,y2) easing at input
// fraction p: solve X(t)=p for t (bisection), then return Y(t).
func cubicBezierEase(x1, y1, x2, y2, p float64) float64 {
bez := func(t, a, b float64) float64 {
mt := 1 - t
return 3*a*mt*mt*t + 3*b*mt*t*t + t*t*t
}
lo, hi := 0.0, 1.0
for i := 0; i < 100; i++ {
mid := (lo + hi) / 2
if bez(mid, x1, x2) < p {
lo = mid
} else {
hi = mid
}
}
return bez((lo+hi)/2, y1, y2)
}
// statsigID reproduces grok.com's x-statsig-id anti-bot token for a request. The
// token binds to the request METHOD and URL path and to a coarse timestamp, so
// it must be regenerated per request. See the package doc for the layout. It uses
// the session's self-healed (header, salt) when available, else static defaults.
func statsigID(path, method, token string) string {
header, suffix, trailer := statsigHeader, statsigSuffix, statsigTrailer
statsigMu.Lock()
ch, ok := statsigCache[token]
statsigMu.Unlock()
if ok {
header, suffix, trailer = ch.header, ch.suffix, ch.trailer
// Primary: run grok's own signer in goja (durable across reships). Falls
// through to the static computation below on any failure.
if ch.seedB64 != "" && ch.curvesJSON != "" {
if id, err := signWithEngine(ch.seedB64, ch.curvesJSON, path, method); err == nil {
return id
} else if !errors.Is(err, errEngineNotReady) {
log.Printf("grok statsig: js signer failed, using static fallback: %v", err)
}
}
}
counter := uint32(time.Now().Unix() - statsigEpoch)
sig := fmt.Sprintf("%s!%s!%d%s", method, path, counter, suffix)
hash := sha256.Sum256([]byte(sig))
raw := make([]byte, 0, 70)
raw = append(raw, header...)
raw = binary.LittleEndian.AppendUint32(raw, counter)
raw = append(raw, hash[:16]...)
raw = append(raw, trailer)
key := byte(rand.IntN(256))
for i := range raw {
raw[i] ^= key
}
return base64.RawStdEncoding.EncodeToString(raw)
}
// applyHeaders sets the browser-like header set + sso cookie + spoofed statsig id.
// extra overrides/adds per-request headers (e.g. content-type).
func (c *Client) applyHeaders(req *http.Request, token string, extra map[string]string) {
h := http.Header{
"accept": {"*/*"},
"accept-language": {"en-US,en;q=0.9"},
"content-type": {"application/json"},
"origin": {origin},
"referer": {origin + "/"},
"user-agent": {userAgent},
"x-statsig-id": {statsigID(req.URL.Path, req.Method, token)},
"x-xai-request-id": {uuid.NewString()},
"sec-ch-ua": {`"Chromium";v="133", "Not(A:Brand";v="99"`},
"sec-ch-ua-mobile": {"?0"},
"sec-ch-ua-platform": {`"Windows"`},
"sec-fetch-dest": {"empty"},
"sec-fetch-mode": {"cors"},
"sec-fetch-site": {"same-origin"},
"cookie": {"sso=" + token + "; sso-rw=" + token},
}
for k, v := range extra {
h[k] = []string{v}
}
h[http.HeaderOrderKey] = []string{
"accept", "accept-language", "content-type", "origin", "referer",
"user-agent", "x-statsig-id", "x-xai-request-id", "x-grpc-web",
"sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "cookie",
}
req.Header = h
}
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(true) }
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
// reference-frame upload and result (video) download; only the generate submit
// (/rest/app-chat/conversations/new) uses the proxy.
func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) }
func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) {
options := []tlsclient.HttpClientOption{
// Video generation streams inline until progress=100; a 15s clip can take
// several minutes, so allow up to 10m (caller's genCtx caps at 12m).
tlsclient.WithTimeoutSeconds(600),
tlsclient.WithClientProfile(profiles.Chrome_133),
tlsclient.WithRandomTLSExtensionOrder(),
}
if useProxy && c.proxy != "" {
options = append(options, tlsclient.WithProxyUrl(c.proxy))
}
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
}
// --- gRPC-web / protobuf decoding for GetGrokCreditsConfig ---
// parseCreditsConfig extracts (remaining credits, reset RFC3339-ish unix string)
// from the gRPC-web framed protobuf. Layout (reverse-engineered):
//
// frame: 1-byte flag + 4-byte big-endian length + payload
// payload: field 1 (message) {
// field 1: float32 -> remaining credits
// field 5: message { field 1: varint -> reset unix seconds }
// }
func parseCreditsConfig(buf []byte) (remaining int, resetUnix string, ok bool) {
for len(buf) >= 5 {
flag := buf[0]
ln := int(buf[1])<<24 | int(buf[2])<<16 | int(buf[3])<<8 | int(buf[4])
buf = buf[5:]
if ln > len(buf) {
break
}
payload := buf[:ln]
buf = buf[ln:]
if flag&0x80 != 0 { // trailer frame (grpc-status), skip
continue
}
// payload: expect field 1 (wire type 2) wrapping the config message.
fn, wt, val, rest, good := readField(payload)
if !good || fn != 1 || wt != 2 {
continue
}
rem, reset, found := scanConfigMessage(val)
_ = rest
if found {
return rem, reset, true
}
}
return 0, "", false
}
func scanConfigMessage(msg []byte) (used int, resetUnix string, ok bool) {
var usedF float32
seen := false
for len(msg) > 0 {
fn, wt, val, rest, good := readField(msg)
if !good {
break
}
msg = rest
seen = true
switch {
case fn == 1 && wt == 5: // float32 credits USED this period
usedF = float32FromLE(val)
case fn == 5 && wt == 2: // reset timestamp message { #1 varint=seconds }
if sec, sok := firstVarint(val); sok {
resetUnix = strconv.FormatInt(sec, 10)
}
}
}
// A valid config message may OMIT field #1 when used == 0 (proto3 drops zero
// scalars) — a full-quota account. So as long as the message had any field,
// treat it as parsed with used defaulting to 0 (= 100 remaining).
return int(usedF), resetUnix, seen
}
// readField reads one protobuf field: returns (fieldNum, wireType, value, rest, ok).
// For wt 2 value is the length-delimited bytes; wt 5 the 4 LE bytes; wt 0 the
// raw varint bytes; wt 1 the 8 bytes.
func readField(b []byte) (fn int, wt int, val []byte, rest []byte, ok bool) {
tag, n := readVarint(b)
if n == 0 {
return 0, 0, nil, b, false
}
b = b[n:]
fn = int(tag >> 3)
wt = int(tag & 7)
switch wt {
case 0:
_, m := readVarint(b)
if m == 0 {
return 0, 0, nil, b, false
}
return fn, wt, b[:m], b[m:], true
case 1:
if len(b) < 8 {
return 0, 0, nil, b, false
}
return fn, wt, b[:8], b[8:], true
case 2:
ln, m := readVarint(b)
if m == 0 || int(ln) > len(b)-m {
return 0, 0, nil, b, false
}
return fn, wt, b[m : m+int(ln)], b[m+int(ln):], true
case 5:
if len(b) < 4 {
return 0, 0, nil, b, false
}
return fn, wt, b[:4], b[4:], true
default:
return 0, 0, nil, b, false
}
}
func firstVarint(b []byte) (int64, bool) {
fn, wt, val, _, ok := readField(b)
if !ok || fn != 1 || wt != 0 {
return 0, false
}
v, _ := readVarint(val)
return int64(v), true
}
func readVarint(b []byte) (uint64, int) {
var v uint64
var s uint
for i := 0; i < len(b); i++ {
v |= uint64(b[i]&0x7f) << s
if b[i]&0x80 == 0 {
return v, i + 1
}
s += 7
}
return 0, 0
}
func float32FromLE(b []byte) float32 {
if len(b) < 4 {
return 0
}
bits := uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24
return math.Float32frombits(bits)
}
// --- small helpers (mirror the other provider clients) ---
func decodeJWTPayload(token string) map[string]any {
parts := strings.Split(strings.TrimSpace(strings.TrimPrefix(token, "Bearer ")), ".")
if len(parts) < 2 {
return map[string]any{}
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return map[string]any{}
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
return map[string]any{}
}
return out
}
func stringValue(v any) string {
switch x := v.(type) {
case string:
return x
case nil:
return ""
default:
b, _ := json.Marshal(x)
return strings.TrimSpace(string(b))
}
}
func emptyStringNil(v string) any {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
return v
}
func unknownBalance(reason string) map[string]any {
return map[string]any{
"remaining": nil, "used": nil, "total": nil,
"unknown": true, "error": reason,
}
}
func clip(b []byte, n int) string {
s := strings.TrimSpace(string(b))
if len(s) > n {
return s[:n]
}
return s
}