feat: add Grok (grok.com) video provider; runway nano-banana image; unify runway/grok pool policy
- grok provider: imagine text/image-to-video (media.post.create → conversations/new), GetGrokCreditsConfig credit query (remaining = 100 - used) + weekly reset, spoofed x-statsig-id (no Cloudflare clearance needed), /api/auth/session email lookup, 6 reference images, 10 concurrent jobs/account, no token refresh (dead = dead) - runway: nano-banana-2 image flow (Nano Banana 2); drop pre-deduct + post-success reconcile; out-of-credits/403 → dead (no revive); 10-ratio support - imagine: drop post-success credit reconcile (consistent with krea) - account gate: per-account N-concurrency (grok=10, others=1) - admin: provider health lists all 7 providers; frontend import auto-detects Grok SSO - docs: README (CN/EN) updated to 7 providers + Grok Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
// 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; the web app's
|
||||
// value is just a base64-encoded fake JS TypeError string, which the upstream
|
||||
// accepts — so we spoof it the same way (no Cloudflare clearance needed). Uses
|
||||
// tls-client so the JA3/JA4 fingerprint matches Chrome.
|
||||
package grok
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
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.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 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.
|
||||
used, reset, ok := parseCreditsConfig(raw)
|
||||
if !ok {
|
||||
return unknownBalance("unparsable credits config"), nil
|
||||
}
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
if used > fullCredits {
|
||||
used = fullCredits
|
||||
}
|
||||
remaining := fullCredits - used
|
||||
return map[string]any{
|
||||
"remaining": remaining,
|
||||
"used": used,
|
||||
"total": fullCredits,
|
||||
"reset_after": emptyStringNil(reset),
|
||||
"unknown": false,
|
||||
"error": nil,
|
||||
}, 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.newTLSClient()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
return strings.TrimSpace(body.Session.Email), strings.TrimSpace(body.Session.UserID), nil
|
||||
}
|
||||
|
||||
// statsigID mirrors grok2api's _statsig_id: base64 of a fake JS TypeError string.
|
||||
// The upstream's anti-bot check accepts this spoofed value.
|
||||
func statsigID() string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, 5)
|
||||
for i := range b {
|
||||
b[i] = charset[rand.IntN(len(charset))]
|
||||
}
|
||||
msg := fmt.Sprintf("x1:TypeError: Cannot read properties of null (reading 'children['%s']')", string(b))
|
||||
return base64.StdEncoding.EncodeToString([]byte(msg))
|
||||
}
|
||||
|
||||
// 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()},
|
||||
"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) {
|
||||
options := []tlsclient.HttpClientOption{
|
||||
tlsclient.WithTimeoutSeconds(120),
|
||||
tlsclient.WithClientProfile(profiles.Chrome_133),
|
||||
tlsclient.WithRandomTLSExtensionOrder(),
|
||||
}
|
||||
if 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) (remaining int, resetUnix string, ok bool) {
|
||||
var remF float32
|
||||
haveRem := false
|
||||
for len(msg) > 0 {
|
||||
fn, wt, val, rest, good := readField(msg)
|
||||
if !good {
|
||||
break
|
||||
}
|
||||
msg = rest
|
||||
switch {
|
||||
case fn == 1 && wt == 5: // float32 remaining credits
|
||||
remF = float32FromLE(val)
|
||||
haveRem = true
|
||||
case fn == 5 && wt == 2: // reset timestamp message { #1 varint=seconds }
|
||||
if sec, sok := firstVarint(val); sok {
|
||||
resetUnix = strconv.FormatInt(sec, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
if haveRem {
|
||||
return int(remF), resetUnix, true
|
||||
}
|
||||
return 0, resetUnix, false
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user