Files
image2api/backend/internal/provider/grok/console.go
T
chiyi ebcf3c5779 feat(grok): 视频改走 Grok Console 并新增图片模型
- grok 视频/图片统一走 console.x.ai:sso 换 DPoP 短期 token,绕开 grok.com 的 statsig 反爬

- 新增 grok-image:不带参考图走 /v1/images/generations,带图自动走 quality 上游 + /v1/images/edits

- grok 额度改本地写死 图5/视频2,成功各扣一次,两份归零直接判死,无恢复时间

- 视频渲染预算统一 20 分钟(adobe/runway/grok/custom)

- 删除参考图人脸打码(facemask + onnxruntime 依赖 + 前台开关)
2026-08-08 11:15:42 +08:00

610 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package grok
// Grok Console (console.x.ai) is xAI's own product console. It serves the same
// grok-imagine media models as grok.com, but over a plain JSON API that is NOT
// anti-bot gated: no x-statsig-id, no media-post + streaming-conversation dance.
// Auth reuses the very same website "sso" cookie — POST /v1/dpop/token exchanges
// it for a short-lived access token bound to a locally generated P-256 key, and
// every call then carries "Authorization: DPoP <token>" plus a per-request ES256
// DPoP proof (RFC 9449) over (method, url, token hash).
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"strings"
"sync"
"time"
http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/google/uuid"
)
const (
consoleBase = "https://console.x.ai"
consoleOrigin = "https://console.x.ai"
// Upstream model ids of the Console media catalog. The -quality image model
// is what the reference-image (edit) path uses.
ConsoleVideoModel = "grok-imagine-video"
ConsoleImageModel = "grok-imagine-image"
ConsoleImageQualityModel = "grok-imagine-image-quality"
// consoleTokenSkew refreshes a DPoP token slightly before it expires.
consoleTokenSkew = 20 * time.Second
// consoleMaxEditImages is the upstream cap for image-to-image references
// (video takes a single 首图).
consoleMaxEditImages = 3
consoleVideoPollEvery = 2 * time.Second
consoleVideoDeadline = 20 * time.Minute
)
// consoleSession is one minted DPoP token plus the key it is bound to. skew is
// (server time local time) learned from the mint response Date header; the
// proof iat is shifted by it so a drifting host clock doesn't fail every proof.
type consoleSession struct {
accessToken string
key *ecdsa.PrivateKey
jwk consoleJWK
expiresAt time.Time
skew time.Duration
}
type consoleJWK struct {
Crv string `json:"crv"`
Kty string `json:"kty"`
X string `json:"x"`
Y string `json:"y"`
}
var (
consoleMu sync.Mutex
consoleSessions = map[string]consoleSession{} // keyed by sso token hash
)
// GenerateConsoleVideo runs Console's video pipeline: POST /v1/videos/generations
// returns a request_id, GET /v1/videos/{id} is polled until the clip is rendered
// and reports its vidgen.x.ai URL. frames is optional (image-to-video, one 首图
// max) and is inlined as a data URL — Console takes the image in the request, so
// there is no separate upload step. When downloadResult is false, returns nil
// bytes and the artifact URL in meta["video_url"]; otherwise downloads the mp4.
func (c *Client) GenerateConsoleVideo(ctx context.Context, token, prompt, aspectRatio, resolution string, seconds int, frames [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
if token == "" {
return nil, nil, ErrAuth
}
prompt = strings.TrimSpace(prompt)
if prompt == "" && len(frames) == 0 {
return nil, nil, fmt.Errorf("grok console: prompt required")
}
if seconds < 1 || seconds > 15 {
seconds = 10
}
submitClient, err := c.newTLSClient()
if err != nil {
return nil, nil, err
}
directClient, err := c.newDirectTLSClient()
if err != nil {
return nil, nil, err
}
payload := map[string]any{"model": ConsoleVideoModel, "duration": seconds}
if prompt != "" {
payload["prompt"] = prompt
}
if ratio := strings.TrimSpace(aspectRatio); ratio != "" {
payload["aspect_ratio"] = ratio
}
if res := consoleVideoResolution(resolution); res != "" {
payload["resolution"] = res
}
for _, f := range frames {
if len(f) == 0 {
continue
}
payload["image"] = map[string]any{"url": dataURL(f)}
break // 上游只吃 1 张首图
}
created, err := c.consoleJSON(ctx, submitClient, token, http.MethodPost, "/v1/videos/generations", payload)
if err != nil {
return nil, nil, err
}
requestID := strings.TrimSpace(stringValue(created["request_id"]))
if requestID == "" {
return nil, nil, fmt.Errorf("%w: video create missing request_id", ErrTemporaryUpstream)
}
deadline := time.Now().Add(consoleVideoDeadline)
videoURL := ""
for {
status, pollErr := c.consoleJSON(ctx, submitClient, token, http.MethodGet, "/v1/videos/"+url.PathEscape(requestID), nil)
if pollErr != nil {
return nil, nil, pollErr
}
url, done, sErr := parseConsoleVideoStatus(status)
if sErr != nil {
return nil, nil, sErr
}
if done {
videoURL = url
break
}
if time.Now().After(deadline) {
return nil, nil, fmt.Errorf("%w: video render did not complete in time", ErrTemporaryUpstream)
}
select {
case <-ctx.Done():
return nil, nil, ctx.Err()
case <-time.After(consoleVideoPollEvery):
}
}
meta := map[string]any{
"provider": "grok",
"request_id": requestID,
"video_url": videoURL,
}
if !downloadResult {
return nil, meta, nil
}
data, err := c.download(ctx, directClient, token, videoURL)
if err != nil {
return nil, nil, err
}
return data, meta, nil
}
// GenerateConsoleImage runs Console's image pipeline: /v1/images/generations on
// grok-imagine-image for text-to-image, and /v1/images/edits on the
// grok-imagine-image-quality model (up to 3 inlined reference images) as soon as
// references are supplied — 带参考图自动走 quality 那个上游模型. Returns the
// artifact URL in meta["image_url"]; the bytes are downloaded unless urlOnly.
func (c *Client) GenerateConsoleImage(ctx context.Context, token, prompt, aspectRatio, resolution string, refs [][]byte, urlOnly bool) ([]byte, map[string]any, error) {
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
if token == "" {
return nil, nil, ErrAuth
}
if strings.TrimSpace(prompt) == "" {
return nil, nil, fmt.Errorf("grok console: prompt required")
}
submitClient, err := c.newTLSClient()
if err != nil {
return nil, nil, err
}
directClient, err := c.newDirectTLSClient()
if err != nil {
return nil, nil, err
}
payload := map[string]any{
"model": ConsoleImageModel, "prompt": strings.TrimSpace(prompt),
"n": 1, "response_format": "url",
}
if ratio := consoleImageAspectRatio(aspectRatio); ratio != "" {
payload["aspect_ratio"] = ratio
}
if res := consoleImageResolution(resolution); res != "" {
payload["resolution"] = res
}
path := "/v1/images/generations"
var images []map[string]any
for _, r := range refs {
if len(r) == 0 || len(images) >= consoleMaxEditImages {
continue
}
images = append(images, map[string]any{"type": "image_url", "url": dataURL(r)})
}
if len(images) > 0 {
path = "/v1/images/edits"
payload["model"] = ConsoleImageQualityModel
if len(images) == 1 {
payload["image"] = images[0]
} else {
payload["images"] = images
}
}
res, err := c.consoleJSON(ctx, submitClient, token, http.MethodPost, path, payload)
if err != nil {
return nil, nil, err
}
items, _ := res["data"].([]any)
imageURL := ""
for _, item := range items {
entry, _ := item.(map[string]any)
if entry == nil {
continue
}
if v := strings.TrimSpace(stringValue(entry["url"])); v != "" {
imageURL = v
break
}
}
if imageURL == "" {
return nil, nil, fmt.Errorf("%w: image response missing url", ErrTemporaryUpstream)
}
meta := map[string]any{"provider": "grok", "image_url": imageURL}
if urlOnly {
return nil, meta, nil
}
data, err := c.download(ctx, directClient, token, imageURL)
if err != nil {
return nil, nil, err
}
return data, meta, nil
}
// consoleJSON does an authed Console JSON call and parses the response object. A
// 401 means the DPoP token went stale (or the sso died): the cached session is
// dropped and the call retried once with a freshly minted token.
func (c *Client) consoleJSON(ctx context.Context, client tlsclient.HttpClient, token, method, path string, body any) (map[string]any, error) {
var payload []byte
if body != nil {
encoded, err := json.Marshal(body)
if err != nil {
return nil, err
}
payload = encoded
}
for attempt := 0; attempt < 2; attempt++ {
session, err := c.consoleSession(ctx, client, token)
if err != nil {
return nil, err
}
var reader io.Reader
if len(payload) > 0 {
reader = strings.NewReader(string(payload))
}
req, err := http.NewRequest(method, consoleBase+path, reader)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyConsoleHeaders(req, token, session); err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
raw, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == 401 && attempt == 0 {
consoleForget(token, session.accessToken)
continue
}
if readErr != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, readErr)
}
if e := consoleMapStatus(path, resp.StatusCode, raw); e != nil {
return nil, e
}
out := map[string]any{}
if len(raw) == 0 {
return out, nil
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("%w: %s non-json: %s", ErrTemporaryUpstream, path, clip(raw, 120))
}
return out, nil
}
return nil, fmt.Errorf("%w: console retry state invalid", ErrTemporaryUpstream)
}
// consoleSession returns a live DPoP session for the sso token, minting one when
// the cache is empty or the token is about to expire.
func (c *Client) consoleSession(ctx context.Context, client tlsclient.HttpClient, token string) (consoleSession, error) {
key := consoleCacheKey(token)
consoleMu.Lock()
cached, ok := consoleSessions[key]
consoleMu.Unlock()
if ok && cached.expiresAt.After(time.Now().Add(consoleTokenSkew)) {
return cached, nil
}
session, err := c.mintConsoleSession(ctx, client, token)
if err != nil {
return consoleSession{}, err
}
consoleMu.Lock()
consoleSessions[key] = session
consoleMu.Unlock()
return session, nil
}
// consoleForget drops a cached session (only if it is still the one that failed,
// so a concurrent refresh isn't thrown away).
func consoleForget(token, accessToken string) {
key := consoleCacheKey(token)
consoleMu.Lock()
if current, ok := consoleSessions[key]; ok && current.accessToken == accessToken {
delete(consoleSessions, key)
}
consoleMu.Unlock()
}
func consoleCacheKey(token string) string {
sum := sha256.Sum256([]byte(token))
return fmt.Sprintf("%x", sum[:])
}
// mintConsoleSession posts the freshly generated public JWK to /v1/dpop/token
// with the sso cookie and keeps the returned DPoP-bound access token.
func (c *Client) mintConsoleSession(ctx context.Context, client tlsclient.HttpClient, token string) (consoleSession, error) {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return consoleSession{}, err
}
jwk := consoleJWK{
Crv: "P-256", Kty: "EC",
X: base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.FillBytes(make([]byte, 32))),
Y: base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.FillBytes(make([]byte, 32))),
}
body, err := json.Marshal(map[string]any{"jwk": jwk})
if err != nil {
return consoleSession{}, err
}
req, err := http.NewRequest(http.MethodPost, consoleBase+"/v1/dpop/token", strings.NewReader(string(body)))
if err != nil {
return consoleSession{}, err
}
req = req.WithContext(ctx)
c.applyConsoleBrowserHeaders(req, token, nil)
before := time.Now().UTC()
resp, err := client.Do(req)
if err != nil {
return consoleSession{}, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
after := time.Now().UTC()
raw, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return consoleSession{}, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
if e := consoleMapStatus("/v1/dpop/token", resp.StatusCode, raw); e != nil {
return consoleSession{}, e
}
var out struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return consoleSession{}, fmt.Errorf("%w: dpop token non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
}
if strings.TrimSpace(out.AccessToken) == "" || !strings.EqualFold(strings.TrimSpace(out.TokenType), "DPoP") {
return consoleSession{}, fmt.Errorf("%w: dpop token response invalid", ErrTemporaryUpstream)
}
if out.ExpiresIn <= 0 || out.ExpiresIn > 3600 {
out.ExpiresIn = 300
}
return consoleSession{
accessToken: out.AccessToken,
key: privateKey,
jwk: jwk,
expiresAt: time.Now().Add(time.Duration(out.ExpiresIn) * time.Second),
skew: consoleClockSkew(resp.Header.Get("Date"), before, after),
}, nil
}
// applyConsoleHeaders sets the browser-like header set plus the DPoP
// Authorization header and its per-request proof.
func (c *Client) applyConsoleHeaders(req *http.Request, token string, session consoleSession) error {
proof, err := consoleProof(session, req)
if err != nil {
return err
}
c.applyConsoleBrowserHeaders(req, token, map[string]string{
"authorization": "DPoP " + session.accessToken,
"dpop": proof,
})
return nil
}
func (c *Client) applyConsoleBrowserHeaders(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": {consoleOrigin},
"referer": {consoleOrigin + "/"},
"user-agent": {userAgent},
"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", "authorization", "content-type", "dpop",
"origin", "referer", "user-agent", "sec-ch-ua", "sec-ch-ua-mobile",
"sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "cookie",
}
req.Header = h
}
// consoleProof builds the ES256 DPoP proof JWT for one request: it binds the
// method + URL (without query) and the access token hash (ath), signed with the
// key the token was issued against.
func consoleProof(session consoleSession, req *http.Request) (string, error) {
if session.key == nil || strings.TrimSpace(session.accessToken) == "" || req == nil || req.URL == nil {
return "", errors.New("grok console: dpop session invalid")
}
path := req.URL.EscapedPath()
if path == "" {
path = "/"
}
ath := sha256.Sum256([]byte(session.accessToken))
header := map[string]any{"typ": "dpop+jwt", "alg": "ES256", "jwk": session.jwk}
claims := map[string]any{
"jti": uuid.NewString(),
"htm": strings.ToUpper(req.Method),
"htu": req.URL.Scheme + "://" + req.URL.Host + path,
"iat": time.Now().Add(session.skew).UTC().Unix(),
"ath": base64.RawURLEncoding.EncodeToString(ath[:]),
}
headerJSON, err := json.Marshal(header)
if err != nil {
return "", err
}
claimsJSON, err := json.Marshal(claims)
if err != nil {
return "", err
}
signingInput := base64.RawURLEncoding.EncodeToString(headerJSON) + "." + base64.RawURLEncoding.EncodeToString(claimsJSON)
digest := sha256.Sum256([]byte(signingInput))
r, s, err := ecdsa.Sign(rand.Reader, session.key, digest[:])
if err != nil {
return "", err
}
signature := make([]byte, 64)
r.FillBytes(signature[:32])
s.FillBytes(signature[32:])
return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil
}
// consoleClockSkew mirrors the console.x.ai frontend: proof iat is local time
// corrected by (server Date local time) so host clock drift doesn't break
// every proof. before/after bound the RTT around the mint response.
func consoleClockSkew(dateHeader string, before, after time.Time) time.Duration {
dateHeader = strings.TrimSpace(dateHeader)
if dateHeader == "" {
return 0
}
serverTime, err := http.ParseTime(dateHeader)
if err != nil {
return 0
}
if after.Before(before) {
after = before
}
mid := before.Add(after.Sub(before) / 2)
return serverTime.UTC().Sub(mid.UTC()).Round(time.Second)
}
// parseConsoleVideoStatus reads one poll response: (url, done, error).
func parseConsoleVideoStatus(res map[string]any) (string, bool, error) {
status := strings.ToLower(strings.TrimSpace(stringValue(res["status"])))
switch status {
case "done", "completed", "succeeded", "success", "ready":
video, _ := res["video"].(map[string]any)
url := ""
if video != nil {
url = strings.TrimSpace(stringValue(video["url"]))
}
if url == "" {
return "", false, fmt.Errorf("%w: video done without content url", ErrTemporaryUpstream)
}
return url, true, nil
case "pending", "processing", "in_progress", "queued", "":
return "", false, nil
default:
message := consoleErrorMessage(res["error"])
if isCreditError(message) {
return "", false, fmt.Errorf("%w: %s", ErrQuotaExhausted, message)
}
if message == "" {
message = status
}
return "", false, fmt.Errorf("grok console: video generation failed: %s", message)
}
}
// consoleMapStatus maps a Console HTTP status to the shared provider sentinels.
// Console is not anti-bot gated, so a 403 is a real rejection (dead sso / no
// access) unless the body is a Cloudflare interstitial.
func consoleMapStatus(path string, status int, raw []byte) error {
body := string(raw)
switch {
case status >= 200 && status < 300:
return nil
case status == 401:
return fmt.Errorf("%w: %s 401 %s", ErrAuth, path, clip(raw, 160))
case status == 403:
if isBotChallenge(body) {
return fmt.Errorf("%w: %s 403 %s", ErrTemporaryUpstream, path, clip(raw, 160))
}
return fmt.Errorf("%w: %s 403 %s", ErrAuth, path, clip(raw, 160))
case status == 429:
if isCreditError(body) {
return fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 160))
}
return fmt.Errorf("%w: %s 429 %s", ErrTemporaryUpstream, path, clip(raw, 160))
case status >= 500:
return fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, path, status, clip(raw, 160))
default:
if isCreditError(body) {
return fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(raw, 160))
}
return fmt.Errorf("grok console: %s %d %s", path, status, clip(raw, 160))
}
}
func consoleErrorMessage(value any) string {
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
case map[string]any:
for _, key := range []string{"message", "code", "type"} {
if text := strings.TrimSpace(stringValue(v[key])); text != "" {
return text
}
}
}
return ""
}
// consoleImageAspectRatio keeps only the ratios Console's image models accept;
// anything else is dropped so the upstream picks its default instead of 400ing.
func consoleImageAspectRatio(value string) string {
switch strings.TrimSpace(value) {
case "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "2:1", "1:2":
return strings.TrimSpace(value)
}
return ""
}
// consoleImageResolution maps our tier to the Console enum (only 1k / 2k exist).
func consoleImageResolution(value string) string {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "1K":
return "1k"
case "2K", "4K":
return "2k"
}
return ""
}
// consoleVideoResolution clamps to the two tiers Console accepts.
func consoleVideoResolution(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "480p":
return "480p"
default:
return "720p"
}
}
// dataURL inlines reference image bytes; Console takes images in the request
// body, so no upload endpoint is involved.
func dataURL(img []byte) string {
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(img)
}