feat: add Grok (grok.com) video provider; runway nano-banana image; unify runway/grok pool policy

- grok provider: imagine text/image-to-video (media.post.create → conversations/new),
  GetGrokCreditsConfig credit query (remaining = 100 - used) + weekly reset, spoofed
  x-statsig-id (no Cloudflare clearance needed), /api/auth/session email lookup,
  6 reference images, 10 concurrent jobs/account, no token refresh (dead = dead)
- runway: nano-banana-2 image flow (Nano Banana 2); drop pre-deduct + post-success
  reconcile; out-of-credits/403 → dead (no revive); 10-ratio support
- imagine: drop post-success credit reconcile (consistent with krea)
- account gate: per-account N-concurrency (grok=10, others=1)
- admin: provider health lists all 7 providers; frontend import auto-detects Grok SSO
- docs: README (CN/EN) updated to 7 providers + Grok

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 04:01:06 +08:00
co-authored by Claude Opus 4.8
parent 8633addf64
commit 17cd289dfd
16 changed files with 1442 additions and 166 deletions
+10 -8
View File
@@ -2,7 +2,7 @@
<h1>image2api</h1> <h1>image2api</h1>
**Multi-provider AI image / video generation gateway — one OpenAI-compatible API, six platforms aggregated, a ready-to-run operations system** **Multi-provider AI image / video generation gateway — one OpenAI-compatible API, seven platforms aggregated, a ready-to-run operations system**
<sub>Live instance (brand): [Vivid AI · vividai.run](https://vividai.run)</sub> <sub>Live instance (brand): [Vivid AI · vividai.run](https://vividai.run)</sub>
@@ -15,7 +15,7 @@
[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](#-deployment) [![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](#-deployment)
[![OpenAI Compatible](https://img.shields.io/badge/OpenAI-compatible-412991?logo=openai&logoColor=white)](#-openai-compatible-api) [![OpenAI Compatible](https://img.shields.io/badge/OpenAI-compatible-412991?logo=openai&logoColor=white)](#-openai-compatible-api)
[![HTTPS](https://img.shields.io/badge/HTTPS-acme.sh%20auto--issue-success)](#option-1-docker-one-command-recommended) [![HTTPS](https://img.shields.io/badge/HTTPS-acme.sh%20auto--issue-success)](#option-1-docker-one-command-recommended)
[![Providers](https://img.shields.io/badge/providers-6-orange)](#-supported-models--providers) [![Providers](https://img.shields.io/badge/providers-7-orange)](#-supported-models--providers)
[![Self-hosted](https://img.shields.io/badge/self--hosted-yes-success)](#-deployment) [![Self-hosted](https://img.shields.io/badge/self--hosted-yes-success)](#-deployment)
[![License](https://img.shields.io/badge/license-MIT-blue)](#-license) [![License](https://img.shields.io/badge/license-MIT-blue)](#-license)
@@ -45,13 +45,13 @@
## ✨ Overview ## ✨ Overview
**image2api** wraps the image / video capabilities of Adobe Firefly, OpenAI, Runway, Leonardo, Krea and Imagine into **a single OpenAI-compatible API**. Behind it, multi-account pools are scheduled automatically — out of quota → switch account, auth expired → refresh or kill, transient errors → retry, tokens proactively renewed before they expire — to deliver a stable service. **image2api** wraps the image / video capabilities of Adobe Firefly, OpenAI, Runway, Grok, Leonardo, Krea and Imagine into **a single OpenAI-compatible API**. Behind it, multi-account pools are scheduled automatically — out of quota → switch account, auth expired → refresh or kill, transient errors → retry, tokens proactively renewed before they expire — to deliver a stable service.
It's more than an API proxy: it ships with **credit billing, CDK top-ups, referral rewards, a user system, an admin console, and a modern generation frontend**, so a single command turns it into a fully operational AI generation site — the author's live instance **[Vivid AI · vividai.run](https://vividai.run)** (brand) is built on this project. It's more than an API proxy: it ships with **credit billing, CDK top-ups, referral rewards, a user system, an admin console, and a modern generation frontend**, so a single command turns it into a fully operational AI generation site — the author's live instance **[Vivid AI · vividai.run](https://vividai.run)** (brand) is built on this project.
> 💡 Both frontend and backend are **fully open-source** (MIT) — Go + Vue 3, free to fork and self-host. > 💡 Both frontend and backend are **fully open-source** (MIT) — Go + Vue 3, free to fork and self-host.
**At a glance** 🔌 OpenAI-compatible · 🤖 6 platforms, 10+ models · 🔁 auto failover / token keep-alive · 💳 credits + agent pricing · 🎨 generation frontend + admin console · 🐳 one-command deploy + auto HTTPS **At a glance** 🔌 OpenAI-compatible · 🤖 7 platforms, 10+ models · 🔁 auto failover / token keep-alive · 💳 credits + agent pricing · 🎨 generation frontend + admin console · 🐳 one-command deploy + auto HTTPS
## 🖼️ Screenshots ## 🖼️ Screenshots
@@ -66,7 +66,7 @@ It's more than an API proxy: it ships with **credit billing, CDK top-ups, referr
#### 🎨 Generation #### 🎨 Generation
- Images + videos in one place, with **image-to-image / reference frames** (first frame, last frame, style reference) - Images + videos in one place, with **image-to-image / reference frames** (first frame, last frame, style reference)
- Multiple resolutions (1K / 2K / 4K), aspect ratios and video durations — configured and priced per model - Multiple resolutions (1K / 2K / 4K), aspect ratios and video durations — configured and priced per model
- 6 providers, 10+ models, **enable / disable / re-price from the admin console**, no code changes - 7 providers, 10+ models, **enable / disable / re-price from the admin console**, no code changes
#### 🔌 OpenAI Compatible #### 🔌 OpenAI Compatible
- Text-to-image `/v1/images/generations` · image-to-image `/v1/images/edits` (multipart ref upload) · video `/v1/videos` (Sora-style async: create → poll → `/content`) · `/v1/models` - Text-to-image `/v1/images/generations` · image-to-image `/v1/images/edits` (multipart ref upload) · video `/v1/videos` (Sora-style async: create → poll → `/content`) · `/v1/models`
@@ -103,9 +103,10 @@ It's more than an API proxy: it ships with **credit billing, CDK top-ups, referr
| Provider | Models (examples) | Type | | Provider | Models (examples) | Type |
|---|---|---| |---|---|---|
| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · nano-banana-2 · flux-kontext-max · firefly-video · firefly-ray · gemini-veo31 | Image / Video | | **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · flux-kontext-max · firefly-video · firefly-ray · gemini-veo31 | Image / Video |
| **OpenAI** | gpt-image-2 | Image | | **OpenAI** | gpt-image-2 | Image |
| **Runway** | runway-gen4-turbo | Video | | **Runway** | runway-gen4-turbo · nano-banana-2 (Nano Banana 2) | Video / Image |
| **Grok (grok.com)** | grok-video (imagine text/image-to-video) | Video |
| **Leonardo.ai** | seedream-4.5 | Image | | **Leonardo.ai** | seedream-4.5 | Image |
| **Krea.ai** | flux-klein-2 | Image | | **Krea.ai** | flux-klein-2 | Image |
| **Imagine.art** | imagine-1.5 · imagine-1.5pro | Image | | **Imagine.art** | imagine-1.5 · imagine-1.5pro | Image |
@@ -233,7 +234,8 @@ backend/ Backend source (Go)
│ ├── provider/ Upstream provider clients │ ├── provider/ Upstream provider clients
│ │ ├── adobe/ Adobe Firefly (tls-client fingerprint) │ │ ├── adobe/ Adobe Firefly (tls-client fingerprint)
│ │ ├── chatgpt/ OpenAI (incl. PoW / turnstile) │ │ ├── chatgpt/ OpenAI (incl. PoW / turnstile)
│ │ ├── runway/ Runway video │ │ ├── runway/ Runway video + Nano Banana image
│ │ ├── grok/ Grok (grok.com, spoofed statsig, video)
│ │ ├── leonardo/ Leonardo │ │ ├── leonardo/ Leonardo
│ │ ├── krea/ Krea │ │ ├── krea/ Krea
│ │ └── imagine/ Imagine.art │ │ └── imagine/ Imagine.art
+10 -8
View File
@@ -2,7 +2,7 @@
<h1>image2api</h1> <h1>image2api</h1>
**多供应商 AI 生图 / 生视频网关 —— 一套 OpenAI 兼容 API,聚合大平台,开箱即用的运营系统** **多供应商 AI 生图 / 生视频网关 —— 一套 OpenAI 兼容 API,聚合大平台,开箱即用的运营系统**
<sub>线上实例(品牌):[Vivid AI · vividai.run](https://vividai.run)</sub> <sub>线上实例(品牌):[Vivid AI · vividai.run](https://vividai.run)</sub>
@@ -15,7 +15,7 @@
[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](#-部署) [![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](#-部署)
[![OpenAI Compatible](https://img.shields.io/badge/OpenAI-compatible-412991?logo=openai&logoColor=white)](#-openai-兼容-api) [![OpenAI Compatible](https://img.shields.io/badge/OpenAI-compatible-412991?logo=openai&logoColor=white)](#-openai-兼容-api)
[![HTTPS](https://img.shields.io/badge/HTTPS-acme.sh%20自动签发-success)](#方式一docker-一键推荐) [![HTTPS](https://img.shields.io/badge/HTTPS-acme.sh%20自动签发-success)](#方式一docker-一键推荐)
[![Providers](https://img.shields.io/badge/供应商-6%20平台-orange)](#-支持的模型--供应商) [![Providers](https://img.shields.io/badge/供应商-7%20平台-orange)](#-支持的模型--供应商)
[![Self-hosted](https://img.shields.io/badge/self--hosted-yes-success)](#-部署) [![Self-hosted](https://img.shields.io/badge/self--hosted-yes-success)](#-部署)
[![License](https://img.shields.io/badge/license-MIT-blue)](#-license) [![License](https://img.shields.io/badge/license-MIT-blue)](#-license)
@@ -45,13 +45,13 @@
## ✨ 简介 ## ✨ 简介
**image2api** 把 Adobe Firefly、OpenAI、Runway、Leonardo、Krea、Imagine 等平台的图像 / 视频能力,统一封装成**一套 OpenAI 兼容的 API**;背后用多账号池自动调度 —— 额度耗尽自动换号、认证失效自动刷新或判死、临时错误自动重试、token 到期前主动续期 —— 对外提供稳定服务。 **image2api** 把 Adobe Firefly、OpenAI、Runway、Grok、Leonardo、Krea、Imagine 等平台的图像 / 视频能力,统一封装成**一套 OpenAI 兼容的 API**;背后用多账号池自动调度 —— 额度耗尽自动换号、认证失效自动刷新或判死、临时错误自动重试、token 到期前主动续期 —— 对外提供稳定服务。
它不只是 API 代理:自带**积分计费、CDK 充值、邀请奖励、用户体系、管理后台、现代化画图前端**,一条命令即可跑成一个对外运营的 AI 生成站点 —— 作者的线上实例 **[Vivid AI · vividai.run](https://vividai.run)**(品牌)即基于本项目搭建。 它不只是 API 代理:自带**积分计费、CDK 充值、邀请奖励、用户体系、管理后台、现代化画图前端**,一条命令即可跑成一个对外运营的 AI 生成站点 —— 作者的线上实例 **[Vivid AI · vividai.run](https://vividai.run)**(品牌)即基于本项目搭建。
> 💡 前后端**完全开源**(MIT),Go + Vue 3,可自由二开 / 自部署。 > 💡 前后端**完全开源**(MIT),Go + Vue 3,可自由二开 / 自部署。
**一句话亮点** 🔌 OpenAI 兼容 · 🤖 6 平台十余模型 · 🔁 自动换号 / Token 保活 · 💳 积分 + 代理价计费 · 🎨 画图前端 + 管理后台 · 🐳 一键部署 + 自动 HTTPS **一句话亮点** 🔌 OpenAI 兼容 · 🤖 7 平台十余模型 · 🔁 自动换号 / Token 保活 · 💳 积分 + 代理价计费 · 🎨 画图前端 + 管理后台 · 🐳 一键部署 + 自动 HTTPS
## 🖼️ 界面预览 ## 🖼️ 界面预览
@@ -66,7 +66,7 @@
#### 🎨 生成能力 #### 🎨 生成能力
- 生图 + 生视频一站式,支持**图生图 / 参考图**(首帧、末帧、风格参考) - 生图 + 生视频一站式,支持**图生图 / 参考图**(首帧、末帧、风格参考)
- 多分辨率(1K / 2K / 4K)、多宽高比、视频多时长,按模型独立配置与定价 - 多分辨率(1K / 2K / 4K)、多宽高比、视频多时长,按模型独立配置与定价
- 6 大供应商、十余模型,后台**动态启用 / 下架 / 改价**,无需改代码 - 7 大供应商、十余模型,后台**动态启用 / 下架 / 改价**,无需改代码
#### 🔌 OpenAI 兼容 #### 🔌 OpenAI 兼容
- 文生图 `/v1/images/generations` · 图生图 `/v1/images/edits`(multipart 上传参考图) · 视频 `/v1/videos`(Sora 式异步:创建→轮询→`/content` 下载) · `/v1/models` - 文生图 `/v1/images/generations` · 图生图 `/v1/images/edits`(multipart 上传参考图) · 视频 `/v1/videos`(Sora 式异步:创建→轮询→`/content` 下载) · `/v1/models`
@@ -103,9 +103,10 @@
| 供应商 | 模型(示例) | 类型 | | 供应商 | 模型(示例) | 类型 |
|---|---|---| |---|---|---|
| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · nano-banana-2 · flux-kontext-max · firefly-video · firefly-ray · gemini-veo31 | 图像 / 视频 | | **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · flux-kontext-max · firefly-video · firefly-ray · gemini-veo31 | 图像 / 视频 |
| **OpenAI** | gpt-image-2 | 图像 | | **OpenAI** | gpt-image-2 | 图像 |
| **Runway** | runway-gen4-turbo | 视频 | | **Runway** | runway-gen4-turbo · nano-banana-2(Nano Banana 2) | 视频 / 图像 |
| **Grokgrok.com** | grok-videoimagine 文生 / 图生视频) | 视频 |
| **Leonardo.ai** | seedream-4.5 | 图像 | | **Leonardo.ai** | seedream-4.5 | 图像 |
| **Krea.ai** | flux-klein-2 | 图像 | | **Krea.ai** | flux-klein-2 | 图像 |
| **Imagine.art** | imagine-1.5 · imagine-1.5pro | 图像 | | **Imagine.art** | imagine-1.5 · imagine-1.5pro | 图像 |
@@ -233,7 +234,8 @@ backend/ 后端源码(Go)
│ ├── provider/ 各上游供应商客户端 │ ├── provider/ 各上游供应商客户端
│ │ ├── adobe/ Adobe Firefly(tls-client 指纹) │ │ ├── adobe/ Adobe Firefly(tls-client 指纹)
│ │ ├── chatgpt/ OpenAI(含 PoW / turnstile) │ │ ├── chatgpt/ OpenAI(含 PoW / turnstile)
│ │ ├── runway/ Runway 视频 │ │ ├── runway/ Runway 视频 + Nano Banana 图像
│ │ ├── grok/ Grok(grok.com,statsig 伪造,视频)
│ │ ├── leonardo/ Leonardo │ │ ├── leonardo/ Leonardo
│ │ ├── krea/ Krea │ │ ├── krea/ Krea
│ │ └── imagine/ Imagine.art │ │ └── imagine/ Imagine.art
+4 -2
View File
@@ -12,6 +12,7 @@ import (
"backend/internal/model" "backend/internal/model"
"backend/internal/provider/adobe" "backend/internal/provider/adobe"
"backend/internal/provider/chatgpt" "backend/internal/provider/chatgpt"
"backend/internal/provider/grok"
"backend/internal/provider/imagine" "backend/internal/provider/imagine"
"backend/internal/provider/krea" "backend/internal/provider/krea"
"backend/internal/provider/leonardo" "backend/internal/provider/leonardo"
@@ -108,14 +109,15 @@ func NewApp(ctx context.Context) (*App, error) {
leonardoClient := leonardo.NewClient("") leonardoClient := leonardo.NewClient("")
kreaClient := krea.NewClient("") kreaClient := krea.NewClient("")
imagineClient := imagine.NewClient("") imagineClient := imagine.NewClient("")
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, tokenRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, rustfsClient) grokClient := grok.NewClient("")
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, tokenRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, rustfsClient)
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle) siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
showcaseSvc := service.NewShowcaseService(showcaseRepo) showcaseSvc := service.NewShowcaseService(showcaseRepo)
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient) adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient)
adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo) adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo)
cdkSvc := service.NewCDKService(cdkRepo, userRepo) cdkSvc := service.NewCDKService(cdkRepo, userRepo)
apiKeySvc := service.NewAPIKeyService(apiKeyRepo) apiKeySvc := service.NewAPIKeyService(apiKeyRepo)
tokenSvc := service.NewTokenService(tokenRepo, refreshRepo, eventRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient) tokenSvc := service.NewTokenService(tokenRepo, refreshRepo, eventRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient)
refreshSvc := service.NewRefreshProfileService(refreshRepo, tokenRepo, adobeClient) refreshSvc := service.NewRefreshProfileService(refreshRepo, tokenRepo, adobeClient)
// Enable refresh-then-retry on a mid-request Adobe 401 (re-mint access token // Enable refresh-then-retry on a mid-request Adobe 401 (re-mint access token
// from the cookie). Wired post-construction to avoid a ctor init cycle. // from the cookie). Wired post-construction to avoid a ctor init cycle.
@@ -102,6 +102,36 @@ func (h *ProviderAdminHandler) ImportRunwayToken(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"}) c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
} }
func (h *ProviderAdminHandler) ImportGrokToken(c *gin.Context) {
var body struct {
AccessToken string `json:"access_token"`
Value string `json:"value"`
SSO string `json:"sso"`
Name string `json:"name"`
ID string `json:"id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
token := body.AccessToken
for _, v := range []string{body.Value, body.SSO} {
if token == "" {
token = v
}
}
name := body.Name
if name == "" {
name = body.ID
}
item, err := h.tokens.ImportGrokToken(c.Request.Context(), token, name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
}
func (h *ProviderAdminHandler) ImportKreaCookie(c *gin.Context) { func (h *ProviderAdminHandler) ImportKreaCookie(c *gin.Context) {
var body struct { var body struct {
Cookie string `json:"cookie"` Cookie string `json:"cookie"`
+1
View File
@@ -108,6 +108,7 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
authed.POST("/tokens/import-leonardo-cookie", handlers.ProviderAdmin.ImportLeonardoCookie) authed.POST("/tokens/import-leonardo-cookie", handlers.ProviderAdmin.ImportLeonardoCookie)
authed.POST("/tokens/import-krea-cookie", handlers.ProviderAdmin.ImportKreaCookie) authed.POST("/tokens/import-krea-cookie", handlers.ProviderAdmin.ImportKreaCookie)
authed.POST("/tokens/import-imagine-token", handlers.ProviderAdmin.ImportImagineToken) authed.POST("/tokens/import-imagine-token", handlers.ProviderAdmin.ImportImagineToken)
authed.POST("/tokens/import-grok-token", handlers.ProviderAdmin.ImportGrokToken)
authed.POST("/tokens/delete-bulk", handlers.ProviderAdmin.TokenDeleteBulk) authed.POST("/tokens/delete-bulk", handlers.ProviderAdmin.TokenDeleteBulk)
authed.PATCH("/tokens/:pool/:id", handlers.ProviderAdmin.TokenUpdate) authed.PATCH("/tokens/:pool/:id", handlers.ProviderAdmin.TokenUpdate)
authed.DELETE("/tokens/:pool/:id", handlers.ProviderAdmin.TokenDelete) authed.DELETE("/tokens/:pool/:id", handlers.ProviderAdmin.TokenDelete)
+438
View File
@@ -0,0 +1,438 @@
// Package grok implements the Grok (grok.com / xAI) provider client. Auth is the
// website "sso" session cookie (a JWT whose only claim is a session_id — no exp,
// no refresh: when the session dies upstream the account is simply dead, never
// renewed). grok.com gates requests with an x-statsig-id header; the web app's
// value is just a base64-encoded fake JS TypeError string, which the upstream
// accepts — so we spoof it the same way (no Cloudflare clearance needed). Uses
// tls-client so the JA3/JA4 fingerprint matches Chrome.
package grok
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"math/rand/v2"
"strconv"
"strings"
http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/bogdanfinn/tls-client/profiles"
"github.com/google/uuid"
)
const (
apiBase = "https://grok.com"
origin = "https://grok.com"
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
// fullCredits is the weekly grant — UI shows "100 满额".
fullCredits = 100
)
var (
ErrAuth = errors.New("grok auth failed")
ErrQuotaExhausted = errors.New("grok quota exhausted")
ErrTemporaryUpstream = errors.New("grok upstream temporary error")
)
type Client struct {
proxy string
}
func NewClient(proxy string) *Client {
return &Client{proxy: strings.TrimSpace(proxy)}
}
func (c *Client) SetProxy(proxy string) {
c.proxy = strings.TrimSpace(proxy)
}
// IsGrokToken reports whether a JWT looks like a Grok website "sso" cookie: a
// payload whose ONLY claim is "session_id". That disambiguates it from a runway
// token (id + sso claims) or a chatgpt token (openai.com claims).
func IsGrokToken(token string) bool {
claims := decodeJWTPayload(token)
if len(claims) == 0 {
return false
}
if _, ok := claims["session_id"]; !ok {
return false
}
// Reject tokens that ALSO carry other-provider markers.
for k := range claims {
if k == "session_id" {
continue
}
if k == "sso" || k == "id" || strings.HasPrefix(k, "https://api.openai.com/") {
return false
}
}
return true
}
// SessionIDFromToken returns the sso session id (for dedup / display).
func SessionIDFromToken(token string) string {
return strings.TrimSpace(stringValue(decodeJWTPayload(token)["session_id"]))
}
// ExtractAccountInfo returns the free (no-network) account view. grok sso has no
// email/exp claim, so identity falls back to the session id.
func ExtractAccountInfo(token string) map[string]any {
sid := SessionIDFromToken(token)
return map[string]any{
"email": emptyStringNil(sid),
"session_id": emptyStringNil(sid),
"expires_at": nil,
}
}
// FetchCreditsBalance reads the account's live credit balance via the billing
// gRPC-web endpoint GetGrokCreditsConfig (empty request). The response carries
// the remaining credits (field 1, a float32) and the weekly reset timestamp
// (field 5). A 401/403 maps to ErrAuth (the session is dead). Returns the
// normalized map the TokenService quota plumbing expects.
func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[string]any, error) {
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
if token == "" {
return unknownBalance("empty token"), nil
}
client, err := c.newTLSClient()
if err != nil {
return nil, err
}
// gRPC-web empty message frame: 1-byte flag + 4-byte length (both zero).
body := []byte{0, 0, 0, 0, 0}
req, err := http.NewRequest(http.MethodPost, apiBase+"/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig", strings.NewReader(string(body)))
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
c.applyHeaders(req, token, map[string]string{
"content-type": "application/grpc-web+proto",
"x-grpc-web": "1",
"accept": "application/grpc-web+proto",
})
resp, err := client.Do(req)
if err != nil {
return unknownBalance("network: " + err.Error()), nil
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return nil, ErrAuth
}
if resp.StatusCode != 200 {
return unknownBalance(fmt.Sprintf("http %d: %s", resp.StatusCode, clip(raw, 160))), nil
}
// GetGrokCreditsConfig field #1 is the credits USED this period (not remaining):
// an exhausted account reads 100, a fresh one reads ~0. Remaining = 100 - used.
used, reset, ok := parseCreditsConfig(raw)
if !ok {
return unknownBalance("unparsable credits config"), nil
}
if used < 0 {
used = 0
}
if used > fullCredits {
used = fullCredits
}
remaining := fullCredits - used
return map[string]any{
"remaining": remaining,
"used": used,
"total": fullCredits,
"reset_after": emptyStringNil(reset),
"unknown": false,
"error": nil,
}, nil
}
// FetchSession reads the account profile via GET /api/auth/session and returns
// (email, userID). A 401/403 means the sso session is dead → ErrAuth.
func (c *Client) FetchSession(ctx context.Context, token string) (email, userID string, err error) {
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
if token == "" {
return "", "", ErrAuth
}
client, err := c.newTLSClient()
if err != nil {
return "", "", err
}
req, err := http.NewRequest(http.MethodGet, apiBase+"/api/auth/session", nil)
if err != nil {
return "", "", err
}
req = req.WithContext(ctx)
c.applyHeaders(req, token, nil)
resp, err := client.Do(req)
if err != nil {
return "", "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return "", "", ErrAuth
}
if resp.StatusCode != 200 {
return "", "", fmt.Errorf("%w: session http %d", ErrTemporaryUpstream, resp.StatusCode)
}
var body struct {
Session struct {
Email string `json:"email"`
UserID string `json:"userId"`
} `json:"session"`
}
if err := json.Unmarshal(raw, &body); err != nil {
return "", "", fmt.Errorf("%w: session non-json", ErrTemporaryUpstream)
}
return strings.TrimSpace(body.Session.Email), strings.TrimSpace(body.Session.UserID), nil
}
// statsigID mirrors grok2api's _statsig_id: base64 of a fake JS TypeError string.
// The upstream's anti-bot check accepts this spoofed value.
func statsigID() string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 5)
for i := range b {
b[i] = charset[rand.IntN(len(charset))]
}
msg := fmt.Sprintf("x1:TypeError: Cannot read properties of null (reading 'children['%s']')", string(b))
return base64.StdEncoding.EncodeToString([]byte(msg))
}
// applyHeaders sets the browser-like header set + sso cookie + spoofed statsig id.
// extra overrides/adds per-request headers (e.g. content-type).
func (c *Client) applyHeaders(req *http.Request, token string, extra map[string]string) {
h := http.Header{
"accept": {"*/*"},
"accept-language": {"en-US,en;q=0.9"},
"content-type": {"application/json"},
"origin": {origin},
"referer": {origin + "/"},
"user-agent": {userAgent},
"x-statsig-id": {statsigID()},
"x-xai-request-id": {uuid.NewString()},
"sec-ch-ua": {`"Chromium";v="133", "Not(A:Brand";v="99"`},
"sec-ch-ua-mobile": {"?0"},
"sec-ch-ua-platform": {`"Windows"`},
"sec-fetch-dest": {"empty"},
"sec-fetch-mode": {"cors"},
"sec-fetch-site": {"same-origin"},
"cookie": {"sso=" + token + "; sso-rw=" + token},
}
for k, v := range extra {
h[k] = []string{v}
}
h[http.HeaderOrderKey] = []string{
"accept", "accept-language", "content-type", "origin", "referer",
"user-agent", "x-statsig-id", "x-xai-request-id", "x-grpc-web",
"sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "cookie",
}
req.Header = h
}
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) {
options := []tlsclient.HttpClientOption{
tlsclient.WithTimeoutSeconds(120),
tlsclient.WithClientProfile(profiles.Chrome_133),
tlsclient.WithRandomTLSExtensionOrder(),
}
if c.proxy != "" {
options = append(options, tlsclient.WithProxyUrl(c.proxy))
}
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
}
// --- gRPC-web / protobuf decoding for GetGrokCreditsConfig ---
// parseCreditsConfig extracts (remaining credits, reset RFC3339-ish unix string)
// from the gRPC-web framed protobuf. Layout (reverse-engineered):
//
// frame: 1-byte flag + 4-byte big-endian length + payload
// payload: field 1 (message) {
// field 1: float32 -> remaining credits
// field 5: message { field 1: varint -> reset unix seconds }
// }
func parseCreditsConfig(buf []byte) (remaining int, resetUnix string, ok bool) {
for len(buf) >= 5 {
flag := buf[0]
ln := int(buf[1])<<24 | int(buf[2])<<16 | int(buf[3])<<8 | int(buf[4])
buf = buf[5:]
if ln > len(buf) {
break
}
payload := buf[:ln]
buf = buf[ln:]
if flag&0x80 != 0 { // trailer frame (grpc-status), skip
continue
}
// payload: expect field 1 (wire type 2) wrapping the config message.
fn, wt, val, rest, good := readField(payload)
if !good || fn != 1 || wt != 2 {
continue
}
rem, reset, found := scanConfigMessage(val)
_ = rest
if found {
return rem, reset, true
}
}
return 0, "", false
}
func scanConfigMessage(msg []byte) (remaining int, resetUnix string, ok bool) {
var remF float32
haveRem := false
for len(msg) > 0 {
fn, wt, val, rest, good := readField(msg)
if !good {
break
}
msg = rest
switch {
case fn == 1 && wt == 5: // float32 remaining credits
remF = float32FromLE(val)
haveRem = true
case fn == 5 && wt == 2: // reset timestamp message { #1 varint=seconds }
if sec, sok := firstVarint(val); sok {
resetUnix = strconv.FormatInt(sec, 10)
}
}
}
if haveRem {
return int(remF), resetUnix, true
}
return 0, resetUnix, false
}
// readField reads one protobuf field: returns (fieldNum, wireType, value, rest, ok).
// For wt 2 value is the length-delimited bytes; wt 5 the 4 LE bytes; wt 0 the
// raw varint bytes; wt 1 the 8 bytes.
func readField(b []byte) (fn int, wt int, val []byte, rest []byte, ok bool) {
tag, n := readVarint(b)
if n == 0 {
return 0, 0, nil, b, false
}
b = b[n:]
fn = int(tag >> 3)
wt = int(tag & 7)
switch wt {
case 0:
_, m := readVarint(b)
if m == 0 {
return 0, 0, nil, b, false
}
return fn, wt, b[:m], b[m:], true
case 1:
if len(b) < 8 {
return 0, 0, nil, b, false
}
return fn, wt, b[:8], b[8:], true
case 2:
ln, m := readVarint(b)
if m == 0 || int(ln) > len(b)-m {
return 0, 0, nil, b, false
}
return fn, wt, b[m : m+int(ln)], b[m+int(ln):], true
case 5:
if len(b) < 4 {
return 0, 0, nil, b, false
}
return fn, wt, b[:4], b[4:], true
default:
return 0, 0, nil, b, false
}
}
func firstVarint(b []byte) (int64, bool) {
fn, wt, val, _, ok := readField(b)
if !ok || fn != 1 || wt != 0 {
return 0, false
}
v, _ := readVarint(val)
return int64(v), true
}
func readVarint(b []byte) (uint64, int) {
var v uint64
var s uint
for i := 0; i < len(b); i++ {
v |= uint64(b[i]&0x7f) << s
if b[i]&0x80 == 0 {
return v, i + 1
}
s += 7
}
return 0, 0
}
func float32FromLE(b []byte) float32 {
if len(b) < 4 {
return 0
}
bits := uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24
return math.Float32frombits(bits)
}
// --- small helpers (mirror the other provider clients) ---
func decodeJWTPayload(token string) map[string]any {
parts := strings.Split(strings.TrimSpace(strings.TrimPrefix(token, "Bearer ")), ".")
if len(parts) < 2 {
return map[string]any{}
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return map[string]any{}
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
return map[string]any{}
}
return out
}
func stringValue(v any) string {
switch x := v.(type) {
case string:
return x
case nil:
return ""
default:
b, _ := json.Marshal(x)
return strings.TrimSpace(string(b))
}
}
func emptyStringNil(v string) any {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
return v
}
func unknownBalance(reason string) map[string]any {
return map[string]any{
"remaining": nil, "used": nil, "total": nil,
"unknown": true, "error": reason,
}
}
func clip(b []byte, n int) string {
s := strings.TrimSpace(string(b))
if len(s) > n {
return s[:n]
}
return s
}
+278
View File
@@ -0,0 +1,278 @@
package grok
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"regexp"
"strings"
http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
)
// assetBase is where generated media artifacts live (the stream returns a
// path like "users/<uid>/generated/<id>/generated_video.mp4").
const assetBase = "https://assets.grok.com/"
var videoURLRe = regexp.MustCompile(`"videoUrl":"([^"]+)"`)
// GenerateVideo runs grok's imagine video pipeline:
// 1. POST /rest/media/post/create -> a media post id (parentPostId)
// 2. POST /rest/app-chat/conversations/new with modelName "imagine-video-gen"
// and a videoGenModelConfig referencing that post id; the streaming response
// reports progress and, at completion, the artifact videoUrl.
//
// frames (optional, up to the model's max) enable image-to-video: each image is
// uploaded to grok and referenced as an imageReference. aspectRatio is passed
// through ("9:16" etc.); resolution is the tier ("720p"); seconds is the clip
// length (6 or 10). When downloadResult is false, returns nil bytes and the
// artifact URL in meta["video_url"]; otherwise downloads the mp4.
func (c *Client) GenerateVideo(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
}
if strings.TrimSpace(prompt) == "" {
return nil, nil, fmt.Errorf("grok: prompt required")
}
if strings.TrimSpace(aspectRatio) == "" {
aspectRatio = "16:9"
}
if strings.TrimSpace(resolution) == "" {
resolution = "720p"
}
if seconds != 6 && seconds != 10 {
seconds = 10
}
client, err := c.newTLSClient()
if err != nil {
return nil, nil, err
}
// Image-to-video: upload each reference frame and collect its asset URL.
var imageRefs []string
for _, f := range frames {
if len(f) == 0 {
continue
}
url, upErr := c.uploadImage(ctx, client, token, f)
if upErr != nil {
return nil, nil, upErr
}
imageRefs = append(imageRefs, url)
}
postID, err := c.createPost(ctx, client, token, prompt)
if err != nil {
return nil, nil, err
}
videoCfg := map[string]any{
"parentPostId": postID,
"aspectRatio": aspectRatio,
"videoLength": seconds,
"resolutionName": resolution,
"isReferenceToVideo": len(imageRefs) > 0,
}
if len(imageRefs) > 0 {
videoCfg["imageReferences"] = imageRefs
}
payload := map[string]any{
"temporary": true,
"modelName": "imagine-video-gen",
"message": prompt + " --mode=custom",
"enableSideBySide": true,
"responseMetadata": map[string]any{
"modelConfigOverride": map[string]any{
"modelMap": map[string]any{"videoGenModelConfig": videoCfg},
},
},
}
body, err := c.postStream(ctx, client, token, "/rest/app-chat/conversations/new", payload)
if err != nil {
return nil, nil, err
}
// Out-of-credits surfaces as a stream error (HTTP is still 200).
if strings.Contains(body, "usagePoolExhausted") || strings.Contains(body, "media generation credits") {
return nil, nil, fmt.Errorf("%w: media generation credits exhausted", ErrQuotaExhausted)
}
// The artifact path appears as "videoUrl":"users/.../generated_video.mp4".
var artifact string
for _, m := range videoURLRe.FindAllStringSubmatch(body, -1) {
if v := strings.TrimSpace(m[1]); v != "" {
artifact = v // keep the last (progress=100) one
}
}
if artifact == "" {
return nil, nil, fmt.Errorf("%w: no video artifact in response: %s", ErrTemporaryUpstream, clip([]byte(body), 200))
}
fullURL := artifact
if !strings.HasPrefix(fullURL, "http") {
fullURL = assetBase + strings.TrimPrefix(artifact, "/")
}
meta := map[string]any{
"provider": "grok",
"post_id": postID,
"video_url": fullURL,
}
if !downloadResult {
return nil, meta, nil
}
data, err := c.download(ctx, client, token, fullURL)
if err != nil {
return nil, nil, err
}
return data, meta, nil
}
// uploadImage uploads one reference frame via /rest/app-chat/upload-file (JSON
// with base64 content) and returns its asset content URL for imageReferences.
func (c *Client) uploadImage(ctx context.Context, client tlsclient.HttpClient, token string, img []byte) (string, error) {
res, err := c.postJSON(ctx, client, token, "/rest/app-chat/upload-file", map[string]any{
"fileName": "ref.png",
"fileMimeType": "image/png",
"content": base64.StdEncoding.EncodeToString(img),
})
if err != nil {
return "", err
}
fileURI := strings.TrimSpace(stringValue(res["fileUri"]))
if fileURI == "" {
return "", fmt.Errorf("%w: upload missing fileUri", ErrTemporaryUpstream)
}
if strings.HasPrefix(fileURI, "http") {
return fileURI, nil
}
return assetBase + strings.TrimPrefix(fileURI, "/"), nil
}
// createPost registers a video media post and returns its id (parentPostId).
func (c *Client) createPost(ctx context.Context, client tlsclient.HttpClient, token, prompt string) (string, error) {
res, err := c.postJSON(ctx, client, token, "/rest/media/post/create", map[string]any{
"mediaType": "MEDIA_POST_TYPE_VIDEO",
"prompt": prompt,
})
if err != nil {
return "", err
}
post, _ := res["post"].(map[string]any)
id := strings.TrimSpace(stringValue(post["id"]))
if id == "" {
return "", fmt.Errorf("%w: media post missing id", ErrTemporaryUpstream)
}
return id, nil
}
// postJSON does an authed JSON POST and parses a single JSON object response.
func (c *Client) postJSON(ctx context.Context, client tlsclient.HttpClient, token, path string, body any) (map[string]any, error) {
raw, status, err := c.doPost(ctx, client, token, path, body)
if err != nil {
return nil, err
}
if e := mapStatus(path, status, raw); e != nil {
return nil, e
}
var out map[string]any
if len(raw) == 0 {
return map[string]any{}, 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
}
// postStream does an authed JSON POST and returns the full (streamed) text body.
func (c *Client) postStream(ctx context.Context, client tlsclient.HttpClient, token, path string, body any) (string, error) {
raw, status, err := c.doPost(ctx, client, token, path, body)
if err != nil {
return "", err
}
if e := mapStatus(path, status, raw); e != nil {
return "", e
}
return string(raw), nil
}
func (c *Client) doPost(ctx context.Context, client tlsclient.HttpClient, token, path string, body any) ([]byte, int, error) {
var reader io.Reader
if body != nil {
b, _ := json.Marshal(body)
reader = strings.NewReader(string(b))
}
req, err := http.NewRequest(http.MethodPost, apiBase+path, reader)
if err != nil {
return nil, 0, err
}
req = req.WithContext(ctx)
c.applyHeaders(req, token, nil)
resp, err := client.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return raw, resp.StatusCode, nil
}
func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, token, url string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"user-agent": {userAgent},
"referer": {origin + "/"},
"cookie": {"sso=" + token + "; sso-rw=" + token},
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%w: download %d", ErrTemporaryUpstream, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if len(data) == 0 {
return nil, fmt.Errorf("%w: empty video download", ErrTemporaryUpstream)
}
return data, nil
}
// mapStatus maps an HTTP status to the shared provider error sentinels.
func mapStatus(path string, status int, raw []byte) error {
switch {
case status == 200:
return nil
case status == 401 || status == 403:
return fmt.Errorf("%w: %s %d %s", ErrAuth, path, status, clip(raw, 160))
case status == 429:
return fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 160))
case status >= 500:
return fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, path, status, clip(raw, 160))
default:
if isCreditError(string(raw)) {
return fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(raw, 160))
}
return fmt.Errorf("grok: %s %d %s", path, status, clip(raw, 160))
}
}
func isCreditError(s string) bool {
s = strings.ToLower(s)
return strings.Contains(s, "usagepoolexhausted") || strings.Contains(s, "credit") || strings.Contains(s, "insufficient") || strings.Contains(s, "quota")
}
@@ -139,6 +139,8 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Per ops decision a rate-limit (403) is treated as a dead account too, same as
// a 401 — a throttled Runway token is considered done.
if resp.StatusCode == 401 || resp.StatusCode == 403 { if resp.StatusCode == 401 || resp.StatusCode == 403 {
return nil, ErrAuth return nil, ErrAuth
} }
+139
View File
@@ -0,0 +1,139 @@
package runway
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"strings"
"time"
http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/google/uuid"
)
// GenerateImage runs the Runway "Nano Banana 2" (gemini_3_1_flash_image)
// text/image-to-image pipeline: upload each reference image (DATASET +
// DATASET_PREVIEW → dataset) to obtain its {assetId, url}, create a gemini image
// task and poll it to completion, then download the rendered PNG. teamID is the
// workspace id; if empty it's derived from the token. aspectRatio is passed
// through as-is (e.g. "16:9"); imageSize is the "1K"/"2K"/"4K" tier. refs may be
// empty (pure text-to-image).
func (c *Client) GenerateImage(ctx context.Context, token, teamID, prompt, aspectRatio, imageSize string, refs [][]byte) ([]byte, map[string]any, error) {
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
if token == "" {
return nil, nil, ErrAuth
}
if teamID == "" {
teamID = TeamIDFromToken(token)
}
if teamID == "" {
return nil, nil, errors.New("runway: no team id")
}
if strings.TrimSpace(aspectRatio) == "" {
aspectRatio = "16:9"
}
if strings.TrimSpace(imageSize) == "" {
imageSize = "1K"
}
client, err := c.newTLSClient()
if err != nil {
return nil, nil, err
}
var refImages []map[string]any
for i, raw := range refs {
if len(raw) == 0 {
continue
}
filename := fmt.Sprintf("ref_%s_%d.png", time.Now().UTC().Format("20060102_150405"), i+1)
assetID, url, upErr := c.uploadReference(ctx, client, token, teamID, filename, raw)
if upErr != nil {
return nil, nil, upErr
}
refImages = append(refImages, map[string]any{
"tag": fmt.Sprintf("IMG_%d", i+1),
"assetId": assetID,
"url": url,
})
}
taskID, err := c.createImageTask(ctx, client, token, teamID, prompt, aspectRatio, imageSize, refImages)
if err != nil {
return nil, nil, err
}
artifactURL, err := c.pollTask(ctx, client, token, teamID, taskID)
if err != nil {
return nil, nil, err
}
data, err := c.download(ctx, client, artifactURL)
if err != nil {
return nil, nil, err
}
meta := map[string]any{
"provider": "runway",
"task_id": taskID,
"team_id": teamID,
"image_url": artifactURL,
}
return data, meta, nil
}
// uploadReference uploads one reference image through the dataset pipeline
// (DATASET_PREVIEW + DATASET uploads → /v1/datasets) and returns its asset id
// (= dataset id) and the cloudfront URL the task references.
func (c *Client) uploadReference(ctx context.Context, client tlsclient.HttpClient, token, teamID, filename string, data []byte) (string, string, error) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return "", "", errors.New("runway: failed to decode reference image")
}
previewUploadID, _, err := c.uploadFile(ctx, client, token, teamID, filename, "DATASET_PREVIEW", data)
if err != nil {
return "", "", err
}
// The DATASET upload's completed URL is exactly what the task references.
datasetUploadID, refURL, err := c.uploadFile(ctx, client, token, teamID, filename, "DATASET", data)
if err != nil {
return "", "", err
}
assetID, _, err := c.createDataset(ctx, client, token, teamID, filename, datasetUploadID, previewUploadID, cfg.Width, cfg.Height)
if err != nil {
return "", "", err
}
return assetID, refURL, nil
}
// createImageTask creates a gemini_3_1_flash_image task and returns its id.
func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, prompt, aspectRatio, imageSize string, refImages []map[string]any) (string, error) {
opts := map[string]any{
"name": "Nano Banana 2 - " + prompt,
"text_prompt": prompt,
"aspect_ratio": aspectRatio,
"num_images": 1,
"image_size": imageSize,
"model": "gemini-3.1-flash-image-preview",
"exploreMode": false,
"creationSource": "tool-mode",
}
if len(refImages) > 0 {
opts["reference_images"] = refImages
}
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/tasks", map[string]any{
"taskType": "gemini_3_1_flash_image",
"options": opts,
"asTeamId": jsonNumberOrString(teamID),
"sessionId": uuid.NewString(),
})
if err != nil {
return "", err
}
task, _ := res["task"].(map[string]any)
id := strings.TrimSpace(stringValue(task["id"]))
if id == "" {
return "", fmt.Errorf("%w: image task missing id", ErrTemporaryUpstream)
}
return id, nil
}
@@ -300,6 +300,7 @@ func (c *Client) apiJSON(ctx context.Context, client tlsclient.HttpClient, token
} }
switch { switch {
case resp.StatusCode == 401 || resp.StatusCode == 403: case resp.StatusCode == 401 || resp.StatusCode == 403:
// Rate-limit (403) is treated as a dead account too, same as a 401.
return nil, fmt.Errorf("%w: %s %d %s", ErrAuth, path, resp.StatusCode, clip(raw, 200)) return nil, fmt.Errorf("%w: %s %d %s", ErrAuth, path, resp.StatusCode, clip(raw, 200))
case resp.StatusCode == 429: case resp.StatusCode == 429:
return nil, fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 200)) return nil, fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 200))
+5
View File
@@ -342,6 +342,11 @@ func (s *AdminReadService) Providers(ctx context.Context) ([]map[string]any, err
}{ }{
{Name: "chatgpt", Pool: "chatgpt", Type: "openai"}, {Name: "chatgpt", Pool: "chatgpt", Type: "openai"},
{Name: "adobe", Pool: "adobe", Type: "adobe"}, {Name: "adobe", Pool: "adobe", Type: "adobe"},
{Name: "runway", Pool: "runway", Type: "runway"},
{Name: "leonardo", Pool: "leonardo", Type: "leonardo"},
{Name: "krea", Pool: "krea", Type: "krea"},
{Name: "imagine", Pool: "imagine", Type: "imagine"},
{Name: "grok", Pool: "grok", Type: "grok"},
} }
out := make([]map[string]any, 0, len(providers)) out := make([]map[string]any, 0, len(providers))
for _, item := range providers { for _, item := range providers {
+160 -10
View File
@@ -18,6 +18,7 @@ import (
"backend/internal/provider/imagine" "backend/internal/provider/imagine"
"backend/internal/provider/krea" "backend/internal/provider/krea"
"backend/internal/provider/leonardo" "backend/internal/provider/leonardo"
"backend/internal/provider/grok"
"backend/internal/provider/runway" "backend/internal/provider/runway"
"backend/internal/repo" "backend/internal/repo"
@@ -32,6 +33,7 @@ var validTokenPools = map[string]string{
"leonardo": "leonardo", "leonardo": "leonardo",
"krea": "krea", "krea": "krea",
"imagine": "imagine", "imagine": "imagine",
"grok": "grok",
} }
type TokenService struct { type TokenService struct {
@@ -45,6 +47,7 @@ type TokenService struct {
leonardo *leonardo.Client leonardo *leonardo.Client
krea *krea.Client krea *krea.Client
imagine *imagine.Client imagine *imagine.Client
grok *grok.Client
// sem caps concurrent background pending-probe goroutines (mirrors Python's // sem caps concurrent background pending-probe goroutines (mirrors Python's
// 10-worker _quota_check_pool) so a big paste doesn't fire hundreds of // 10-worker _quota_check_pool) so a big paste doesn't fire hundreds of
// simultaneous upstream requests. // simultaneous upstream requests.
@@ -54,7 +57,7 @@ type TokenService struct {
kreaActivating atomic.Bool kreaActivating atomic.Bool
} }
func NewTokenService(tokens *repo.TokenRepository, refresh *repo.RefreshProfileRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client) *TokenService { func NewTokenService(tokens *repo.TokenRepository, refresh *repo.RefreshProfileRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client, grokClient *grok.Client) *TokenService {
return &TokenService{ return &TokenService{
tokens: tokens, tokens: tokens,
refresh: refresh, refresh: refresh,
@@ -66,6 +69,7 @@ func NewTokenService(tokens *repo.TokenRepository, refresh *repo.RefreshProfileR
leonardo: leonardoClient, leonardo: leonardoClient,
krea: kreaClient, krea: kreaClient,
imagine: imagineClient, imagine: imagineClient,
grok: grokClient,
sem: make(chan struct{}, 10), sem: make(chan struct{}, 10),
} }
} }
@@ -91,6 +95,9 @@ func (s *TokenService) applyProxy(ctx context.Context) {
if s.imagine != nil { if s.imagine != nil {
s.imagine.SetProxy(proxy) s.imagine.SetProxy(proxy)
} }
if s.grok != nil {
s.grok.SetProxy(proxy)
}
} }
// RefreshExpiringTokens proactively renews krea/imagine sessions ~10min before // RefreshExpiringTokens proactively renews krea/imagine sessions ~10min before
@@ -840,6 +847,106 @@ func (s *TokenService) checkPendingRunway(tokenID, accessToken string) {
s.finishPending(ctx, "runway", tokenID, "active", false, quotaMeta) s.finishPending(ctx, "runway", tokenID, "active", false, quotaMeta)
} }
// ImportGrokToken lands a Grok website "sso" cookie (a JWT carrying only a
// session_id) as a pending account and probes its credit balance off-thread.
// Identity is the session id (grok sso has no email/exp claim). No refresh: a
// dead session just dies (失效就失效).
func (s *TokenService) ImportGrokToken(ctx context.Context, ssoToken, tokenID string) (*model.TokenAccount, error) {
ssoToken = strings.TrimSpace(strings.TrimPrefix(ssoToken, "Bearer "))
ssoToken = strings.TrimPrefix(ssoToken, "sso=")
if ssoToken == "" {
return nil, errors.New("sso token required")
}
if !grok.IsGrokToken(ssoToken) {
return nil, errors.New("not a grok sso token")
}
sid := grok.SessionIDFromToken(ssoToken)
// Resolve the real account email up front (GET /api/auth/session) for dedup +
// display — the sso session_id rotates per login, so email is the stable id.
email := ""
if s.grok != nil {
s.applyProxy(ctx)
if e, _, ferr := s.grok.FetchSession(ctx, ssoToken); ferr == nil {
email = e
}
}
idKey := email
if idKey == "" {
idKey = sid
}
// Identity is (pool, email): reuse the row for this account, else mint.
if existing, _ := s.tokens.GetByPoolEmail(ctx, "grok", idKey); existing != nil {
tokenID = existing.ID
} else if idKey != "" || tokenID == "" {
tokenID = newTokenID("grok")
}
meta := datatypes.JSONMap{"pending_check": true}
if sid != "" {
meta["session_id"] = sid
}
item, err := s.createToken(ctx, "grok", tokenID, ssoToken, "pending", meta)
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
if item, err = s.tokens.Update(ctx, "grok", tokenID, map[string]any{
"value": ssoToken, "status": "pending", "meta": meta,
}); err != nil {
return nil, err
}
} else {
return nil, err
}
}
if idKey != "" {
if updated, uerr := s.tokens.Update(ctx, "grok", tokenID, map[string]any{"account_email": idKey}); uerr == nil {
item = updated
}
}
go s.checkPendingGrok(tokenID, ssoToken)
return item, nil
}
func (s *TokenService) checkPendingGrok(tokenID, ssoToken string) {
defer func() {
if r := recover(); r != nil {
log.Printf("token import: grok pending check panicked for %s: %v", tokenID, r)
}
}()
s.sem <- struct{}{}
defer func() { <-s.sem }()
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
if s.grok == nil {
s.finishPending(ctx, "grok", tokenID, "active", false, nil)
return
}
s.applyProxy(ctx)
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)
}
// finishPending writes the terminal status/dead flag and clears the pending_check // finishPending writes the terminal status/dead flag and clears the pending_check
// marker (merging any cached quota) for a background import probe. // marker (merging any cached quota) for a background import probe.
func (s *TokenService) finishPending(ctx context.Context, pool, id, status string, dead bool, quotaMeta map[string]any) { func (s *TokenService) finishPending(ctx context.Context, pool, id, status string, dead bool, quotaMeta map[string]any) {
@@ -1224,13 +1331,10 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
meta := cloneJSONMap(item.Meta) meta := cloneJSONMap(item.Meta)
meta["cached_quota_at"] = int(time.Now().Unix()) meta["cached_quota_at"] = int(time.Now().Unix())
if remaining, ok := data["remaining"].(int); ok { if remaining, ok := data["remaining"].(int); ok {
// Refresh only updates the displayed balance number — it never flips
// status. Out-of-credits is judged at generation time (dead/401), so a
// refresh can't sink a runway account into a revivable "quota" state.
meta["cached_quota_remaining"] = remaining meta["cached_quota_remaining"] = remaining
// Refreshing in account management: an account below the credit floor
// is sunk into "限额" (quota) so it stops being scheduled. Mark-down
// only — recovery is a separate (unwritten) path.
if remaining < runwayMinCredits && item.Status == "active" {
patch["status"] = "quota"
}
} }
if used, ok := data["used"].(int); ok { if used, ok := data["used"].(int); ok {
meta["cached_quota_used"] = used meta["cached_quota_used"] = used
@@ -1256,12 +1360,58 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
"error": data["error"], "error": data["error"],
}, nil }, 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
}
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
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
}
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"],
}, nil
}
remaining, hasRemaining := jsonMapInt(item.Meta, "cached_quota_remaining") remaining, hasRemaining := jsonMapInt(item.Meta, "cached_quota_remaining")
quotaAt, _ := jsonMapInt(item.Meta, "cached_quota_at") quotaAt, _ := jsonMapInt(item.Meta, "cached_quota_at")
typeLabel := poolToType(item.Pool) typeLabel := poolToType(item.Pool)
return map[string]any{ return map[string]any{
"supported": typeLabel == "openai" || typeLabel == "adobe" || typeLabel == "runway", "supported": typeLabel == "openai" || typeLabel == "adobe" || typeLabel == "runway" || typeLabel == "grok",
"remaining": valueOrNil((typeLabel == "openai" || typeLabel == "runway") && hasRemaining, remaining), "remaining": valueOrNil((typeLabel == "openai" || typeLabel == "runway" || typeLabel == "grok") && hasRemaining, remaining),
"total": nil, "total": nil,
"reset_after": emptyToNil(item.CachedQuotaResetAfter), "reset_after": emptyToNil(item.CachedQuotaResetAfter),
"quota_cached_at": valueOrNil(quotaAt != 0, quotaAt), "quota_cached_at": valueOrNil(quotaAt != 0, quotaAt),
@@ -1372,7 +1522,7 @@ func accountRow(item model.TokenAccount, inFlight int64) map[string]any {
if item.Meta != nil { if item.Meta != nil {
teamID = strings.TrimSpace(stringValue(item.Meta["team_id"])) teamID = strings.TrimSpace(stringValue(item.Meta["team_id"]))
} }
hasQuota := typeLabel == "openai" || typeLabel == "adobe" || typeLabel == "runway" || typeLabel == "leonardo" || typeLabel == "krea" || typeLabel == "imagine" hasQuota := typeLabel == "openai" || typeLabel == "adobe" || typeLabel == "runway" || typeLabel == "leonardo" || typeLabel == "krea" || typeLabel == "imagine" || typeLabel == "grok"
return map[string]any{ return map[string]any{
"id": item.ID, "id": item.ID,
"pool": item.Pool, "pool": item.Pool,
+331 -130
View File
@@ -19,6 +19,7 @@ import (
"backend/internal/model" "backend/internal/model"
"backend/internal/provider/adobe" "backend/internal/provider/adobe"
"backend/internal/provider/chatgpt" "backend/internal/provider/chatgpt"
"backend/internal/provider/grok"
"backend/internal/provider/imagine" "backend/internal/provider/imagine"
"backend/internal/provider/krea" "backend/internal/provider/krea"
"backend/internal/provider/leonardo" "backend/internal/provider/leonardo"
@@ -68,6 +69,7 @@ type V1Service struct {
leonardo *leonardo.Client leonardo *leonardo.Client
krea *krea.Client krea *krea.Client
imagine *imagine.Client imagine *imagine.Client
grok *grok.Client
store *storage.Client store *storage.Client
// refresh re-mints an Adobe access token from its cookie when a request hits a // refresh re-mints an Adobe access token from its cookie when a request hits a
// 401 mid-flight (set via SetRefresh — wired after construction to avoid an // 401 mid-flight (set via SetRefresh — wired after construction to avoid an
@@ -97,17 +99,37 @@ type V1Service struct {
// account isn't already running a generation; release frees it when done. // account isn't already running a generation; release frees it when done.
type accountGate struct{ m sync.Map } // accountID -> struct{} held while busy type accountGate struct{ m sync.Map } // accountID -> struct{} held while busy
func (g *accountGate) tryAcquire(id string) bool { // tryAcquireN wins if the account has fewer than max in-flight jobs, atomically
// bumping its counter. max=1 is the default 1-job-per-account policy; some
// providers (grok) allow more.
func (g *accountGate) tryAcquireN(id string, max int) bool {
if id == "" { if id == "" {
return true return true
} }
_, loaded := g.m.LoadOrStore(id, struct{}{}) if max < 1 {
return !loaded max = 1
}
v, _ := g.m.LoadOrStore(id, new(int64))
cnt := v.(*int64)
for {
cur := atomic.LoadInt64(cnt)
if cur >= int64(max) {
return false
}
if atomic.CompareAndSwapInt64(cnt, cur, cur+1) {
return true
}
}
} }
func (g *accountGate) tryAcquire(id string) bool { return g.tryAcquireN(id, 1) }
func (g *accountGate) release(id string) { func (g *accountGate) release(id string) {
if id != "" { if id == "" {
g.m.Delete(id) return
}
if v, ok := g.m.Load(id); ok {
atomic.AddInt64(v.(*int64), -1)
} }
} }
@@ -173,7 +195,7 @@ type V1VideoRequest struct {
BaseURL string BaseURL string
} }
func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.UserRepository, events *repo.EventRepository, tokens *repo.TokenRepository, settings *repo.SiteSettingRepository, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client, store *storage.Client) *V1Service { func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.UserRepository, events *repo.EventRepository, tokens *repo.TokenRepository, settings *repo.SiteSettingRepository, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client, grokClient *grok.Client, store *storage.Client) *V1Service {
return &V1Service{ return &V1Service{
cfg: cfg, cfg: cfg,
models: models, models: models,
@@ -187,6 +209,7 @@ func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.
leonardo: leonardoClient, leonardo: leonardoClient,
krea: kreaClient, krea: kreaClient,
imagine: imagineClient, imagine: imagineClient,
grok: grokClient,
store: store, store: store,
inflight: &InflightRegistry{}, inflight: &InflightRegistry{},
} }
@@ -415,6 +438,23 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
} }
} }
imageBytes = b imageBytes = b
case "runway":
b, execErr := s.generateRunwayImage(genCtx, eventID, modelItem, in, aspectRatio, resolution)
if execErr != nil {
_ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
switch {
case errors.Is(execErr, runway.ErrAuth):
return nil, ErrProviderAuth
case errors.Is(execErr, runway.ErrQuotaExhausted):
return nil, ErrProviderQuota
case errors.Is(execErr, runway.ErrTemporaryUpstream):
return nil, ErrProviderTemporary
default:
return nil, fmt.Errorf("%w: %v", ErrProviderExecution, execErr)
}
}
imageBytes = b
default: default:
_ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0) _ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0)
@@ -517,6 +557,8 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
videoBytes, _, execErr = s.generateAdobeVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), true) videoBytes, _, execErr = s.generateAdobeVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), true)
case "runway": case "runway":
videoBytes, _, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), true) videoBytes, _, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), true)
case "grok":
videoBytes, _, execErr = s.generateGrokVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), true)
default: default:
_ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0) _ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0)
@@ -528,11 +570,11 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
switch { switch {
case errors.Is(execErr, ErrNoProviderAccount): case errors.Is(execErr, ErrNoProviderAccount):
return nil, ErrNoProviderAccount return nil, ErrNoProviderAccount
case errors.Is(execErr, adobe.ErrAuth), errors.Is(execErr, runway.ErrAuth): case errors.Is(execErr, adobe.ErrAuth), errors.Is(execErr, runway.ErrAuth), errors.Is(execErr, grok.ErrAuth):
return nil, ErrProviderAuth return nil, ErrProviderAuth
case errors.Is(execErr, adobe.ErrQuotaExhausted), errors.Is(execErr, runway.ErrQuotaExhausted): case errors.Is(execErr, adobe.ErrQuotaExhausted), errors.Is(execErr, runway.ErrQuotaExhausted), errors.Is(execErr, grok.ErrQuotaExhausted):
return nil, ErrProviderQuota return nil, ErrProviderQuota
case errors.Is(execErr, adobe.ErrTemporaryUpstream), errors.Is(execErr, runway.ErrTemporaryUpstream): case errors.Is(execErr, adobe.ErrTemporaryUpstream), errors.Is(execErr, runway.ErrTemporaryUpstream), errors.Is(execErr, grok.ErrTemporaryUpstream):
return nil, ErrProviderTemporary return nil, ErrProviderTemporary
default: default:
return nil, fmt.Errorf("%w: %v", ErrProviderExecution, execErr) return nil, fmt.Errorf("%w: %v", ErrProviderExecution, execErr)
@@ -621,6 +663,8 @@ func (s *V1Service) runVideoJob(ctx context.Context, principal *APIPrincipal, in
_, videoURL, execErr = s.generateAdobeVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), false) _, videoURL, execErr = s.generateAdobeVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), false)
case "runway": case "runway":
_, videoURL, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), false) _, videoURL, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), false)
case "grok":
_, videoURL, execErr = s.generateGrokVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), false)
default: default:
_ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0) _ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0)
@@ -1106,6 +1150,16 @@ func (s *V1Service) finishUnimplementedEvent(ctx context.Context, eventID string
// same account; account-level errors (auth/quota) skip straight to the next. // same account; account-level errors (auth/quota) skip straight to the next.
const maxSameAccountAttempts = 3 const maxSameAccountAttempts = 3
// grokConcurrencyPerAccount is how many simultaneous generations one grok account
// may run (grok tolerates 10, unlike the 1-per-account default elsewhere).
const grokConcurrencyPerAccount = 10
// maxTempDeadAccounts caps how many accounts the "temporary error = dead account"
// policy (tempAsDead, used by adobe) is allowed to mark dead + fail over before
// giving up, so an upstream-wide blip ("system under load") can't nuke the whole
// pool. After this many accounts fail this way, the request fails.
const maxTempDeadAccounts = 3
// runPoolWithFailover drives a generation across a round-robin-ordered account // runPoolWithFailover drives a generation across a round-robin-ordered account
// list with per-error-class behavior, so a bad request never burns the whole // list with per-error-class behavior, so a bad request never burns the whole
// pool while genuinely limited accounts still fail over: // pool while genuinely limited accounts still fail over:
@@ -1115,9 +1169,13 @@ const maxSameAccountAttempts = 3
// - 认证失效 auth → refresh the token from its cookie and retry ONCE with the // - 认证失效 auth → refresh the token from its cookie and retry ONCE with the
// fresh token; if it still auth-fails (or there's nothing to refresh, e.g. // fresh token; if it still auth-fails (or there's nothing to refresh, e.g.
// chatgpt's JWT IS the credential), mark the account and fail over. // chatgpt's JWT IS the credential), mark the account and fail over.
// - 上游临时 temporary → retry the SAME account up to maxSameAccountAttempts // - 上游临时 temporary → behavior depends on tempAsDead:
// times (not counted); if still failing, STOP (no fan-out — an upstream-wide // • tempAsDead=false (default): retry the SAME account up to
// blip fails identically everywhere). // maxSameAccountAttempts times (not counted); if still failing, STOP
// (no fan-out — an upstream-wide blip fails identically everywhere).
// • tempAsDead=true (adobe): treat the temporary error as a DEAD account —
// mark it like a 401 and fail over to the next account, capped at
// maxTempDeadAccounts accounts so a pool-wide blip can't kill everything.
// - 参数错 / request-level (anything else) → return immediately, no retry, no // - 参数错 / request-level (anything else) → return immediately, no retry, no
// account penalty (the account isn't at fault). // account penalty (the account isn't at fault).
// //
@@ -1130,9 +1188,11 @@ func (s *V1Service) runPoolWithFailover(ctx context.Context, eventID, pool strin
attempt func(token model.TokenAccount) ([]byte, error), attempt func(token model.TokenAccount) ([]byte, error),
classify func(error) (isAuth, isQuota, isTemporary bool), classify func(error) (isAuth, isQuota, isTemporary bool),
refreshOnAuth func(tokenID string) (model.TokenAccount, bool), refreshOnAuth func(tokenID string) (model.TokenAccount, bool),
tempAsDead bool,
) ([]byte, error) { ) ([]byte, error) {
var lastErr error var lastErr error
busy := 0 busy := 0
tempDeadCount := 0
for _, token := range active { for _, token := range active {
// 1 concurrent job per account: skip any account already generating. // 1 concurrent job per account: skip any account already generating.
if !s.gate.tryAcquire(token.ID) { if !s.gate.tryAcquire(token.ID) {
@@ -1140,14 +1200,23 @@ func (s *V1Service) runPoolWithFailover(ctx context.Context, eventID, pool strin
continue continue
} }
// release via defer so a panic in tryAccount can't leak the 1-job slot. // release via defer so a panic in tryAccount can't leak the 1-job slot.
data, err, failover := func() ([]byte, error, bool) { data, err, failover, tempDead := func() ([]byte, error, bool, bool) {
defer s.gate.release(token.ID) defer s.gate.release(token.ID)
return s.tryAccount(ctx, eventID, pool, token, kind, attempt, classify, refreshOnAuth) return s.tryAccount(ctx, eventID, pool, token, kind, attempt, classify, refreshOnAuth, tempAsDead)
}() }()
if err == nil { if err == nil {
return data, nil return data, nil
} }
lastErr = err lastErr = err
if tempDead {
// temp-as-dead policy: this account was marked dead for a temporary
// upstream error. Cap how many accounts that can burn before we stop,
// so an upstream-wide blip doesn't wipe the whole pool.
tempDeadCount++
if tempDeadCount >= maxTempDeadAccounts {
return nil, lastErr
}
}
if failover { if failover {
continue continue
} }
@@ -1173,7 +1242,8 @@ func (s *V1Service) tryAccount(ctx context.Context, eventID, pool string, token
attempt func(token model.TokenAccount) ([]byte, error), attempt func(token model.TokenAccount) ([]byte, error),
classify func(error) (isAuth, isQuota, isTemporary bool), classify func(error) (isAuth, isQuota, isTemporary bool),
refreshOnAuth func(tokenID string) (model.TokenAccount, bool), refreshOnAuth func(tokenID string) (model.TokenAccount, bool),
) ([]byte, error, bool) { tempAsDead bool,
) ([]byte, error, bool, bool) {
_ = s.events.SetAccount(ctx, eventID, token.ID) _ = s.events.SetAccount(ctx, eventID, token.ID)
_ = s.tokens.TouchLastUsed(ctx, token.ID) _ = s.tokens.TouchLastUsed(ctx, token.ID)
authRefreshed := false authRefreshed := false
@@ -1186,12 +1256,12 @@ func (s *V1Service) tryAccount(ctx context.Context, eventID, pool string, token
"success_total": gorm.Expr("success_total + 1"), "success_total": gorm.Expr("success_total + 1"),
"fails": 0, "fails": 0,
}) })
return data, nil, false return data, nil, false, false
} }
isAuth, isQuota, isTemp := classify(err) isAuth, isQuota, isTemp := classify(err)
if isQuota { if isQuota {
s.markTokenFailure(ctx, pool, token, kind, false, true) s.markTokenFailure(ctx, pool, token, kind, false, true)
return nil, err, true return nil, err, true, false
} }
if isAuth { if isAuth {
// Refresh from cookie and retry ONCE; otherwise the credential is dead. // Refresh from cookie and retry ONCE; otherwise the credential is dead.
@@ -1203,24 +1273,32 @@ func (s *V1Service) tryAccount(ctx context.Context, eventID, pool string, token
} }
} }
s.markTokenFailure(ctx, pool, token, kind, true, false) s.markTokenFailure(ctx, pool, token, kind, true, false)
return nil, err, true return nil, err, true, false
} }
if isTemp { if isTemp {
if tempAsDead {
// Ops policy (adobe): a temporary upstream error ("system under
// load" etc.) means this account is effectively dead — mark it
// like a 401 and fail over to the next account. The pool driver
// caps how many accounts this is allowed to burn.
s.markTokenFailure(ctx, pool, token, kind, true, false)
return nil, err, true, true
}
tempAttempts++ tempAttempts++
if tempAttempts < maxSameAccountAttempts { if tempAttempts < maxSameAccountAttempts {
// Short linear backoff (1s, 2s) so an overloaded/rate-limited upstream // Short linear backoff (1s, 2s) so an overloaded/rate-limited upstream
// (e.g. adobe "system under load") gets a moment to recover before the // gets a moment to recover before the same-account retry, instead of
// same-account retry, instead of hammering it instantly. // hammering it instantly.
select { select {
case <-time.After(time.Duration(tempAttempts) * time.Second): case <-time.After(time.Duration(tempAttempts) * time.Second):
case <-ctx.Done(): case <-ctx.Done():
return nil, err, false return nil, err, false, false
} }
continue continue
} }
return nil, err, false // exhausted; no fan-out return nil, err, false, false // exhausted; no fan-out
} }
return nil, err, false // 参数错 / request-level return nil, err, false, false // 参数错 / request-level
} }
} }
@@ -1262,8 +1340,10 @@ func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, mode
return nil, err return nil, err
} }
// Round-robin order; same-account retry on transient errors, fail over to the // Round-robin order. Adobe uses tempAsDead=true: a temporary upstream error
// next account on auth/quota (see runPoolWithFailover). // ("system under load") marks the account dead (like a 401) and fails over to
// the next account, capped at maxTempDeadAccounts; auth/quota also fail over
// (see runPoolWithFailover).
return s.runPoolWithFailover(ctx, eventID, "adobe", active, "image", func(token model.TokenAccount) ([]byte, error) { return s.runPoolWithFailover(ctx, eventID, "adobe", active, "image", func(token model.TokenAccount) ([]byte, error) {
var blobIDs []string var blobIDs []string
for _, ref := range refs { for _, ref := range refs {
@@ -1277,7 +1357,7 @@ func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, mode
return data, genErr return data, genErr
}, adobeErrClass, func(id string) (model.TokenAccount, bool) { }, adobeErrClass, func(id string) (model.TokenAccount, bool) {
return s.refreshAdobeToken(ctx, id) return s.refreshAdobeToken(ctx, id)
}) }, true)
} }
func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1VideoRequest, aspectRatio, resolution string, durationSeconds int, downloadResult bool) ([]byte, string, error) { func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1VideoRequest, aspectRatio, resolution string, durationSeconds int, downloadResult bool) ([]byte, string, error) {
@@ -1323,8 +1403,9 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
referenceMode := defaultString(strings.TrimSpace(modelItem.ReferenceMode), "frame") referenceMode := defaultString(strings.TrimSpace(modelItem.ReferenceMode), "frame")
// Round-robin order; same-account retry on transient errors, fail over to the // Round-robin order; same-account retry on transient errors, fail over to the
// next account on auth/quota (see runPoolWithFailover). videoURL is captured // next account on auth/quota; temporary upstream errors mark the account dead
// from the successful attempt's meta (the upstream presigned URL). // and fail over too (tempAsDead, capped at maxTempDeadAccounts). videoURL is
// captured from the successful attempt's meta (the upstream presigned URL).
var videoURL string var videoURL string
data, err := s.runPoolWithFailover(ctx, eventID, "adobe", active, "video", func(token model.TokenAccount) ([]byte, error) { data, err := s.runPoolWithFailover(ctx, eventID, "adobe", active, "video", func(token model.TokenAccount) ([]byte, error) {
var blobIDs []string var blobIDs []string
@@ -1342,21 +1423,10 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
return bytes, genErr return bytes, genErr
}, adobeErrClass, func(id string) (model.TokenAccount, bool) { }, adobeErrClass, func(id string) (model.TokenAccount, bool) {
return s.refreshAdobeToken(ctx, id) return s.refreshAdobeToken(ctx, id)
}) }, true)
return data, videoURL, err return data, videoURL, err
} }
// runwayMinCredits gates account scheduling: a Runway account with fewer than
// this many credits remaining is treated as quota-limited and skipped, so we
// never dial upstream with an account that's about to run dry. Flat threshold
// (not per-duration) by request.
const runwayMinCredits = 50
// runwayCreditsPerSecond is Gen-4 Turbo's price (5 credits/sec → 5s=25, 10s=50),
// used to pre-reserve the exact render cost so concurrent picks of one account
// can't over-commit it.
const runwayCreditsPerSecond = 5
// leonardoMinCredits is the per-generation token cost (one Leonardo image = 30 // leonardoMinCredits is the per-generation token cost (one Leonardo image = 30
// tokens). An account with fewer is treated as 限额 and skipped — it can't afford // tokens). An account with fewer is treated as 限额 and skipped — it can't afford
// a generation. Daily renewal (tokenRenewalDate) drives auto-recovery. // a generation. Daily renewal (tokenRenewalDate) drives auto-recovery.
@@ -1391,10 +1461,10 @@ func (s *V1Service) generateRunwayVideo(ctx context.Context, eventID string, mod
if item.Status != "active" || item.Dead || strings.TrimSpace(item.Value) == "" { if item.Status != "active" || item.Dead || strings.TrimSpace(item.Value) == "" {
continue continue
} }
// Skip accounts under the credit floor (treated as quota-limited). Only // No pre-deduct (same policy as the image flow): skip only accounts we KNOW
// skip when we KNOW the balance is too low — an unknown balance gets the // are out of credits (cached remaining <= 0) — those are treated as dead.
// benefit of the doubt (upstream will reject if it's truly empty). // Unknown balance gets the benefit of the doubt.
if rem, ok := jsonMapInt(item.Meta, "cached_quota_remaining"); ok && rem < runwayMinCredits { if rem, ok := jsonMapInt(item.Meta, "cached_quota_remaining"); ok && rem <= 0 {
continue continue
} }
active = append(active, item) active = append(active, item)
@@ -1404,9 +1474,6 @@ func (s *V1Service) generateRunwayVideo(ctx context.Context, eventID string, mod
} }
s.rotateRoundRobin("runway", active) s.rotateRoundRobin("runway", active)
// Pre-reserve the exact render cost (5 credits/sec) so two concurrent renders
// can't over-commit the same account.
cost := durationSeconds * runwayCreditsPerSecond
var lastErr error var lastErr error
var videoURL string var videoURL string
busy := 0 busy := 0
@@ -1417,22 +1484,10 @@ func (s *V1Service) generateRunwayVideo(ctx context.Context, eventID string, mod
continue continue
} }
var data []byte var data []byte
ok := func() bool { done, failover := func() (bool, bool) {
defer s.gate.release(token.ID) defer s.gate.release(token.ID)
_ = s.events.SetAccount(ctx, eventID, token.ID) _ = s.events.SetAccount(ctx, eventID, token.ID)
_ = s.tokens.TouchLastUsed(ctx, token.ID) _ = s.tokens.TouchLastUsed(ctx, token.ID)
// Atomic pre-deduction (row-locked). Known-insufficient → sink to 限额
// and fail over to the next account.
allowed, deducted, rerr := s.tokens.ReserveQuota(ctx, "runway", token.ID, cost)
if rerr != nil {
lastErr = fmt.Errorf("%w: reserve: %v", runway.ErrTemporaryUpstream, rerr)
return false
}
if !allowed {
s.markTokenFailure(ctx, "runway", token, "video", false, true)
lastErr = runway.ErrQuotaExhausted
return false
}
teamID := "" teamID := ""
if token.Meta != nil { if token.Meta != nil {
teamID = strings.TrimSpace(stringValue(token.Meta["team_id"])) teamID = strings.TrimSpace(stringValue(token.Meta["team_id"]))
@@ -1444,26 +1499,31 @@ func (s *V1Service) generateRunwayVideo(ctx context.Context, eventID string, mod
"success_total": gorm.Expr("success_total + 1"), "success_total": gorm.Expr("success_total + 1"),
"fails": 0, "fails": 0,
}) })
// Re-fetch the REAL balance after the render and sink to 限额 if below
// the floor. Best-effort — never fail an already-successful render.
s.reconcileRunwayCredits(ctx, token.ID, token.Value)
data = d data = d
videoURL = strings.TrimSpace(stringValue(meta["video_url"])) videoURL = strings.TrimSpace(stringValue(meta["video_url"]))
return true return true, false
}
// Release the hold so a failed render doesn't burn credits.
if deducted {
_ = s.tokens.RefundQuota(ctx, "runway", token.ID, cost)
} }
lastErr = genErr lastErr = genErr
s.markTokenFailure(ctx, "runway", token, "video", switch {
errors.Is(genErr, runway.ErrAuth), case errors.Is(genErr, runway.ErrAuth), errors.Is(genErr, runway.ErrQuotaExhausted):
errors.Is(genErr, runway.ErrQuotaExhausted)) // 额度没了 / token 失效 → 当 401 判死(status=disabled, dead),换号。
return false s.markTokenFailure(ctx, "runway", token, "video", true, false)
return false, true
case errors.Is(genErr, runway.ErrTemporaryUpstream):
// 上游临时错误 → 直接换下一个号。
return false, true
default:
// 参数级错误(如 prompt 未过审)→ 直接失败,不换号。
return false, false
}
}() }()
if ok { if done {
return data, videoURL, nil return data, videoURL, nil
} }
if failover {
continue
}
return nil, "", lastErr
} }
if lastErr == nil { if lastErr == nil {
if busy > 0 { if busy > 0 {
@@ -1474,41 +1534,209 @@ func (s *V1Service) generateRunwayVideo(ctx context.Context, eventID string, mod
return nil, "", lastErr return nil, "", lastErr
} }
// reconcileRunwayCredits re-fetches an account's authoritative credit balance // generateGrokVideo runs grok's imagine video pipeline across the grok pool.
// (after a render) and writes it back, flipping the account to "限额" (quota) // Mirrors the runway policy: no pre-deduct, skip accounts known out of credits
// when it's below the floor. Concurrency-safe: every write stores a freshly // (cached remaining <= 0), and treat an out-of-credits / auth failure as a dead
// observed real balance — no local arithmetic that could lose updates under // account (the grok sso can't be renewed — 失效就失效). Text-to-video only for
// concurrent renders. Best-effort; marks down only (recovery is unwritten). // now (grok reference-image upload isn't wired yet).
func (s *V1Service) reconcileRunwayCredits(ctx context.Context, tokenID, tokenValue string) { 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")
}
if s.settings != nil {
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil {
s.grok.SetProxy(proxy)
}
}
// Optional reference frames (image-to-video), up to the model's max.
frames, 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, "cached_quota_remaining"); ok && rem <= 0 {
continue
}
active = append(active, item)
}
if len(active) == 0 {
return nil, "", ErrNoProviderAccount
}
s.rotateRoundRobin("grok", active)
res := strings.TrimSpace(resolution)
if res == "" {
res = "720p"
}
var lastErr error
var videoURL string
busy := 0
for _, token := range active {
// grok allows 10 concurrent jobs per account (unlike the 1-per-account
// default of the other pools).
if !s.gate.tryAcquireN(token.ID, grokConcurrencyPerAccount) {
busy++
continue
}
var data []byte
done, failover := func() (bool, bool) {
defer s.gate.release(token.ID)
_ = s.events.SetAccount(ctx, eventID, token.ID)
_ = s.tokens.TouchLastUsed(ctx, token.ID)
d, meta, genErr := s.grok.GenerateVideo(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,
})
data = d
videoURL = strings.TrimSpace(stringValue(meta["video_url"]))
return true, false
}
lastErr = genErr
switch {
case errors.Is(genErr, grok.ErrAuth), errors.Is(genErr, grok.ErrQuotaExhausted):
// 失效 / 额度没了 → 当 401 判死(不续期),换号。
s.markTokenFailure(ctx, "grok", token, "video", true, false)
return false, true
case errors.Is(genErr, grok.ErrTemporaryUpstream):
return false, true
default:
return false, false
}
}()
if done {
return data, videoURL, 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 "Nano Banana 2" (gemini_3_1_flash_image)
// image pipeline across the runway pool. Unlike the video path it does NOT
// pre-deduct credits: it simply round-robins the pool and generates. Per ops
// decision an out-of-credits account is treated like a dead 401 — marked
// dead (status=disabled) and skipped — because Runway credits don't refill
// daily, so a "quota" mark (which the maintenance loop would revive) is wrong.
// Reference images (up to the model's max) are uploaded per attempt.
func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) {
if s.runway == nil { if s.runway == nil {
return return nil, errors.New("runway client not configured")
} }
data, err := s.runway.FetchCreditsBalance(ctx, tokenValue) if s.settings != nil {
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil {
s.runway.SetProxy(proxy)
}
}
refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages))
if err != nil { if err != nil {
return return nil, err
} }
rem, ok := data["remaining"].(int)
if !ok { items, err := s.tokens.ListByPool(ctx, "runway")
return
}
item, err := s.tokens.Get(ctx, "runway", tokenID)
if err != nil { if err != nil {
return return nil, err
} }
meta := cloneJSONMap(item.Meta) var active []model.TokenAccount
meta["cached_quota_remaining"] = rem for _, item := range items {
meta["cached_quota_at"] = int(time.Now().Unix()) if item.Status != "active" || item.Dead || strings.TrimSpace(item.Value) == "" {
if used, ok := data["used"].(int); ok { continue
meta["cached_quota_used"] = used }
// No pre-deduct: skip only accounts we KNOW are out of credits
// (cached remaining <= 0); they're treated as dead. Unknown balance gets
// the benefit of the doubt — upstream rejects if it's truly empty.
if rem, ok := jsonMapInt(item.Meta, "cached_quota_remaining"); ok && rem <= 0 {
continue
}
active = append(active, item)
} }
if total, ok := data["total"].(int); ok { if len(active) == 0 {
meta["cached_quota_total"] = total return nil, ErrNoProviderAccount
} }
patch := map[string]any{"meta": meta} s.rotateRoundRobin("runway", active)
if rem < runwayMinCredits && item.Status == "active" {
patch["status"] = "quota" imageSize := strings.TrimSpace(resolution)
if imageSize == "" {
imageSize = "1K"
} }
_, _ = s.tokens.Update(ctx, "runway", tokenID, patch) var lastErr error
busy := 0
for _, token := range active {
// 1 concurrent job per account: skip any account already generating.
if !s.gate.tryAcquire(token.ID) {
busy++
continue
}
var data []byte
done, failover := func() (bool, bool) {
defer s.gate.release(token.ID)
_ = s.events.SetAccount(ctx, eventID, token.ID)
_ = s.tokens.TouchLastUsed(ctx, token.ID)
teamID := ""
if token.Meta != nil {
teamID = strings.TrimSpace(stringValue(token.Meta["team_id"]))
}
d, _, genErr := s.runway.GenerateImage(ctx, token.Value, teamID, in.Prompt, aspectRatio, imageSize, refs)
if genErr == nil {
_, _ = s.tokens.Update(ctx, "runway", token.ID, map[string]any{
"last_used_at": time.Now(),
"success_total": gorm.Expr("success_total + 1"),
"fails": 0,
})
data = d
return true, false
}
lastErr = genErr
switch {
case errors.Is(genErr, runway.ErrAuth), errors.Is(genErr, runway.ErrQuotaExhausted):
// 额度没了 / token 失效 → 当 401 判死(status=disabled, dead),换号。
s.markTokenFailure(ctx, "runway", token, "image", true, false)
return false, true
case errors.Is(genErr, runway.ErrTemporaryUpstream):
// 上游临时错误 → 直接换下一个号。
return false, true
default:
// 参数级错误(如 prompt 未过审)→ 直接失败,不换号。
return false, false
}
}()
if done {
return data, nil
}
if failover {
continue
}
return nil, lastErr
}
if lastErr == nil {
if busy > 0 {
return nil, ErrConcurrencyFull
}
lastErr = ErrProviderExecution
}
return nil, lastErr
} }
// reconcileChatGPTQuota re-reads OpenAI's image_gen remaining right after a // reconcileChatGPTQuota re-reads OpenAI's image_gen remaining right after a
@@ -1590,7 +1818,7 @@ func (s *V1Service) generateChatGPTImage(ctx context.Context, eventID string, mo
return data, genErr return data, genErr
}, func(e error) (bool, bool, bool) { }, func(e error) (bool, bool, bool) {
return errors.Is(e, chatgpt.ErrAuth), errors.Is(e, chatgpt.ErrQuotaExhausted), errors.Is(e, chatgpt.ErrTemporaryUpstream) return errors.Is(e, chatgpt.ErrAuth), errors.Is(e, chatgpt.ErrQuotaExhausted), errors.Is(e, chatgpt.ErrTemporaryUpstream)
}, nil) // chatgpt token IS the credential — no cookie to refresh }, nil, false) // chatgpt token IS the credential — no cookie to refresh
} }
// leonardoResetAfter returns when a Leonardo account's daily free tokens renew. // leonardoResetAfter returns when a Leonardo account's daily free tokens renew.
@@ -1717,7 +1945,7 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m
return data, nil return data, nil
}, func(e error) (bool, bool, bool) { }, func(e error) (bool, bool, bool) {
return errors.Is(e, leonardo.ErrAuth), errors.Is(e, leonardo.ErrQuotaExhausted), errors.Is(e, leonardo.ErrTemporaryUpstream) return errors.Is(e, leonardo.ErrAuth), errors.Is(e, leonardo.ErrQuotaExhausted), errors.Is(e, leonardo.ErrTemporaryUpstream)
}, nil) }, nil, false)
} }
// reconcileLeonardoCredits re-fetches an account's real token balance after a // reconcileLeonardoCredits re-fetches an account's real token balance after a
@@ -1847,7 +2075,7 @@ func (s *V1Service) generateKreaImage(ctx context.Context, eventID string, model
return data, genErr return data, genErr
}, func(e error) (bool, bool, bool) { }, func(e error) (bool, bool, bool) {
return errors.Is(e, krea.ErrAuth), errors.Is(e, krea.ErrQuotaExhausted), errors.Is(e, krea.ErrTemporaryUpstream) return errors.Is(e, krea.ErrAuth), errors.Is(e, krea.ErrQuotaExhausted), errors.Is(e, krea.ErrTemporaryUpstream)
}, nil) }, nil, false)
} }
// imagineRefreshAndPersist ensures the account's Imagine credential has a valid // imagineRefreshAndPersist ensures the account's Imagine credential has a valid
@@ -1916,37 +2144,10 @@ func (s *V1Service) generateImagineImage(ctx context.Context, eventID string, mo
if genErr != nil { if genErr != nil {
return nil, genErr return nil, genErr
} }
// Success → re-sync the displayed balance (best-effort).
s.reconcileImagineCredits(ctx, token.ID, cred)
return data, nil return data, nil
}, func(e error) (bool, bool, bool) { }, func(e error) (bool, bool, bool) {
return errors.Is(e, imagine.ErrAuth), errors.Is(e, imagine.ErrQuotaExhausted), errors.Is(e, imagine.ErrTemporaryUpstream) return errors.Is(e, imagine.ErrAuth), errors.Is(e, imagine.ErrQuotaExhausted), errors.Is(e, imagine.ErrTemporaryUpstream)
}, nil) }, nil, false)
}
// reconcileImagineCredits re-fetches the account's real balance after a render
// and writes it back (best-effort; never fails a done render). Imagine credits
// don't daily-reset, so there's no reset marker to advance.
func (s *V1Service) reconcileImagineCredits(ctx context.Context, tokenID, cred string) {
if s.imagine == nil {
return
}
data, err := s.imagine.FetchCreditsBalance(ctx, cred)
if err != nil {
return
}
rem, ok := data["remaining"].(int)
if !ok {
return
}
item, err := s.tokens.Get(ctx, "imagine", tokenID)
if err != nil {
return
}
meta := cloneJSONMap(item.Meta)
meta["cached_quota_remaining"] = rem
meta["cached_quota_at"] = int(time.Now().Unix())
_, _ = s.tokens.Update(ctx, "imagine", tokenID, map[string]any{"meta": meta})
} }
func (s *V1Service) refundIfNeeded(ctx context.Context, principal *APIPrincipal, eventID string, price float64) error { func (s *V1Service) refundIfNeeded(ctx context.Context, principal *APIPrincipal, eventID string, price float64) error {
+8 -1
View File
@@ -21,7 +21,8 @@ const detected = computed(() => {
const leonardo = items.filter((x) => x.type === 'leonardo').length const leonardo = items.filter((x) => x.type === 'leonardo').length
const krea = items.filter((x) => x.type === 'krea').length const krea = items.filter((x) => x.type === 'krea').length
const imagine = items.filter((x) => x.type === 'imagine').length const imagine = items.filter((x) => x.type === 'imagine').length
return { total: items.length, openai, adobe, runway, leonardo, krea, imagine } const grok = items.filter((x) => x.type === 'grok').length
return { total: items.length, openai, adobe, runway, leonardo, krea, imagine, grok }
}) })
function setStatus(text, err = false) { function setStatus(text, err = false) {
@@ -44,6 +45,8 @@ async function doSmartImport() {
try { try {
const r = it.type === 'openai' const r = it.type === 'openai'
? await api('/tokens/import-chatgpt-token', jsonBody('POST', { access_token: it.value })) ? await api('/tokens/import-chatgpt-token', jsonBody('POST', { access_token: it.value }))
: it.type === 'grok'
? await api('/tokens/import-grok-token', jsonBody('POST', { access_token: it.value }))
: it.type === 'runway' : it.type === 'runway'
? await api('/tokens/import-runway-token', jsonBody('POST', { access_token: it.value })) ? await api('/tokens/import-runway-token', jsonBody('POST', { access_token: it.value }))
: it.type === 'leonardo' : it.type === 'leonardo'
@@ -94,6 +97,7 @@ async function doSmartImport() {
<strong class="text-slate-700">Leonardo Cookie</strong>( better-auth) <strong class="text-slate-700">Leonardo Cookie</strong>( better-auth)
<strong class="text-slate-700">Krea Cookie</strong>( sb-superb-auth) <strong class="text-slate-700">Krea Cookie</strong>( sb-superb-auth)
<strong class="text-slate-700">Imagine Token</strong>(<code class="px-1 bg-slate-100 rounded">{"token","refreshToken","email","parentId"}</code>) <strong class="text-slate-700">Imagine Token</strong>(<code class="px-1 bg-slate-100 rounded">{"token","refreshToken","email","parentId"}</code>)
<strong class="text-slate-700">Grok SSO</strong>(grok.com <code class="px-1 bg-slate-100 rounded">sso</code> ,仅含 session_id,自动与 ChatGPT/Runway 区分)
<strong class="text-slate-700">多个 JWT</strong>(换行分隔) <strong class="text-slate-700">多个 JWT</strong>(换行分隔)
全粘进来即可无需任何前缀 全粘进来即可无需任何前缀
</p> </p>
@@ -121,6 +125,9 @@ async function doSmartImport() {
<span v-if="detected.imagine" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-teal-700 bg-teal-50 ring-1 ring-teal-200"> <span v-if="detected.imagine" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-teal-700 bg-teal-50 ring-1 ring-teal-200">
Imagine · <span class="tabular-nums">{{ detected.imagine }}</span> Imagine · <span class="tabular-nums">{{ detected.imagine }}</span>
</span> </span>
<span v-if="detected.grok" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-slate-700 bg-slate-100 ring-1 ring-slate-300">
Grok · <span class="tabular-nums">{{ detected.grok }}</span>
</span>
</template> </template>
<span v-else class="text-rose-600">未识别到任何 Cookie JWT</span> <span v-else class="text-rose-600">未识别到任何 Cookie JWT</span>
</div> </div>
+17 -3
View File
@@ -28,6 +28,16 @@ export function looksLikeRunwayJwt(s) {
return 'sso' in claims && claims.id != null return 'sso' in claims && claims.id != null
} }
// Grok website "sso" JWTs carry ONLY a session_id claim (no openai claims, no
// runway id/sso) — that's what tells them apart from a ChatGPT/Runway JWT.
export function looksLikeGrokJwt(s) {
const claims = decodeJwtPayload(s)
if (!claims || typeof claims !== 'object') return false
if (Object.keys(claims).some((k) => k.startsWith('https://api.openai.com/'))) return false
if ('sso' in claims || claims.id != null) return false
return 'session_id' in claims
}
// Leonardo cookies carry the better-auth session cookie — that's what tells them // Leonardo cookies carry the better-auth session cookie — that's what tells them
// apart from an Adobe cookie (both are otherwise opaque cookie strings). // apart from an Adobe cookie (both are otherwise opaque cookie strings).
export function looksLikeLeonardoCookie(s) { export function looksLikeLeonardoCookie(s) {
@@ -104,11 +114,15 @@ export function parseImportInput(text) {
// treated as an Adobe cookie string. // treated as an Adobe cookie string.
const lines = text.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) const lines = text.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
return lines.map((line) => { return lines.map((line) => {
if (looksLikeJwt(line)) { // Accept a bare JWT or one with a leading `sso=` (grok cookie value form).
const value = line.replace(/^Bearer\s+/i, '') const stripped = line.replace(/^Bearer\s+/i, '').replace(/^sso=/, '')
if (looksLikeJwt(stripped)) {
const value = stripped
return looksLikeRunwayJwt(value) return looksLikeRunwayJwt(value)
? { type: 'runway', value } ? { type: 'runway', value }
: { type: 'openai', value } : looksLikeGrokJwt(value)
? { type: 'grok', value }
: { type: 'openai', value }
} }
return { type: cookieType(line), value: line } return { type: cookieType(line), value: line }
}) })
+8 -4
View File
@@ -35,6 +35,7 @@ const stats = computed(() => {
total: rows.value.length, total: rows.value.length,
openai: by('openai'), adobe: by('adobe'), runway: by('runway'), openai: by('openai'), adobe: by('adobe'), runway: by('runway'),
leonardo: by('leonardo'), krea: by('krea'), imagine: by('imagine'), leonardo: by('leonardo'), krea: by('krea'), imagine: by('imagine'),
grok: by('grok'),
} }
}) })
@@ -135,7 +136,7 @@ async function reconcile() {
// probed here — the pending poll just reads the store until the worker writes // probed here — the pending poll just reads the store until the worker writes
// their quota/email. OLD accounts (active) get a real live /quota probe for // their quota/email. OLD accounts (active) get a real live /quota probe for
// up-to-date remaining + refresh time. // up-to-date remaining + refresh time.
const quotaRows = visible.filter((r) => !r.pending && r.status === 'active' && (r.type === 'openai' || r.type === 'adobe' || r.type === 'runway' || r.type === 'leonardo' || r.type === 'krea' || r.type === 'imagine')) const quotaRows = visible.filter((r) => !r.pending && r.status === 'active' && (r.type === 'openai' || r.type === 'adobe' || r.type === 'runway' || r.type === 'leonardo' || r.type === 'krea' || r.type === 'imagine' || r.type === 'grok'))
const adobeNeedEmail = visible.filter((r) => !r.pending && r.type === 'adobe' && !r.email) const adobeNeedEmail = visible.filter((r) => !r.pending && r.type === 'adobe' && !r.email)
const total = quotaRows.length + adobeNeedEmail.length const total = quotaRows.length + adobeNeedEmail.length
if (total === 0) { quotaStatus.value = ''; return } if (total === 0) { quotaStatus.value = ''; return }
@@ -296,7 +297,7 @@ onMounted(loadAccounts)
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div> <div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
<div class="text-[10px] text-white/35 mt-0.5">成功/失败/限额</div> <div class="text-[10px] text-white/35 mt-0.5">成功/失败/限额</div>
</div> </div>
<div v-for="t in [['openai','OpenAI','text-emerald-300/80'],['adobe','Adobe','text-rose-300/80'],['runway','Runway','text-violet-300/80'],['leonardo','Leonardo','text-amber-300/80'],['krea','Krea','text-sky-300/80'],['imagine','Imagine','text-teal-300/80']]" <div v-for="t in [['openai','OpenAI','text-emerald-300/80'],['adobe','Adobe','text-rose-300/80'],['runway','Runway','text-violet-300/80'],['leonardo','Leonardo','text-amber-300/80'],['krea','Krea','text-sky-300/80'],['imagine','Imagine','text-teal-300/80'],['grok','Grok','text-slate-300/80']]"
:key="t[0]" class="card p-4"> :key="t[0]" class="card p-4">
<div class="text-[11px] uppercase tracking-wider" :class="t[2]">{{ t[1] }}</div> <div class="text-[11px] uppercase tracking-wider" :class="t[2]">{{ t[1] }}</div>
<div class="text-2xl font-semibold mt-1 tabular-nums"> <div class="text-2xl font-semibold mt-1 tabular-nums">
@@ -328,6 +329,9 @@ onMounted(loadAccounts)
<button @click="setFilter(() => typeFilter = 'imagine')" class="fp" :class="typeFilter === 'imagine' && 'fp-teal'"> <button @click="setFilter(() => typeFilter = 'imagine')" class="fp" :class="typeFilter === 'imagine' && 'fp-teal'">
<span class="w-1.5 h-1.5 rounded-full bg-teal-400"></span>Imagine <span class="w-1.5 h-1.5 rounded-full bg-teal-400"></span>Imagine
</button> </button>
<button @click="setFilter(() => typeFilter = 'grok')" class="fp" :class="typeFilter === 'grok' && 'fp-on'">
<span class="w-1.5 h-1.5 rounded-full bg-slate-400"></span>Grok
</button>
</div> </div>
<div class="w-px h-5 bg-white/10"></div> <div class="w-px h-5 bg-white/10"></div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@@ -436,9 +440,9 @@ onMounted(loadAccounts)
<td class="px-3 py-3.5 align-middle text-right text-sm tabular-nums whitespace-nowrap"> <td class="px-3 py-3.5 align-middle text-right text-sm tabular-nums whitespace-nowrap">
<!-- quota column: 数字 / (never "未知"/"失败"/"检测中") --> <!-- quota column: 数字 / (never "未知"/"失败"/"检测中") -->
<!-- remaining === -1 is the provider "unlimited" sentinel show not a scary red -1 --> <!-- remaining === -1 is the provider "unlimited" sentinel show not a scary red -1 -->
<span v-if="(a.type === 'openai' || a.type === 'runway' || a.type === 'leonardo' || a.type === 'krea' || a.type === 'imagine') && a.remaining != null && a.remaining !== -1" <span v-if="(a.type === 'openai' || a.type === 'runway' || a.type === 'leonardo' || a.type === 'krea' || a.type === 'imagine' || a.type === 'grok') && a.remaining != null && a.remaining !== -1"
class="font-mono font-semibold" class="font-mono font-semibold"
:class="a.remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.remaining }}</span> :class="a.remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.remaining }}{{ a.type === 'grok' ? '%' : '' }}</span>
<span v-else class="text-white/25" :title="a._quotaError || ''"></span> <span v-else class="text-white/25" :title="a._quotaError || ''"></span>
</td> </td>
<!-- reset_after --> <!-- reset_after -->