diff --git a/README.en.md b/README.en.md index b111f94..579cd22 100644 --- a/README.en.md +++ b/README.en.md @@ -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 diff --git a/README.md b/README.md index 135815b..21841e6 100644 --- a/README.md +++ b/README.md @@ -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) | 视频 / 图像 | -| **Grok(grok.com)** | grok-video(imagine 文生 / 图生视频) | 视频 | +| **Grok(console.x.ai)** | grok-video(imagine 文生 / 图生视频)、grok-image / grok-image-quality(imagine 文生图 / 图生图) | 视频 / 图像 | | **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 diff --git a/backend/Dockerfile b/backend/Dockerfile index 322ad58..806f4c7 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 的 debian(onnxruntime 官方包不支持 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). diff --git a/backend/go.mod b/backend/go.mod index 1d71b82..e2d4df0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 3f35ee9..49a24f7 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/http/handler/user_generation.go b/backend/internal/http/handler/user_generation.go index 1796951..e6bbb4c 100644 --- a/backend/internal/http/handler/user_generation.go +++ b/backend/internal/http/handler/user_generation.go @@ -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", diff --git a/backend/internal/provider/custom/client.go b/backend/internal/provider/custom/client.go index 7ef1957..c011249 100644 --- a/backend/internal/provider/custom/client.go +++ b/backend/internal/provider/custom/client.go @@ -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 diff --git a/backend/internal/provider/grok/console.go b/backend/internal/provider/grok/console.go new file mode 100644 index 0000000..71adf2a --- /dev/null +++ b/backend/internal/provider/grok/console.go @@ -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 " 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) +} diff --git a/backend/internal/provider/grok/video.go b/backend/internal/provider/grok/video.go index 973ab74..290f66f 100644 --- a/backend/internal/provider/grok/video.go +++ b/backend/internal/provider/grok/video.go @@ -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 diff --git a/backend/internal/repo/token_repo.go b/backend/internal/repo/token_repo.go index a58a619..963d5d5 100644 --- a/backend/internal/repo/token_repo.go +++ b/backend/internal/repo/token_repo.go @@ -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. diff --git a/backend/internal/service/assets/yunet.onnx b/backend/internal/service/assets/yunet.onnx deleted file mode 100644 index 73a37b1..0000000 Binary files a/backend/internal/service/assets/yunet.onnx and /dev/null differ diff --git a/backend/internal/service/facemask.go b/backend/internal/service/facemask.go deleted file mode 100644 index 7cdf9ad..0000000 --- a/backend/internal/service/facemask.go +++ /dev/null @@ -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 = "参考图人物脸部覆盖的细密网格线仅为隐私打码,不是人物本身的特征:生成时请完全忽略这些网格线,不要在画面中出现任何网格、方格、纹理或遮挡;请依据参考图的五官轮廓完整还原人物真实面孔,保留妆容、眉眼、发型、发饰、耳饰、头冠等一切面部与头部装饰细节,人物面部必须清晰完整、前后镜头保持一致。" diff --git a/backend/internal/service/tokens.go b/backend/internal/service/tokens.go index 72e466e..4b81640 100644 --- a/backend/internal/service/tokens.go +++ b/backend/internal/service/tokens.go @@ -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), diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go index feb33e3..e087b58 100644 --- a/backend/internal/service/v1.go +++ b/backend/internal/service/v1.go @@ -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 diff --git a/frontend/src/views/AccountsView.vue b/frontend/src/views/AccountsView.vue index af5c0fb..d06ccb5 100644 --- a/frontend/src/views/AccountsView.vue +++ b/frontend/src/views/AccountsView.vue @@ -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() }) - +
@@ -480,7 +482,7 @@ onMounted(() => { loadAccounts(); loadModelList() }) 子号 母号 + :title="'会员号 (' + a.plan + ')'">会员号 普号 { loadAccounts(); loadModelList() }) - + + {{ a.image_remaining }} + / + {{ a.video_remaining }} + + {{ a.remaining }}{{ a.type === 'grok' ? '%' : '' }} + :class="a.remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.remaining }} diff --git a/frontend/src/views/PlaygroundView.vue b/frontend/src/views/PlaygroundView.vue index 19a11c6..a43067c 100644 --- a/frontend/src/views/PlaygroundView.vue +++ b/frontend/src/views/PlaygroundView.vue @@ -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() 在结果落库后认领它。