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 依赖 + 前台开关)
This commit is contained in:
2026-08-08 11:15:42 +08:00
parent 554dea6252
commit ebcf3c5779
16 changed files with 956 additions and 446 deletions
+2 -2
View File
@@ -133,7 +133,7 @@ It's more than an API proxy: it ships with **credit billing, CDK top-ups, referr
| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · flux-kontext-max · nano-banana-2 · nano-banana-pro · firefly-video · firefly-ray · gemini-veo31 | Image / Video |
| **OpenAI** | gpt-image-2 | Image |
| **Runway** | runway-gen4-turbo · runway-nano-banana-2 (Nano Banana 2) · runway-nano-banana-pro (Nano Banana Pro) | Video / Image |
| **Grok (grok.com)** | grok-video (imagine text/image-to-video) | Video |
| **Grok (console.x.ai)** | grok-video (imagine text/image-to-video), grok-image / grok-image-quality (imagine text/image-to-image) | Video / Image |
| **Leonardo.ai** | seedream-4.5 | Image |
| **Krea.ai** | flux-klein-2 | Image |
| **Imagine.art** | imagine-1.5 · imagine-1.5pro | Image |
@@ -245,7 +245,7 @@ backend/ Backend source (Go)
│ │ ├── adobe/ Adobe Firefly (tls-client fingerprint)
│ │ ├── chatgpt/ OpenAI (incl. PoW / turnstile)
│ │ ├── runway/ Runway video + Nano Banana image
│ │ ├── grok/ Grok (grok.com, spoofed statsig, video)
│ │ ├── grok/ Grok (console.x.ai DPoP, video / image; account state via grok.com)
│ │ ├── leonardo/ Leonardo
│ │ ├── krea/ Krea
│ │ ├── imagine/ Imagine.art
+2 -2
View File
@@ -140,7 +140,7 @@
| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · flux-kontext-max · nano-banana-2 · nano-banana-pro · firefly-video · firefly-ray · gemini-veo31 | 图像 / 视频 |
| **OpenAI** | gpt-image-2 | 图像 |
| **Runway** | runway-gen4-turbo · runway-nano-banana-2 (Nano Banana 2) · runway-nano-banana-pro (Nano Banana Pro) | 视频 / 图像 |
| **Grokgrok.com** | grok-videoimagine 文生 / 图生视频) | 视频 |
| **Grokconsole.x.ai** | grok-videoimagine 文生 / 图生视频)、grok-image / grok-image-qualityimagine 文生图 / 图生图) | 视频 / 图像 |
| **Leonardo.ai** | seedream-4.5 | 图像 |
| **Krea.ai** | flux-klein-2 | 图像 |
| **Imagine.art** | imagine-1.5 · imagine-1.5pro | 图像 |
@@ -251,7 +251,7 @@ backend/ 后端源码(Go)
│ │ ├── adobe/ Adobe Firefly(tls-client 指纹)
│ │ ├── chatgpt/ OpenAI(含 PoW / turnstile)
│ │ ├── runway/ Runway 视频 + Nano Banana 图像
│ │ ├── grok/ Grok(grok.com,statsig 伪造,视频)
│ │ ├── grok/ Grok(console.x.ai DPoP,视频 / 图像;账号态走 grok.com)
│ │ ├── leonardo/ Leonardo
│ │ ├── krea/ Krea
│ │ ├── imagine/ Imagine.art
+1 -11
View File
@@ -1,30 +1,20 @@
# syntax=docker/dockerfile:1
# onnxruntime 版本要与 go.mod 里 yalue/onnxruntime_go 的 API 版本匹配
ARG ORT_VERSION=1.28.0
# --- Stage 1: build the Go binary from source ---
# YuNet 人脸检测走 onnxruntime,需要 CGO(gcc) + libonnxruntime 动态库,
# 因此构建/运行镜像都用 glibc 的 debianonnxruntime 官方包不支持 musl/alpine)。
FROM golang:1.26-bookworm AS build
ARG ORT_VERSION
WORKDIR /src
RUN wget -qO /tmp/ort.tgz https://github.com/microsoft/onnxruntime/releases/download/v${ORT_VERSION}/onnxruntime-linux-x64-${ORT_VERSION}.tgz \
&& tar -xzf /tmp/ort.tgz -C /opt && rm /tmp/ort.tgz
# Cache deps first for faster rebuilds.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
# --- Stage 2: minimal runtime image ---
FROM debian:bookworm-slim
ARG ORT_VERSION
# ca-certificates: outbound HTTPS to the AI providers. tzdata: POSTGRES_DSN sets
# TimeZone=Asia/Shanghai. wget: container healthcheck.
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata wget \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /opt/onnxruntime-linux-x64-${ORT_VERSION}/lib/libonnxruntime.so* /usr/local/lib/
WORKDIR /app
COPY --from=build /out/api /app/api
# Local fallback for generated media / reference uploads (RustFS/S3 is primary).
-1
View File
@@ -13,7 +13,6 @@ require (
github.com/google/uuid v1.6.0
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/redis/go-redis/v9 v9.16.0
github.com/yalue/onnxruntime_go v1.32.0
golang.org/x/crypto v0.54.0
golang.org/x/image v0.43.0
gorm.io/datatypes v1.2.7
-2
View File
@@ -156,8 +156,6 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yalue/onnxruntime_go v1.32.0 h1:O4pPw3IT+46CRrfuT0lcHWkczVoFvtfq6kMAO/iIVKc=
github.com/yalue/onnxruntime_go v1.32.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
@@ -4,6 +4,8 @@ import (
"errors"
"net/http"
"strings"
"sync"
"time"
"backend/internal/service"
"github.com/gin-gonic/gin"
@@ -12,12 +14,63 @@ import (
type UserGenerationHandler struct {
userGen *service.UserGenerationService
admin *service.AdminReadService
idem *idemStore
}
func NewUserGenerationHandler(userGen *service.UserGenerationService, admin *service.AdminReadService) *UserGenerationHandler {
return &UserGenerationHandler{
userGen: userGen,
admin: admin,
idem: &idemStore{m: map[string]*idemEntry{}},
}
}
// /generate 是同步长请求(视频要跑好几分钟)。等待期间连接一旦被重置(CDN 回源
// 超时 / HTTP2 GOAWAY),浏览器会把还没拿到响应的 POST 透明重发,后端就会再生成
// 一次、再扣一次积分。前端为每个任务带一个 Idempotency-Key,同一个 key 只真正
// 执行一次:原任务还在跑就直接拒绝,已经跑完就把原结果返回。
const idemTTL = 10 * time.Minute
type idemEntry struct {
done bool
resp map[string]any
at time.Time
}
type idemStore struct {
mu sync.Mutex
m map[string]*idemEntry
}
// begin 登记一个 key。第二个返回值为 true 表示这个 key 已经在处理或刚处理完,
// 返回的 entry 是原来那次的状态。
func (s *idemStore) begin(key string) (*idemEntry, bool) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for k, e := range s.m {
if e.done && now.Sub(e.at) > idemTTL {
delete(s.m, k)
}
}
if e, ok := s.m[key]; ok {
return e, true
}
e := &idemEntry{at: now}
s.m[key] = e
return e, false
}
// finish 记下结果供重发命中;resp 为 nil(本次失败)时直接释放 key,允许用户重试。
func (s *idemStore) finish(key string, resp map[string]any) {
s.mu.Lock()
defer s.mu.Unlock()
if resp == nil {
delete(s.m, key)
return
}
if e, ok := s.m[key]; ok {
e.done, e.resp, e.at = true, resp, time.Now()
}
}
@@ -76,6 +129,20 @@ func (h *UserGenerationHandler) Generate(c *gin.Context) {
return
}
var generated map[string]any // 成功时的响应,供同 key 的重发直接命中
if key := strings.TrimSpace(c.GetHeader("Idempotency-Key")); key != "" {
key = user.ID + "|" + key
if e, dup := h.idem.begin(key); dup {
if e.done {
c.JSON(http.StatusOK, e.resp)
} else {
c.JSON(http.StatusConflict, gin.H{"detail": "该任务已在生成中,已忽略重复提交"})
}
return
}
defer func() { h.idem.finish(key, generated) }()
}
resp, err := h.userGen.Generate(c.Request.Context(), user, service.UserGenerateRequest{
Model: body.Model,
Prompt: body.Prompt,
@@ -113,6 +180,7 @@ func (h *UserGenerationHandler) Generate(c *gin.Context) {
}
return
}
generated = resp
c.JSON(http.StatusOK, resp)
}
@@ -602,11 +670,22 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"type": "video",
"ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"},
"resolutions": []string{"720p"},
"durations": []string{"6s", "10s"},
"max_reference_images": 6,
"reference_mode": "asset",
"durations": []string{"6s", "10s", "15s"},
"max_reference_images": 1,
"reference_mode": "frame",
"description": "Grok Imagine video (文/图生视频)",
},
{
"id": "grok-image",
"provider": "grok",
"type": "image",
"ratios": []string{"1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9"},
"resolutions": []string{"1K", "2K"},
"image_to_image": true,
"max_reference_images": 3,
"reference_mode": "asset",
"description": "Grok Imagine image (文生图 / 图生图)",
},
{
"id": "seedream-4.5",
"provider": "leonardo",
@@ -801,6 +880,15 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
"description": "Grok Imagine video",
"stub": false,
},
{
"id": "grok-image",
"provider": "grok",
"kind": "image",
"ratios": []string{"1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9"},
"resolutions": []string{"1K", "2K"},
"description": "Grok Imagine image",
"stub": false,
},
{
"id": "seedream-4.5",
"provider": "leonardo",
+1 -1
View File
@@ -55,7 +55,7 @@ func sanitizeErr(err error) string {
return "upstream request failed"
}
func httpClient() *http.Client { return &http.Client{Timeout: 10 * time.Minute} }
func httpClient() *http.Client { return &http.Client{Timeout: 20 * time.Minute} }
// GenerateImage calls the upstream OpenAI image API. With reference images it
// uses /v1/images/edits (multipart); otherwise /v1/images/generations. Returns
+609
View File
@@ -0,0 +1,609 @@
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)
}
+1 -1
View File
@@ -291,7 +291,7 @@ func (c *Client) createPost(ctx context.Context, client tlsclient.HttpClient, to
// assets.grok.com is not anti-bot gated) until grok finishes rendering it.
// 404 means "still rendering"; auth failures abort.
func (c *Client) waitForAsset(ctx context.Context, client tlsclient.HttpClient, token, url string) error {
deadline := time.Now().Add(6 * time.Minute)
deadline := time.Now().Add(20 * time.Minute)
for {
if err := ctx.Err(); err != nil {
return err
+52
View File
@@ -124,6 +124,58 @@ func (r *TokenRepository) ReserveQuota(ctx context.Context, pool, id string, amo
return allowed, deducted, err
}
// Grok accounts carry a forced local quota instead of an upstream balance:
// Console 没有额度接口,所以导入时写死 图 5 / 视频 2,用一次扣一次,两个都归零直接判死。
const (
GrokImageQuotaKey = "grok_image_remaining"
GrokVideoQuotaKey = "grok_video_remaining"
GrokImageQuota = 5
GrokVideoQuota = 2
)
// ConsumeGrokQuota deducts one unit from a grok account's local per-kind quota
// under a row lock. Zeroed kinds are flagged (image_limited / video_limited) so
// scheduling skips them; once both are zero the account is dead (no reset time —
// 用完就废).
func (r *TokenRepository) ConsumeGrokQuota(ctx context.Context, id, kind string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var item model.TokenAccount
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
First(&item, "pool = ? AND id = ?", "grok", id).Error; e != nil {
return e
}
images, known := metaInt(item.Meta, GrokImageQuotaKey)
if !known {
images = GrokImageQuota
}
videos, known := metaInt(item.Meta, GrokVideoQuotaKey)
if !known {
videos = GrokVideoQuota
}
if kind == "video" {
videos = max(0, videos-1)
} else {
images = max(0, images-1)
}
meta := cloneMeta(item.Meta)
meta[GrokImageQuotaKey] = images
meta[GrokVideoQuotaKey] = videos
patch := map[string]any{
"meta": meta,
"image_limited": images <= 0,
"video_limited": videos <= 0,
"updated_at": time.Now(),
}
if images <= 0 && videos <= 0 {
patch["status"] = "disabled"
patch["dead"] = true
}
return tx.Model(&model.TokenAccount{}).
Where("pool = ? AND id = ?", "grok", id).
Updates(patch).Error
})
}
// RefundQuota atomically adds `amount` back to cached_quota_remaining (releasing a
// hold from a reservation whose render then failed). No-op if the balance is
// unknown. Row-locked like ReserveQuota.
Binary file not shown.
-305
View File
@@ -1,305 +0,0 @@
package service
import (
"bytes"
_ "embed"
"errors"
"fmt"
"image"
"image/draw"
"image/png"
"math"
"os"
"runtime"
"sort"
"sync"
ort "github.com/yalue/onnxruntime_go"
)
// YuNet 人脸检测模型(opencv_zoo face_detection_yunet_2023mar,输入尺寸已改为
// 动态),编译进二进制,运行时只额外依赖 onnxruntime 动态库。
//
//go:embed assets/yunet.onnx
var yunetModel []byte
const (
// 检出分数下限与 NMS 的 IoU 阈值
faceScoreThreshold = 0.35
faceNMSIoU = 0.3
// 推理输入的长边上限:更大的图先等比缩小,检出框再映射回原图,
// 避免超大参考图把内存和耗时拉爆。
faceMaxInferSide = 2560
)
// YuNet 的三个输出分支步长
var faceStrides = []int{8, 16, 32}
// ErrNoFaceDetected 表示图中没有检出人脸,调用方按原图处理。
var ErrNoFaceDetected = errors.New("no face detected")
var (
faceOnce sync.Once
faceSession *ort.DynamicAdvancedSession
faceInitErr error
)
// onnxruntimeLibPath 返回 onnxruntime 动态库路径:ONNXRUNTIME_LIB_PATH 优先,
// 否则用各平台的默认位置。
func onnxruntimeLibPath() string {
if p := os.Getenv("ONNXRUNTIME_LIB_PATH"); p != "" {
return p
}
if runtime.GOOS == "windows" {
return "onnxruntime.dll"
}
return "/usr/local/lib/libonnxruntime.so"
}
// faceOutputNames 是 YuNet 需要读取的输出名,顺序与 readOutputs 的下标约定一致:
// 先 cls_*、再 obj_*、最后 bbox_*(关键点分支用不到)。
func faceOutputNames() []string {
names := make([]string, 0, len(faceStrides)*3)
for _, prefix := range []string{"cls", "obj", "bbox"} {
for _, s := range faceStrides {
names = append(names, fmt.Sprintf("%s_%d", prefix, s))
}
}
return names
}
func faceDetector() (*ort.DynamicAdvancedSession, error) {
faceOnce.Do(func() {
ort.SetSharedLibraryPath(onnxruntimeLibPath())
if err := ort.InitializeEnvironment(); err != nil {
faceInitErr = fmt.Errorf("onnxruntime init: %w", err)
return
}
faceSession, faceInitErr = ort.NewDynamicAdvancedSessionWithONNXData(
yunetModel, []string{"input"}, faceOutputNames(), nil)
})
if faceInitErr != nil {
return nil, faceInitErr
}
return faceSession, nil
}
type faceDetection struct {
rect image.Rectangle
score float32
}
// detectFaces 返回图中的人脸矩形框(坐标基于 src 的原始尺寸)。
func detectFaces(src image.Image) ([]image.Rectangle, error) {
sess, err := faceDetector()
if err != nil {
return nil, err
}
bounds := src.Bounds()
long := bounds.Dx()
if bounds.Dy() > long {
long = bounds.Dy()
}
scale := 1.0
if long > faceMaxInferSide {
scale = float64(faceMaxInferSide) / float64(long)
}
inW := int(float64(bounds.Dx()) * scale)
inH := int(float64(bounds.Dy()) * scale)
if inW < 1 || inH < 1 {
return nil, nil
}
// 输入补齐到 32 的整数倍,三个步长分支才有整数网格
padW := (inW + 31) / 32 * 32
padH := (inH + 31) / 32 * 32
// YuNet 吃 BGR、NCHW、未归一化的 0~255 像素
pixels := make([]float32, 3*padW*padH)
plane := padW * padH
for y := 0; y < inH; y++ {
srcY := bounds.Min.Y + int(float64(y)/scale)
for x := 0; x < inW; x++ {
r, g, b, _ := src.At(bounds.Min.X+int(float64(x)/scale), srcY).RGBA()
i := y*padW + x
pixels[i] = float32(b >> 8)
pixels[plane+i] = float32(g >> 8)
pixels[2*plane+i] = float32(r >> 8)
}
}
input, err := ort.NewTensor(ort.NewShape(1, 3, int64(padH), int64(padW)), pixels)
if err != nil {
return nil, err
}
defer input.Destroy()
outputs := make([]ort.Value, len(faceStrides)*3)
if err := sess.Run([]ort.Value{input}, outputs); err != nil {
return nil, err
}
defer func() {
for _, out := range outputs {
if out != nil {
out.Destroy()
}
}
}()
branch := func(i int) ([]float32, error) {
t, ok := outputs[i].(*ort.Tensor[float32])
if !ok {
return nil, fmt.Errorf("yunet output %d is not a float32 tensor", i)
}
return t.GetData(), nil
}
inferBounds := image.Rect(0, 0, inW, inH)
var dets []faceDetection
for si, stride := range faceStrides {
cls, err := branch(si)
if err != nil {
return nil, err
}
obj, err := branch(len(faceStrides) + si)
if err != nil {
return nil, err
}
box, err := branch(2*len(faceStrides) + si)
if err != nil {
return nil, err
}
cols, rows := padW/stride, padH/stride
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
idx := row*cols + col
score := float32(math.Sqrt(float64(clampUnit(cls[idx]) * clampUnit(obj[idx]))))
if score < faceScoreThreshold {
continue
}
cx := (float32(col) + box[idx*4]) * float32(stride)
cy := (float32(row) + box[idx*4+1]) * float32(stride)
w := float32(math.Exp(float64(box[idx*4+2]))) * float32(stride)
h := float32(math.Exp(float64(box[idx*4+3]))) * float32(stride)
rect := image.Rect(int(cx-w/2), int(cy-h/2), int(cx+w/2), int(cy+h/2)).Intersect(inferBounds)
if rect.Dx() > 0 && rect.Dy() > 0 {
dets = append(dets, faceDetection{rect: rect, score: score})
}
}
}
}
boxes := make([]image.Rectangle, 0, len(dets))
for _, d := range suppressOverlaps(dets, faceNMSIoU) {
rect := d.rect
if scale != 1 {
rect = image.Rect(
int(float64(rect.Min.X)/scale), int(float64(rect.Min.Y)/scale),
int(float64(rect.Max.X)/scale), int(float64(rect.Max.Y)/scale),
).Intersect(image.Rect(0, 0, bounds.Dx(), bounds.Dy()))
}
if rect.Dx() > 0 && rect.Dy() > 0 {
boxes = append(boxes, rect.Add(bounds.Min))
}
}
return boxes, nil
}
func clampUnit(v float32) float32 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
// suppressOverlaps 按分数从高到低做 NMS,丢掉与已保留框 IoU 超过阈值的框。
func suppressOverlaps(dets []faceDetection, iouThreshold float64) []faceDetection {
sort.SliceStable(dets, func(i, j int) bool { return dets[i].score > dets[j].score })
kept := make([]faceDetection, 0, len(dets))
for _, d := range dets {
overlaps := false
for _, k := range kept {
if rectIoU(d.rect, k.rect) > iouThreshold {
overlaps = true
break
}
}
if !overlaps {
kept = append(kept, d)
}
}
return kept
}
func rectIoU(a, b image.Rectangle) float64 {
inter := a.Intersect(b)
if inter.Empty() {
return 0
}
interArea := float64(inter.Dx() * inter.Dy())
return interArea / (float64(a.Dx()*a.Dy()+b.Dx()*b.Dy()) - interArea)
}
// applyFaceNotice 给图中每张人脸盖一层黑丝网眼,返回 PNG。
// 没检出人脸(或不是可解码的图片)时返回 ErrNoFaceDetected,调用方应继续用原图;
// 其它错误说明检测器不可用,调用方不应把未打码的图上传。
func applyFaceNotice(b []byte) ([]byte, error) {
src, _, err := image.Decode(bytes.NewReader(b))
if err != nil {
return nil, ErrNoFaceDetected
}
boxes, err := detectFaces(src)
if err != nil {
return nil, err
}
if len(boxes) == 0 {
return nil, ErrNoFaceDetected
}
w, h := src.Bounds().Dx(), src.Bounds().Dy()
dst := image.NewRGBA(image.Rect(0, 0, w, h))
draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src)
offset := image.Pt(-src.Bounds().Min.X, -src.Bounds().Min.Y)
for _, box := range boxes {
r := box.Add(offset).Intersect(dst.Bounds())
// 网眼只盖脸中央,留出边缘的发型与轮廓。
r = image.Rect(r.Min.X+r.Dx()/8, r.Min.Y+r.Dy()/8, r.Max.X-r.Dx()/8, r.Max.Y-r.Dy()/8)
drawStocking(dst, r)
}
var out bytes.Buffer
if err := png.Encode(&out, dst); err != nil {
return nil, err
}
return out.Bytes(), nil
}
// drawStocking 在给定区域上盖一层黑丝网眼:细密的深色网格线,遮住五官细节但保留轮廓。
func drawStocking(dst *image.RGBA, r image.Rectangle) {
r = r.Intersect(dst.Bounds())
step := r.Dx() / 24
if step < 3 {
step = 3
}
line := step / 2
if line < 1 {
line = 1
}
for y := r.Min.Y; y < r.Max.Y; y++ {
for x := r.Min.X; x < r.Max.X; x++ {
if (x-r.Min.X)%step >= line && (y-r.Min.Y)%step >= line {
continue
}
c := dst.RGBAAt(x, y)
c.R = uint8(uint32(c.R) * 10 / 100)
c.G = uint8(uint32(c.G) * 10 / 100)
c.B = uint8(uint32(c.B) * 10 / 100)
dst.SetRGBA(x, y, c)
}
}
}
// faceMaskPromptNote 附加到 Seedance 提示词后:告知模型参考图脸部的网格线只是打码,
// 需要忽略网格本身并完整还原面部细节。
const faceMaskPromptNote = "参考图人物脸部覆盖的细密网格线仅为隐私打码,不是人物本身的特征:生成时请完全忽略这些网格线,不要在画面中出现任何网格、方格、纹理或遮挡;请依据参考图的五官轮廓完整还原人物真实面孔,保留妆容、眉眼、发型、发饰、耳饰、头冠等一切面部与头部装饰细节,人物面部必须清晰完整、前后镜头保持一致。"
+45 -85
View File
@@ -964,30 +964,13 @@ func (s *TokenService) checkPendingGrok(tokenID, ssoToken string) {
} else if strings.TrimSpace(email) != "" {
_, _ = s.tokens.Update(ctx, "grok", tokenID, map[string]any{"account_email": strings.TrimSpace(email)})
}
data, err := s.grok.FetchCreditsBalance(ctx, ssoToken)
if err != nil {
if errors.Is(err, grok.ErrAuth) {
s.finishPending(ctx, "grok", tokenID, "disabled", true, nil)
return
}
s.finishPending(ctx, "grok", tokenID, "active", false, nil)
return
}
quotaMeta := map[string]any{}
if rem, ok := data["remaining"].(int); ok {
quotaMeta["cached_quota_remaining"] = rem
quotaMeta["cached_quota_at"] = int(time.Now().Unix())
}
if used, ok := data["used"].(int); ok {
quotaMeta["cached_quota_used"] = used
}
if total, ok := data["total"].(int); ok {
quotaMeta["cached_quota_total"] = total
}
if reset := strings.TrimSpace(stringValue(data["reset_after"])); reset != "" {
_, _ = s.tokens.Update(ctx, "grok", tokenID, map[string]any{"cached_quota_reset_after": reset})
}
s.finishPending(ctx, "grok", tokenID, "active", false, quotaMeta)
// Console 没有额度接口,所以额度不查上游:导入即写死 图 5 / 视频 2,生成时各扣各的,
// 两个都归零就判死;没有恢复时间。
s.finishPending(ctx, "grok", tokenID, "active", false, map[string]any{
repo.GrokImageQuotaKey: repo.GrokImageQuota,
repo.GrokVideoQuotaKey: repo.GrokVideoQuota,
"cached_quota_at": int(time.Now().Unix()),
})
}
// RefreshGrokLiveness re-validates every live grok account each maintenance tick.
@@ -1039,28 +1022,15 @@ func (s *TokenService) RefreshGrokLiveness(ctx context.Context) {
_, _ = s.tokens.Update(ctx, "grok", it.ID, map[string]any{"status": "disabled", "dead": true})
continue
}
data, derr := s.grok.FetchCreditsBalance(ctx, it.Value)
if derr != nil {
// Same policy as the subscription probe: a credits-balance 401/403 is
// transient, never a reason to kill a live account. Skip and retry.
// 额度不查上游(Console 无额度接口):只给老号补齐写死的 图 5 / 视频 2。
if _, ok := jsonMapInt(it.Meta, repo.GrokImageQuotaKey); ok {
continue
}
meta := cloneJSONMap(it.Meta)
meta[repo.GrokImageQuotaKey] = repo.GrokImageQuota
meta[repo.GrokVideoQuotaKey] = repo.GrokVideoQuota
meta["cached_quota_at"] = int(time.Now().Unix())
if rem, ok := data["remaining"].(int); ok {
meta["cached_quota_remaining"] = rem
}
if used, ok := data["used"].(int); ok {
meta["cached_quota_used"] = used
}
if total, ok := data["total"].(int); ok {
meta["cached_quota_total"] = total
}
patch := map[string]any{"meta": meta}
if reset := strings.TrimSpace(stringValue(data["reset_after"])); reset != "" {
patch["cached_quota_reset_after"] = reset
}
_, _ = s.tokens.Update(ctx, "grok", it.ID, patch)
_, _ = s.tokens.Update(ctx, "grok", it.ID, map[string]any{"meta": meta})
}
}
@@ -1567,53 +1537,28 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
"error": data["error"],
}, nil
}
if poolToType(item.Pool) == "grok" && s.grok != nil {
data, err := s.grok.FetchCreditsBalance(ctx, item.Value)
if err != nil {
if errors.Is(err, grok.ErrAuth) {
_, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{
"status": "disabled",
"dead": true,
"fails": gorm.Expr("fails + 1"),
})
}
return nil, err
if poolToType(item.Pool) == "grok" {
// 本地写死的额度(图 5 / 视频 2),没有上游接口可查,所以刷新只是回读本地计数。
images, ok := jsonMapInt(item.Meta, repo.GrokImageQuotaKey)
if !ok {
images = repo.GrokImageQuota
}
patch := map[string]any{}
meta := cloneJSONMap(item.Meta)
meta["cached_quota_at"] = int(time.Now().Unix())
if remaining, ok := data["remaining"].(int); ok {
// Refresh only updates the displayed credit number; never flips status.
// Out-of-credits is judged at generation time (dead/401, no renewal).
meta["cached_quota_remaining"] = remaining
}
if used, ok := data["used"].(int); ok {
meta["cached_quota_used"] = used
}
if total, ok := data["total"].(int); ok {
meta["cached_quota_total"] = total
}
patch["meta"] = meta
// Recovery time is the credits' weekly reset (when the grant refills) —
// purely informational, NOT a death deadline (liveness is judged by the
// subscriptions sweep / real 401s), so it's safe to refresh every time.
if reset := strings.TrimSpace(stringValue(data["reset_after"])); reset != "" {
patch["cached_quota_reset_after"] = reset
item.CachedQuotaResetAfter = reset
}
if updated, updateErr := s.tokens.Update(ctx, item.Pool, item.ID, patch); updateErr == nil {
item = updated
videos, ok := jsonMapInt(item.Meta, repo.GrokVideoQuotaKey)
if !ok {
videos = repo.GrokVideoQuota
}
return map[string]any{
"supported": true,
"remaining": data["remaining"],
"used": data["used"],
"total": data["total"],
"reset_after": emptyToNil(item.CachedQuotaResetAfter),
"quota_cached_at": meta["cached_quota_at"],
"unchanged": false,
"unknown": boolValueWithDefault(data["unknown"], false),
"error": data["error"],
"remaining": images + videos,
"image_remaining": images,
"video_remaining": videos,
"used": nil,
"total": nil,
"reset_after": nil,
"quota_cached_at": item.Meta["cached_quota_at"],
"unchanged": true,
"unknown": false,
"error": nil,
}, nil
}
remaining, hasRemaining := jsonMapInt(item.Meta, "cached_quota_remaining")
@@ -1734,6 +1679,19 @@ func accountRow(item model.TokenAccount, inFlight int64) map[string]any {
if item.Meta != nil {
teamID = strings.TrimSpace(stringValue(item.Meta["team_id"]))
}
// grok 额度是本地写死的两个计数(图 / 视频),前台单独一列展示成 "5/2"。
var grokImages, grokVideos any
if typeLabel == "grok" {
images, ok := jsonMapInt(item.Meta, repo.GrokImageQuotaKey)
if !ok {
images = repo.GrokImageQuota
}
videos, ok := jsonMapInt(item.Meta, repo.GrokVideoQuotaKey)
if !ok {
videos = repo.GrokVideoQuota
}
grokImages, grokVideos = images, videos
}
hasQuota := typeLabel == "openai" || typeLabel == "adobe" || typeLabel == "runway" || typeLabel == "leonardo" || typeLabel == "krea" || typeLabel == "imagine" || typeLabel == "grok"
return map[string]any{
"id": item.ID,
@@ -1742,6 +1700,8 @@ func accountRow(item model.TokenAccount, inFlight int64) map[string]any {
"email": emptyToNil(email),
"team_id": emptyToNil(teamID),
"remaining": valueOrNil(hasQuota && hasRemaining, remaining),
"image_remaining": grokImages,
"video_remaining": grokVideos,
"reset_after": emptyToNil(item.CachedQuotaResetAfter),
"quota_cached_at": valueOrNil(quotaAt != 0, quotaAt),
"created_at": unixOrNil(item.AddedAt),
+129 -28
View File
@@ -568,6 +568,24 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
}
imageBytes = b
upstreamURL = u
case "grok":
b, u, execErr := s.generateGrokImage(genCtx, eventID, modelItem, in, aspectRatio, resolution, noStore)
if execErr != nil {
_ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
switch {
case errors.Is(execErr, grok.ErrAuth):
return nil, ErrProviderAuth
case errors.Is(execErr, grok.ErrQuotaExhausted):
return nil, ErrProviderQuota
case errors.Is(execErr, grok.ErrTemporaryUpstream):
return nil, ErrProviderTemporary
default:
return nil, fmt.Errorf("%w: %v", ErrProviderExecution, execErr)
}
}
imageBytes = b
upstreamURL = u
case "runway":
b, u, execErr := s.generateRunwayImage(genCtx, eventID, modelItem, in, aspectRatio, resolution, noStore)
if execErr != nil {
@@ -718,7 +736,7 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
return nil, err
}
}
genCtx, cancel := context.WithTimeout(ctx, 12*time.Minute)
genCtx, cancel := context.WithTimeout(ctx, videoGenBudget)
defer cancel()
// Per-user concurrency gate (画图台 + API key combined); admin tests exempt.
@@ -920,7 +938,7 @@ func (s *V1Service) StartVideoJob(ctx context.Context, principal *APIPrincipal,
// runVideoJob renders the clip in the background, capturing the upstream URL
// (downloadResult=false → no bytes, no RustFS) and storing it on the event.
func (s *V1Service) runVideoJob(ctx context.Context, principal *APIPrincipal, in V1VideoRequest, modelItem *model.ModelConfig, eventID, aspectRatio, resolution, duration string, price float64) {
genCtx, cancel := context.WithTimeout(ctx, 12*time.Minute)
genCtx, cancel := context.WithTimeout(ctx, videoGenBudget)
defer cancel()
s.inflight.Add(eventID, cancel)
defer s.inflight.Done(eventID)
@@ -1487,6 +1505,10 @@ func (s *V1Service) finishUnimplementedEvent(ctx context.Context, eventID string
return s.events.UpdateStatus(ctx, eventID, "failed", "generation executor not implemented yet", 0)
}
// videoGenBudget caps one video render end-to-end (submit + poll + download).
// 上游慢的时候(seedance 长镜头)12 分钟不够,统一给 20 分钟。
const videoGenBudget = 20 * time.Minute
// grokConcurrencyPerAccount is how many simultaneous generations one grok account
// may run (grok tolerates 10, unlike the 1-per-account default elsewhere).
const grokConcurrencyPerAccount = 10
@@ -1801,26 +1823,7 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
imgRefs = append(imgRefs, r)
}
}
// Seedance 参考图先做人脸打码再上传;检不出人脸就沿用原图,
// 检测器不可用则直接报错,避免把未打码的人脸传给上游。
prompt := in.Prompt
if isSeedanceModel(modelItem.ID) {
faceMasked := false
for i, r := range imgRefs {
marked, mErr := applyFaceNotice(r)
if errors.Is(mErr, ErrNoFaceDetected) {
continue
}
if mErr != nil {
return nil, "", fmt.Errorf("face mask: %w", mErr)
}
imgRefs[i] = marked
faceMasked = true
}
if faceMasked {
prompt = strings.TrimSpace(prompt + "\n\n" + faceMaskPromptNote)
}
}
engine, upstreamModel := resolveAdobeVideoEngine(modelItem.ID)
referenceMode := defaultString(strings.TrimSpace(modelItem.ReferenceMode), "frame")
@@ -2279,11 +2282,11 @@ func upstreamQuality(resolution string) string {
return ""
}
// generateGrokVideo runs grok's imagine video pipeline across the grok pool.
// Mirrors the runway policy: no pre-deduct, skip accounts known out of credits
// (cached remaining <= 0), and treat an out-of-credits / auth failure as a dead
// account (the grok sso can't be renewed — 失效就失效). Text-to-video only for
// now (grok reference-image upload isn't wired yet).
// generateGrokVideo runs grok's imagine video pipeline across the grok pool,
// via Grok Console (console.x.ai) — the same sso account, but the clean JSON
// media API instead of the anti-bot gated grok.com website flow.
// 额度是本地写死的(每号 图 5 / 视频 2):视频计数归零的号不再调度,成功一次扣一个,
// 图/视频都归零直接判死;auth / 额度错误同样判死换号(grok sso 不续期,失效就失效)。
func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1VideoRequest, aspectRatio, resolution string, durationSeconds int, downloadResult bool) ([]byte, string, error) {
if s.grok == nil {
return nil, "", errors.New("grok client not configured")
@@ -2309,7 +2312,7 @@ func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, model
if item.Status != "active" || item.Dead || strings.TrimSpace(item.Value) == "" {
continue
}
if rem, ok := jsonMapInt(item.Meta, "cached_quota_remaining"); ok && rem <= 0 {
if rem, ok := jsonMapInt(item.Meta, repo.GrokVideoQuotaKey); ok && rem <= 0 {
continue
}
active = append(active, item)
@@ -2338,13 +2341,15 @@ func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, model
defer s.acctRelease(ctx, token.ID, eventID)
_ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail)
_ = s.tokens.TouchLastUsed(ctx, token.ID)
d, meta, genErr := s.grok.GenerateVideo(ctx, token.Value, in.Prompt, aspectRatio, res, durationSeconds, frames, downloadResult)
d, meta, genErr := s.grok.GenerateConsoleVideo(ctx, token.Value, in.Prompt, aspectRatio, res, durationSeconds, frames, downloadResult)
if genErr == nil {
_, _ = s.tokens.Update(ctx, "grok", token.ID, map[string]any{
"last_used_at": time.Now(),
"success_total": gorm.Expr("success_total + 1"),
"fails": 0,
})
// 本地额度各扣各的;图/视频都归零时账号直接判死。
_ = s.tokens.ConsumeGrokQuota(ctx, token.ID, "video")
data = d
videoURL = strings.TrimSpace(stringValue(meta["video_url"]))
return true, false
@@ -2378,6 +2383,102 @@ func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, model
return nil, "", lastErr
}
// generateGrokImage runs Grok Console's image pipeline (grok-imagine-image)
// across the grok pool. 额度策略同视频路径,只是扣的是图片那份计数。带参考图时
// (最多 3 张,内联在请求里)自动走 /images/edits 的 quality 上游 — 图生图。
func (s *V1Service) generateGrokImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string, noStore bool) ([]byte, string, error) {
// API-key (noStore) requests skip the download and return the upstream URL.
urlOnly := noStore
if s.grok == nil {
return nil, "", errors.New("grok client not configured")
}
if s.settings != nil {
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil {
s.grok.SetProxy(proxy)
}
}
refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages))
if err != nil {
return nil, "", err
}
items, err := s.tokens.ListByPool(ctx, "grok")
if err != nil {
return nil, "", err
}
var active []model.TokenAccount
for _, item := range items {
if item.Status != "active" || item.Dead || strings.TrimSpace(item.Value) == "" {
continue
}
if rem, ok := jsonMapInt(item.Meta, repo.GrokImageQuotaKey); ok && rem <= 0 {
continue
}
active = append(active, item)
}
active = pinTestAccount(items, active, in.AccountID)
if len(active) == 0 {
return nil, "", ErrNoProviderAccount
}
s.rotateRoundRobin("grok", active)
var lastErr error
busy := 0
for _, token := range active {
// Per-account concurrency gate.
if !s.acctAcquire(ctx, token.ID, eventID, accountConcurrency(token)) {
busy++
continue
}
var data []byte
var artURL string
done, failover := func() (bool, bool) {
defer s.acctRelease(ctx, token.ID, eventID)
_ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail)
_ = s.tokens.TouchLastUsed(ctx, token.ID)
d, meta, genErr := s.grok.GenerateConsoleImage(ctx, token.Value, in.Prompt, aspectRatio, resolution, refs, urlOnly)
if genErr == nil {
_, _ = s.tokens.Update(ctx, "grok", token.ID, map[string]any{
"last_used_at": time.Now(),
"success_total": gorm.Expr("success_total + 1"),
"fails": 0,
})
// 本地额度各扣各的;图/视频都归零时账号直接判死。
_ = s.tokens.ConsumeGrokQuota(ctx, token.ID, "image")
data = d
artURL = strings.TrimSpace(stringValue(meta["image_url"]))
return true, false
}
lastErr = genErr
switch {
case errors.Is(genErr, grok.ErrAuth), errors.Is(genErr, grok.ErrQuotaExhausted):
// 失效 / 额度没了 → 当 401 判死(不续期),换号。
s.markTokenFailure(ctx, "grok", token, "image", true, false)
return false, true
case errors.Is(genErr, grok.ErrTemporaryUpstream):
return false, true
default:
return false, false
}
}()
if done {
return data, artURL, nil
}
if failover {
continue
}
return nil, "", lastErr
}
if lastErr == nil {
if busy > 0 {
return nil, "", ErrConcurrencyFull
}
lastErr = ErrProviderExecution
}
return nil, "", lastErr
}
// generateRunwayImage runs the Runway gemini image pipeline (Nano Banana Pro or
// Nano Banana 2, selected by the model id) across the runway pool. Unlike the
// video path it does NOT pre-deduct credits: it simply round-robins the pool and
+13 -4
View File
@@ -259,6 +259,8 @@ function applyQuota(row, result) {
if (result.unknown && result.remaining === null) { row.remaining = null; row._unknown = true; return }
row._unknown = false
row.remaining = result.remaining
if (result.image_remaining !== undefined) row.image_remaining = result.image_remaining
if (result.video_remaining !== undefined) row.video_remaining = result.video_remaining
row.reset_after = result.reset_after
// /
if (result.plan !== undefined) row.plan = result.plan
@@ -391,7 +393,7 @@ onMounted(() => { loadAccounts(); loadModelList() })
<button @click="setFilter(() => planFilter = '')" class="fp" :class="planFilter === '' && 'fp-on'">全部账号</button>
<button @click="setFilter(() => planFilter = 'free')" class="fp" :class="planFilter === 'free' && 'fp-slate'">普号</button>
<button @click="setFilter(() => planFilter = 'sub')" class="fp" :class="planFilter === 'sub' && 'fp-sky'">子号</button>
<button @click="setFilter(() => planFilter = 'master')" class="fp" :class="planFilter === 'master' && 'fp-purple'"></button>
<button @click="setFilter(() => planFilter = 'master')" class="fp" :class="planFilter === 'master' && 'fp-purple'">会员</button>
</div>
</template>
<div class="flex-1 min-w-[200px]">
@@ -480,7 +482,7 @@ onMounted(() => { loadAccounts(); loadModelList() })
<span v-if="a.sub_account" class="acct-tag acct-tag-sub"
:title="'子号 · 会员 (剩余 ' + (a.remaining ?? '—') + ' 积分)'">子号</span>
<span v-else-if="a.plan && a.plan !== 'free'" class="acct-tag acct-tag-master"
:title="'母号 · 会员 (' + a.plan + ')'"></span>
:title="'会员 (' + a.plan + ')'">会员</span>
<span v-else-if="a.plan === 'free'" class="acct-tag acct-tag-free"
title="普号 (无会员)">普号</span>
<span v-if="a.image_limited && a.status !== 'quota' && a.plan !== 'free'" class="acct-tag acct-tag-limit"
@@ -498,9 +500,16 @@ onMounted(() => { loadAccounts(); loadModelList() })
<td class="px-3 py-3.5 align-middle text-right text-sm tabular-nums whitespace-nowrap">
<!-- quota column: 数字 / (never "未知"/"失败"/"检测中") -->
<!-- remaining === -1 is the provider "unlimited" sentinel show not a scary red -1 -->
<span v-if="(a.type === 'openai' || a.type === 'adobe' || a.type === 'runway' || a.type === 'leonardo' || a.type === 'krea' || a.type === 'imagine' || a.type === 'grok') && a.remaining != null && a.remaining !== -1"
<!-- grok 额度是本地写死的两份计数 / 视频用完即判死没有恢复时间 -->
<span v-if="a.type === 'grok' && a.image_remaining != null"
class="font-mono font-semibold" title="图片剩余 / 视频剩余">
<span :class="a.image_remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.image_remaining }}</span>
<span class="text-white/20">/</span>
<span :class="a.video_remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.video_remaining }}</span>
</span>
<span v-else-if="(a.type === 'openai' || a.type === 'adobe' || a.type === 'runway' || a.type === 'leonardo' || a.type === 'krea' || a.type === 'imagine') && a.remaining != null && a.remaining !== -1"
class="font-mono font-semibold"
:class="a.remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.remaining }}{{ a.type === 'grok' ? '%' : '' }}</span>
:class="a.remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.remaining }}</span>
<span v-else class="text-white/25" :title="a._quotaError || ''"></span>
</td>
<!-- weight (edit via modal) -->
+10 -1
View File
@@ -556,14 +556,23 @@ async function fireOne() {
payload.reference_images = refs.filter(Boolean)
}
// Idempotency-Key:/
// POST ,
const opts = jsonBody('POST', payload)
opts.headers['Idempotency-Key'] = task.id + '-' + task.ts
try {
const r = await api('/generate', jsonBody('POST', payload))
const r = await api('/generate', opts)
if (r.ok && r.data?.url) {
task.status = 'done'
task.url = r.data.url
task.elapsed_ms = r.data.elapsed_ms
task.charged = r.data.charged ?? chargedPrice
if (auth.user && r.data.credits != null) auth.user.credits = r.data.credits
} else if (r.status === 409 && String(r.data?.detail || '').includes('重复提交')) {
// POST , running,
// loadHistory()
task.status = 'running'
} else if (GATEWAY_TIMEOUT.has(r.status)) {
// CDN/( EdgeOne 524) running,
// loadHistory()